> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.lettbestilt.no/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.lettbestilt.no/_mcp/server.

# Errors, rate limits and retries

## Error format

Errors use one envelope:

```json
{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "Velg minst 1 fra Saus",
    "details": {}
  }
}
```

Branch on `code`, never on `message`. `message` is written for people and is often in Norwegian; for business rules (sold out, closed, outside the delivery zone) it is usually fine to show the customer as it is. `details` is only present for some codes.

| Code                                               | Status | Meaning                                                                                                                                       |
| -------------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `MISSING_SLUG`                                     | 400    | The `slug` is missing                                                                                                                         |
| `MISSING_TOKEN`                                    | 400    | The order token is not in a valid format                                                                                                      |
| `INVALID_JSON`                                     | 400    | The body is not valid JSON                                                                                                                    |
| `INVALID_SUCCESS_URL` / `INVALID_CANCEL_URL`       | 400    | A return URL is not `https` on a verified domain or lettbestilt.no                                                                            |
| `BAD_REQUEST`                                      | 400    | Malformed input, such as an invalid `Idempotency-Key`, or the restaurant is closed or does not offer the chosen fulfillment or payment method |
| `UNAUTHORIZED`                                     | 401    | Missing or wrong API key, and the origin is not trusted                                                                                       |
| `RESTAURANT_NOT_FOUND`                             | 404    | No live restaurant has this slug                                                                                                              |
| `ORDER_NOT_FOUND`                                  | 404    | No order has this token                                                                                                                       |
| `POPUP_NOT_AVAILABLE`                              | 404    | Newsletter signup is turned off for the restaurant                                                                                            |
| `PRICE_CHANGED`                                    | 409    | You sent `expectedTotal` and the server's total is higher. `details` has `expectedTotal`, `actualTotal` and `currency`                        |
| `PAYMENT_PENDING`                                  | 409    | The order exists but its payment link is still being created. Retry shortly with the same key                                                 |
| `PAYMENT_INCOMPLETE`                               | 409    | The order exists but never got a payment link. Start a new order                                                                              |
| `IDEMPOTENCY_CONFLICT`                             | 409    | The `Idempotency-Key` was already used by the other endpoint, or (reservations only) with a different body                                    |
| `VALIDATION_FAILED`                                | 422    | The body failed validation (`details.issues` lists the fields), or the order breaks a business rule                                           |
| `FULFILLMENT_NOT_ALLOWED`                          | 422    | A product is pickup-only or delivery-only, and the order asked for the other                                                                  |
| `DINE_IN_DISABLED`, `PARTY_TOO_LARGE`, `TOO_EARLY` | 422    | Reservation rejections. See [Table reservations](/reservations)                                                                               |
| `RATE_LIMITED`                                     | 429    | Too many requests. See `Retry-After`                                                                                                          |
| `PAYMENT_PROVIDER_INVALID`                         | 502    | The payment provider returned an unusable payment link                                                                                        |
| `INTERNAL_ERROR`                                   | 5xx    | Something failed on our side or at a provider. `503` on order creation means the payment provider did not answer                              |

One exception to the envelope: `409 ONLINE_BOOKING_CLOSED` from `POST /reservations` uses a flat shape, `{ "error": "…", "code": "ONLINE_BOOKING_CLOSED" }`.

## Rate limits

Limits are counted per client IP, and for most endpoints per restaurant as well:

| Endpoint                                               | Limit                                                |
| ------------------------------------------------------ | ---------------------------------------------------- |
| `POST /orders`                                         | 10 per minute per slug and IP                        |
| `POST /reservations`                                   | 20 per minute per slug and IP                        |
| `POST /coupons/validate`, `POST /coupons/preview-auto` | 30 per minute per slug and IP                        |
| `POST /newsletter/signup`                              | 5 per minute per IP, and 200 per hour per restaurant |
| `GET /dine-in/availability`                            | 60 per minute per slug and IP                        |
| `GET /address-search`                                  | 30 per minute per IP                                 |
| `GET /orders/{token}`                                  | 60 per minute per IP, and 30 per minute per token    |
| `GET /prep-time`, `GET /site-gate`                     | 600 per minute per slug and IP                       |
| `GET /menu`, `GET /restaurant`                         | No limit; responses are cached at the edge           |

Rate-limited responses include these headers:

| Header                  | Meaning                                     |
| ----------------------- | ------------------------------------------- |
| `X-RateLimit-Limit`     | Requests allowed in the window              |
| `X-RateLimit-Remaining` | Requests left in the window                 |
| `X-RateLimit-Reset`     | Unix time in seconds when the window resets |
| `Retry-After`           | Seconds to wait (only on `429`)             |

> **Warning**
>
> When you call the API from your server, every customer shares your server's IP. `POST /orders` then allows 10 orders per minute per restaurant from that server. Calls from the browser on a verified domain are counted per customer instead. Talk to LettBestilt if you expect more traffic than the limits allow.

Read the headers rather than hard-coding the numbers; they may change.

## Idempotency

`POST /orders` and `POST /reservations` accept an `Idempotency-Key` header: 16 to 128 URL-safe characters (`a-z`, `A-Z`, `0-9`, `_`, `-`). A UUID works well. Keys are scoped per restaurant, and orders and reservations share the same key space.

|                                        | `POST /orders`                                           | `POST /reservations`                                     |
| -------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- |
| Same key, same body                    | Returns the original order and payment link              | Returns the original request (`200`, `idempotent: true`) |
| Same key, different body               | Returns the original order. The body is **not** compared | `409 IDEMPOTENCY_CONFLICT`                               |
| Key already used by the other endpoint | `409 IDEMPOTENCY_CONFLICT`                               | `409 IDEMPOTENCY_CONFLICT`                               |

Create one key per checkout attempt, store it with the attempt, and reuse it only to retry that attempt.

## When to retry

| Situation                                                          | Retry?                                                                                                                |
| ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- |
| Network error or timeout on `POST /orders` or `POST /reservations` | Yes, with the same `Idempotency-Key`                                                                                  |
| `409 PAYMENT_PENDING`                                              | Yes, after a couple of seconds, with the same key                                                                     |
| `429 RATE_LIMITED`                                                 | Yes, after `Retry-After` seconds                                                                                      |
| `503` on `POST /orders`                                            | Only as a new attempt with a new key; the first order has no payment link                                             |
| `500`                                                              | Yes, with backoff. On `POST /orders` and `POST /reservations`, reuse the same key. Contact LettBestilt if it persists |
| `400`, `401`, `404`, `422`, other `409`                            | No. Change the request first                                                                                          |