# Headless checkout

> Your own checkout page, the embedded checkout or the hosted one, all from the same cart and back to your site.

Source: https://shopfa.st/docs/headless/checkout

Your site owns the cart and as much of the checkout as you want. The payment step is always ShopFast's: the card form,
the wallet buttons or the provider's own page. Your payment methods, taxes, email verification, fraud checks, gift cards,
store credit and limits per payment method all work as they do in your ShopFast store, and card data never touches your
site. After paying, the buyer comes back to your site.

There are three ways in, and all three start from the same Storefront API cart:

| | Your own checkout | Embedded | Hosted |
| --- | --- | --- | --- |
| What you build | The checkout page | A button | One redirect |
| The buyer stays on your site | Until the payment step | Yes, in an overlay | No |
| Discount codes, gift cards and details already in the cart | Carry over | Carry over | Carry over |
| Back to your site after paying | `returnUrl` | The `success` event | `returnUrl` |
| Every payment method and check | Yes | Yes | Yes |

The [Next.js starter](https://shopfa.st/docs/headless/nextjs) ships all three, with its own checkout page as the default.

## Register your site first

ShopFast sends the buyer back only to a site you registered. Add your site's origin (`https://shop.example.com`, or
`http://localhost:3000` while you develop) to a storefront key's allowed origins in **Settings → Headless & MCP →
Storefront keys**. That also lets the embedded checkout open on it. A `returnUrl`, `cancelUrl` or `embedOrigin` on any
other site is refused with `422`.

The return page receives the order's access token, so it must be a page you control. That is why the list is yours to
keep.

## 1. Your own checkout

Draw the checkout from the cart's options, start it with what the buyer filled in, then send the buyer to pay.

### Read the options

```ts
const options = await shop.cart.checkoutOptions(cartId, { country: 'DE' });
```

The answer is everything your store's own checkout would show for this cart, worked out for that country:

| Field | What to do with it |
| --- | --- |
| `methods` | The ways to pay, in your order. Show them as choices. Send the chosen one's `provider`, `method` and `option` (and `manualMethodId` for a manual method). `adjustmentMinor` is how much the subtotal changes with it. |
| `unavailable` | Why some ways to pay are not offered for this total. Show the reasons under the list. |
| `free` | Nothing to pay: start without a method. |
| `fields` | `name`, `country`, `phone` and `address`, each `required`, `optional` or `hidden`. |
| `questions` | Your checkout questions and the products'. Send each answer as `customFields[id]`. A `hidden` one is answered with its `defaultValue`. |
| `consent` | The terms box. `{link}` in `text` is a link to `linkUrl`. Send `consent: true`. |
| `marketingOptIn` | The newsletter box, when you turned it on. |
| `withdrawalWaiver` | Buyers in the EU, the EEA and the UK tick this sentence before a paid order. Send `withdrawalWaiver: true`. |
| `emailVerification` | `required` when a code is always asked for this cart, `possible` when a fraud check may still ask. |
| `codes` | Whether to show the discount code and gift card fields. Apply codes to the cart with `applyCode`. |
| `tips` | Tip presets for this cart, when you take tips. Send `tipMinor`. |
| `seller` | Who sells, to show before the buyer pays. |
| `totals`, `testMode` | The cart's totals, and whether payments are simulated. |

Read the options again when the buyer changes country: the ways to pay, the tax and the waiver can change with it.

### Start the checkout

```ts
const session = await shop.cart.checkout(cartId, {
  email: 'robin@example.com', name: 'Robin', country: 'DE',
  provider: 'shopfast_pay', method: 'card', consent: true, withdrawalWaiver: true,
  customFields: { discord_username: 'robin#0001' },
  returnUrl: 'https://shop.example.com/checkout/complete',
  cancelUrl: 'https://shop.example.com/cart',
});
// { sessionId, payUrl, accessToken, orderUrl, paid, test }
redirect(session.payUrl);
```

Only `email` is required by the API. Your settings decide the rest, and the answer says what is missing.

- `payUrl` opens the payment step on your store: the test payment, the card form, the pay page or the provider's own
  page. Open it in the buyer's browser. It brings the order access with it.
- `paid: true` means nothing was left to pay, for example a gift card or a 100% discount. There is no `payUrl`: show
  the order right away.
- Keep `accessToken` with the order, in an httpOnly cookie for example. It reads the order and its keys.

### When the start says no

| Answer | What happened | What to do |
| --- | --- | --- |
| `409 email-verification-required` | A 6-digit code was emailed to the buyer. | Ask for it, then send the same body again with `verificationCode`. |
| `422 invalid-code` | The code is wrong or expired. | Ask again. A new start without the code sends a new one. |
| `422 validation` | A field is missing or wrong. `fields[].path` names it: `body.email`, `body.customFields.discord_username`, `body.withdrawalWaiver`. | Show the message next to that field. |
| `409 payments-not-set-up` | The store cannot take payments yet. | Say so calmly, without a pay button. |
| `503 payment-provider-failed` | The provider failed before its page opened. Nothing was charged. | Offer the next way to pay. |
| `403 checkout-blocked` | A fraud rule stopped the checkout. | Show `detail`. |
| `429 rate-limited` | Too many tries. | Wait `Retry-After` seconds. |

With the SDK, `error.is('email-verification-required')` and `error.fieldError('email')` read them.

### Back on your site

After paying, the buyer lands on your `returnUrl` with `session` and `access` added:

```
https://shop.example.com/checkout/complete?session=cs_…&access=…
```

Keep the access token, take it out of the address bar, and show the order:

```ts
const order = await shop.orders.get(sessionId, access);
// status: "open" until the payment is confirmed (crypto and bank transfers can take a while), then "paid"
// with keys and downloads
```

A buyer who backs out of paying lands on your `cancelUrl` with `session` added. Their cart is still there. The store
sends the buyer back once, right after paying. The link in the receipt email still opens the order on your store.

## 2. Embedded checkout

The store's checkout opens in an overlay on your page, with the same cart. Ask for a checkout link signed for your
page's origin:

```ts
const link = await shop.cart.checkoutLink(cartId, { embedOrigin: 'https://shop.example.com' });
// link.embed: { scriptUrl, store, cart }
```

Then open it in the browser:

```html
<script src="https://your-store.shopfa.st/_sf/checkout.js" async></script>
<script>
  ShopFastCheckout.open({
    store: embed.store,
    cart: embed.cart,
    onSuccess: ({ sessionId, orderNumber, accessToken }) => {
      location.href = `/orders/${sessionId}?access=${accessToken}`;
    },
  });
</script>
```

`embed.cart` works for two hours, on that origin only. The overlay posts its events only to your page's origin:
`shopfast:checkout:ready`, `success` (with `sessionId`, `orderNumber`, `accessToken`, `totalMinor` and `currency`),
`close` and `error`. See [Embeds](https://shopfa.st/docs/guides/embeds) for buy buttons that open the checkout without the API.

## 3. Hosted checkout

Ask for a checkout link and send the buyer there:

```ts
const link = await shop.cart.checkoutLink(cartId, {
  returnUrl: 'https://shop.example.com/checkout/complete',
  cancelUrl: 'https://shop.example.com/cart',
});
redirect(link.checkoutUrl);
```

The link opens your store's checkout with that cart. It is signed, works only for that cart and store, and expires
after two hours. With `returnUrl`, the buyer comes back to your site after paying, as in your own checkout. Without it,
they stay on your store's order page.

## The return pages live on the cart

`returnUrl` and `cancelUrl` are kept on the cart, so every checkout of that cart uses them. You can set them once when
you create the cart:

```ts
await shop.cart.update(cartId, { returnUrl: 'https://shop.example.com/checkout/complete', cancelUrl: 'https://shop.example.com/cart' });
```

Send `null` to clear them. The cart answer shows them as `returnUrl` and `cancelUrl`.

## Testing

While your store is in test mode (`testMode` in the options), payments are simulated: the payment step offers test
cards, and nothing is charged. Orders are marked test. Agents use the hosted way: the shop MCP server's
`get_checkout_link` tool.
