Checkout with Stripe
Take a card payment end to end, from cart to placed order, with the Stripe Payment Element and exactly-once order placement.
This guide walks through a full card checkout: build a cart, set the addresses and delivery, collect the card with Stripe's Payment Element in the browser, then place the order from your server. The money goes to the store's own Stripe account; card details never touch Elara One or your server.
Every call below except the Stripe.js part runs on your server with X-Tenant-Key and X-Tenant-Secret. The cart token travels as X-Cart-Token; keep it in an httpOnly cookie on your own domain. The TypeScript examples use this small helper:
export const ENGINE = 'https://api.elaraone.app/api/v1'
export function engineHeaders(): Record<string, string> {
return {
Accept: 'application/json',
'X-Tenant-Key': process.env.ELARAONE_TENANT_KEY!,
'X-Tenant-Secret': process.env.ELARAONE_TENANT_SECRET!,
}
}Create a cart
curl -X POST https://api.elaraone.app/api/v1/cart \
-H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \
-H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET"The answer is 201 with cart_token and the empty cart. For a signed-in shopper, send their Authorization: Bearer token too, so the order is linked to their account.
{
"data": {
"cart_token": "…",
"cart": { "id": 51, "currency": "GBP", "lines": [], "total": { "amount": 0, "currency": "GBP", "formatted": "£0.00" } }
}
}Add lines
Send either a variant_id or a sku, and a quantity. Add an Idempotency-Key so a retried request never adds the line twice. The answer is the whole cart.
curl -X POST https://api.elaraone.app/api/v1/cart/lines \
-H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \
-H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET" \
-H "X-Cart-Token: $CART_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "variant_id": 412, "quantity": 1 }'Set the addresses
PUT /v1/cart/addresses takes a shipping and a billing address, or billing_same_as_shipping: true. country_id comes from GET /v1/countries. The required fields are first_name, line_one, city, postcode and country_id.
Always send contact_email. Order emails go to it, and a guest finds their order again with it.
{
"shipping": {
"first_name": "Ada",
"last_name": "Lovelace",
"line_one": "12 Hearth Lane",
"city": "Bath",
"postcode": "BA1 1AA",
"country_id": 235,
"contact_email": "[email protected]",
"contact_phone": "07700 900123"
},
"billing_same_as_shipping": true
}Signed-in customers can send { "address_id": 42 } instead of the fields, using their saved addresses. See Customer accounts.
Choose delivery
GET /v1/cart/shipping-options lists exactly the options the cart can use. Each has an identifier, name, price, discounted_price (after any free-shipping promotion), and kind (delivery or collect).
{
"data": [
{
"identifier": "standard",
"name": "Standard delivery",
"description": "2 to 4 working days",
"price": { "amount": 495, "currency": "GBP", "formatted": "£4.95" },
"discounted_price": null,
"collect": false,
"kind": "delivery",
"collection_location": null
}
],
"meta": { "selected": null, "unavailable": [], "address_required_for_delivery": false }
}Then store the choice:
{ "identifier": "standard" }The engine remembers the price it quoted. If the rate changes before payment, checkout refuses with 409 SHIPPING_OPTION_CHANGED (see below) rather than charging a price the shopper never saw.
Read the checkout config
GET /v1/checkout/config describes the store's payment set-up. It holds only public values.
{
"data": {
"gateways": ["stripe"],
"currency": "GBP",
"country": "GB",
"gateway_mode": "live",
"stripe_publishable_key": "pk_live_…",
"stripe_capture_method": "manual",
"paypal_client_id": null
}
}gateway_modeistestorlive. Show a "test payments" banner when it istest.stripe_capture_methodismanualorautomatic. Withmanual, confirming the card in the browser only authorises it, and the engine captures the money when it places the order. If you create the Payment Element before the PaymentIntent (Stripe's deferred intent flow), pass this value as Elements'captureMethod.
Create the PaymentIntent
POST /v1/checkout/payment-intent creates (or reuses) the PaymentIntent for this cart and returns what Stripe.js needs. Neither value is a secret, so your server can pass both to the browser.
{
"data": {
"client_secret": "pi_…_secret_…",
"publishable_key": "pk_live_…"
}
}A second request for the same cart while one is being set up gets 409 PAYMENT_SETUP_IN_PROGRESS. Wait a moment and try again.
Confirm the card in the browser
Mount the Payment Element with @stripe/stripe-js, then confirm it when the shopper presses Place order.
import { loadStripe } from '@stripe/stripe-js'
const intent = await fetch('/api/checkout/payment-intent', { method: 'POST' }).then((r) => r.json())
const stripe = await loadStripe(intent.data.publishable_key)
const elements = stripe!.elements({ clientSecret: intent.data.client_secret })
elements.create('payment').mount('#payment-element')
let attemptKey: string | null = null
let confirmedIntentId: string | null = null
export async function placeOrder() {
if (!confirmedIntentId) {
const { error, paymentIntent } = await stripe!.confirmPayment({
elements,
redirect: 'if_required',
confirmParams: { return_url: `${location.origin}/checkout/return` },
})
if (error) return showError(error.message ?? 'Your card could not be charged.')
confirmedIntentId = paymentIntent.id
}
// One key per order attempt: a retry after a network error reuses it.
attemptKey ??= crypto.randomUUID()
const res = await fetch('/api/checkout/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': attemptKey },
body: JSON.stringify({ payment_intent: confirmedIntentId }),
})
// Handle the answer as described in "When checkout refuses".
}Payment methods that redirect (some banks' 3-D Secure pages, for example) come back to return_url with Stripe's payment_intent query parameter. Read it there and carry on with the next step.
Place the order from your server
Your server calls POST /v1/checkout/complete with the cart token, the PaymentIntent id and the attempt's Idempotency-Key. The engine checks stock, promotions, the delivery price and the total, then captures the payment and places the order.
curl -X POST https://api.elaraone.app/api/v1/checkout/complete \
-H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \
-H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET" \
-H "X-Cart-Token: $CART_TOKEN" \
-H "Idempotency-Key: 9b2c7a52-6f1e-4c1b-a0d4-2f7e8d1c3b90" \
-H "Content-Type: application/json" \
-d '{ "payment": { "gateway": "stripe", "payment_intent": "pi_3Q…" } }'Success is 201 with the order. A guest order also carries meta.order_access_token; keep it server-side.
{
"data": {
"id": 3307,
"reference": "00003307",
"status": "payment-received",
"status_label": "Payment Received",
"placed_at": "2026-10-24T10:15:02+00:00",
"lines": ["…"],
"sub_total": { "amount": 8900, "currency": "GBP", "formatted": "£89.00" },
"shipping_total": { "amount": 495, "currency": "GBP", "formatted": "£4.95" },
"total": { "amount": 9395, "currency": "GBP", "formatted": "£93.95" }
},
"meta": {
"order_access_token": "…",
"order_access_token_expires_at": "2026-10-24T11:15:02+00:00"
}
}Retrying with the same Idempotency-Key replays this exact answer with Idempotent-Replayed: true, so a double click or a lost response never places two orders. See Idempotency keys.
When checkout refuses
Branch on error.code, never on the message.
| Status | Code | What to do |
|---|---|---|
402 | PAYMENT_FAILED | The payment did not go through. Show a message from details.decline_code (below), or error.message when there is none. |
409 | SHIPPING_OPTION_CHANGED | The chosen delivery option changed price or is no longer offered. details.options lists what is available now. Show it, let the shopper choose, and PUT /v1/cart/shipping-option again. |
409 | PROMOTIONS_CHANGED | A discount changed or ran out. details.cart is the recalculated cart. Show it and ask the shopper to confirm. |
422 | VALIDATION_FAILED | The cart is not ready (for example, stock ran out or an address is missing). details says which field. |
409 | IDEMPOTENCY_IN_PROGRESS | The first request with this key is still running. Retry with the same key shortly. |
404 | CART_NOT_FOUND | The cart token no longer resolves. Start a new cart. |
Decline codes
A 402 PAYMENT_FAILED that the card issuer refused carries details.decline_code:
{
"error": {
"code": "PAYMENT_FAILED",
"message": "Your card was declined. Try another card or way to pay.",
"details": { "decline_code": "insufficient_funds" }
}
}decline_code | Meaning |
|---|---|
card_declined | Refused with no reason the shopper can act on. |
card_expired | The card has expired. |
insufficient_funds | Not enough funds or credit. |
incorrect_card_details | A wrong card number, security code or postcode. |
authentication_required | 3-D Secure was not completed. |
try_another_method | The provider refused this way of paying (PayPal). |
unknown | Any other reason. |
Treat a value you do not recognise as unknown; the list may grow. The engine never returns the provider's own text.
Building a new card form after a refusal
With stripe_capture_method set to manual, any definite refusal from checkout/complete (a 402, 409 or 422) means the engine has already released the hold on the card. Nothing was charged, and that PaymentIntent cannot be used again. Before the shopper tries again, call POST /v1/checkout/payment-intent for a fresh client secret, mount a new Payment Element, and use a new Idempotency-Key.
When checkout/complete gives no definite answer (a network error, a timeout or a 5xx), the hold is still there. Send the same PaymentIntent id again with the same key, without confirming the card a second time.
Never confirm the same PaymentIntent twice
Keep the confirmed PaymentIntent id until checkout/complete gives a definite answer. Confirming it again fails, because it is no longer waiting for payment.
Show the order to a guest
The confirmation page reads the order with the token from meta.order_access_token:
curl https://api.elaraone.app/api/v1/orders/guest/00003307 \
-H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \
-H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET" \
-H "X-Order-Access-Token: $ORDER_ACCESS_TOKEN"The token is short-lived (an hour by default; order_access_token_expires_at says when). A bad or expired token gets 403 ORDER_ACCESS_TOKEN_INVALID with details.reason set to invalid or expired. A guest who comes back later can find the order with POST /v1/orders/lookup and { "reference": "00003307", "email": "[email protected]" }, which returns the order and a new token. A wrong reference and a wrong email give the same 404 NOT_FOUND.
A signed-in customer gets no token: their bearer token already reaches GET /v1/orders/{reference}.
PayPal
The engine also takes PayPal through the store's own PayPal account. When gateways includes paypal, load PayPal's JS SDK with paypal_client_id, then:
createOrder: your server callsPOST /v1/checkout/paypal/orderwith the cart token and returnsdata.paypal_order_idto the buttons.onApprove: your server callsPOST /v1/checkout/paypal/capturewith{ "paypal_order_id": "…" }, the cart token and anIdempotency-Key. It answers201with the order, exactly likecheckout/complete.
On a 402 with try_another_method, reopen the PayPal buttons and capture the same PayPal order again. The starter storefronts ship Stripe card checkout only; PayPal buttons are something you add yourself.