Developer documentation
Tracking API
Send events, identities, orders and refunds from your own server. Machine-readable contract: /api/track/openapi.json (OpenAPI 3.1). Reporting orders from your own store? Jump to the Orders API v1.
Authentication
Every endpoint on this page takes your secret REST key: Authorization: Bearer rk_…. Find it under Tracking → Configuration → Developers. Only its SHA-256 hash is stored, so a lost key can be regenerated but never recovered — and regenerating invalidates the old one immediately.
The public tk_ key that sits in your storefront's page source is not accepted on the order rails. A browser-assertable purchase would let anyone reading your page inject fabricated conversions into your ad accounts; a browser-assertable refund would let them retract real ones.
Five rules that decide everything
- Money is major units on the wire on the
/api/trackendpoints. Send49.99, never4999. The server converts using each currency's own exponent, so JPY and KRW are handled correctly without a special case on your side. The one exception is the Orders API v1 (/api/v1/orders), which takes integer minor units:4999for 49.99. - Consent is required on this rail.
POST /api/trackandPOST /api/track/identifyanswer400 consent_requiredwhencis absent — your server knows what its data subject agreed to.c: "denied"is a real200(reason: consent_deniedon the batch rail): the request did what you asked. - Read the 2xx body.
stored: 0is a legitimate success outcome. Treating any 200 as "delivered" is how a consent-denied or fully-duplicate batch looks identical to a stored one. - Everything is idempotent on a key you control.
orderIdfor purchases,refundIdfor refunds,eidper event. Send them and retry freely; omiteidand a replayed batch is stored twice. - Name your store. Whenever a commerce store (Shopify, WooCommerce, Gomag, MerchantPro) is connected to the account, even a single one, a request without
storeUrl(store_urlon the v1 API) is answered409 store_ambiguousrather than filed under a guess — double-counted revenue is worse than a loud failure. Always sending it is safest.
Track events
POST /api/track
Behavioural events — product views, cart activity, custom events. Each event in the batch is validated on its own, so one malformed event is dropped and counted, never fatal to the rest.
| Field | Type | Meaning |
|---|---|---|
| aidrequired | string · ^[A-Za-z0-9_.:-]{8,100}$ | The visitor id this call is about. One shared shape across every ingest surface: 8-100 characters of [A-Za-z0-9_.:-]. Mint it once per visitor and reuse it — a new id per request creates a new anonymous profile per request. |
| eventsrequired | array · 1-50 items | 1-50 events. Each is validated INDIVIDUALLY: one malformed event is dropped and counted in `rejected`, it does not fail the batch. |
| tk | string · max 100 chars | Public browser key. Server-rail callers omit it entirely and authenticate with `Authorization: Bearer rk_...` instead. |
| sid | string · max 100 chars | Session id. Groups events from one visit. |
| storeUrl | string · max 2000 chars | The origin of the storefront this call is about (https://shop.example.com). Required whenever a commerce store (Shopify, WooCommerce, Gomag, MerchantPro) is connected, even a single one; always sending it is safest. Without it the request is answered 409 store_ambiguous rather than filed under a guess. |
| ua | string · max 500 chars | Visitor user agent, kept on the profile for the merchant's own analytics. NOT used for bot classification — that reads the request's own User-Agent header, which a body field cannot forge. |
| lang | string · max 50 chars | Visitor language. |
| tz | string · max 100 chars | Visitor IANA timezone. |
| sr | string · max 50 chars | Screen resolution, e.g. 1920x1080. |
| c | string · granted | denied | unknown | REQUIRED on the server rail: an `rk_` batch without it is 400 consent_required. A missing value used to earn a 200 with stored:0, which is indistinguishable from success. `c: "denied"` is a real 200 outcome (reason: consent_denied) — the request did exactly what you asked. |
| gpc | boolean | Global Privacy Control. `true` is a hard denial in EVERY consent mode, overriding c: "granted". The `Sec-GPC: 1` and `DNT: 1` request headers have identical effect. |
| cp | object | Purpose-specific consent. an = analytics/measurement (same meaning as `c`; `c` wins when both are sent), ad = advertising/user-data matching (Meta CAPI, TikTok Events, Google Enhanced Conversions require "granted"), pz = personalization, ss = sale/sharing opt-out (CPRA and kin). Any purpose you do not state is `unknown`, and an unknown advertising purpose means no hashed identifier leaves for the ad platforms — a scalar `c: "granted"` is measurement consent only. A browser-level opt-out (GPC/DNT/Sec-GPC) denies every purpose regardless of this object. |
| cid | string · ^[A-Za-z0-9:_.-]{8,128}$ | Identity of THIS consent decision. Generate it once when the visitor answers and send the same value on every retry: a repeated decision id is a duplicate (recorded, no-op), a new id is a new answer. Without it the server treats each request as a possibly-new decision ordered by receipt. |
| cseq | integer · >= 0 | Client-side monotonic counter per visitor, incremented on every consent answer. When both the stored and the incoming decision carry one, a lower or equal value is SUPERSEDED (kept in history, projection untouched) — a delayed old grant can never override a newer withdrawal. |
| cts | integer · >= 1 | Client clock at decision time, milliseconds since epoch. Stored as evidence; the server's receipt time is what orders decisions that carry no `cseq`. |
| attr | object | Ad-click attribution captured from the landing URL and the merchant's own Meta/Google/TikTok pixel cookies. Stored on the visitor profile and forwarded to the conversion APIs. |
Unknown fields are ignored on this endpoint — installed SDK builds in the wild send fields newer than the server they reach.
curl -X POST https://app.aopy.com/api/track \
-H "Authorization: Bearer rk_YOUR_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"aid": "srv_7f3c1a9e2b4d8c60",
"c": "granted",
"storeUrl": "https://shop.example.com",
"events": [
{ "event": "viewedProduct", "eid": "evt-0001",
"properties": { "productId": "SKU-12", "value": 49.99, "currency": "EUR" } }
]
}'Identify a visitor
POST /api/track/identify
Bind a visitor id to an email address. Holding the secret key makes this a proven claim: it may bind an existing contact, which a browser call deliberately may not.
| Field | Type | Meaning |
|---|---|---|
| aidrequired | string · ^[A-Za-z0-9_.:-]{8,100}$ | The visitor id this call is about. One shared shape across every ingest surface: 8-100 characters of [A-Za-z0-9_.:-]. Mint it once per visitor and reuse it — a new id per request creates a new anonymous profile per request. |
| emailrequired | string · email · max 255 chars | The address this visitor is claiming. TRUST MATTERS: an `rk_` caller is proven and may bind the visitor to an EXISTING contact. A browser `tk_` caller is merely asserting — if the address already belongs to a contact, nothing is merged and the answer is 200 { identified: false, reason: "verification_required" }, because typing a stranger's address must never graft your browsing onto their profile. |
| tk | string · max 100 chars | Public browser key; omit it on the server rail. |
| firstName | string · max 100 chars | Applied on contact CREATE only — an existing contact is never overwritten from this endpoint. |
| lastName | string · max 100 chars | Applied on contact CREATE only. |
| phone | string · max 50 chars | Applied on contact CREATE only. |
| customFields | object | Applied on contact CREATE only. |
| c | string · granted | denied | unknown | Consent state. REQUIRED on the server rail: an `rk_` identify call without it is 400 consent_required, because a missing value used to earn a 200 { identified: false, reason: "consent_required" } that is byte-identical to a genuine refusal. An explicit "denied" refuses identification in EVERY mode — linking a browser to a named person is the act a stated refusal most clearly forbids. |
| gpc | boolean | Global Privacy Control; blocks identification in every mode. |
| cp | object | Purpose-specific consent. an = analytics/measurement (same meaning as `c`; `c` wins when both are sent), ad = advertising/user-data matching (Meta CAPI, TikTok Events, Google Enhanced Conversions require "granted"), pz = personalization, ss = sale/sharing opt-out (CPRA and kin). Any purpose you do not state is `unknown`, and an unknown advertising purpose means no hashed identifier leaves for the ad platforms — a scalar `c: "granted"` is measurement consent only. A browser-level opt-out (GPC/DNT/Sec-GPC) denies every purpose regardless of this object. |
| cid | string · ^[A-Za-z0-9:_.-]{8,128}$ | Identity of THIS consent decision. Generate it once when the visitor answers and send the same value on every retry: a repeated decision id is a duplicate (recorded, no-op), a new id is a new answer. Without it the server treats each request as a possibly-new decision ordered by receipt. |
| cseq | integer · >= 0 | Client-side monotonic counter per visitor, incremented on every consent answer. When both the stored and the incoming decision carry one, a lower or equal value is SUPERSEDED (kept in history, projection untouched) — a delayed old grant can never override a newer withdrawal. |
| cts | integer · >= 1 | Client clock at decision time, milliseconds since epoch. Stored as evidence; the server's receipt time is what orders decisions that carry no `cseq`. |
Unknown fields are ignored on this endpoint — installed SDK builds in the wild send fields newer than the server they reach.
curl -X POST https://app.aopy.com/api/track/identify \
-H "Authorization: Bearer rk_YOUR_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"aid": "srv_7f3c1a9e2b4d8c60",
"email": "buyer@example.com",
"c": "granted"
}'Confirm an order
POST /api/track/order-confirm
Persists the order, writes one order_complete event, and sends the conversion to every connected ad platform exactly once. Send aid to stitch the purchase onto the browsing that led to it.
| Field | Type | Meaning |
|---|---|---|
| orderIdrequired | string · max 100 chars | Your own order identifier. THE dedup key: the same orderId submitted N times produces exactly ONE order row and ONE conversion per platform (event_uid `order:<integrationId>:<orderId>`). Retry freely. |
| currencyrequired | string · max 3 chars | 3-letter ISO 4217 code, e.g. EUR. Uppercased server-side. |
| totalrequired | number · >= 0 | MAJOR-unit order total, e.g. 49.99 — never pre-multiplied into cents. The server converts using the currency's own exponent, so a zero-exponent currency (JPY, KRW) is handled correctly. 0 is accepted: a 100%-discount order is a real conversion. |
| customerEmailrequired | string · email · max 255 chars | Buyer email. Resolves an existing contact or creates one. |
| customerPhone | string · max 50 chars | Applied on contact CREATE only. |
| customerFirstName | string · max 100 chars | Applied on contact CREATE only. |
| customerLastName | string · max 100 chars | Applied on contact CREATE only. |
| lineItems | array · 0-500 items | Purchased items, forwarded to every conversion destination. `price` is the MAJOR-unit PER-UNIT price, never a line total. An item with neither `id` nor `sku` is skipped rather than given a fabricated id. |
| clientIpAddress | string · max 64 chars | The BUYER's IP, not your server's. An ad-platform match key. |
| clientUserAgent | string · max 500 chars | The buyer's user agent. An ad-platform match key. |
| storeUrl | string · max 2000 chars | The origin of the storefront this call is about (https://shop.example.com). Required whenever a commerce store (Shopify, WooCommerce, Gomag, MerchantPro) is connected, even a single one; always sending it is safest. Without it the request is answered 409 store_ambiguous rather than filed under a guess. |
| placedAt | string · date-time | ISO-8601 placement time. Absent means now — right for a live thank-you page, wrong for a backfill, and `placed_at` is what every revenue report and attribution window reads. |
| fbp | string · max 512 chars | Meta `_fbp` cookie value. |
| fbc | string · max 512 chars | Meta `_fbc` cookie value. A real cookie always wins over one constructed from `fbclid`. |
| fbclid | string · max 512 chars | Raw Meta click id; used to construct `fbc` only when no real cookie was captured. |
| gclid | string · max 512 chars | Google click id. |
| gbraid | string · max 512 chars | Google app-to-web click id. |
| wbraid | string · max 512 chars | Google web-to-app click id. |
| ttclid | string · max 512 chars | TikTok click id. |
| gaClientId | string · max 200 chars | GA4 client id (`X.Y` from the `_ga` cookie) captured at checkout. Without it NO GA4 Measurement Protocol purchase is sent — the server never fabricates a client id, because a fabricated one creates a phantom user in the merchant's GA4 property. |
| gaSessionId | string · max 200 chars | GA4 session id from the `_ga_<container>` cookie; attributes the purchase to the session that produced it. |
| utmSource | string · max 200 chars | UTM source of the visit that led to the order. Stored lowercased on the order row for the channel reports. |
| utmMedium | string · max 200 chars | UTM medium; stored lowercased. |
| utmCampaign | string · max 500 chars | UTM campaign; stored as sent. |
| landingSite | string · max 2000 chars | Full landing URL of the visit (query string included — that is where the UTMs and click ids live). |
| referringSite | string · max 2000 chars | HTTP referrer of the landing visit. |
| aid | string · ^[A-Za-z0-9_.:-]{8,100}$ | The visitor id this buyer browsed as. Optional, but supplying it is what stitches the purchase onto the browsing that led to it — otherwise you keep the sale and the journey is hard-deleted by the 30-day retention cron. Because you hold the secret key this stitch is proven: it may bind an existing contact and it settles any unverified claim a browser left open. If the response carries `rotate_aid`, replace your stored id with that value. |
| consent | string · granted | denied | What the data subject agreed to for ANALYTICS (measurement), as your server knows it. Records a consent decision the ad-platform fan-out reads before sending this person's hashed email to Meta/Google/TikTok. A "denied" stated here is enforced on this very conversion. On its own it never authorises advertising: state `consentPurposes.advertising` for that. |
| consentPurposes | object | Purpose-specific consent, long form of the browser rail's `cp`: analytics (same meaning as `consent`; `consent` wins when both are sent), advertising (required "granted" for Meta/TikTok/Google), personalization, saleSharing. Unstated purposes are `unknown`; the advertising destinations fail closed on it. A purchase confirmation is never advertising permission by itself. |
| consentDecisionId | string · ^[A-Za-z0-9:_.-]{8,128}$ | Identity of THIS consent decision. Generate it once when the visitor answers and send the same value on every retry: a repeated decision id is a duplicate (recorded, no-op), a new id is a new answer. Without it the server treats each request as a possibly-new decision ordered by receipt. |
Strict schema: an unknown field is a 400 validation_failed, never a silently ignored extra. A typo in a field name fails loudly.
curl -X POST https://app.aopy.com/api/track/order-confirm \
-H "Authorization: Bearer rk_YOUR_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"orderId": "1042",
"currency": "EUR",
"total": 49.99,
"customerEmail": "buyer@example.com",
"storeUrl": "https://shop.example.com",
"aid": "srv_7f3c1a9e2b4d8c60",
"consent": "granted",
"lineItems": [ { "id": "SKU-12", "quantity": 1, "price": 49.99 } ]
}'Refund an order
POST /api/track/order-refund
Retracts or reduces a conversion you previously reported. Without this, a refunded order stays a conversion forever and ROAS overstates by the whole refund rate.
| Field | Type | Meaning |
|---|---|---|
| orderIdrequired | string · max 100 chars | The ORIGINAL order's id — what ties this refund to its sale. |
| currencyrequired | string · max 3 chars | 3-letter ISO 4217 code. |
| refundedAmountrequired | number · >= 0 | MAJOR-unit amount refunded, POSITIVE. A negative value is rejected rather than silently absolute-valued — send 12.50, never -12.50. |
| customerEmailrequired | string · email · max 255 chars | Buyer email. NO contact is created here — a refund for someone we have never seen buy anything answers 404 contact_not_found. |
| refundId | string · max 100 chars | Your own refund identifier. Strongly recommended: it is what keeps two partial refunds against one order distinguishable. Without it the dedup key falls back to amount + timestamp. |
| remainingValue | number · >= 0 | MAJOR-unit value the order retains after this refund. Omit only if genuinely unknown: the conversion is then fully retracted rather than adjusted. |
| refundedAt | string · max 64 chars | ISO-8601 refund time. Absent means now. |
| storeUrl | string · max 2000 chars | The origin of the storefront this call is about (https://shop.example.com). Required whenever a commerce store (Shopify, WooCommerce, Gomag, MerchantPro) is connected, even a single one; always sending it is safest. Without it the request is answered 409 store_ambiguous rather than filed under a guess. |
| aid | string · ^[A-Za-z0-9_.:-]{8,100}$ | Visitor id to stitch, exactly as on order-confirm. Proven trust; honour `rotate_aid` in the response. |
| consent | string · granted | denied | What the data subject agreed to for ANALYTICS (measurement), as your server knows it. Records a consent decision the ad-platform fan-out reads before sending this person's hashed email to Meta/Google/TikTok. A "denied" stated here is enforced on this very conversion. On its own it never authorises advertising: state `consentPurposes.advertising` for that. |
| consentPurposes | object | Purpose-specific consent, long form of the browser rail's `cp`: analytics (same meaning as `consent`; `consent` wins when both are sent), advertising (required "granted" for Meta/TikTok/Google), personalization, saleSharing. Unstated purposes are `unknown`; the advertising destinations fail closed on it. A purchase confirmation is never advertising permission by itself. |
| consentDecisionId | string · ^[A-Za-z0-9:_.-]{8,128}$ | Identity of THIS consent decision. Generate it once when the visitor answers and send the same value on every retry: a repeated decision id is a duplicate (recorded, no-op), a new id is a new answer. Without it the server treats each request as a possibly-new decision ordered by receipt. |
Strict schema: an unknown field is a 400 validation_failed, never a silently ignored extra. A typo in a field name fails loudly.
curl -X POST https://app.aopy.com/api/track/order-refund \
-H "Authorization: Bearer rk_YOUR_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"orderId": "1042",
"refundId": "rf-9",
"currency": "EUR",
"refundedAmount": 12.50,
"remainingValue": 37.49,
"customerEmail": "buyer@example.com",
"storeUrl": "https://shop.example.com"
}'Orders API v1
The versioned way to report orders from a custom store or any platform Aopy has no plugin for. Three calls, all authenticated with the secret rk_ key, all idempotent, all in integer minor units (cents). The older POST /api/track/order-confirm above stays supported and keeps its major-unit amounts. Machine-readable contract: /api/track/openapi.json.
Rules that decide everything
- Money is integer minor units. Send 4999 for 49.99 EUR, never 49.99. This is the opposite of /api/track/order-confirm, which takes major units. Only currencies with a 2-decimal minor unit are accepted (EUR, RON, USD, GBP); JPY, KRW, HUF, BHD, KWD are refused with a 400.
- Every call needs an Idempotency-Key header. 1-200 characters from A-Z a-z 0-9 _ -, unique per logical request, identical on every retry of it. The same key with the same body replays the first response (status and body) with the header Idempotency-Replayed: true, for 24 hours. The same key with a different body, method or path is a 409 idempotency_key_reused. A 409 idempotency_in_progress means the first attempt is still running: retry shortly. 5xx answers, 404 order_not_found and 409 store_ambiguous are not remembered, so you can fix the cause and retry under the same key.
- Unknown fields are a 400. Every body is strict: a misspelled or unsupported field fails with validation_failed and the offending names in details. It is never silently ignored.
- Status changes. POST /api/v1/orders carries the whole order and is an upsert on external_id. To move an order from pending to paid, POST it again with the same external_id, status paid and a newer updated_at. PATCH only accepts cancelled, fulfilled or delivered. An older updated_at than the stored one is accepted but changes nothing.
- Refunds need an existing order. POST /api/v1/orders/{externalId}/refunds finds the order and its buyer by externalId, so create the order first (404 order_not_found otherwise). The store is NOT read from the stored order: send store_url on PATCH and refunds too, exactly as on the POST, or a connected commerce store makes the call a 409 store_ambiguous.
- Body size. 256 KB for POST /api/v1/orders, 16 KB for PATCH and refunds. Over the limit is a 413.
Create or update an order
POST /api/v1/orders
Upsert on external_id. Answers 200 { success, external_id, contact_id, rotate_aid?, reason? }. A paid, fulfilled or delivered order is reported to your connected ad platforms exactly once; pending and cancelled orders never are.
| Field | Type | Meaning |
|---|---|---|
| external_idrequired | string,number | Your own order identifier, 1-100 characters (a number is accepted and stringified). THE identity of the order: posting the same external_id again updates that order, it never creates a second one. |
| statusrequired | string · pending | paid | fulfilled | delivered | cancelled | refunded | partially_refunded | REQUIRED, no default. pending, cancelled, refunded and partially_refunded never fire a purchase conversion; paid, fulfilled and delivered are a sale and fire it exactly once; cancelled and refunded take back a conversion already fired. partially_refunded retracts nothing: report a partial refund with POST /api/v1/orders/{externalId}/refunds. To move pending to paid, POST the same external_id again with status paid and a newer updated_at (PATCH cannot do this). |
| updated_atrequired | string · date-time | REQUIRED. The last-modified time of the order in YOUR system, ISO-8601 with offset. Not placed_at: that never changes. A delivery whose updated_at is older than the stored one is answered 200 but changes nothing, so a late retry cannot overwrite a newer state. Send a newer value on every status-changing re-POST. |
| currencyrequired | string · 3 chars | 3-letter ISO 4217 code, uppercased server-side. Only currencies with a 2-decimal minor unit are accepted (EUR, RON, USD, GBP, ...). JPY, KRW, HUF, BHD, KWD and the like are a 400. |
| total_minorrequired | integer · >= 0 · <= 100000000000 | Order grand total in MINOR units (cents): 4999 means 49.99. An integer, never a decimal. 0 is accepted (a 100%-discount order is still a sale). |
| customer_emailrequired | string · email · max 255 chars | Buyer email, lowercased server-side. Resolves an existing contact or creates one. |
| placed_at | string · date-time | ISO-8601 time the order was placed, offset allowed. Absent means now: right for a live order, wrong for a backfill. Every revenue report and attribution window reads this. |
| subtotal_minor | integer · >= 0 · <= 100000000000 | Subtotal in minor units (cents). |
| tax_minor | integer · >= 0 · <= 100000000000 | Tax in minor units (cents). |
| shipping_minor | integer · >= 0 · <= 100000000000 | Shipping in minor units (cents). |
| discount_minor | integer · >= 0 · <= 100000000000 | Discount in minor units (cents), as a positive number. |
| payment_method | string · cod | card | transfer | other | How the buyer paid. One of cod, card, transfer, other. |
| customer_phone | string · max 50 chars | Applied on contact CREATE only. |
| customer_first_name | string · max 100 chars | Applied on contact CREATE only. |
| customer_last_name | string · max 100 chars | Applied on contact CREATE only. |
| line_items | array · 0-500 items | Purchased items, up to 500, forwarded to every conversion destination. Each item needs at least one of product_id, sku, variant_id. unit_price_minor is the PER-UNIT price in minor units, never a line total. |
| store_url | string · max 500 chars | Origin of the storefront the order belongs to (https://shop.example.com). Required whenever a commerce store (Shopify, WooCommerce, Gomag, MerchantPro) is connected to the account, even a single one: without it the call is answered 409 store_ambiguous rather than filed under a guess. Always sending it is safest; it must match a connected store. |
| client_ip | string · max 64 chars | The BUYER's IP address, not your server's. An ad-platform match key. |
| client_user_agent | string · max 500 chars | The buyer's browser user agent. An ad-platform match key. |
| fbp | string · max 512 chars | Meta `_fbp` cookie value. |
| fbc | string · max 512 chars | Meta `_fbc` cookie value. A real cookie wins over one built from fbclid. |
| fbclid | string · max 512 chars | Raw Meta click id; used to build fbc only when no real cookie was captured. |
| gclid | string · max 512 chars | Google click id. |
| gbraid | string · max 512 chars | Google app-to-web click id. |
| wbraid | string · max 512 chars | Google web-to-app click id. |
| ttclid | string · max 512 chars | TikTok click id. |
| ga_client_id | string · max 200 chars | GA4 client id (`X.Y` from the `_ga` cookie). Without it no GA4 purchase hit is sent: the server never invents a client id. |
| ga_session_id | string · max 200 chars | GA4 session id from the `_ga_<container>` cookie. |
| utm_source | string · max 200 chars | UTM source of the visit that led to the order. |
| utm_medium | string · max 200 chars | UTM medium. |
| utm_campaign | string · max 500 chars | UTM campaign. |
| utm_content | string · max 500 chars | UTM content. |
| utm_term | string · max 500 chars | UTM term. |
| landing_url | string · max 2000 chars | Full landing URL of the visit, query string included (that is where UTMs and click ids live). |
| referrer_url | string · max 2000 chars | HTTP referrer of the landing visit. |
| aid | string · ^[A-Za-z0-9_.:-]{8,100}$ | The visitor id the browser snippet reports for this buyer: call cdm.visitorId() on the storefront and pass it through your checkout to your server. Sending it stitches the sale onto the browsing that led to it. Because you hold the secret key the stitch is proven. If the response carries rotate_aid, replace your stored id with it. |
| consent | string · granted | denied | What the buyer agreed to for ANALYTICS, as your server knows it. A denied stated here is enforced on this very order. It never authorises advertising by itself: state consent_purposes.advertising for that. |
| consent_purposes | object | Purpose-specific consent: analytics, advertising, personalization, saleSharing, each granted, denied or unknown. Ad-platform conversions require advertising granted. An unstated purpose is unknown. |
| consent_decision_id | string · ^[A-Za-z0-9:_.-]{8,128}$ | Identity of this consent decision (8-128 characters). Generate once when the buyer answers and resend the same value on retries. |
Strict schema: an unknown field is a 400 validation_failed, never a silently ignored extra. A typo in a field name fails loudly.
curl -X POST https://app.aopy.com/api/v1/orders \
-H "Authorization: Bearer rk_YOUR_SECRET_KEY" \
-H "Idempotency-Key: order-1042-paid" \
-H "Content-Type: application/json" \
-d '{
"external_id": "1042",
"status": "paid",
"placed_at": "2026-10-13T09:15:00+03:00",
"updated_at": "2026-10-13T09:16:30+03:00",
"currency": "EUR",
"total_minor": 4999,
"tax_minor": 867,
"shipping_minor": 0,
"payment_method": "card",
"customer_email": "buyer@example.com",
"customer_first_name": "Ana",
"line_items": [
{
"product_id": "SKU-12",
"name": "Wireless Headphones",
"quantity": 1,
"unit_price_minor": 4999
}
],
"store_url": "https://shop.example.com",
"aid": "srv_7f3c1a9e2b4d8c60",
"consent": "granted"
}'line_items[] fields
Each item needs at least one of product_id, sku, variant_id. Unknown fields are a 400 here too.
| Field | Type | Meaning |
|---|---|---|
| product_id | string · max 200 chars | Your product id. MUST equal the product's id in your product feed, or the item cannot be matched to the catalogue. |
| variant_id | string · max 200 chars | Variant id, if the product has variants. |
| sku | string · max 200 chars | SKU. |
| name | string · max 500 chars | Product name. |
| category | string · max 200 chars | Product category. |
| quantity | integer · > 0 · <= 100000 | Units bought, a positive integer. |
| unit_price_minor | integer · >= 0 · <= 100000000000 | Price of ONE unit in minor units (cents). |
Strict schema: an unknown field is a 400 validation_failed, never a silently ignored extra. A typo in a field name fails loudly.
Cancel, fulfil or deliver an order
PATCH /api/v1/orders/{externalId}
Only for an order that already exists (404 order_not_found otherwise). Send store_url here too when a commerce store is connected. Answers 200 { success, external_id, applied }; applied: false means the change was older than the stored state. For pending to paid, POST the order again instead.
| Field | Type | Meaning |
|---|---|---|
| statusrequired | string · cancelled | fulfilled | delivered | cancelled retracts the order's conversion; fulfilled and delivered record shipping progress. Nothing else is accepted here: moving pending to paid is a re-POST of the order, and refunds have their own call. |
| occurred_at | string · date-time | ISO-8601 time the change happened in your system. Absent means now. A change older than the stored state is answered 200 with applied: false. |
| store_url | string · max 500 chars | Origin of the storefront the order belongs to (https://shop.example.com). Required whenever a commerce store (Shopify, WooCommerce, Gomag, MerchantPro) is connected to the account, even a single one: without it the call is answered 409 store_ambiguous rather than filed under a guess. Always sending it is safest; it must match a connected store. |
Strict schema: an unknown field is a 400 validation_failed, never a silently ignored extra. A typo in a field name fails loudly.
curl -X PATCH https://app.aopy.com/api/v1/orders/1042 \
-H "Authorization: Bearer rk_YOUR_SECRET_KEY" \
-H "Idempotency-Key: order-1042-fulfilled" \
-H "Content-Type: application/json" \
-d '{
"status": "fulfilled",
"occurred_at": "2026-10-14T11:00:00+03:00"
}'Refund an order
POST /api/v1/orders/{externalId}/refunds
The order must already exist, and its buyer is found from it. The store is not: send store_url here too, or a connected commerce store makes this a 409 store_ambiguous. Answers 200 { success, external_id }. A refund larger than what the order has left is 409 refund_exceeds_remaining.
| Field | Type | Meaning |
|---|---|---|
| refunded_minorrequired | integer · > 0 · <= 100000000000 | Amount refunded in MINOR units (cents), a positive integer. More than the order has left is a 409 refund_exceeds_remaining. |
| refund_id | string · min 1 chars · max 100 chars | Your own refund identifier, 1-100 characters. Strongly recommended: it keeps two partial refunds of one order distinguishable. Omitted, the Idempotency-Key stands in for it. |
| remaining_minor | integer · >= 0 · <= 100000000000 | What the order retains after ALL refunds to date, in minor units. Omit only when genuinely unknown: the conversion is then treated as fully refunded. |
| refunded_at | string · date-time | ISO-8601 time of the refund. Absent means now. |
| store_url | string · max 500 chars | Origin of the storefront the order belongs to (https://shop.example.com). Required whenever a commerce store (Shopify, WooCommerce, Gomag, MerchantPro) is connected to the account, even a single one: without it the call is answered 409 store_ambiguous rather than filed under a guess. Always sending it is safest; it must match a connected store. |
Strict schema: an unknown field is a 400 validation_failed, never a silently ignored extra. A typo in a field name fails loudly.
curl -X POST https://app.aopy.com/api/v1/orders/1042/refunds \
-H "Authorization: Bearer rk_YOUR_SECRET_KEY" \
-H "Idempotency-Key: order-1042-refund-rf-9" \
-H "Content-Type: application/json" \
-d '{
"refund_id": "rf-9",
"refunded_minor": 1250,
"remaining_minor": 3749,
"refunded_at": "2026-10-20T10:00:00+03:00"
}'Read your configuration
GET /api/track/config
With the REST key this returns your organization's consent mode, retention window, rail: "server" and consentFieldRequired: true. The response is never cached. Called with only the public tk query parameter it fails closed to strict defaults and is deliberately indistinguishable from a genuinely-strict organization — it is not a key oracle.
curl https://app.aopy.com/api/track/config \
-H "Authorization: Bearer rk_YOUR_SECRET_KEY"What to do with every response
| Status | Meaning | What you do |
|---|---|---|
| 2xx | Accepted. READ THE BODY. | `stored` can legitimately be 0 — `reason: "consent_denied"` means the data subject refused and the request did exactly what you asked. Never retry a 2xx. |
| 400 | Permanent. The payload is wrong. | Never retry unchanged. `validation_failed` names the offending fields; on the order rails an unknown field is itself a 400 (those schemas are strict). `consent_required` means the server rail needs `c`. |
| 401 | Permanent. The key is missing, malformed, or revoked. | Stop and re-provision. Regenerating the REST key in the dashboard invalidates the old one immediately. The one exception is `clock_skew` (a signed request whose timestamp is off): re-sign once using the `now` in the body. |
| 404 | Permanent (refund only). `contact_not_found` or `order_not_found`. | The buyer has no contact, or no store of this organization has received the order. Send the order first, or correct the email. |
| 409 | Permanent until you add information. `store_ambiguous`. | A commerce store is connected and `storeUrl` is missing or matches none. Re-send with `storeUrl` naming the storefront. |
| 413 | Permanent. Body over 256 KB (64 KB on refund). | Split the batch. Never retry the same oversized body. |
| 429 | Throttled. | Honour the `Retry-After` header exactly; do not substitute your own schedule. Limits are charged per EVENT received, not per request. |
| 5xx | Transient. | Retry with exponential backoff. Every endpoint here is idempotent on its own key (`orderId`, `refundId`, per-event `eid`), so a retry cannot double-count. |
Rate limits
- 1000 events per minute per credential. The REST key's SHA-256 hash is the bucket — the plaintext secret never becomes a Redis key.
- 100 events per minute per (organization, visitor id) — one visitor cannot exhaust a merchant's budget.
- Both are charged per EVENT in the batch, not per HTTP request.