> ## Documentation Index
> Fetch the complete documentation index at: https://docs.shoppex.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Headless checkout SDK

> Build your own checkout UI with @shoppexio/checkout-js/headless while Shoppex handles payment sessions, webhooks, invoices, and fulfillment.

The Headless Checkout SDK is for merchants who want a fully custom checkout page on their own domain. You own the layout, fields, and buyer experience. Shoppex still creates the invoice, starts the payment session, handles 3DS or redirects, receives webhooks, and fulfills the order.

Use this when the hosted checkout page or modal embed is not flexible enough.

<Note>
  Headless checkout requires an active Business plan.
</Note>

## What you need

* A storefront or app page on your own domain.
* An active Business plan.
* A headless checkout publishable key from the dashboard.
* At least one allowed origin for the site that will call the browser API.
* `@shoppexio/checkout-js` installed in your frontend app.

## Dashboard setup

Open **Dashboard -> Settings -> Developer -> Headless**.

<Steps>
  <Step title="Generate a publishable key">
    Click **Generate key**. Copy the `pk_live_...` key immediately. The full key is shown only once.
  </Step>

  <Step title="Add your storefront origin">
    Add the exact origin that hosts your checkout page, for example `https://yourstore.com`.
  </Step>

  <Step title="Add local development origins">
    For local testing, add the exact local origin too, for example `http://localhost:3000`.
  </Step>
</Steps>

An origin is only the scheme, host, and optional port. Do not include a path.

| URL you paste                | Saved origin            |
| ---------------------------- | ----------------------- |
| `https://vyy.gg/checkout`    | `https://vyy.gg`        |
| `https://www.vyy.gg`         | `https://www.vyy.gg`    |
| `http://localhost:3000/cart` | `http://localhost:3000` |

If your site works on both `https://vyy.gg` and `https://www.vyy.gg`, add both origins.

<Warning>
  Never put a secret `shx_...` API key in browser code. Headless checkout uses `pk_live_...` or `pk_test_...` publishable keys only.
</Warning>

## Install

```bash theme={"system"}
npm install @shoppexio/checkout-js
# or
bun add @shoppexio/checkout-js
```

## React example

```tsx theme={"system"}
import {
  ShoppexCheckoutProvider,
  useCheckoutSession,
  useStripePaymentSession,
  ShoppexPoweredBy,
} from '@shoppexio/checkout-js/headless/react';

function CheckoutForm() {
  const { session, loading, error } = useCheckoutSession({
    product_id: 'PROD_123',
    email: 'customer@example.com',
    quantity: 1,
  });

  const stripe = useStripePaymentSession(session?.id, {
    returnUrl: 'https://yourstore.com/thanks',
  });

  if (loading) return <p>Preparing checkout...</p>;
  if (error) return <p>{error.message}</p>;
  if (!session) return null;

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault();
        void stripe.confirm();
      }}
    >
      <h1>{session.product?.title ?? 'Checkout'}</h1>
      <p>Total: {session.breakdown.total} {session.currency}</p>

      <div ref={stripe.mountRef} />

      <button type="submit" disabled={!stripe.ready}>
        Pay now
      </button>

      <ShoppexPoweredBy />
    </form>
  );
}

export default function Page() {
  return (
    <ShoppexCheckoutProvider publishableKey="pk_live_your_key_here">
      <CheckoutForm />
    </ShoppexCheckoutProvider>
  );
}
```

<Note>
  `ShoppexPoweredBy` is required unless Shoppex has manually assigned the hidden White Label plan to the merchant. The SDK checks that the badge is visible before payment confirmation whenever the server returns `attribution.required: true`. The server owns this directive; the browser cannot disable it.
</Note>

## API shape

The browser SDK sends the publishable key in the `x-shoppex-publishable-key` header. Shoppex also checks the request `Origin` header against your allowed origins.

The SDK can:

* create a checkout session
* read the current session status
* apply or remove coupons
* set tips
* select add-ons
* save buyer email, marketing preference, billing address, and method-specific terms acceptance
* save product custom fields and delivery instructions
* start a gateway payment session
* complete zero-total checkouts
* request and verify Customer Balance OTPs, then apply full or partial balance payments
* submit browser-bound proof for manual payment methods
* capture/finalize PayPal and SumUp payments
* fetch delivery after the payment is complete

The session response includes line items, totals, authoritative gateway fee previews, payment-method presentation and requirements, buyer identity state, Customer Balance availability, product custom-field definitions, delivery instructions, shop branding, terms, and delivery state. Render against the gateway `kind`, not the provider name, so your UI works across card, redirect, crypto, balance, and manual payment flows.

## Buyer details and payment requirements

Each entry in `session.gateways_available` contains:

* `fee_preview`: the server-calculated fee for that method
* `presentation`: merchant label, button label, icon, and provider-attribution preference
* `requirements`: whether method terms and a billing address must be saved

`presentation.hide_provider_attribution` only controls the payment provider's
attribution. It does not change the server-owned `ShoppexPoweredBy` requirement.

Persist the details before starting the gateway:

```ts theme={"system"}
await client.updateSession(session.id, {
  email: 'customer@example.com',
  payment_method_terms_accepted: true,
  billing_address: {
    name: 'Customer Name',
    line1: '1 Main Street',
    city: 'Berlin',
    country: 'DE',
    postal_code: '10115',
  },
  custom_fields: {
    line_item_id: { discord_username: 'customer' },
  },
  delivery_instructions: {
    line_item_id: 'Send to customer on Discord',
  },
});
```

## EU withdrawal consent (digital goods)

EU consumer law (Consumer Rights Directive Art. 16(m)) lets a buyer of digital
content waive the 14-day right of withdrawal in exchange for immediate
delivery. Shops opt in under **Settings → Checkout → EU Withdrawal Consent**.
When they have, the session carries the server's verdict for this buyer:

```ts theme={"system"}
session.withdrawal_consent;
// { required: true, text: 'I expressly request immediate performance …', text_version: 'v1', recorded_at: null }
```

`required` is true only for EU buyers (or an unknown country) with at least
one immediately delivered digital line item; renewals and non-EU buyers are
never asked. While `required` is true and `recorded_at` is null, every
`startPaymentSession()` is refused with `409 withdrawal_consent_required`.

Render `text` verbatim as an unchecked checkbox and record the proof once the
buyer ticks it. The proof is first-write-wins and returns the refreshed view:

```ts theme={"system"}
if (session.withdrawal_consent.required && !session.withdrawal_consent.recorded_at) {
  session = await client.recordWithdrawalConsent(session.id, session.withdrawal_consent.text_version);
}
```

With React, `useCheckoutSession()` exposes `recordWithdrawalConsent()` and
updates the session in place. `useStripePaymentSession()` starts its attempt as
soon as the session id arrives; if that start is refused because the consent is
outstanding, the hook re-issues it by itself once the proof is recorded. Other
gateways start on your call, so start them after the consent.

## Free and Customer Balance checkout

```ts theme={"system"}
if (!session.payment_required) {
  await client.completeFreeCheckout(session.id);
}

await client.requestBalanceOtp(session.id, 'customer@example.com');
const verified = await client.verifyBalanceOtp(
  session.id,
  'customer@example.com',
  '123456',
);
await client.payWithBalance(session.id, verified.session_token);
```

Customer Balance uses the same wallet, policy, attempt, reconciliation, stock-hold, and completion-access services as Hosted Checkout.

## Crypto progress and underpayments

Call `getSession()` or the React `refresh()` action to read the authoritative
payment projection. `session.payment_detail` includes the exact decimal strings
needed for a crypto progress or underpayment UI:

```ts theme={"system"}
const detail = session.payment_detail;

if (typeof detail?.confirmations_needed === 'number') {
  console.log(`${detail.confirmations ?? 0}/${detail.confirmations_needed}`);
}

if (detail?.remaining && detail.buyer_actionable !== false) {
  console.log(`Send ${detail.remaining} ${detail.crypto_currency ?? 'crypto'}`);
}

if (detail?.buyer_actionable === false) {
  console.log(detail.buyer_action_reason);
}

if (detail?.received && detail.received_fiat_estimate) {
  console.log(
    `Received on-chain: ${detail.received} ${detail.crypto_currency} (~${detail.received_fiat_estimate} ${detail.fiat_currency})`,
  );
  console.log(`Credited by provider: ${detail.credited_fiat ?? '0'} ${detail.fiat_currency}`);
}
```

The detail contains `expected`, `received`, `remaining`, `crypto_currency`,
`buyer_actionable`, `buyer_action_reason`, `provider_status`, `credited_fiat`,
`received_fiat_estimate`, `fiat_currency`, `confirmations`, and
`confirmations_needed` when those values are available. `credited_fiat` is the
amount the provider applied to the invoice. `received_fiat_estimate` is only a
display estimate for crypto observed on-chain and must never be counted as paid.
Keep the amount values as strings. Do not recalculate the remainder with
JavaScript floating-point numbers.

## Embed provider adapters

Stripe uses `useStripePaymentSession`. The framework-agnostic Headless export also includes:

```ts theme={"system"}
import {
  mountSquareCard,
  mountNmiCardFields,
  mountSumupCard,
  mountPaypalButtons,
} from '@shoppexio/checkout-js/headless';
```

These helpers mount provider-owned fields into your element and call the same Shoppex session/finalize endpoints as Hosted Checkout. Square and NMI perform their tokenized second session call; SumUp finalizes after the widget succeeds; PayPal captures the approved order. NMI includes Kount and 3D Secure when the merchant gateway requires them.

## Manual payment proof

When a manual payment session has `require_proof: true`, use `proof_type` to render a note input, image input, or both. Then submit the proof with the completion grant returned by the start call:

```ts theme={"system"}
await client.submitManualProof(session.id, start.completion_access_grant, {
  customer_note: 'Bank transfer sent',
  image: proofFile,
});
```

The SDK registers completion access before uploading. A publishable key alone cannot submit proof for another buyer's invoice.

## Troubleshooting

### Generate key fails

Check these first:

* The shop has an active Business plan.
* You are using the shop owner account or a team member with permission to manage webhooks/developer settings.
* Your dashboard session is fresh. Refresh the page or log out and back in.

If it still fails, open browser DevTools -> Network and inspect:

```text theme={"system"}
POST /v1/dashboard/checkout/headless/rotate-key
```

Common responses:

| Status                       | Meaning                                                                      |
| ---------------------------- | ---------------------------------------------------------------------------- |
| `403 BUSINESS_PLAN_REQUIRED` | The shop is not on an active Business plan.                                  |
| `401`                        | The dashboard session or shop permission is invalid.                         |
| `500`                        | Backend or database issue. Contact support with the request id if available. |

### Add origin fails

Use a full `http://` or `https://` origin. These are valid:

```text theme={"system"}
https://vyy.gg
https://www.vyy.gg
http://localhost:3000
```

These are not valid saved origins:

```text theme={"system"}
vyy.gg
https://vyy.gg/checkout
ftp://vyy.gg
```

The dashboard normalizes paths away before saving, but the backend still rejects non-http origins and malformed values.

If the UI only shows a generic error, inspect this request:

```text theme={"system"}
PUT /v1/dashboard/checkout/headless/origins
```

### Browser API calls fail

Check the browser response code:

| Status                       | Meaning                                                                                            |
| ---------------------------- | -------------------------------------------------------------------------------------------------- |
| `401`                        | Missing, malformed, or unknown publishable key. Make sure it starts with `pk_live_` or `pk_test_`. |
| `403 ORIGIN_NOT_ALLOWED`     | The calling site's origin is not in the allowed origins list.                                      |
| `403 BUSINESS_PLAN_REQUIRED` | Headless checkout is not enabled for the shop's current plan.                                      |

## Related docs

<CardGroup cols={2}>
  <Card title="Headless Commerce Overview" icon="sitemap" href="/developers/headless/overview">
    Pick the right integration shape for custom storefronts, apps, and backend flows.
  </Card>

  <Card title="Checkout Embed SDK" icon="window-maximize" href="/developers/embeds/overview">
    Use the hosted modal when you do not need a fully custom checkout UI.
  </Card>

  <Card title="Storefront SDK" icon="browser" href="/developers/storefront-sdk/overview">
    Read public product, cart, and storefront data from browser code.
  </Card>

  <Card title="Webhooks" icon="webhook" href="/developers/webhooks">
    Fulfill orders and update your app after Shoppex receives payment events.
  </Card>
</CardGroup>
