Every Seller API order moves through a defined set of states. This page is the authoritative model: the states, the transitions, the webhook emitted at each one, and what to do when a transition fails. Other order pages describe individual endpoints; this page describes how they fit together.
The golden path#
Three rules govern the whole flow:
- Never charge before
canFulfill: true. Avrix does not partially fulfil. - Always send a stable
Idempotency-Keyon writes. It is the only thing standing between a network blip and a double fulfilment. - Persist entitlements on first receipt. Key plaintext is returned once.
States#
Checkout and fulfilment path:
After fulfilment:
| State | Meaning | Terminal |
|---|---|---|
previewed | Priced and checked. No commitment, no capacity held. Not a persisted order. | No |
held | Capacity and price provisionally reserved for a short TTL. | No |
reserved | Hot-drop capacity claimed, awaiting commit. | No |
preordered | Legacy alias for a key-locked preorder reservation. New durable writes use awaiting_release. | No |
awaiting_release | True preorder accepted against allocation headroom. Keys not locked. | No |
awaiting_stock | Customer-order backorder accepted against headroom. Keys not locked. | No |
ready | Inventory arrived; call POST /orders/{orderId}/fulfill to reveal keys. | No |
partially_fulfilled | Schema-ready for multi-line carts. v1 single-line orders do not use this. | No |
fulfilled | Entitlements issued. Money is owed to Avrix. | Yes |
failed | Rejected at commit. No entitlement, no charge from Avrix. | Yes |
cancelled | Reservation or preorder released before fulfilment. | Yes |
returned | Keys returned via quarantine (not recycled into sellable inventory without review). | Yes |
refunded | Refund recorded against a fulfilled sale. | Yes |
These labels describe the integration lifecycle (what your storefront should track). They are
not a top-level status enum on GET /orders.
GET /orders response shape (verified)#
GET /api/seller/v1/orders?orderReference=… (or orderReadableId=…) returns an aggregate, not
a single status string:
{
"data": {
"orderReference": "store-order-10432",
"status": "fulfilled",
"fulfilled": [
{
"skuCode": "EXAMPLE-WIN-US",
"allocationId": "f8b1...",
"fulfilledQuantity": 2,
"firstKeyPullId": "…",
"orderReadableId": "ord_…"
}
],
"preOrderActive": [
{
"skuCode": "EXAMPLE-PRE-US",
"allocationId": "c410...",
"reservedQuantity": 1,
"reservedUntil": "2026-09-01T00:00:00.000Z"
}
],
"refunds": []
}
}
| Field | Meaning for reconciliation |
|---|---|
status | Reconstruction default: preordered, fulfilled, returned, or refunded. When durable-order reads are on: status comes from sales.orders (awaiting_release, awaiting_stock, ready, fulfilled, …) and orderId is the durable ord_…. Lines may expose qtyRequested / qtyPending / qtyAssigned / qtyRevealed. |
fulfilled[] | Key pulls already completed for this orderReference (quantities and allocation ids). No key plaintext. |
preOrderActive[] | Active pre-order reservations still waiting for release. |
refunds[] | Refund/chargeback/return records against this reference: { refundId, status, keyIds }. |
Both lines empty / 404 | No pulls or reservations for that reference — treat as not committed (or wrong lookup). |
States that leave no durable rows (previewed, held, reserved, failed, cancelled) cannot
be reconstructed by lookup — a reference that never committed returns 404.
Transitions and events#
| Transition | Trigger | Webhook |
|---|---|---|
→ previewed | POST /orders/preview | None — preview is read-only |
previewed → held | POST /orders/hold | None |
held → previewed | DELETE /orders/hold or TTL expiry | None |
previewed → reserved | POST /orders/reserve | order.reserved |
reserved → fulfilled | POST /orders/commit | order.fulfilled |
previewed → fulfilled | POST /orders | order.fulfilled |
previewed → awaiting_release | POST /orders on a catalog pre_order SKU when preorderEnabled is true | order.reserved |
previewed → awaiting_stock | POST /orders with backorderOnUnavailable when the capability and vendor policy allow it | order.reserved |
awaiting_* → ready | Inventory covers the pending quantity | order.ready |
ready → fulfilled | POST /orders/{orderId}/fulfill | order.fulfilled (and deprecated order.preorder_fulfilled when the order was a preorder) |
previewed → preordered | Legacy key-locked preorder reservation | order.reserved |
preordered → fulfilled | Legacy cron converts reserved keys | order.preorder_fulfilled |
any → failed | Rejection at commit | order.failed |
any → cancelled | POST /orders/cancel (pre-order / reservation) | order.returned |
fulfilled → returned | POST /keys/return | order.keys_returned |
fulfilled → refunded | POST /refunds | refund.completed |
Step by step#
1. Preview#
Call when the buyer reaches checkout. Preview is read-only, holds nothing, and is safe to retry.
Gate on canFulfill. If it is false, do not show a pay button — POST /orders will fail
because Avrix never partially delivers.
Keep unitPriceCents. You must echo it as expectedUnitPriceCents on the order.
estimateOnly: true appears on every preview. Prices and availability reflect the moment of the
call and can move. Do not persist a preview beyond the open cart.
2. Hold — optional#
For hot drops, slow checkouts, or multi-step flows where seconds matter, POST /orders/hold provisionally reserves capacity and binds the price for a short TTL.
curl -s -X POST "https://api.avrix.io/api/seller/v1/orders/hold" \
-H "Authorization: Bearer avrix_sk_your_key_here" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: hold-store-order-10432" \
-d '{
"skuCode": "EXAMPLE-WIN-US",
"quantity": 1,
"countryCode": "US",
"orderReference": "store-order-10432",
"integrationOrderContext": {
"schemaVersion": 1,
"salesCountryCode": "US",
"currencyCode": "USD",
"salesPriceGrossMinor": 7999,
"salesTaxRatePercent": 0,
"salesChannel": "web"
}
}'
A hold carries the same settlement declarations as the order it precedes (gross price, tax rate, channel), so the bound snapshot is settled on the same basis; production keys also declare the buyer's IP. See Integration order context.
Idempotency-Key is required on both POST and DELETE /orders/hold.
While a hold is active, live catalog price drift is ignored — the bound snapshot is authoritative.
Pass checkoutHoldId on POST /orders to use it. Release the hold with DELETE /orders/hold if
the buyer abandons; otherwise it expires on its own.
Holds are capability-gated. Check capabilities.checkoutHoldEnabled on GET /whoami; when
disabled, hold requests return CHECKOUT_HOLD_DISABLED and you should fall back to preview
gating alone.
3. Charge the buyer#
Entirely in your system, at your own PSP. Avrix has no visibility into this step and never charges or refunds your buyer.
Capture the payment reference — you send it as commercial evidence on the order.
4. Create the order#
This is the committing call. It consumes allocation capacity and issues entitlements.
Required: a stable Idempotency-Key, an orderReference, and expectedUnitPriceCents matching
the preview. See Integration recipes for the full call contract and
the interactive API reference for the request schema.
5. Deliver and persist#
Write the entitlement to durable storage before returning a response to the buyer. Keys are
returned once; GET /orders never returns plaintext. Persist keys[] with matching keyIds[]
from the create-order response.
6. Reconcile#
Confirm your record matches Avrix. GET /orders?orderReference=… is the lookup, and it is also
your recovery path when anything goes wrong.
Failure modes#
Implement all of these. Each is reachable in normal operation.
The order result is unknown#
The most important failure mode in the integration. You charged the buyer and POST /orders
timed out, returned a network error, or your process crashed before recording the response. You do
not know whether keys were pulled.
Do this:
- Retry the identical request — same
Idempotency-Key, same body, byte for byte. If the original succeeded you receive the original response, including the original keys. If it never executed, it executes now. Either way you end with exactly one fulfilment. - If retries keep failing, look the order up:
GET /orders?orderReference=store-order-10432. If it exists, the order succeeded and you need the entitlement — which you can no longer retrieve, so escalate to Avrix support with the order reference and request id. - If the order does not exist, it never committed. Retry from step 1 or refund the buyer.
Never do this:
- Do not charge the buyer again.
- Do not retry with a new idempotency key. That is a new order and will pull a second key.
- Do not assume failure from a timeout. A timeout is not an outcome.
Inventory sold out between preview and order#
409 NO_AVAILABLE_KEYS. Avrix pulled nothing — the order is all-or-nothing, so there is no partial
state to clean up. Pre-order reservations follow the same rule: the reserved quantity must equal
the requested quantity or the transaction releases every claim and fails.
backorderOnUnavailable: true does not create a zero-stock backorder today. Unless
whoami.capabilities.backorderEnabled is true, that flag returns 403 FEATURE_DISABLED.
You have already charged the buyer. Refund at your PSP. Avrix will not do it and has no mechanism to. Then either offer an alternative SKU or apologise.
Prevent it by gating on canFulfill and, for high-contention drops, using a checkout hold or the
reserve/commit flow.
Price changed between preview and order#
CATALOG_PRICE_MISMATCH under the API business model (a legacy WHOLESALE_PRICE_MISMATCH code may
still appear in the contract for non-API grants). Nothing was pulled and nothing was charged by
Avrix.
Re-run preview to get the current price. Then decide, deliberately: if the new cost is higher than what you sold at, you may be selling at a loss. Do not blindly re-submit with the new price — this guard exists to force that decision rather than let it happen silently.
Prevent it with a checkout hold, which binds the price for its TTL.
Duplicate submission#
Handled by the idempotency layer.
| Situation | Result |
|---|---|
| Same key, identical body | Original response replayed. No second fulfilment. |
| Same key, different body | IDEMPOTENCY_KEY_MISMATCH with details.requestBodyHash |
| Same key, first request still running | IDEMPOTENCY_REQUEST_IN_FLIGHT with Retry-After |
| Idempotency store unavailable | IDEMPOTENCY_STORE_UNAVAILABLE — rejected rather than risk a duplicate |
Keys are honoured for 24 hours. After that a replay is treated as a new request.
Product not sellable#
PRODUCT_NOT_SELLABLE. The product is not transactable: launchPhase is not available (including
announced / pre_order), or it is discontinued / archived.
Your catalog is stale. Re-sync, delist the SKU, and refund if you charged.
Territory rejected#
TERRITORY_NOT_ALLOWED_FOR_SKU, TERRITORY_EXCLUDED_FOR_RELEASE, or SKU_REGION_UNRESOLVED. The
buyer's country is not permitted for this SKU.
Gate earlier: check allowedCountries on product detail before offering the SKU to that market.
Webhook arrives before the HTTP response#
Possible and legal. order.fulfilled can reach your endpoint before POST /orders returns.
Design for it: key your webhook handler on orderReference, and make it tolerate an order it has
not yet recorded — buffer it, or create the record from the webhook and reconcile when the HTTP
response lands. Never assume an ordering relationship between the response and the event.
Duplicate or out-of-order webhooks#
Delivery is at-least-once and not globally ordered. Deduplicate on X-Avrix-Event-Id and make
handlers idempotent. See the webhook guide.
Missed webhooks#
An endpoint outage past the retry schedule means lost events. Recover by polling
GET /orders?orderReference=… for each open order and reconciling, and by replaying deliveries
from the Console. Do not
depend on webhooks as your only source of truth for money-relevant state.
Preorders#
A preorder SKU accepts orders before the release date. POST /orders returns a reservation rather
than keys, and order.reserved fires. When the release date arrives Avrix fulfils automatically
and order.preorder_fulfilled fires with the entitlement.
Acceptance is decided by the effective window, not the launch-phase label alone: with the
capability on, an order is accepted only between preorderStart and preorderEnd (or once
preorderUnlocked is set). Outside the window the order is rejected with
PRODUCT_NOT_SELLABLE — never converted into a silent live pull. If the pre-order state cannot
be verified at all, the API answers 503 PREORDER_STATE_UNAVAILABLE with Retry-After: 5;
keys are never revealed on unverifiable state. The same gate now applies at every commercial
stage — preview, hold, reserve, commit, and bulk — so a rejection surfaces before the buyer pays,
not after.
Between those two events the buyer has paid and has no key. Communicate the release date clearly at checkout, and make sure your fulfilment pipeline handles delivery triggered by a webhook rather than by a synchronous response — this is the one flow where the entitlement genuinely arrives out of band.
Cancel with POST /orders/cancel before fulfilment — this releases pre-order reservations and
emits order.returned (use a stable Idempotency-Key so retries do not double-emit). After
fulfilment, use the refund and return path (order.keys_returned / refund.completed).
Release-date slips#
When a publisher moves a release date, Avrix emits release.date_changed with
{ productId, releaseId, skuIds, oldDate, newDate }. Playbook for stores holding pre-orders:
- Update the listing's release date from
newDate— do not wait for the next catalog sync. - Notify buyers with open pre-orders for the affected
skuIds(a slipped date is the single biggest source of pre-order support tickets). - Re-check your pre-order window logic: fulfilment still happens automatically when the (new)
release date arrives, and
order.preorder_fulfilledfires then. - If the slip is unacceptable to a buyer, cancel their reservation with
POST /orders/canceland refund at your PSP.
Reservation extend#
Hot-drop and some reserved states support extending TTL before commit:
POST /api/seller/v1/orders/{orderId}/extend
Use this when the buyer is still in checkout and the reservation would otherwise expire. Extend is only for reservation TTL — it does not re-issue keys.
Refunds and returns#
You refund the buyer at your PSP. That is your decision and Avrix is not involved.
Separately, you may return the keys to Avrix inventory through quarantine, which is what affects settlement:
curl -s -X POST "https://api.avrix.io/api/seller/v1/keys/return" \
-H "Authorization: Bearer avrix_sk_your_key_here" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: return-store-order-10432" \
-d '{
"orderReference": "store-order-10432",
"keyIds": ["k_8f21...", "k_9a02..."],
"reason": "buyer_refund"
}'
keyIds must come from the original POST /orders response or the order.fulfilled webhook.
There is no endpoint that will give them to you later — another reason to persist the full
fulfilment payload, not just the key strings.
Returned keys enter quarantine rather than going straight back to sellable inventory.
Related#
- Integration recipes — the order call contract per flow
- Catalog and allocations — what is sellable
- Webhook guide — consuming lifecycle events
- Error reference — every code