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 exactly three decimals ("10.500") — compute them with a decimal type, never floating point. Both prices you send must be greater than zero, with no sign, no leading zeros and at most 15 digits before the point; anything else is refused as validation_failed, and nothing is bought. (In Anis’s answers only a wallet balance can be negative, while the wallet uses allowed debt.) From SDK 1.1.0, the .NET SDK refuses a unit price that is not greater than zero before sending, with ArgumentOutOfRangeException.

{
  "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). useAllowedDebt defaults to false; set it to true only if you mean to spend the account’s allowed debt.

Check the quantity limits before you order. Each card in the catalogue carries minimumQuantity and maximumQuantity — always 1 and the platform order limit of 100. A quantity outside them is refused as quantity_unavailable. A subcategory can also carry a disclaimer, Anis’s current note for buyers of its cards: show it to your end customer before they buy.

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.

What every answer says about the order

Every answer for an order — the first one, a repeat of it and a read — says which order it is: the cardId, the quantity and your own externalReference (when you sent one). Once the order has completed it carries the total too. So a lookup or a replay tells you which of your sales it is, and what it cost, without your database. The card codes are still only in the first answer.

{
  "operationId": "c1f6a3b2-5d47-4e89-9a10-3b7e2d8f4c65",
  "status": "completed",
  "invoiceId": "5a2e9c47-1b83-4f06-8d2a-7c4b9e1f3a60",
  "externalReference": "your-order-123",
  "cardId": "8d4b1e73-9a25-4c60-8f37-6b2e9d5a1c48",
  "quantity": 2,
  "total": { "amount": "21.000", "currency": "LYD" }
}

A read of an order that failed says why: failureCode is the refusal code Anis recorded. Branch on it like on any other error code — nothing was bought. It is left out when that refusal has no public code. Repeating a failed order’s key is still answered with the refusal itself, as before.

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.

Between two resumes, wait at least the Retry-After of the last answer when it carries one (the .NET SDK gives it to you as RetryAfter or SuggestedDelay).

// resuming: true when this call repeats an attempt whose outcome you did not learn
var outcome = resuming
    ? await anis.Orders.ResumeAsync(walletId, operationId, request, ct)
    : await anis.Orders.CreateAsync(walletId, operationId, request, ct);

await (outcome switch
{
    OrderCompleted c when c.CodesWithheld => ReportCodesWithheldAsync(operationId, ct),  // paid, codes withheld: do not buy again; write to support@anis.ly
    OrderCompleted c      => vault.StoreAsync(c.Credentials, ct),                // store codes FIRST; this answer carries them once
    OrderReplayed r       => EnsureStoredAsync(r.Order, ct),                     // if the first answer was lost, reveal by invoice id
    OrderProcessing p     => ResumeLaterAsync(operationId, p.RetryAfter),        // may still complete
    OrderOutcomeUnknown u => ResumeLaterAsync(operationId, u.SuggestedDelay),    // no answer, untrusted answer, a rate limit, an access refusal, or a fresh refusal on resume
    OrderNotPlaced n      => CloseAsNotPlacedAsync(operationId, n.Refusal),      // nothing bought; fix the cause, new order, new id
    _                     => throw new InvalidOperationException($"Unhandled order outcome {outcome}"),
});

The .NET SDK applies the resume rule below for you: a refusal of a resume that is not marked Idempotency-Replayed comes back as OrderOutcomeUnknown, never as OrderNotPlaced. From SDK 1.1.0, a rate-limited CreateAsync comes back as OrderOutcomeUnknown too (earlier versions returned OrderNotPlaced): resume it with the same id. From SDK 1.1.0, CreateAsync also returns invalid_credentials, insufficient_scope and wallet_not_granted as OrderOutcomeUnknown (suggested delay 60 seconds), like a resume always did: restore access first, then resume the same id. A badly built signed request, malformed_signed_request, comes back the same way: fix how the request is signed, then resume the same id.

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: Anis makes up to five attempts to finish an order in each 15 minutes, and the answer’s Retry-After (about a minute for such an order) is the least you should wait. A later resume can still return the completion with its codes. Write to support@anis.ly with 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.
  • 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 rate limit is not “bought nothing”. rate_limited turns this copy away, but it says nothing about an earlier copy under the same id — a call that timed out, or a retry your HTTP client made for you — which may already have gone through. Wait for Retry-After, then resume with the same id, never a new one.
  • Refused before Anis looked at the order. invalid_credentials, insufficient_scope, wallet_not_granted and malformed_signed_request say nothing about an earlier attempt with the same operation id. Restore access (for malformed_signed_request: fix how the request is signed) first, then resume the same id (or read it with GET /orders/{operationId}). Never place it again under a new 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 no Re-read the catalogue; new order
card_unavailable no Re-read the catalogue and check availability for this account and wallet; a listed card can still be refused, so choose an eligible card for a new order
allowed_debt_consent_required no New order with useAllowedDebt: true, only if you mean it
owner_limit_exceeded no Reserved, not sent today. If you ever receive it: do not retry in a loop; new order once the limit changes
daily_limit_exceeded no Do not retry in a loop; new order once the limit changes
spending_limit_exceeded no Do not retry in a loop; after Retry-After, a new order with a new id
rate_limited may still complete Wait for Retry-After, then resume with the same id
invalid_credentials, insufficient_scope, wallet_not_granted no — but an earlier copy may have placed it Restore access first, then resume the same id (or read it with GET /orders/{operationId}); never a new id
malformed_signed_request no — but an earlier copy may have placed it Fix how the request is signed first, then resume the same id (or read it with GET /orders/{operationId}); never a new id
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

Some cards may be sold only in certain locations. If a card is not sold at your account’s location, its order is refused with card_unavailable, even if the catalogue lists it.

Send orders on one wallet one at a time, not several at once.

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

When answers are lost or arrive twice

An order can reach Anis more than once — a retry, a resume that overlaps the first send, a client that retries by itself. Anis buys once per operation id, but what you are told depends on which copy you hear from.

  • “Already delivered”, without codes. A resume that answers with Idempotency-Replayed: true and no codes — the SDK’s OrderReplayed — means the order was placed and its codes were in an answer you never received. Do not buy again: reveal the codes with the order’s invoice id (it needs cards:reveal). The same goes for a signed dependency_unavailable or request_timeout after which the order turns out to have completed: the resume returns it without codes, and the reveal is how you get them. replay_detected means an identical copy got there first; it is resumed like any other unknown outcome, and it ends the same way.
  • Completed, codes withheld. An order can also be completed with no codes at all: a card in it was invalidated or refunded, or its code cannot be released. The order is paid and complete — do not buy it again. Anis says so in so many words: the answer that first reports such an order carries codesWithheld: true (it is never false, and a repeat or a read does not say). Treat a completed order with no codes at all the same way, flag or not. A reveal of the withheld codes is refused too; the other cards of the order may still be revealed one by one. Write to support@anis.ly with the operation id. (.NET: OrderCompleted.CodesWithheld is true.)
  • A copy that arrives late, or access that changed. A resend that reaches Anis after its signature’s expires (60 seconds with the Anis SDKs), or more than 300 seconds after it was signed, is refused as invalid_credentials; Anis staff may also have revoked the key, removed a permission (insufficient_scope) or removed the wallet from your application (wallet_not_granted) while an earlier copy was selling. Each is refused before Anis looked at the order, so it says nothing about an earlier attempt with the same operation id — that copy may already have placed the order. Restore access first, then resume the same id, freshly signed (or read it with GET /orders/{operationId}, which needs orders:read, or orders:create for your own orders). Never place it again under a new operation id. A new id is for the definite refusals above, or a recorded refusal marked Idempotency-Replayed.
  • wallet_not_granted on a resume. Anis staff may have removed this wallet from your application while the order was unresolved; every resume gets the same refusal until the wallet is granted again. Read the order with GET /orders/{operationId} — the read does not depend on the wallet — and write to support@anis.ly with the operation id. Do not place a new order.

Owned cards and reveals

Listing or reading an owned card never shows its codes, but it tells you what you need to serve your customer: unitPrice (what you paid, unchanged by a later refund), expiryDate, faceValue, the subcategory, and the invoiceNumber printed on the invoice — quote that number when you write to support. credentialAvailable is true only when revealing the card would give you a code; a refunded or invalid card reads false, so do not offer it.

A reveal (one card or a whole invoice) names what it reveals: besides the codes, each card carries its card (id and name), the invoiceId, purchasedAt and the expiryDate. Tell your end customer the expiry date. The soldCards of a first order answer carry the expiryDate too.

Meter and direct recharge purchases

Keep a saved operation id and exact body for meter charges and direct recharges too. Their accepted operations continue in the background and have signed reads and webhook outcomes. The card-code first-disclosure and invoice-reveal rules on this page apply to card orders; meter reads return the full token list after success.