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-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, request_timeout, internal_error, or replay_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.