# Checkout and orders

URL: /docs/concepts/checkout

Set up payment with Stripe or PayPal, place the order exactly once, give guests access to their order, and follow an order through its states.

Checkout turns a ready cart into an order. Your storefront sets up the payment with the store's own Stripe or PayPal account, the shopper pays in the provider's own form, and one call places the order. With an `Idempotency-Key`, that call can be retried safely and still places exactly one order.

## Before you start [#before-you-start]

A cart is ready for checkout when it has at least one line, a billing address, and, if anything in it ships, a shipping address and a chosen shipping option. See [carts](/docs/concepts/carts). A cart that is not ready is refused with `422 VALIDATION_FAILED`.

## The steps [#the-steps]

<Steps>
<Step>

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

`GET /v1/checkout/config` describes the store's payment set-up. It needs no cart.

```json title="Response"
{
  "data": {
    "gateways": ["stripe", "paypal"],
    "currency": "GBP",
    "country": "GB",
    "gateway_mode": "live",
    "stripe_publishable_key": "pk_live_…",
    "stripe_capture_method": "manual",
    "paypal_client_id": "AX…"
  }
}
```

| Field | Use it to |
|---|---|
| `gateways` | Decide which ways to pay to offer |
| `gateway_mode` | `test` or `live`: show a "test payments" banner in test mode, and load PayPal's script for the same mode |
| `stripe_publishable_key` | Initialise Stripe.js |
| `stripe_capture_method` | `manual` or `automatic`. Pass it as Elements' `captureMethod` if you mount the Payment Element before creating the PaymentIntent. |
| `paypal_client_id` | The `client-id` of PayPal's JavaScript SDK |

</Step>
<Step>

### Set up the payment [#set-up-the-payment]

**Stripe.** `POST /v1/checkout/payment-intent` (with `X-Cart-Token`) creates a PaymentIntent for the cart's current total and returns its `client_secret` and the `publishable_key`. Mount Stripe's Payment Element with them, and let the shopper confirm the payment in the browser.

Each cart has one open PaymentIntent. Calling the endpoint again after the cart changes reuses it with the new total when that is possible, so call it again whenever the total changes before payment.

**PayPal.** `POST /v1/checkout/paypal/order` (with `X-Cart-Token`) creates a PayPal order and returns its `paypal_order_id`. Return it from the PayPal buttons' `createOrder` callback.

</Step>
<Step>

### Place the order [#place-the-order]

For Stripe, once the shopper has confirmed the payment, call `POST /v1/checkout/complete` with the PaymentIntent's id. For PayPal, once the shopper approves, call `POST /v1/checkout/paypal/capture` with the PayPal order id. Both place the order and return `201 Created`. Send an `Idempotency-Key` on both.

</Step>
</Steps>

```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: 9b2c6f0e-4a7d-4c1e-9f3b-2d8a5e7c1b04" \
  -H "Content-Type: application/json" \
  -d '{ "payment": { "gateway": "stripe", "payment_intent": "pi_3QxYz…" } }'
```

```ts tab="TypeScript"
// Make the key once per "Pay" click and keep it for every retry of that click.
const idempotencyKey = crypto.randomUUID()

const res = await fetch('https://api.elaraone.app/api/v1/checkout/complete', {
  method: 'POST',
  headers: {
    'X-Tenant-Key': process.env.ELARAONE_TENANT_KEY!,
    'X-Tenant-Secret': process.env.ELARAONE_TENANT_SECRET!,
    'X-Cart-Token': cartToken,
    'Idempotency-Key': idempotencyKey,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ payment: { gateway: 'stripe', payment_intent: paymentIntentId } }),
})

if (res.status === 201) {
  const { data: order, meta } = await res.json()
  // meta.order_access_token is present for a guest order.
}
```

```php tab="PHP"
$response = Http::withHeaders([
    'X-Tenant-Key' => env('ELARAONE_TENANT_KEY'),
    'X-Tenant-Secret' => env('ELARAONE_TENANT_SECRET'),
    'X-Cart-Token' => $cartToken,
    'Idempotency-Key' => $idempotencyKey,
])->post('https://api.elaraone.app/api/v1/checkout/complete', [
    'payment' => ['gateway' => 'stripe', 'payment_intent' => $paymentIntentId],
]);
```

For PayPal, the body of `POST /v1/checkout/paypal/capture` is `{ "paypal_order_id": "5O190127TN364715T" }`. `POST /v1/checkout/complete` also accepts `{ "payment": { "gateway": "paypal", "paypal_order_id": "…" } }`.

## The order [#the-order]

```json title="201 Created (trimmed)"
{
  "data": {
    "id": 3051,
    "reference": "00003051",
    "status": "payment-received",
    "status_label": "Payment Received",
    "placed_at": "2026-10-24T10:12:31+00:00",
    "dispatched_at": null,
    "delivered_at": null,
    "cancelled_at": null,
    "lines": [
      {
        "id": 9120,
        "type": "physical",
        "description": "Linen cushion",
        "identifier": "CUSH-LIN-NAT",
        "option": null,
        "quantity": 2,
        "unit_price": { "amount": 3400, "currency": "GBP", "formatted": "£34.00" },
        "total": { "amount": 6800, "currency": "GBP", "formatted": "£68.00" }
      }
    ],
    "shipping_method": { "identifier": "standard", "name": "Standard delivery", "kind": "delivery", "collection_location": null },
    "shipments": [],
    "sub_total": { "amount": 6800, "currency": "GBP", "formatted": "£68.00" },
    "shipping_total": { "amount": 495, "currency": "GBP", "formatted": "£4.95" },
    "tax_total": { "amount": 1216, "currency": "GBP", "formatted": "£12.16" },
    "total": { "amount": 7295, "currency": "GBP", "formatted": "£72.95" }
  },
  "meta": {
    "order_access_token": "eyJ…",
    "order_access_token_expires_at": "2026-10-24T11:12:31+00:00"
  }
}
```

After a successful checkout the cart token stops working (`404 CART_NOT_FOUND`). Create a new cart for the shopper's next order. The store's order confirmation email is sent automatically, if the store has it switched on.

## Exactly once [#exactly-once]

Send an `Idempotency-Key` on `POST /v1/checkout/complete` and `POST /v1/checkout/paypal/capture`. If the response is lost (a timeout, a dropped connection, a double click), retry with the same key and the same body:

- if the first request placed the order, you get the same `201` back, with `Idempotent-Replayed: true`, and no second order is created;
- if the first request is still running, you get `409 IDEMPOTENCY_IN_PROGRESS`; wait a moment and retry with the same key;
- if the first request failed, the retry runs again, so a shopper who fixes their card can pay with the same key.

A PayPal capture is recorded before the order is placed, so a retry never captures the payment twice. See [idempotency keys](/docs/concepts/idempotency).

## What can go wrong [#what-can-go-wrong]

| Status | `error.code` | What happened | What to do |
|---|---|---|---|
| 402 | `PAYMENT_FAILED` | The payment was declined or could not be taken. The cart and stock are unchanged. | Show the reason from `details.decline_code` and let the shopper try again. See [payments](/docs/concepts/payments). |
| 409 | `SHIPPING_OPTION_CHANGED` | The chosen shipping option is no longer offered, or its price changed since it was chosen. Nothing was charged. | Show `details.options` (the options on offer now), let the shopper choose again with `PUT /v1/cart/shipping-option`, then set up the payment again. |
| 409 | `PROMOTIONS_CHANGED` | The basket's discounts changed since the payment was set up (an offer ended, ran out of uses, or a first-order offer no longer applies). Nothing was charged. | Show the new total from `details.cart` and set up the payment again. `details.reason` is `changed`, `used_up`, `limit_reached` or `first_order`. |
| 409 | `PAYMENT_REFUNDED` | The payment was taken but the order could no longer be placed (stock gone, cart changed), so the payment was refunded. | Tell the shopper, and start a new checkout. |
| 409 | `PAYMENT_REFUND_PENDING` | As above, but the automatic refund is still being retried. The store has been told. | Tell the shopper their payment will be refunded. Do not ask them to pay again for this cart. |
| 409 | `PAYMENT_ALREADY_USED` | This payment already paid for another checkout. | Set up a new payment. |
| 409 | `PAYMENT_SETUP_IN_PROGRESS` | Another request is setting up this cart's PaymentIntent. | Retry shortly; you get the same intent. |
| 422 | `VALIDATION_FAILED` | The cart is not ready, or an item no longer has enough stock. | Show `details` and send the shopper back to fix the cart. |
| 422 | `PAYMENT_GATEWAY_UNAVAILABLE` | The gateway is unknown or not set up for this store. | Only offer gateways listed in `checkout/config`. |

`SHIPPING_OPTION_CHANGED` can also come back from `POST /v1/checkout/payment-intent` and `POST /v1/checkout/paypal/order`: the engine checks the shipping price before any payment is set up.

## Order states [#order-states]

| `status` | Label | Moves to |
|---|---|---|
| `awaiting-payment` | Awaiting Payment | `payment-offline`, `payment-received`, `cancelled` |
| `payment-offline` | Payment Offline | `payment-received`, `cancelled` |
| `payment-received` | Payment Received | `dispatched`, `cancelled` |
| `dispatched` | Dispatched | `delivered` |
| `delivered` | Delivered | Nothing (final) |
| `cancelled` | Cancelled | Nothing (final) |

Branch on `status`, which never changes meaning, and show `status_label`, which is the store's own wording. `dispatched_at`, `delivered_at` and `cancelled_at` are filled in when the order reaches that state, and `shipments` lists the parcels with their carrier and tracking details once the order is dispatched. A cancelled order's stock is put back.

## Guest orders [#guest-orders]

A guest has no account, so a guest order comes with a short-lived access token in `meta.order_access_token`, and `meta.order_access_token_expires_at` says when it stops working. Use it straight away to show the confirmation page:

```bash
curl https://api.elaraone.app/api/v1/orders/guest/00003051 \
  -H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \
  -H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET" \
  -H "X-Order-Access-Token: $ORDER_ACCESS_TOKEN"
```

To let a guest look up an order later, ask for the order reference and the email used at checkout, and call `POST /v1/orders/lookup`:

```json title="POST /v1/orders/lookup"
{ "reference": "00003051", "email": "alan@example.com" }
```

It returns the order and a fresh `meta.order_access_token`. A reference and email that do not match return `404`. Lookups share the sign-in rate limit (10 a minute per store and IP), because a reference and email together act like a password.

A missing, tampered or expired access token returns `403 ORDER_ACCESS_TOKEN_INVALID`. `details.reason` is `expired` (ask for the email again) or `invalid`.

Signed-in customers see their orders with `GET /v1/orders` and `GET /v1/orders/{reference}`. See [customers](/docs/concepts/customers).

<Cards>
  <Card title="Payments" href="/docs/concepts/payments" description="Stripe, PayPal, test mode and decline codes." />
  <Card title="Errors" href="/docs/concepts/errors" description="The error envelope and every code." />
</Cards>
