Public API: Orders
The orders API is a read-only diagnostics view over the purchases your snippet records. Use it to confirm purchase tracking works. It gives you totals, a searchable order list, and a feed of the most recent orders, straight from the analytics store.
All orders endpoints live under a project and authenticate with a service token. The org comes from the token, so the path carries only {projectId}, not {orgId}.
https://app.avsb.cloud/api/v1/projects/{projectId}/ordersEvery response here follows the shared conventions: the { data } envelope and the standard error shape. Every /api/v1 token is rate-limited the same way everywhere: a scoped token gets 600 reads and 120 writes per minute, and an admin:* token gets 600 requests per minute, reads and writes together. Both endpoints below also need two plan features at once: Integrations and API access, and Commerce. Missing either one fails the call with 403 feature_disabled, even with a fully-permissioned token. See rate-limit headers and Refusals at 403.
Scopes
A scope is a named permission on your token. It decides exactly what that token is allowed to read or change.
| Operation | Scope |
|---|---|
| List orders, get the orders summary | orders:read |
Orders are read-only on the public API: there is no orders:write. Purchases are recorded by the snippet's track calls, not written through this API.
How duplicate orders are handled
A vs B stores one row per (projectId, orderId) pair. Your store might send the same orderId twice: a retried request, a page reload, or a refund update. When that happens, the newer row wins. Whichever write carries the latest updatedAt value is what both endpoints below report. This is last-write-wins deduplication, not an average or a sum of the two writes.
See Revenue Deduplication and Refunds for how refunds and net-versus-gross revenue work on top of this.
Get the orders summary
GET /api/v1/projects/{projectId}/orders/summary: totals across the project's orders, a per-currency breakdown, and the 50 most recent orders.
curl https://app.avsb.cloud/api/v1/projects/<projectId>/orders/summary \ -H "Authorization: Bearer avsb_svc_..."Add from and to to look at one date range instead of all time. Both are calendar days in YYYY-MM-DD format, and you must send both together:
curl "https://app.avsb.cloud/api/v1/projects/<projectId>/orders/summary?from=2026-06-01&to=2026-06-30" \ -H "Authorization: Bearer avsb_svc_..."const res = await fetch( 'https://app.avsb.cloud/api/v1/projects/<projectId>/orders/summary?from=2026-06-01&to=2026-06-30', { headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}` } },)const { data } = await res.json()console.log(data.totals.netMinor)import os, requestsres = requests.get( "https://app.avsb.cloud/api/v1/projects/<projectId>/orders/summary", params={"from": "2026-06-01", "to": "2026-06-30"}, headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"},)data = res.json()["data"]print(data["totals"]["netMinor"]){ "data": { "totals": { "orderCount": 1284, "grossMinor": 9817500, "netMinor": 9512300, "currencyMismatchCount": 3, "currency": "USD" }, "totalsByCurrency": [ { "currency": "USD", "orderCount": 1280, "grossMinor": 9805100, "netMinor": 9499900 }, { "currency": "EUR", "orderCount": 3, "grossMinor": 27400, "netMinor": 27400 } ], "unattributedCount": 42, "testCount": 5, "computedAt": "2026-06-30T23:59:59.000Z", "recent": [ { "orderId": "ord_abc", "visitorId": "vis_1", "source": "snippet", "status": "placed", "totalMinor": 5000, "refundedMinor": 0, "currency": "USD", "currencyMismatch": false, "itemCount": 2, "timestamp": "2026-06-18T10:00:00Z" } ] }}Field notes
*Minoramounts are in the currency's minor unit (for example cents):5000means$50.00for USD. Divide by the currency's exponent to display.totals.currencyis the project's own configured currency, not any one order's currency.totalscovers only orders placed in that currency, withcurrencyMismatchrows excluded.totalsByCurrencycovers every non-test order instead, grouped by whatever currency each order was actually placed in (each recent row also carries its owncurrency). A mismatched order still counts in its own currency's row there. So the two do not have to add up to the same total.currencyMismatchCounttells you how many orderstotalsskipped because their currency does not match the project's, so the top-line numbers stay trustworthy.unattributedCountis how many orders in the window arrived with no visitor id, so they cannot be credited to an experiment.testCountis how many synthetic test orders (created from the dashboard's "Send test order" button) fell in the window. They never count toward any total.computedAtis when this snapshot was taken. Two calls a second apart can return slightly different numbers if new orders arrived in between.netMinoris gross minus refunds.recentalways holds the 50 newest orders in the window. It has no query parameter of its own to change that count.
Order data is kept for up to two years, but you can only read part of that window: 90 days back without billing details on file, 365 days with them (see Data retention). Ask for a wider from/to range than that and the summary silently uses the narrower window instead. The response does not say which window it actually used, so if your numbers look smaller than expected, that is the first thing to check.
Error response
Requesting this endpoint without the Commerce plan feature enabled, even with a correctly-scoped token, answers 403:
{ "error": { "code": "feature_disabled", "message": "This feature is not included in your current plan. Upgrade to unlock it.", "details": { "kind": "plan", "feature": "commerce" } }}List orders
GET /api/v1/projects/{projectId}/orders: every order, newest first, cursor-paginated. The summary answers "is purchase tracking working"; the list answers "where did this order go".
curl "https://app.avsb.cloud/api/v1/projects/<projectId>/orders?limit=20" \ -H "Authorization: Bearer avsb_svc_..."const res = await fetch( 'https://app.avsb.cloud/api/v1/projects/<projectId>/orders?limit=20', { headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}` } },)const { data, page } = await res.json()console.log(data.length, page.hasMore)import os, requestsres = requests.get( "https://app.avsb.cloud/api/v1/projects/<projectId>/orders", params={"limit": 20}, headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"},)body = res.json()orders, page = body["data"], body["page"]{ "data": [ { "orderId": "ord_abc", "visitorId": "vis_1", "source": "snippet", "status": "placed", "totalMinor": 5000, "refundedMinor": 0, "currency": "USD", "currencyMismatch": false, "test": false, "itemCount": 2, "timestamp": "2026-06-18 10:00:00.000" } ], "page": { "nextCursor": null, "hasMore": false }}Query parameters
| Parameter | Notes |
|---|---|
limit | 1 to 100, default 20. A bad value answers 400 pagination_invalid. |
cursor | The opaque page.nextCursor from the previous response. A malformed cursor also answers 400 pagination_invalid. |
from, to | Calendar days as YYYY-MM-DD. Send both or neither; from must not be after to. |
q | Matches an order-id prefix or an exact visitor id, up to 200 characters. |
attributed | false narrows to orders with no visitor attached (they arrived without a visitor id). |
includeTest | true keeps synthetic test orders in the results. They are excluded by default. |
limit and cursor are the only two parameters that fail as pagination_invalid. Everything else that is malformed, meaning from/to/q/attributed/includeTest, answers 400 validation_failed instead of being silently ignored, so a mistyped filter never quietly widens your results.
Field notes
testmarks a synthetic test order. Test orders are excluded from every revenue total, which is why the list has to say which rows are test rows.visitorIdis an empty string when the order arrived without one. Those orders cannot be credited to an experiment.timestampis the analytics-store format (YYYY-MM-DD HH:MM:SS.mmm, UTC), not ISO-8601. It is the value the cursor is built from.sourceis one ofsnippet,datalayer,server, orshopify, depending on how the order was recorded.statusis one ofplaced,partially_refunded, orrefunded.
Error response
A limit outside 1 to 100 answers 400, distinct from the general validation error:
{ "error": { "code": "pagination_invalid", "message": "limit must be an integer between 1 and 100" }}