Skip to content
Elara One docs

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:

lib/engine.ts
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.

Response
{
  "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.

PUT /v1/cart/addresses
{
  "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).

Response (trimmed)
{
  "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:

PUT /v1/cart/shipping-option
{ "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.

Response
{
  "data": {
    "gateways": ["stripe"],
    "currency": "GBP",
    "country": "GB",
    "gateway_mode": "live",
    "stripe_publishable_key": "pk_live_…",
    "stripe_capture_method": "manual",
    "paypal_client_id": null
  }
}
  • gateway_mode is test or live. Show a "test payments" banner when it is test.
  • stripe_capture_method is manual or automatic. With manual, 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.

Response
{
  "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.

checkout.client.ts
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.

Response (trimmed)
{
  "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.

StatusCodeWhat to do
402PAYMENT_FAILEDThe payment did not go through. Show a message from details.decline_code (below), or error.message when there is none.
409SHIPPING_OPTION_CHANGEDThe 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.
409PROMOTIONS_CHANGEDA discount changed or ran out. details.cart is the recalculated cart. Show it and ask the shopper to confirm.
422VALIDATION_FAILEDThe cart is not ready (for example, stock ran out or an address is missing). details says which field.
409IDEMPOTENCY_IN_PROGRESSThe first request with this key is still running. Retry with the same key shortly.
404CART_NOT_FOUNDThe 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_codeMeaning
card_declinedRefused with no reason the shopper can act on.
card_expiredThe card has expired.
insufficient_fundsNot enough funds or credit.
incorrect_card_detailsA wrong card number, security code or postcode.
authentication_required3-D Secure was not completed.
try_another_methodThe provider refused this way of paying (PayPal).
unknownAny 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:

  1. createOrder: your server calls POST /v1/checkout/paypal/order with the cart token and returns data.paypal_order_id to the buttons.
  2. onApprove: your server calls POST /v1/checkout/paypal/capture with { "paypal_order_id": "…" }, the cart token and an Idempotency-Key. It answers 201 with the order, exactly like checkout/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.