The Shopify SDK is the storefront adapter for Fanfare. You configure one provider with your store
facts; it assembles the product→experience resolver, the checkout verifier, and the claim/checkout
plumbing internally. The hooks then cover the storefront seams: which product carries which
experience, proving a shopper cleared the drop before checkout, and running the on-buy gated
checkout in the correct order.
For the higher-level walkthrough (installing the Fanfare Shopify app, embedding experiences in your
theme, connecting products), read the Shopify integration guide
first. This page covers the SDK package itself.
Install
pnpm add @fanfare-io/fanfare-sdk-shopify @fanfare-io/fanfare-sdk-core
@fanfare-io/fanfare-sdk-core is a required peer dependency. react and react-dom are optional
peers — install them only if you use the ./react subpath.
# Only if you use the React provider/hooks
pnpm add react react-dom
Two import surfaces
The package has two entry points so a server-only consumer can install it without React.
| Import | When to use | Carries React |
|---|
@fanfare-io/fanfare-sdk-shopify | The SSR-safe root barrel: metafield primitives, config types, and the framework-free checkout vocabulary (GatedLine, productGid/variantGid, FanfareGatedCheckoutResult). Use from Hydrogen/Oxygen loaders, RSC, and Next/Remix server code. | No |
@fanfare-io/fanfare-sdk-shopify/react | The "use client" provider and hooks. Use in client components. | Yes |
The resolver, verifier, and claimer are internal — FanfareShopifyProvider assembles them from
your store config, so you configure one provider rather than wiring loose factories.
Wrap your storefront once. In production, shopify.proxyBaseUrl points at the Fanfare Shopify app
proxy; that is the only required store fact.
"use client";
import { FanfareShopifyProvider } from "@fanfare-io/fanfare-sdk-shopify/react";
export function StorefrontProvider({ children }: { children: React.ReactNode }) {
return (
<FanfareShopifyProvider
organizationId="org_abc123"
publishableKey="pk_live_..."
shopify={{ proxyBaseUrl: "https://shop.myshopify.com/apps/fanfare/api" }}
>
{children}
</FanfareShopifyProvider>
);
}
mode defaults to "production" (real verify + claim via the proxy) and experiences defaults to
{ source: "metafield" }. Production without a proxyBaseUrl throws FanfareConfigurationError at
mount — it never silently degrades.
Resolve a product to an experience
useFanfareExperience(product) maps a Shopify product to the experience it gates, or null when the
product is not gated. Pass a ShopifyProductRef ({ handle, id }).
import { useFanfareExperience } from "@fanfare-io/fanfare-sdk-shopify/react";
function ProductPage({ product }: { product: { handle: string; id: string } }) {
const { experienceId, loading } = useFanfareExperience({ handle: product.handle, id: product.id });
if (loading) return <Spinner />;
if (experienceId === null) return <NativePurchaseUI />; // not gated
return <GatedPurchaseUI experienceId={experienceId} />;
}
experienceId === null means not gated — render your native purchase UI. It is SSR-safe: any miss
or failure yields null, never a throw.
If you already have the raw experienceIds value, parse it directly. Both helpers are pure and never
throw.
import { parseExperienceIds, parseFirstExperienceId, FANFARE_PRODUCT_METAFIELD } from "@fanfare-io/fanfare-sdk-shopify";
parseExperienceIds('["exp_123","exp_456"]'); // ["exp_123", "exp_456"]
parseFirstExperienceId('["exp_123"]'); // "exp_123"
FANFARE_PRODUCT_METAFIELD.key; // "experienceIds"
useFanfareWidgetBridge() returns the onJourneyChange handler you spread onto your
<ExperienceWidget>. That spread feeds the cleared-drop grant and the routed journey snapshot into
the provider, so the gate and the checkout can read them.
import { useFanfareWidgetBridge } from "@fanfare-io/fanfare-sdk-shopify/react";
function GatedPurchaseUI({ experienceId }: { experienceId: string }) {
const bridge = useFanfareWidgetBridge();
return <ExperienceWidget experienceId={experienceId} {...bridge} />;
}
Gate checkout entry
<FanfareCheckoutGate> is the drop-in guard for the checkout route: it renders children only when
the grant for the cart’s gated items is valid, otherwise onDenied.
import { FanfareCheckoutGate } from "@fanfare-io/fanfare-sdk-shopify/react";
<FanfareCheckoutGate
cartItems={cart.lines.map((line) => ({ handle: line.product.handle, id: line.product.id }))}
onDenied={(reason) => <BackToDropPrompt reason={reason} />}
>
<Checkout />
</FanfareCheckoutGate>;
For manual control, useFanfareGrant() returns { state: "checking" | "allowed" | "blocked", reason? }.
Run the gated checkout (recommended)
useFanfareCheckout(adapter) is the recommended on-buy path. Its gatedCheckout owns the ordering
that keeps Shopify’s gate validation happy — create an empty cart, claim it (mint the gate token
and reserve the slot), then add the line(s) to that same cart. You supply the cart I/O; the SDK owns
the order, so the protected line can never be added before the claim.
import { useFanfareCheckout, productGid, variantGid } from "@fanfare-io/fanfare-sdk-shopify/react";
function BuyButton({ product, variant }: { product: { id: string }; variant: { id: string } }) {
const { gatedCheckout, pending } = useFanfareCheckout({
// createCart MUST return an EMPTY cart (no line) — Shopify's cartCreate with no input.
createCart: () => storefront.cartCreate(),
// addLine adds one line; merchandiseId is the variant GID.
addLine: (cart, line) => storefront.cartLinesAdd(cart.id, line.merchandiseId, line.quantity),
readCart: (cart) => ({ id: cart.id, checkoutUrl: cart.checkoutUrl }),
});
async function onBuy() {
const res = await gatedCheckout({
gatedLine: {
shopifyProductId: productGid(product.id), // gid://shopify/Product/...
merchandiseId: variantGid(variant.id), // gid://shopify/ProductVariant/...
quantity: 1,
},
});
if (res.ok) {
window.location.assign(res.checkoutUrl);
} else if (res.retryable && res.code === "add_line_failed") {
// The token is already minted and the slot reserved — resume the add on the
// SAME cart. NEVER start a fresh gatedCheckout here (that would double-reserve).
const retried = await res.retry?.();
if (retried?.ok) window.location.assign(retried.checkoutUrl);
} else {
showError(res.code);
}
}
return <button disabled={pending} onClick={onBuy}>Buy</button>;
}
The line identifier
GatedLine carries two Shopify ids, both required, branded so a transposition fails to compile:
| Field | What | Why |
|---|
shopifyProductId | Shopify Product GID (gid://shopify/Product/…) | The gate’s policy anchor |
merchandiseId | Shopify ProductVariant GID (gid://shopify/ProductVariant/…) | Shopify’s cartLinesAdd merchandise id |
Build them with the productGid / variantGid constructors (they throw if handed the opposite
entity’s GID).
Result codes
gatedCheckout never rejects — every outcome is a FanfareGatedCheckoutResult. Branch on
retryable (or the presence of retry), never on code alone.
code | retryable | Safe to start a fresh gatedCheckout? | What to do |
|---|
cart_create_failed | yes | yes (nothing claimed) | restart |
no_claim_context | no | n/a | wire the widget grant first |
cart_not_materialized | yes | yes | restart |
claim_failed | yes | yes (nothing reserved) | restart |
add_line_failed | yes | NO — resume only | res.retry() (same claimed cart) |
reservation_expired | no | yes (must restart) | fresh gatedCheckout |
cart_id_mismatch | no | n/a | adapter bug — surface |
checkout_url_missing | no | n/a | adapter bug — surface |
busy | yes | n/a | disable the buy button on pending |
Multiple lines are forward-compatible by shape only. gatedCheckout({ gatedLine, ungatedLines })
accepts extra lines and adds them to the cart, but the gate token corresponds to a single
product/experience — only gatedLine is policy-checked. ungatedLines are convenience adds, not
gated. Multi-product enforcement is future work.
Advanced: bring-your-own-cart claim
If you own your cart system and want to add lines yourself, use the low-level claim (also returned
by useFanfareCheckout, and available standalone as useFanfareClaim). You materialize an empty
cart, claim it, then add your gated line — in that order.
import { useFanfareClaim } from "@fanfare-io/fanfare-sdk-shopify/react";
const { claim } = useFanfareClaim();
let cart = await createEmptyCart(); // you own this
const res = await claim({
cartId: cart.id,
purchase: { shopifyProductId: productGid(product.id), merchandiseId: variantGid(variant.id), quantity: 1 },
});
if (res.ok) {
const checkout = await addGatedLine(cart.id, variant.id); // YOU add the line, after the claim
window.location.assign(checkout.checkoutUrl);
}
claim(cartId) (a bare string) stays as the no-purchase-context escape hatch. On
cart_not_materialized (recoverable), re-materialize the cart and claim again.
Notes and gotchas
- The root barrel is React-free and SSR-safe. It imports zero React, so a server-only consumer
installs and uses it (the metafield primitives + the checkout vocabulary) without React.
gatedCheckout owns the ordering. Your createCart must return an EMPTY cart; the SDK adds the
gated line only after the claim writes the token. Adding the line inside createCart reintroduces
the line-before-token bug the hook exists to prevent.
- Never re-claim on
add_line_failed. The slot is already reserved; use res.retry() to resume
the add on the same cart. A blind fresh gatedCheckout double-reserves (oversell).
- The gate is enforced server-side.
GrantRecord is UX/continuity state; admission is enforced
through the proxy using the admission credential, and the platform gate token is server-minted into
the cart metafield.
Next steps