Headless checkout
Your own checkout page, the embedded checkout or the hosted one, all from the same cart and back to your site.
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 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
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
const session = await shop.cart.checkout(cartId, {
email: '[email protected]', 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.
payUrlopens 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: truemeans nothing was left to pay, for example a gift card or a 100% discount. There is nopayUrl: show the order right away.- Keep
accessTokenwith 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:
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:
const link = await shop.cart.checkoutLink(cartId, { embedOrigin: 'https://shop.example.com' });
// link.embed: { scriptUrl, store, cart }
Then open it in the browser:
<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 for buy buttons that open the checkout without the API.
3. Hosted checkout
Ask for a checkout link and send the buyer there:
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:
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.