# Order Lifecycle

Understand the complete order flow from validation through fulfillment.

## Order Flow

Every order goes through two API steps: **validation** and **placement**.

<Mermaid chart={`graph LR
    A[Build Cart] --> B[Validate Order]
    B --> C{Errors?}
    C -->|Yes| A
    C -->|No| D[Place Order]
    D --> E[Track Status]`} />

:::note[Reading the diagram]
"Errors?" means `isValid: false` on the HTTP 200 `validateOrder` response — business/order problems never produce a 4xx from validate.
:::

## End-user client context

:::warning[Required on every order request]
Both order calls require a `client` object carrying the **end user's** originating IP and user agent. Requests without it are rejected with a `400 ProblemDetail` (`errorCode: CLIENT_CONTEXT_REQUIRED`).

**This is a breaking change for existing integrations.** The enforcement date is being communicated to each live partner directly; if you have not received one, contact your Gett integration lead before shipping.
:::

```jsonc
{
  "cart": { ... },
  "fulfillment": { ... },
  "client": {
    "ip": "203.0.113.7",                                    // the customer's address, not your server's
    "userAgent": "Mozilla/5.0 (X11; Linux x86_64) Chrome/128.0 Safari/537.36"
  }
}
```

Your order requests reach Gett server-to-server, so the connection we see belongs to your backend rather than to the person ordering. The commerce partners we forward orders to run anti-fraud checks that need the real customer's signals, so you have to forward them from the request your own front end received — the same request that produced the cart.

We reject rather than substitute a placeholder: a synthetic address forwarded to a partner counts against the integration in the traffic reviews they run, and it would do so silently.

Three named errors cover the field:

| `errorCode` | Cause |
|---|---|
| [`CLIENT_CONTEXT_REQUIRED`](/errors/CLIENT_CONTEXT_REQUIRED) | `client` absent, or `ip`/`userAgent` missing or blank |
| [`CLIENT_IP_INVALID`](/errors/CLIENT_IP_INVALID) | `ip` unparseable, or in a range no end user can originate from — private, loopback, link-local, CGNAT, reserved |
| [`CLIENT_USER_AGENT_INVALID`](/errors/CLIENT_USER_AGENT_INVALID) | `userAgent` longer than 512 characters |

If your front end sits behind a proxy or CDN, `client.ip` is the **first** address in the forwarded-for chain, not your own server's. `client` is declared on `OrderCore`, so it applies to `validate` and `place` alike — see [Schema inheritance](/distribution-partners/marketfront-api/conventions).

## Validation vs Placement

The API uses a **two-step model**: a soft pre-check (`validateOrder`) followed by the committing transaction (`placeOrder`). Understanding when each returns errors — and in what shape — is central to a correct integration.

### How each call behaves

**`POST /v1/marketfront/orders/validate`** is a dry run. It computes real server-side pricing and checks availability without writing anything. Think of it like Square's `CalculateOrder` or Stripe's `requirements.errors` — a preview you can show to the user before they commit.

- Always returns **HTTP 200** (or 401 for auth failures). There is no 4xx for business/order problems.
- On 200 the body is the full `Order` object with an `errors[]` array. When the order was priced, it also carries a computed `amounts` breakdown; when validate **refuses**, `amounts` is `null` rather than an echo of what you sent. A refusal means we did not price the order, and a zeroed or echoed block would be indistinguishable on the wire from a successful validate that priced the cart at nothing.
- The read-only boolean `isValid` on the order tells you whether placement is safe to proceed. When `isValid: false`, `errors[]` is non-empty and describes what is wrong. When `isValid: true` and `errors` is empty, the order passed all checks at that moment.
- Because validate is read-only it is naturally idempotent — no `Idempotency-Key` header is required.

**`POST /v1/marketfront/orders/place`** is the committing call. It re-validates, charges the customer, and submits the order to the restaurant.

- On success it returns **2xx** with the authoritative post-placement `Order`.
- On failure it returns **4xx** as an RFC 9457 `application/problem+json` `ProblemDetail`. The `400` response covers payment declines, store rejections, and missing idempotency keys; the top-level `errorCode` field (e.g. `PAYMENT_DECLINED`, `MISSING_IDEMPOTENCY_KEY`) names the overall failure reason.

### Decision table

| Situation | Endpoint | HTTP | Key fields | Partner action |
|-----------|----------|------|-----------|----------------|
| Validate clean | `validateOrder` | `200` | `isValid: true`, `errors: []`, `amounts` populated | Show pricing preview; proceed to place |
| Validate with problems | `validateOrder` | `200` | `isValid: false`, `errors[]` non-empty, `amounts: null` | Surface errors to user; do not call place |
| Unknown `errors[].code` value | `validateOrder` | `200` | unrecognized code in `errors[]` | Treat as `OTHER`; show a generic message |
| Place success | `placeOrder` | `2xx` | `Order` with final `amounts` | Confirm order to user; start tracking |
| Place hard failure | `placeOrder` | `4xx` | `ProblemDetail` + top-level `errorCode` + `errors[]` | Handle per `errorCode`; retry with same idempotency key only for `PAYMENT_FAILED` |
| Malformed request | Either | `400` / `422` | `ProblemDetail` | Fix request body before retrying |

:::tip{title="A clean validate does not guarantee place succeeds"}
Stock levels, prices, and payment authorization can change in the window between the two calls. Always inspect `errors` even on a 200 validate response, and always be prepared for `placeOrder` to return a 4xx even after a successful validate.
:::

### Unified OrderError shape

`OrderError` is the same object on both paths — `order.errors[]` on a validate 200 and `ProblemDetail.errors[]` on a place 4xx. You only need one error-rendering path in your client:

| Field | Type | Description |
|-------|------|-------------|
| `code` | string (open enum) | Machine-readable reason — see [Error codes](#error-codes) below |
| `message` | string | Curated, user-facing description |
| `pointer` | string \| null | [RFC 6901](https://www.rfc-editor.org/rfc/rfc6901) JSON Pointer to the offending element, e.g. `/cart/lineItems/0`. `null` when the failure comes back from a commerce partner that did not tell us which element caused it |

Note the distinction between `OrderError.code` (per-element, in `errors[]`) and the top-level `ProblemDetail.errorCode` (single string naming the overall place failure). Both can appear on a 4xx place response but they serve different purposes.

:::warning[`pointer` is now RFC 6901 everywhere — check your parsing]
A small number of `pointer` values previously shipped as **dotted, unrooted paths** — `amounts.total`,
`fulfillment.address`, `paymentId`, `environment` — which are not JSON Pointers and which a conformant
RFC 6901 library rejects. Every emitted pointer is now the rooted, slash-separated form:
`/amounts/total`, `/fulfillment/address`, `/paymentId`, `/environment`.

This brings the wire into line with what this page and all five OpenAPI documents have always
described, so a client built against the published contract is unaffected. **If you special-cased the
dotted values, or split on `.`, remove that handling.** Resolving `pointer` with any standard JSON
Pointer library is now correct for every value we emit.
:::

### Error codes

`errors[].code` is an **open enum** (`x-extensible-enum`). The list below is the current set, but it is **not exhaustive** — Gett may add new codes at any time as new failure modes are identified. Following the Zalando API extension convention: clients **must** provide default/fallback behaviour for unknown values (route to `OTHER`) and **must not** hard-fail or throw on an unrecognized code. Do not deserialize into a closed/strict enum type.

| Code | Meaning | Suggested action |
|------|---------|-----------------|
| `ITEM_UNAVAILABLE` | An item in the cart is no longer available | Remove the item and re-validate |
| `PAYMENT_FAILED` | The payment was declined and the processor did not tell us why | Ask the user for a different payment method |
| `STORE_CLOSED` | Store is not accepting orders right now | Re-discover stores or try a scheduled order |
| `DELIVERY_UNAVAILABLE` | Delivery is not available for this order | Offer pickup or a different store |
| `OUTSIDE_AVAILABILITY_WINDOW` | Requested scheduled time is outside the store's hours | Adjust `scheduledTime` or switch to ASAP |
| `MINIMUM_NOT_MET` | The **whole order** is below the store's order minimum | Add more items. Contrast [`MODIFIER_REQUIRED`](/errors/MODIFIER_REQUIRED), which is about one item |
| [`MODIFIER_REQUIRED`](/errors/MODIFIER_REQUIRED) | **One item** is missing a choice its menu requires (a modifier group below its `minimumAllowed`) | Resolve `pointer` to the line item and have the user pick the required option. Adding more items does not help |
| `ORDER_TOTAL_DIFFERENT` | Total changed since the order was last validated | Re-validate to get the updated `amounts` |
| `OTHER` | Catch-all for any reason not covered above, and the fallback for unrecognized codes | Show a generic error message |
| `INSUFFICIENT_FUNDS` | The card was declined for insufficient funds | Ask the user for a different payment method |
| `INCORRECT_CVC` | The card was declined because the security code did not match | Let the user re-enter the security code and retry the same card |
| `CARD_EXPIRED` | The card has expired, or expired while stored | Collect a new card |
| `CARD_LOST_OR_STOLEN` | The card cannot be used | Ask the user for a different payment method. Show a **generic** decline message — never tell a cardholder their card is flagged |
| `PROCESSING_ERROR` | A temporary fault at the processor, not a decision about the card | Retrying later with the same card may succeed |

The last five refine `PAYMENT_FAILED` rather than replacing it: you will still receive `PAYMENT_FAILED`
whenever a payment is declined without an attributable reason. `PROCESSING_ERROR` is the only member of
this group for which retrying the same card is sensible advice — the others are decisions about the card,
not transient faults.

## Order State & Status

Orders use two fields: **state** (lifecycle position) and **status** (granular progress).

### State

<Mermaid chart={`stateDiagram-v2
    [*] --> open
    open --> completed
    open --> cancelled`} />

### Status

Within the `open` state, status tracks progress:

<Mermaid chart={`stateDiagram-v2
    [*] --> pending
    pending --> confirmed
    confirmed --> preparing
    preparing --> ready
    ready --> inTransit: Delivery orders
    ready --> [*]: Pickup (completed)
    inTransit --> [*]: completed`} />

For complete details on state values, status values, and error codes, see the **[API Reference](/api/marketfront)**.

## Fulfillment

Every order carries a `fulfillment` object that is a **discriminated union** on the `mode` property. The two variants are `PICKUP` and `DELIVERY_BY_MERCHANT`.

### Common fields (all variants)

| Field | Required | Description |
|-------|----------|-------------|
| `mode` | Yes | Discriminator: `PICKUP` or `DELIVERY_BY_MERCHANT` |
| `scheduleType` | Yes | `ASAP` or `SCHEDULED` |
| `scheduledTime` | Conditional | UTC ISO-8601 date-time. **Required when `scheduleType` is `SCHEDULED`; must be null (or omitted) when `ASAP`.** |

### Delivery-only fields (`DELIVERY_BY_MERCHANT`)

| Field | Required | Description |
|-------|----------|-------------|
| `address` | Yes | Delivery destination. On a request, provide either an `addressId` reference or inline street fields. On a response, the fully resolved and geocoded address is returned. |
| `deliveryType` | Yes | `DOOR_TO_DOOR` or `LEAVE_AT_DOOR` |
| `instructions` | No | Free-text delivery instructions, max 500 characters |

### Examples

**PICKUP — ASAP**

```json
{
  "fulfillment": {
    "mode": "PICKUP",
    "scheduleType": "ASAP"
  }
}
```

**DELIVERY_BY_MERCHANT — scheduled**

```json
{
  "fulfillment": {
    "mode": "DELIVERY_BY_MERCHANT",
    "scheduleType": "SCHEDULED",
    "scheduledTime": "2025-09-15T19:30:00Z",
    "address": {
      "address1": "123 Main St",
      "address2": "Apt 4B",
      "city": "New York",
      "state": "NY",
      "postalCode": "10001"
    },
    "deliveryType": "DOOR_TO_DOOR",
    "instructions": "Leave at the front desk"
  }
}
```

:::note[Fulfillment vs store discovery]
The `fulfillment` object on an order (discriminated union above) is distinct from the `fulfillmentType` flat enum (`PICKUP` | `DELIVERY_BY_MERCHANT`) used on `DiscoverStoresRequest` to filter search results. They share the same values but serve different purposes — one describes how to fulfill a specific order, the other filters which stores to surface.
:::

## Idempotency

`POST /v1/marketfront/orders/place` **requires** an `Idempotency-Key` header. Calls without it are rejected with a `400 ProblemDetail` (`errorCode: MISSING_IDEMPOTENCY_KEY`).

:::tip{title="One rule: a key identifies an order attempt, not a retry"}
**If you received a response — success or failure — that attempt is finished. A new attempt needs a
new key.**

**If you received no response, the outcome is unknown. Replay the same key to find out what
happened before you decide anything.**

Nearly every case below follows from that one test — *did I get a response?* — so you rarely have to
branch on which error you got. There is exactly one exception, and it is explicit about itself:
`409 IDEMPOTENCY_KEY_IN_PROGRESS` means the attempt is **not** finished, so the first half of the
rule does not apply. It carries `retryable: true`; every other terminal response does not.
:::

- **Generate a fresh UUID v4 per order attempt.**
- **No response — timeout, dropped connection, `5xx`.** Replay the **same** key. If the order was in
  fact placed, you get the original response back instead of placing a second one. This is the case
  idempotency exists for, and using a fresh key here is what causes duplicate orders.
  The original response comes back even if the store's menu or status, or the payment method, has changed
  since that attempt: the key is checked before any of them.
- **A response arrived, and it failed** (HTTP `200` with `errors[]`). That attempt is complete and
  the outcome is cached for 24h, so replaying the same key returns the identical failure without
  re-contacting the processor — it can never become a success. A genuine re-attempt needs a **fresh**
  key. See [`PROCESSING_ERROR`](/errors/PROCESSING_ERROR) for the one failure where re-attempting is
  worthwhile, and for the charge risk that comes with it.
- **A response arrived saying the attempt is still running** (`409`,
  `errorCode: IDEMPOTENCY_KEY_IN_PROGRESS`, `errorCategory: idempotency`, `retryable: true`). An
  earlier request on this key is still in flight — most often your own client retried before the
  first call returned. Retry the **same** key, unchanged, until you get a terminal answer. This is
  the one response where reusing the key is correct: the in-flight attempt owns your order's
  outcome, and a fresh key here presents the same order to us as a new one.
  The response carries a `Retry-After` header giving the number of seconds to wait before that
  retry — honour it rather than retrying immediately, or you will simply queue behind the same
  in-flight attempt and collect another `409`.
- **Do not reuse a key with a different payload.** Same-key/different-body is rejected with `422`
  (`errorCode: IDEMPOTENCY_KEY_CONFLICT`, `errorCategory: idempotency`). The fingerprint is a hash of
  the canonicalized body, so key order and whitespace do not matter — any changed value does.
  A key is also scoped to the partner and end user that placed the order. The same key sent by a different partner or
  end user does not get the original response: it is refused with the same `422`, or earlier with `403` if its payment
  type is not allowed for that caller.

### Telling a replay from a fresh attempt

A replayed response carries **`Idempotent-Replayed: true`**. Absence means the request executed; we
never send `false`, so test for the header's presence.

This matters most on the case you cannot otherwise distinguish. A payment failure arrives as an HTTP
`200` with `isValid: false` and a populated `errors[]` — and a **replayed** failure is byte-identical
to a fresh one. Without this header, a client that retried after a timeout has no way to tell whether
we re-contacted the processor or handed back the answer we already had.

```http
HTTP/1.1 200 OK
Idempotent-Replayed: true
```

Concurrent duplicates are held off rather than replayed: a request arriving while an earlier one on
the same key is still in flight receives `409 IDEMPOTENCY_KEY_IN_PROGRESS`, which is retryable on
that same key and carries a `Retry-After` header saying when. The conflict is **not** recorded
against the key, so the in-flight attempt's real outcome is still yours to collect on the retry.

:::warning[What idempotency cannot protect]
These guarantees are about **our** record of the order. If a payment processor times out, the
authorization may have succeeded even though the order came back failed — a fresh-key re-attempt can
then charge the customer twice, and no `Idempotency-Key` we hold can prevent that. Reconcile before
re-attempting when you can, and treat a customer-reported charge against an order you never saw
succeed as this case.
:::

`validate` is naturally idempotent (read-only pricing) and does not use the header.

## Related

- **[Validate Order](/api/marketfront#validateOrder)** — Check availability and pricing
- **[Place Order](/api/marketfront#placeOrder)** — Submit order for fulfillment
- [Payments](/distribution-partners/shared-guides/payments) — Payment options and Card-on-File setup
- [Webhooks](/distribution-partners/shared-guides/webhooks) — Signature verification and retry policy for order status webhooks
