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.
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 — 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.
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.
{
"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 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.
// 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 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-Replayedwas 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,request_timeout,internal_error, orreplay_detected(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 |
no | Re-read the catalogue; new order at the new unitPrice |
insufficient_balance |
no | Top up; new order |
quantity_unavailable, card_unavailable |
no | Re-read the catalogue; new order |
allowed_debt_consent_required |
no | New order with useAllowedDebt: true, only if you mean it |
owner_limit_exceeded, daily_limit_exceeded |
no | Do not retry in a loop; new order once the limit changes |
rate_limited |
no | Wait for Retry-After, then send again |
idempotency_conflict |
no | If you meant to resume, send the exact stored body; for a new purchase, use a new id |
dependency_unavailable, request_timeout, internal_error, replay_detected |
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.