Orders

Idempotent, asynchronous purchasing — the Idempotency-Key contract, the status lifecycle, the order's history, and the filters a sync reads changes with.

An order turns a priced item into delivered digital goods, paid from your prefunded wallet. Creation is asynchronous and idempotent; the status lifecycle below is the contract to build against. Creating orders requires orders.create; reading them, their history and their reveal allowance requires orders.read.

Create an order

POST /api/v1/orders
Idempotency-Key: <your unique key, 1–128 printable characters>

Name what to buy in exactly one of two ways.

The item itself — priced and ordered in one call:

{
  "clientReference": "your-order-1001",
  "item": {
    "productId": "0198d72d-99d6-75a6-9f12-971050ba7a5f",
    "variantId": "0198d72d-a15b-7cbf-ab69-b95410e98636",
    "value": null,
    "quantity": 1,
    "currency": "USD"
  },
  "maximumTotal": 55.00
}

A quote you created with POST /quotes:

{
  "quoteId": "0198d72d-a6ce-77c6-9586-fc36708731d0",
  "clientReference": "your-order-1001",
  "purchaseInput": { "phoneNumber": "+15551234567" }
}
FieldRules
itemThe purchase, exactly as a quote request names it: productId, then variantId for a listed denomination or a positive value for an amount of your choosing, quantity 1–20, and currency — the product's sellCurrency. Send item or quoteId, never both.
maximumTotalOptional, with item only, positive: the most you accept to pay. When today's total is above it nothing is ordered and the response is 409 price_above_maximum, carrying quotedTotal, currency and maximumTotal so you can decide without another call. Set it to the price your buyer saw.
quoteIdAn unexpired, unused quote created by your workspace through the API. Send quoteId or item, never both.
clientReferenceRequired, ≤ 200 characters, no control characters; surrounding spaces are trimmed. Your own identifier — echoed on the order, order lists, and webhooks so you can correlate without storing our ids, and searchable with GET /orders?clientReference=.
purchaseInput.phoneNumberRequired for phone-top-up products, in E.164 form: +, then 8–15 digits, the first not 0, with no spaces or punctuation (+14155550123). Omit purchaseInput for every other product.

The body takes no other members: a misspelt or unknown member — maximumTotl, item.valu, a workspaceId — is refused with 400 invalid_request and named in the problem's errors map, and nothing is ordered.

A successful create returns 202 Accepted with a Location header (/api/v1/orders/{orderId}) and the order body in state reserved — your wallet has reserved the order's total, and fulfillment proceeds in the background (typically seconds).

Idempotency — the retry contract

The Idempotency-Key header is mandatory. Its guarantee: for a given key, an order is placed at most once. Send the same key again — after a timeout, a crash, a network error, or a 5xx — and you get the same order back, never a duplicate purchase.

  • The key space is your workspace's. Every API key of the workspace shares it: a key string one of your services spent cannot be reused by another.
  • A spent key is spent for good. The key is recorded with the order it placed and never expires; a new purchase needs a new key.
  • A replay answers 202 Accepted with the order as it stands now — it may already be fulfilled. Nothing is priced, reserved or charged again.
  • With quoteId, a retry is the same request when it names the same quote, the same clientReference and the same purchaseInput. Anything else under a spent key is 409 idempotency_conflict.
  • With item, a retry is judged by the request it repeats, as it was sent — never by today's catalog: the same productId, variantId, value (or the same absence of one; 25 and 25.00 are one amount), quantity, currency (any letter case), clientReference (surrounding spaces ignored) and purchaseInput. It must also accept the order's total: a retry whose maximumTotal is below the total the key's order was placed at is a different request. Anything else — naming the amount the original left out, or the reverse, included — is 409 idempotency_conflict.
  • When the key's order kept no record of the request it was sent with, an item retry is judged from the order itself instead: it is the same purchase when the order has exactly one line, of the same productId and variantId, the same quantity, the same settlement currency, the same clientReference and the same purchaseInput, for the amount the retry names (an omitted value being the product's default face value, as a fresh quote would read it) — and the order's total is still within the retry's maximumTotal. Two kinds of order have no such record: one placed before Xegora began recording the request with the key, and one placed from a quoteId (a quote is not an item request, so there is nothing to record). A genuine retry of either is therefore answered with the order it already placed, not refused.
  • A quoteId request never answers for an item order. A one-call purchase makes a quote of its own, so a spent key can never be presented with the same quoteId again: send item to retry an item order.
  • A refused request does not spend the key. price_above_maximum, insufficient_funds, an unavailable product or any other refusal places nothing: correct the request and send it again under the same key.

Rules of thumb: derive the key from your own order identity (e.g. order-{yourOrderId}), not from a random value per attempt — randomness defeats the whole point — and keep it stable across process restarts (persist it with your order row).

Why an order is refused

Nothing is reserved by a refused request, and its key is not spent.

StatuscodeWhyYour move
400invalid_requestMissing or oversized Idempotency-Key; both or neither of item and quoteId; maximumTotal without item or not positive; quantity outside 1–20; an empty id; a missing, oversized or control-character clientReference; a body member the request does not have (named in errors).Fix the request.
403api_access_inactiveThe workspace may not order right now — setup fee unpaid, subscription overdue, entitlement ended, or workspace suspended; reason says which.Settle it in the dashboard; see Authentication.
403entitlement_requiredAPI purchasing is not enabled for the workspace — normally answered earlier as api_access_inactive.Contact your account manager.
404not_foundThe quoteId is not a quote your workspace made, or the item's product is not in your catalog.Quote again, or refresh the product.
409insufficient_fundsThe wallet's available balance does not cover the total.Fund the wallet and resend with the same key.
409price_above_maximumToday's total is above your maximumTotal; the body carries quotedTotal, currency and maximumTotal.Show the new price, or resend with a higher ceiling.
409quote_unavailableThe quote expired or was already used.Quote again, or send the item.
409quote_channel_mismatchThe quote was made for another channel (your Xegora storefront), not through the API.Quote again through the API.
409quote_buyer_mismatchThe quote is bound to a storefront buyer.Quote again through the API.
409product_unavailableThe product was withdrawn after your quote was made; nothing was charged.Refresh the product; offer another denomination.
409idempotency_conflictThe key already placed a different purchase (see above). Nothing was ordered a second time.First find out what the key did buy: GET /orders?clientReference= (or ?reference=). Only once you are sure the purchase you want is genuinely absent should you send it under a new key — reaching for a new key before checking is how an order gets bought twice.
409concurrent_updateThe wallet or quote changed while the order was being placed.Resend with the same key.
409wallet_funding_on_holdNew orders in the wallet's currency are on hold while Xegora resolves an incident affecting how that currency is funded.Retry later; contact support if it persists.
409order_held_for_reviewThe order waits for a review by Xegora before it can be placed; the body carries reviewHours. Xegora's current rules never hold an order placed through the API — it is placed or refused — so treat this as reserved for future rules.Resend under the same key once Xegora has decided.
422quote_unavailableThe quote cannot be ordered.Quote again.
422product_unavailableWith item: the product or denomination is not sold right now.Refresh the product; offer another denomination.
422currency_not_supportedcurrency is not the product's sellCurrency.Use the product's sellCurrency.
422pricing_unavailableWith item: the item cannot be priced right now.Try again shortly.
422wallet_unavailableThe workspace holds no active wallet in that currency.Order in a currency your workspace holds.
422purchase_input_invalidA top-up's phone number is missing or not in E.164 form, or purchaseInput was sent for a product that takes none.Correct or remove purchaseInput.
422order_limit_exceededOne order may cost at most the limit Xegora set for your workspace; the body carries limit and currency.Order less, or ask Xegora to raise the limit.
422order_period_limit_exceededThe last 30 days of orders, this one included, would pass your workspace's limit; used is what those days already count.Wait for older orders to leave the 30 days, or ask Xegora.
422currency_limits_not_configuredLimits are set for your workspace in other currencies but not in this one, so orders in it are refused until Xegora sets them.Contact support.

GET /api/v1/workspace shows the limits your orders are held to, currency by currency, with what the last 30 days already count (limits[].rolling30DayOrdersUsed) and whether a currency is blocked for want of limits.

The status lifecycle

status is a string, always lowercase on the wire; treat unknown future values as non-terminal and keep polling. These are the statuses an API order takes:

StatusMeaningFinal?
reservedAdmitted: the order's total is reserved on your wallet and it is queued for fulfillment. Every order starts here.no
processingBeing fulfilled; its outcome was not immediate.no
fulfilledDelivered — artifacts are ready to reveal and the funds are captured.yes, unless it is refunded later
failedNot delivered; the reservation was released back to your available balance. The order's statusReason names the cause when there is one you can act on — a recipient number that cannot be topped up, or a void or a close by Xegora support.yes
refundedThe money came back to your wallet: a fulfilled order was refunded and your wallet credited (refund says how much and when, and how much cashback it took back), or the purchase was refunded before it was delivered and the reservation released.yes
reconciliationrequiredUnder review by Xegora — typically an outcome that could not be confirmed. The funds stay reserved; it resolves to fulfilled, failed or refunded, or back to processing. Keep it visible in your back office, and contact support with the order id if it lingers.no

The status vocabulary also names pending, cancelled and refundpending, which an API order does not take: an order is created reserved (its history starts with a pending entry at the same instant), it is never cancelled — an order that is not delivered is failed — and its refunds complete in one step. Treat them as you would any status you do not recognize.

reserved → processing → fulfilled → refunded
reserved → fulfilled                                 (delivered at the first attempt)
reserved | processing → failed                       (not delivered; reservation released)
reserved | processing → refunded                     (refunded before delivery; reservation released)
reserved | processing → reconciliationrequired → processing | fulfilled | failed | refunded

A phone top-up's recipient number

purchaseInput.phoneNumber is checked for form when the order is placed — a number that is not E.164 is refused on the spot with 422 purchase_input_invalid, and nothing is reserved. Whether that number can actually receive the top-up is a different question, and one only the network behind the product can answer, so Xegora asks it while fulfilling the order, before any money is spent. A number the product cannot reach ends the order at failed on its first fulfillment attempt, with the whole reservation released and a statusReason saying which way it was wrong:

statusReasonWhat is wrongYour move
recipient_number_not_supportedThe product does not top up that number at all.Offer a product that covers the number, or ask the buyer to check it.
recipient_number_other_operatorThe number is a real mobile number, but on a different operator from the one this product tops up.Order the product for the number's own operator.

Both are ordinary failed outcomes: the reservation is released in full, nothing is charged, and the Idempotency-Key that placed the order is spent (the order exists), so a corrected order is a new order under a new key. Products other than phone-top-up never carry these reasons.

Read an order

GET /api/v1/orders/{orderId}

Returns the order or 404 (code: "not_found") — including for orders that belong to another workspace, which are indistinguishable from nonexistent ones by design.

{
  "id": "0198e000-1111-7abc-9def-222233334444",
  "clientReference": "your-order-1001",
  "status": "fulfilled",
  "total": 58.50,
  "currency": "USD",
  "createdAtUtc": "2026-09-05T12:01:02+00:00",
  "updatedAtUtc": "2026-09-05T12:01:09+00:00",
  "lines": [
    {
      "productName": "Game Store Gift Card",
      "variantLabel": "50",
      "quantity": 1,
      "unitPrice": 58.50,
      "currency": "USD",
      "faceValue": 50.00,
      "faceCurrency": "EUR",
      "requestedValue": null,
      "productId": "0198d5c0-90aa-7d3e-8f10-3c5f60b1a222",
      "variantId": "0198d5c0-91bb-7e4f-9a21-4d6e70c2b333",
      "lineTotal": 58.50
    }
  ],
  "publicReference": "ord_0198e00011117abc9def222233334444",
  "completedAtUtc": "2026-09-05T12:01:09+00:00",
  "statusReason": null,
  "refund": null
}

An order carries two kinds of amount, and they are never the same number: total in currency is the settlement — what was reserved and debited from your wallet, in your sell currency — while each line's faceValue in faceCurrency is the denomination the buyer receives (the 50 EUR card above, paid for with 58.50 USD). faceValue is null when the item is not money (a data plan, a bundle).

FieldMeaning
publicReferenceXegora's own reference for the order: ord_ and the order id's 32 hex digits. Quote it to support; it is also what the wallet statement shows.
completedAtUtcWhen the order first reached an outcome — delivered, failed or refunded. It does not move afterwards (a delivered order refunded later keeps its delivery instant); null while the order is on its way.
statusReasonWhy the order is in its status, when Xegora recorded a reason you may read; null otherwise — most status changes record nothing you can act on, and then the status speaks for itself. One of outcome_unconfirmed (referred for review because its outcome could not be confirmed), resumed_after_review (fulfillment resumed after a review), voided_by_support (voided by Xegora support; the reservation was released), closed_by_support (closed by Xegora support after a purchase that was never made; the reservation was released), refund_under_review (a refund reported for the delivered order is being reviewed — the order stays fulfilled meanwhile), refund_started (a refund of the order has been started), recipient_number_not_supported or recipient_number_other_operator (see the recipient number). Keep clients tolerant of new reasons: the list grows, and a reason you do not recognize is never a reason to treat the status differently.
refundFor an order refunded after it was delivered: { amount, currency, refundedAtUtc, cashbackReversed, netCredited }. amount is the credit exactly as it reached your wallet — the order's total. If the order had paid you a cashback share, the refund took that share back in the same instant, because the sale that earned it was undone: cashbackReversed is that share (0 when the order earned none) and netCredited is what the refund left on your wallet, amount − cashbackReversed. The two movements appear in your wallet statement as order_refund and cashback_reversal, at the same occurredAtUtc. null for every other order, including one refunded before delivery, whose reservation was simply released.
lines[].lineTotalWhat the line cost in all, in currency, exactly as it was charged.
lines[].productId, lines[].variantIdThe catalog product the line bought and, for a listed denomination, its variant (null for an amount of your choosing). Reconcile by these ids rather than by name.

The money on a line — unitPrice, lineTotal, faceValue, requestedValue — is frozen at order time from the quote that priced the order, so a receipt built from the order can never drift from what you were quoted, and you do not need to keep the quote around. productName and variantLabel are frozen with it: a line shows the product and option names as they were recorded when the order was placed, so a later rename in the catalog never rewrites what you sold. These are the same names webhooks, notifications and the invoice carry, so an order read here and an order.fulfilled event always say the same thing. (Only the oldest orders, placed before names were recorded on the line, fall back to the catalog's current name. A quote is the other way round: it stores no names, so GET /quotes/{quoteId} always reads today's catalog — see Quotes.) Reconcile by productId and variantId all the same.

Read an order's history

GET /api/v1/orders/{orderId}/history

The order's tracking timeline: every status it moved through, oldest first. 404 for an order that does not exist or is another workspace's.

[
  { "status": "pending", "previousStatus": null, "changedAtUtc": "2026-09-05T12:01:02+00:00", "reason": null },
  { "status": "reserved", "previousStatus": "pending", "changedAtUtc": "2026-09-05T12:01:02+00:00", "reason": null },
  { "status": "reconciliationrequired", "previousStatus": "reserved", "changedAtUtc": "2026-09-05T12:31:40+00:00", "reason": "outcome_unconfirmed" },
  { "status": "fulfilled", "previousStatus": "reconciliationrequired", "changedAtUtc": "2026-09-05T14:05:12+00:00", "reason": null }
]

status and previousStatus use the order's status vocabulary; reason is one of the statusReason values, or null when no reason you may read was recorded. An entry whose status equals its previousStatus is an annotation: the status did not change, but a reason was recorded (for example refund_under_review on a delivered order). Who made a change is never part of the history.

List orders

GET /api/v1/orders?offset=0&limit=50&status=fulfilled
GET /api/v1/orders?clientReference=your-order-1001
GET /api/v1/orders?updatedFrom=2026-09-05T12:00:00Z

Newest-first by creation time, offset-paginated. Every filter is optional and they combine:

ParameterMeaning
statusOne status name, case-insensitive (fulfilled, reconciliationrequired, …). A number, a comma-separated list or an unknown name is 400 invalid_request.
statusGroupSeveral statuses read as one state, case-insensitive: open (pending, reserved, processing), delivered (fulfilled), failed (failed, cancelled), refunded, refund_pending (refundpending), under_review (reconciliationrequired). Send status or statusGroup, not both; an unknown group is 400.
clientReferenceExactly this client reference — not a prefix or a substring — compared as order creation stored it (trimmed). The way to find an order by your own id.
referenceExactly the order with this Xegora reference: ord_ and 32 hex digits, in any letter case. Anything else is 400.
productIdOrders with a line that bought this catalog product.
createdFrom / createdToA creation window: from createdFrom inclusive to createdTo exclusive. createdFrom must come before createdTo.
updatedFrom / updatedToA last-change window over updatedAtUtc, start inclusive, end exclusive, with the same rule. A sync that remembers when it last looked reads only the orders that changed since.
offset0–100000; default 0.
limit1–100; default 50.

Response: { items, offset, limit, hasMore }; when hasMore is true, request offset + items.length. Timestamps are ISO 8601 instants — use Z, or URL-encode the + of an offset as %2B.

The list is your reconciliation backbone: a periodic sweep with updatedFrom set to your last sweep's start catches anything a missed webhook or crashed worker left behind.

Tracking progress: webhooks first, polling as backup

  • Webhooks push order.processing, order.under_review, order.fulfilled, order.failed, and order.refunded to your endpoint within moments — see Webhooks.
  • Polling: GET /orders/{id} every 3–6 seconds is fine while an order is in flight (well within the rate limit for reasonable volumes); back off once you rely on webhooks.

Never mark an order delivered in your system from anything but a fulfilled status you read from the API or a verified order.fulfilled webhook.


Did this page help you?