# Orders

An order is the first state-changing commerce step. It rechecks the quote, price and stock, debits
the wallet once and creates a purchase with child orders. The whole request is atomic: a failure
creates no purchase and no debit.

```http
POST /v1/orders
Idempotency-Key: order-2026-001
```

Required scope: `orders:write`. The account must have completed review
(`onboardingStatus: active`); otherwise the API returns `403 account_not_active`.

## Two request shapes

### Quote-based

```json
{
  "reference": "booking-123",
  "quoteId": "22222222-2222-4222-8222-222222222222"
}
```

### One-call

```json
{
  "reference": "booking-124",
  "currency": "USD",
  "expectedTotalMinor": 1618,
  "items": [
    { "offerId": "11111111-1111-4111-8111-111111111111", "quantity": 2 }
  ]
}
```

The one-call shape creates an internal quote, compares `expectedTotalMinor` with the fresh
total and then purchases. It does not skip the quote; it hides the extra round trip. If the total
differs, the API returns `409 price_changed` with the new `totalMinor` and the fresh quote,
and does not charge.

`expectedTotalMinor` is optional. Use it for any autonomous caller so a price move cannot
silently exceed the mandate.

## Rules

| Field | Rule |
| --- | --- |
| `reference` | Required. Stable, human-readable id, unique per partner and environment. |
| `quoteId` | Required for the quote shape. Must be an open, unexpired quote owned by the partner. |
| `currency` | Required for the one-call shape. `USD`, `EUR` or `GBP`. |
| `items` | Required for the one-call shape. At least one line, maximum 200 lines and 5,000 units. |
| `Idempotency-Key` | Required header. Unique per logical order. Reuse it for retries. |

A successful order consumes the quote and changes its status to `accepted`.

:::caution[Reference and idempotency are different]
`reference` is your business identifier. `Idempotency-Key` is the transport replay key.
Keep both stable for the same logical order. Do not reuse a `reference` for a different
purchase, and do not generate a new idempotency key to "retry" an uncertain order.
:::

## Response

```json
{
  "purchaseId": "33333333-3333-4333-8333-333333333333",
  "status": "processing",
  "amountMinor": 1618,
  "currency": "USD",
  "orderIds": ["44444444-4444-4444-8444-444444444444"],
  "balanceAfterMinor": 8382
}
```

| Field | Meaning |
| --- | --- |
| `purchaseId` | Use this for status, redemption and support. |
| `status` | `processing`, `completed` or `failed`. |
| `amountMinor` | Debited total in minor units. |
| `currency` | Wallet currency used. |
| `orderIds` | Child orders in the purchase. |
| `balanceAfterMinor` | Available balance after the debit. On an idempotent replay no new debit occurs, so this can be `0`. |

A `processing` status means at least one unit is awaiting upstream fulfilment. The wallet is
already debited. Poll `GET /v1/orders/{purchaseId}` until the status is terminal.

## Status model

```text
processing ──> completed
     │
     └────────> failed

completed      all child orders fulfilled
processing     at least one child order is paid or awaiting fulfilment
failed         at least one child order is fulfillment_failed or replaced
partially_fulfilled  target contract, not live
cancelled      target contract, no cancel endpoint live
```

Child order statuses are `paid`, `fulfilled`, `fulfillment_failed`, `cancelled`
and `refunded`. A purchase status is derived from its children. `partially_fulfilled` is
part of the target contract but is not returned by the current service.

## Read orders

```http
GET /v1/orders
GET /v1/orders/{purchaseId}
```

`GET /v1/orders` returns the latest 50 purchase operations for the partner. `GET
/v1/orders/{purchaseId}` returns the purchase and its child orders. Both require
`orders:read` and share the order-read limit.

The response currently includes a child order `store` field for internal compatibility.
Do not display, store or rely on it. The public contract is that upstream store and product
identifiers are not exposed; treat any internal field as non-contractual and report it if it
appears in a customer-facing surface. This documentation does not reproduce its values.

## Delivery timing

`GET /v1/delivery` measures completed deliveries over the last 30 days. The clock starts at
payment, or at order creation for wallet orders, and stops when the eSIM is issued and attached to
a fulfilled order. The response reports `sampleSize`, `medianMs`, `p90Ms`, `under10sPercent`,
`fastestMs` and `slowestMs`. A small sample stays a small sample; do not turn it into a public
claim without enough volume.

`GET /v1/orders/{purchaseId}` also returns a `delivery` object with `status`, `startedAt`,
`completedAt` and `durationMs` for that purchase.

## Redemption

```http
POST /v1/orders/{purchaseId}/redeem
Idempotency-Key: redeem-2026-001
```

```json
{ "format": "both" }
```

| Format | Returns |
| --- | --- |
| `qr` | QR SVG only. |
| `lpa` | LPA text only. |
| `both` | LPA text and QR SVG. Default. |

Maximum 20 eSIMs per batch. A retry with the same idempotency key returns the same fixed batch,
not the next 20. The response contains `batchId`, `format`, `items`
(`orderItemId`, `esimCodeId`, `iccid`, `lpa`) and `qrSvgs` when requested.

```json
{
  "batchId": "55555555-5555-4555-8555-555555555555",
  "format": "both",
  "items": [
    {
      "orderItemId": "66666666-6666-4666-8666-666666666666",
      "esimCodeId": "77777777-7777-4777-8777-777777777777",
      "iccid": "8985...REDACTED",
      "lpa": "LPA:1$REDACTED"
    }
  ],
  "qrSvgs": ["<svg>REDACTED</svg>"]
}
```

`GET /v1/orders/{purchaseId}/redeem` is **Planned**. Use the POST form.

:::danger[Redemption output is bearer secret]
LPA text and QR SVG can install and use the eSIM. Deliver them only into the partner's entitled
secure channel. Do not log, cache, email, forward or show them to a model. Every reveal is audited.
:::

## Payment failures

The current service returns `402 insufficient_funds` when available wallet credit is below the
total. The target contract adds `402 payment_required` with a `funding_url` and an
`approval_required` state for orders above a threshold. Neither new shape is live in every
deployment yet. Handle both if present, and never ask an agent for card details.

## Errors

| Status | Code | Meaning |
| ---: | --- | --- |
| 400 | `validation_error` | Bad body, missing reference, bad currency or quantity. |
| 402 | `insufficient_funds` | Wallet balance too low. No order or debit. |
| 403 | `account_not_active` | Onboarding review incomplete. |
| 403 | `forbidden` | Key lacks `orders:write`. |
| 404 | `quote_not_found` | Quote absent, accepted or not owned. |
| 409 | `out_of_stock` | No sellable stock. |
| 409 | `price_changed` | Quote or expected total no longer matches. |
| 409 | `quote_expired` | Quote older than 15 minutes. |
| 409 | `not_redeemable` | Purchase not fulfilled or no credentials. |
| 429 | `rate_limited` / `quota_exceeded` | Order or redeem limit reached. No order or batch created. |

## Related

- [Quotes](/docs/quotes) for the pricing step.
- [Wallet](/docs/wallet) for the debit source.
- [Webhooks](/docs/webhooks) for `order.completed` and `order.processing`.
- [Approval flow](/docs/approval-flow) for threshold decisions.
