Skip to main content
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.
ImportWhen to useCarries React
@fanfare-io/fanfare-sdk-shopifyThe 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/reactThe "use client" provider and hooks. Use in client components.Yes
The resolver, verifier, and claimer are internalFanfareShopifyProvider assembles them from your store config, so you configure one provider rather than wiring loose factories.

Configure the provider

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.

Metafield primitives

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"

Connect the drop widget

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? }. 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:
FieldWhatWhy
shopifyProductIdShopify Product GID (gid://shopify/Product/…)The gate’s policy anchor
merchandiseIdShopify 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.
coderetryableSafe to start a fresh gatedCheckout?What to do
cart_create_failedyesyes (nothing claimed)restart
no_claim_contextnon/awire the widget grant first
cart_not_materializedyesyesrestart
claim_failedyesyes (nothing reserved)restart
add_line_failedyesNO — resume onlyres.retry() (same claimed cart)
reservation_expirednoyes (must restart)fresh gatedCheckout
cart_id_mismatchnon/aadapter bug — surface
checkout_url_missingnon/aadapter bug — surface
busyyesn/adisable 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