# Checkout with Stripe

URL: /docs/guides/stripe-checkout

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:

```ts title="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!,
  }
}
```

<Steps>
<Step>

## Create a cart [#create-a-cart]

```bash tab="cURL"
curl -X POST https://api.elaraone.app/api/v1/cart \
  -H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \
  -H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET"
```

```ts tab="TypeScript"
const res = await fetch(`${ENGINE}/cart`, { method: 'POST', headers: engineHeaders() })
const { data } = await res.json()
// Store data.cart_token in an httpOnly cookie, never in localStorage.
```

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.

```json title="Response"
{
  "data": {
    "cart_token": "…",
    "cart": { "id": 51, "currency": "GBP", "lines": [], "total": { "amount": 0, "currency": "GBP", "formatted": "£0.00" } }
  }
}
```

</Step>
<Step>

## Add lines [#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.

```bash tab="cURL"
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 }'
```

```ts tab="TypeScript"
await fetch(`${ENGINE}/cart/lines`, {
  method: 'POST',
  headers: { ...engineHeaders(), 'X-Cart-Token': cartToken, 'Idempotency-Key': crypto.randomUUID(), 'Content-Type': 'application/json' },
  body: JSON.stringify({ sku: 'LAMP-OAK', quantity: 1 }),
})
```

</Step>
<Step>

## Set the addresses [#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.

```json title="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": "ada@example.com",
    "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](/docs/guides/customer-accounts#use-saved-addresses-at-checkout).

</Step>
<Step>

## Choose delivery [#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`).

```json title="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:

```json title="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.

</Step>
<Step>

## Read the checkout config [#read-the-checkout-config]

`GET /v1/checkout/config` describes the store's payment set-up. It holds only public values.

```json title="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`.

</Step>
<Step>

## Create the PaymentIntent [#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.

```json title="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.

</Step>
<Step>

## Confirm the card in the browser [#confirm-the-card-in-the-browser]

Mount the Payment Element with `@stripe/stripe-js`, then confirm it when the shopper presses **Place order**.

```ts title="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.

</Step>
<Step>

## Place the order from your server [#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.

```bash tab="cURL"
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…" } }'
```

```ts tab="TypeScript"
// app/api/checkout/complete/route.ts
import { cookies } from 'next/headers'

export async function POST(req: Request) {
  const { payment_intent } = await req.json()
  const jar = await cookies()
  const cartToken = jar.get('cart')?.value
  const key = req.headers.get('Idempotency-Key')
  if (!cartToken || !key || typeof payment_intent !== 'string') {
    return Response.json({ error: { code: 'BAD_REQUEST' } }, { status: 400 })
  }

  const res = await fetch(`${ENGINE}/checkout/complete`, {
    method: 'POST',
    headers: { ...engineHeaders(), 'X-Cart-Token': cartToken, 'Idempotency-Key': key, 'Content-Type': 'application/json' },
    body: JSON.stringify({ payment: { gateway: 'stripe', payment_intent } }),
    signal: AbortSignal.timeout(60_000), // capture can take longer than a normal call
  })
  const body = await res.json()

  if (res.status === 201) {
    jar.delete('cart') // the engine retires the cart token on success
    if (body.meta?.order_access_token) {
      jar.set('order_access', body.meta.order_access_token, { httpOnly: true, secure: true, sameSite: 'lax' })
    }
    return Response.json({ data: body.data }, { status: 201 })
  }
  return Response.json(body, { status: res.status })
}
```

Success is `201` with the order. A guest order also carries `meta.order_access_token`; keep it server-side.

```json title="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](/docs/concepts/idempotency).

</Step>
</Steps>

## When checkout refuses [#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 [#decline-codes]

A `402 PAYMENT_FAILED` that the card issuer refused carries `details.decline_code`:

```json
{
  "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 [#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.

<Callout type="warn" title="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.
</Callout>

## Show the order to a guest [#show-the-order-to-a-guest]

The confirmation page reads the order with the token from `meta.order_access_token`:

```bash
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": "ada@example.com" }`, 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 [#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.
