---
title: "Orders and recovery — Anis Developers"
description: "Placing an order safely, the answers it can get, and how to recover when you do not know whether it went through."
url: https://developers.anis.ly/docs/orders-and-recovery/
language: en
---

> Every page of this documentation: https://developers.anis.ly/llms.txt

# Orders and recovery

The part where money moves. Read it once, properly.

## The operation id is yours

Every order carries an **operation id** that **you** choose — a new UUID for each purchase — sent as the `Idempotency-Key` header. Store it, with the exact order, **before** you send it.

The same id sent again never buys twice: it resumes the same order and returns its real outcome. That is what makes a retry safe — even after your process restarts — and it is why the SDKs never invent the id for you: an id made up on each attempt would turn a lost answer plus a retry into a second purchase.

```csharp
var operationId = Guid.NewGuid();
await db.RecordOrderIntentAsync(operationId, walletId, request, ct);   // BEFORE the call

var outcome = await anis.Orders.CreateAsync(walletId, operationId, request, ct);
```

An operation id is unique across all of Anis. Sending one id with a different order body is refused as [`idempotency_conflict`](https://developers.anis.ly/errors/idempotency-conflict.md) — which is also what a resume gets if its body drifted from the stored one. Resume with exactly the body you stored.

## The price to send

Send the card’s **`unitPrice`** from the catalogue, exactly as read, as `expectedUnitPrice` — it is the price this wallet pays and the one the order is checked against. The other prices (`businessPrice`, `personalPrice`, `specialOfferPrice`) are for display; an order at one of them can be refused as [`price_changed`](https://developers.anis.ly/errors/price-changed.md).

`expectedTotal` must be `expectedUnitPrice` times `quantity` **exactly**. Amounts are decimal strings with three decimals (`"10.500"`) — compute them with a decimal type, never floating point.

```json
{
  "cardId": "8d4b1e73-9a25-4c60-8f37-6b2e9d5a1c48",
  "quantity": 2,
  "expectedUnitPrice": { "amount": "10.500", "currency": "LYD" },
  "expectedTotal": { "amount": "21.000", "currency": "LYD" },
  "externalReference": "your-order-123"
}
```

`externalReference` is optional — your own reference, 1 to 100 characters of Latin letters and digits, space and `- _ . : / #`. `quantity` is at least 1 and at most the per-order maximum (100 by default). `useAllowedDebt` defaults to `false`; set it to `true` only if you mean to spend the account’s allowed debt.

## The answers an order can get

| Answer | What it means | What to do |
| --- | --- | --- |
| `201` with card codes | The order completed | **Store the codes before anything else** |
| `200` with card codes | A resume got the completion whose first answer was lost | Store the codes — this is the first time you see them |
| `201` or `200` with `Idempotency-Replayed: true` | This order’s outcome was already sent | If you stored the codes from that first answer, nothing to do. If you never received it — a timeout — the codes were in it: reveal them with the order’s invoice id |
| `202` | Accepted, no outcome yet | Send the **same** order again after `Retry-After` |
| A refusal | See below | Depends on whether it was placed |

**Only the answer that first reports a completion carries the card codes.** Any later repeat carries the order without them, and [reading the order](https://developers.anis.ly/reference/read-order.md) never carries them. So if that first answer is lost — a timeout, a dropped connection, an answer that could not be verified — the order is paid for and its codes are only available by a reveal with the order’s invoice id, which needs the `cards:reveal` permission.

> **Ask for cards:reveal before you go live**
>
> Without it, an order whose first answer was lost cannot give you its codes again.

## Recovery is sending the same order again

A `202` carries a `Location` pointing at the order, so the instinct is to poll it. **Reading an order reports where it stands; it does not move it forward.** The way to recover an order is to send **exactly the same order again — same operation id, same body**, freshly signed.

Do the same whenever you do not know what happened:

- your call **timed out**, or the connection dropped;
- the answer **could not be verified**;
- the refusal says the order **may still complete** (below).

A resume is always safe. A new operation id in these cases is not — it can buy the cards a second time.

```csharp
// resuming: true when this call repeats an attempt whose outcome you did not learn
try
{
    var outcome = resuming
        ? await anis.Orders.ResumeAsync(walletId, operationId, request, ct)
        : await anis.Orders.CreateAsync(walletId, operationId, request, ct);
}
catch (AnisApiException failure) when (failure.OrderOutcome == OrderRefusalOutcome.Unknown
                                       || (resuming && !failure.IsReplayed))
{
    await ResumeLaterAsync(operationId);            // no decision reached — or a resume refused at the door
}
catch (AnisApiException failure)
{
    await CloseAsNotPlacedAsync(operationId, failure);   // nothing bought: fix the cause, new order, new id
}
catch (Exception e) when (e is TaskCanceledException or HttpRequestException or UnverifiableResponseException)
{
    await ResumeLaterAsync(operationId);            // timed out, connection lost, or an untrusted answer
}
```

> **Recovery exhausted**
>
> An order whose status is `recoveryExhausted` is **not** failed — Anis could not learn its outcome yet, and it may have completed. Do not place it again under a new id. Keep resuming the same id, slowly (minutes, not seconds) — a later resume can still return the completion with its codes — and tell Anis the operation id.

## When an order is refused: was it placed?

Every [error page](https://developers.anis.ly/errors.md) answers that first.

- **This request bought nothing.** Fix the cause, then place a new order under a **new** operation id. (A refusal at the door — a rate limit, a missing permission — records nothing against the id, so reusing it also works.)
- **But on a resume**, a refusal that is not marked `Idempotency-Replayed` was decided before Anis looked at the order, so it says nothing about the earlier attempt: if that attempt’s outcome was unknown, it still is — keep resuming with the same id.
- **May still complete.** No decision was reached: [`dependency_unavailable`](https://developers.anis.ly/errors/dependency-unavailable.md), [`request_timeout`](https://developers.anis.ly/errors/request-timeout.md), [`internal_error`](https://developers.anis.ly/errors/internal-error.md), or [`replay_detected`](https://developers.anis.ly/errors/replay-detected.md) (an identical copy of your request got there first). Resume with the **same** operation id.

A refusal marked **`Idempotency-Replayed: true`** is the recorded answer of an order that is already **closed** — nothing was bought, and sending the same id again returns the same answer forever. Close it on your side; a new attempt needs a new id.

| Refusal | Placed by this request? | What to do |
| --- | --- | --- |
| [`price_changed`](https://developers.anis.ly/errors/price-changed.md) | no | Re-read the catalogue; new order at the new `unitPrice` |
| [`insufficient_balance`](https://developers.anis.ly/errors/insufficient-balance.md) | no | Top up; new order |
| [`quantity_unavailable`](https://developers.anis.ly/errors/quantity-unavailable.md), [`card_unavailable`](https://developers.anis.ly/errors/card-unavailable.md) | no | Re-read the catalogue; new order |
| [`allowed_debt_consent_required`](https://developers.anis.ly/errors/allowed-debt-consent-required.md) | no | New order with `useAllowedDebt: true`, only if you mean it |
| [`owner_limit_exceeded`](https://developers.anis.ly/errors/owner-limit-exceeded.md), [`daily_limit_exceeded`](https://developers.anis.ly/errors/daily-limit-exceeded.md) | no | Do not retry in a loop; new order once the limit changes |
| [`rate_limited`](https://developers.anis.ly/errors/rate-limited.md) | no | Wait for `Retry-After`, then send again |
| [`idempotency_conflict`](https://developers.anis.ly/errors/idempotency-conflict.md) | no | If you meant to resume, send the exact stored body; for a new purchase, use a new id |
| [`dependency_unavailable`](https://developers.anis.ly/errors/dependency-unavailable.md), [`request_timeout`](https://developers.anis.ly/errors/request-timeout.md), [`internal_error`](https://developers.anis.ly/errors/internal-error.md), [`replay_detected`](https://developers.anis.ly/errors/replay-detected.md) | **may still complete** | Resume with the **same** id |

A timeout is never `failed`. Only a definite business refusal is `failed`, and a completed order never becomes failed afterwards.
