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" }
}| Field | Rules |
|---|---|
item | The 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. |
maximumTotal | Optional, 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. |
quoteId | An unexpired, unused quote created by your workspace through the API. Send quoteId or item, never both. |
clientReference | Required, ≤ 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.phoneNumber | Required 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 Acceptedwith the order as it stands now — it may already befulfilled. Nothing is priced, reserved or charged again. - With
quoteId, a retry is the same request when it names the same quote, the sameclientReferenceand the samepurchaseInput. Anything else under a spent key is409 idempotency_conflict. - With
item, a retry is judged by the request it repeats, as it was sent — never by today's catalog: the sameproductId,variantId,value(or the same absence of one;25and25.00are one amount),quantity,currency(any letter case),clientReference(surrounding spaces ignored) andpurchaseInput. It must also accept the order's total: a retry whosemaximumTotalis 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 — is409 idempotency_conflict. - When the key's order kept no record of the request it was sent with, an
itemretry is judged from the order itself instead: it is the same purchase when the order has exactly one line, of the sameproductIdandvariantId, the samequantity, the same settlementcurrency, the sameclientReferenceand the samepurchaseInput, for the amount the retry names (an omittedvaluebeing the product's default face value, as a fresh quote would read it) — and the order's total is still within the retry'smaximumTotal. Two kinds of order have no such record: one placed before Xegora began recording the request with the key, and one placed from aquoteId(a quote is not anitemrequest, so there is nothing to record). A genuine retry of either is therefore answered with the order it already placed, not refused. - A
quoteIdrequest never answers for anitemorder. A one-call purchase makes a quote of its own, so a spent key can never be presented with the samequoteIdagain: senditemto retry anitemorder. - 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.
| Status | code | Why | Your move |
|---|---|---|---|
| 400 | invalid_request | Missing 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. |
| 403 | api_access_inactive | The 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. |
| 403 | entitlement_required | API purchasing is not enabled for the workspace — normally answered earlier as api_access_inactive. | Contact your account manager. |
| 404 | not_found | The quoteId is not a quote your workspace made, or the item's product is not in your catalog. | Quote again, or refresh the product. |
| 409 | insufficient_funds | The wallet's available balance does not cover the total. | Fund the wallet and resend with the same key. |
| 409 | price_above_maximum | Today's total is above your maximumTotal; the body carries quotedTotal, currency and maximumTotal. | Show the new price, or resend with a higher ceiling. |
| 409 | quote_unavailable | The quote expired or was already used. | Quote again, or send the item. |
| 409 | quote_channel_mismatch | The quote was made for another channel (your Xegora storefront), not through the API. | Quote again through the API. |
| 409 | quote_buyer_mismatch | The quote is bound to a storefront buyer. | Quote again through the API. |
| 409 | product_unavailable | The product was withdrawn after your quote was made; nothing was charged. | Refresh the product; offer another denomination. |
| 409 | idempotency_conflict | The 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. |
| 409 | concurrent_update | The wallet or quote changed while the order was being placed. | Resend with the same key. |
| 409 | wallet_funding_on_hold | New 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. |
| 409 | order_held_for_review | The 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. |
| 422 | quote_unavailable | The quote cannot be ordered. | Quote again. |
| 422 | product_unavailable | With item: the product or denomination is not sold right now. | Refresh the product; offer another denomination. |
| 422 | currency_not_supported | currency is not the product's sellCurrency. | Use the product's sellCurrency. |
| 422 | pricing_unavailable | With item: the item cannot be priced right now. | Try again shortly. |
| 422 | wallet_unavailable | The workspace holds no active wallet in that currency. | Order in a currency your workspace holds. |
| 422 | purchase_input_invalid | A 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. |
| 422 | order_limit_exceeded | One 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. |
| 422 | order_period_limit_exceeded | The 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. |
| 422 | currency_limits_not_configured | Limits 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:
| Status | Meaning | Final? |
|---|---|---|
reserved | Admitted: the order's total is reserved on your wallet and it is queued for fulfillment. Every order starts here. | no |
processing | Being fulfilled; its outcome was not immediate. | no |
fulfilled | Delivered — artifacts are ready to reveal and the funds are captured. | yes, unless it is refunded later |
failed | Not 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 |
refunded | The 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 |
reconciliationrequired | Under 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:
statusReason | What is wrong | Your move |
|---|---|---|
recipient_number_not_supported | The 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_operator | The 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).
| Field | Meaning |
|---|---|
publicReference | Xegora'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. |
completedAtUtc | When 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. |
statusReason | Why 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. |
refund | For 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[].lineTotal | What the line cost in all, in currency, exactly as it was charged. |
lines[].productId, lines[].variantId | The 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:
| Parameter | Meaning |
|---|---|
status | One status name, case-insensitive (fulfilled, reconciliationrequired, …). A number, a comma-separated list or an unknown name is 400 invalid_request. |
statusGroup | Several 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. |
clientReference | Exactly 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. |
reference | Exactly the order with this Xegora reference: ord_ and 32 hex digits, in any letter case. Anything else is 400. |
productId | Orders with a line that bought this catalog product. |
createdFrom / createdTo | A creation window: from createdFrom inclusive to createdTo exclusive. createdFrom must come before createdTo. |
updatedFrom / updatedTo | A 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. |
offset | 0–100000; default 0. |
limit | 1–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, andorder.refundedto 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.
Updated 13 days ago