GuidesHeadless checkout
Sell from your own site
with a headless checkout.
Keep your own product pages and cart, and let anyshop take the payment, deliver the keys and send the webhooks. Your server starts a checkout session for the buyer, sends them to its url, and they come back to your site once they have paid.
Before you start
- An API key with the Checkouts scope. Dashboard, Developers, API keys, New API key: Checkouts, read and write. A test key (
as_test_) starts test checkouts only; a live key starts live ones once anyshop has reviewed your store. - Your site on your allowed sites. Dashboard, Storefront, Buy buttons, Sites allowed to open checkout: add your site's address (
yoursite.com). The buyer is only ever sent back to an address on this list, over https, or to your own anyshop storefront (https://yourstore.anyshop.io). Each site is exact:shop.yoursite.comis its own entry, and no other anyshop address can be one. - A way to pay, connected in Payments: Stripe (cards on your own Stripe account; some product types may not be available on cards), PayPal (your own PayPal app), Bitcoin and Litecoin to your own wallet, or NOWPayments (live mode only). In test mode the test rail works too.
- A webhook endpoint, recommended: Developers, Webhooks, for
order.paid.
Start a checkouttest key
Send the product option, the quantity, the buyer's email and where the buyer goes back to. Send an Idempotency-Key too (your cart's ID, for example): a retry with the same key answers the same session, as it is now, instead of holding stock twice.
curl https://api.anyshop.io/v1/checkout_sessions \
-H "Authorization: Bearer $ANYSHOP_TEST_KEY" \
-H "Idempotency-Key: cart-4411" \
-H "Content-Type: application/json" \
-d '{
"variant": "var_01m3rs9d2f4k6m8p0r2t4v6x8z",
"quantity": 1,
"buyer_email": "ada@example.com",
"payment_method": "paypal",
"success_url": "https://yoursite.com/thanks",
"cancel_url": "https://yoursite.com/cart",
"metadata": { "cart": "4411" }
}'{
"object": "checkout_session",
"id": "cs_01m3rs9d2f4k6m8p0r2t4v6x8z",
"status": "open",
"url": "https://pay.anyshop.io/session/cs_01m3rs9d2f4k6m8p0r2t4v6x8z#Xk3Fq9...",
"amount": 4900,
"currency": "USD",
"expires_at": "2026-10-04T12:15:00.000Z",
"order": null,
"metadata": { "cart": "4411" }
}| Field | |
|---|---|
variant | The product option to sell; product instead, for a product with a single option. |
quantity | Default 1, within the product's per-order limit. |
buyer_email | Where anyshop delivers the order, in your store's name. |
coupon | Optional: one of your coupon codes. |
payment_method | Optional: paypal, btc, ltc, nowpayments (live mode only) or stripe (cards, on the session's page; some product types may not be available on cards), as your store offers them. It starts when the buyer opens url; without it, the buyer chooses on anyshop's payment page. |
success_url, cancel_url | https addresses on your allowed sites, or your own anyshop storefront. anyshop adds ?checkout={id}. |
metadata | Optional: up to 20 keys (letters, digits, _, . or -, not starting with __), string values of up to 500 characters, echoed in the order's webhook events. Your own references, not the buyer's personal data. |
- The amount is always yours: it comes from your prices, the quantity and the coupon. The API never takes a price.
- The keys are held for the buyer until
expires_at(15 minutes; 30 while PayPal waits on the buyer). Unpaid, they go back to stock. - `url` is the buyer's alone. It holds the session's own secret after the
#: redirect this buyer's browser to it straight away (a redirect keeps it), rather than emailing it, since a mail scanner that runs scripts could open it before the buyer. Do not log it. The session's ID is not a secret and opens nothing.
Send the buyer to url
Redirect the buyer's browser to url, on anyshop's pay host. Opening it changes nothing by itself: a link preview, a mail scanner or a prefetch that fetches it starts no payment. The page, in your store's name, takes the secret out of the address and sends it from the buyer's browser, and that request is where anyshop checks the buyer: your blocklist against their network and country, since your server only gave an email.
Then the payment starts, and the request that starts it records the dispute evidence (the buyer's IP, kept encrypted, their country and device), as on the hosted checkout: on to PayPal's own page when you named PayPal, or to anyshop's payment page for a crypto invoice or the choice of how to pay. A buyer your blocklist stops is sent back to your cancel_url, the session ends and the keys go back, and nothing reaches PayPal. The page needs JavaScript, and says so without it.
PayPal sends the buyer back with a one-time code of that payment, never the secret, so a buyer whom PayPal's app returns to another browser still gets in, once.
app.post("/buy", async (req, res) => {
const session = await startCheckout(req.cart); // POST /v1/checkout_sessions, above
log.info("checkout started", { id: session.id }); // the ID, never the url
res.set("Referrer-Policy", "no-referrer");
res.redirect(303, session.url);
});The buyer comes back
Paid, the buyer lands on success_url?checkout=cs_.... Leaving without paying, they land on cancel_url?checkout=cs_..., and the keys held for them go back to stock. Do not trust the return alone: confirm on your server, with GET /v1/checkout_sessions/{id} or the order.paid webhook.
curl https://api.anyshop.io/v1/checkout_sessions/cs_01m3rs9d2f4k6m8p0r2t4v6x8z \
-H "Authorization: Bearer $ANYSHOP_TEST_KEY"The session answers open, paid (with order, the order's ID), expired or canceled. The order.paid event carries checkout_session: { id, metadata }; verify its signature and handle each webhook-id once (see React to orders).
The keys reach the buyer by anyshop's delivery email, sent in your store's name with your support email as the reply-to, and on their order page. To show a key on your own page, read it with a key that has the audited stock:reveal scope: the Checkouts scope never returns a key.
Canceltest key
POST /v1/checkout_sessions/{id}/cancel ends an unpaid session at once: the payment waiting on the buyer is cancelled and the keys go back to stock. A paid session is refunded instead, with POST /v1/orders/{id}/refund. The buyer can cancel too, on anyshop's payment page.
curl -X POST \
https://api.anyshop.io/v1/checkout_sessions/cs_01m3rs9d2f4k6m8p0r2t4v6x8z/cancel \
-H "Authorization: Bearer $ANYSHOP_TEST_KEY"A buyer who chose PayPal can go back and pay another way. A crypto invoice cannot be undone that way, since coins may already be on their way to its address: the buyer cancels instead, and your server starts a new session.
What the payment lists
Every payment lists the products bought, by name and quantity, so buyers recognize the charge: on the hosted checkout, buy buttons and headless checkouts, for every payment method. The payment goes to your own account, under your own business name.
Limits and errors
- 30 sessions a minute per API key, and 100 open at once per store and mode.
- Open sessions hold at most half of a product's available keys (or one order's maximum, when that is more): the rest stays for your storefront and buy buttons.
- The buyer's email: 8 checkouts in 10 minutes, as on the hosted checkout.
- When your server keeps hitting the last two, your store's owners and admins get an email, at most one a day: a key starting checkouts you did not expect may be in the wrong hands.
- The hosted checkout's rules apply unchanged: your blocklist, product availability, coupon limits, and a suspended store starts no checkout and takes no payment.
| Status | error.code | |
|---|---|---|
| 400 | invalid_body, invalid | A field is missing or wrong; a return address off your allowed sites; a metadata key starting with __. |
| 400 | invalid_json | Not JSON, or a __proto__ key anywhere. |
| 400 | invalid_email, invalid_coupon, invalid_payment_method | |
| 403 | missing_scope | The key has no Checkouts scope. |
| 403 | buyer_blocked | The buyer's email is on your blocklist. Their network and country are checked when they open url. |
| 409 | sold_out, unavailable, no_payment_methods | |
| 409 | idempotency_key_reused | The same Idempotency-Key with another body. |
| 429 | rate_limited, too_many_open_sessions, too_much_stock_held | Wait for open sessions to end, or cancel them, then retry. |