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

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.

FieldTypeMeaning
aidrequiredstring · ^[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.
eventsrequiredarray · 1-50 items1-50 events. Each is validated INDIVIDUALLY: one malformed event is dropped and counted in `rejected`, it does not fail the batch.
tkstring · max 100 charsPublic browser key. Server-rail callers omit it entirely and authenticate with `Authorization: Bearer rk_...` instead.
sidstring · max 100 charsSession id. Groups events from one visit.
storeUrlstring · max 2000 charsThe 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.
uastring · max 500 charsVisitor 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.
langstring · max 50 charsVisitor language.
tzstring · max 100 charsVisitor IANA timezone.
srstring · max 50 charsScreen resolution, e.g. 1920x1080.
cstring · granted | denied | unknownREQUIRED 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.
gpcbooleanGlobal 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.
cpobjectPurpose-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.
cidstring · ^[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.
cseqinteger · >= 0Client-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.
ctsinteger · >= 1Client clock at decision time, milliseconds since epoch. Stored as evidence; the server's receipt time is what orders decisions that carry no `cseq`.
attrobjectAd-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.

FieldTypeMeaning
aidrequiredstring · ^[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.
emailrequiredstring · email · max 255 charsThe 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.
tkstring · max 100 charsPublic browser key; omit it on the server rail.
firstNamestring · max 100 charsApplied on contact CREATE only — an existing contact is never overwritten from this endpoint.
lastNamestring · max 100 charsApplied on contact CREATE only.
phonestring · max 50 charsApplied on contact CREATE only.
customFieldsobjectApplied on contact CREATE only.
cstring · granted | denied | unknownConsent 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.
gpcbooleanGlobal Privacy Control; blocks identification in every mode.
cpobjectPurpose-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.
cidstring · ^[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.
cseqinteger · >= 0Client-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.
ctsinteger · >= 1Client 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.

FieldTypeMeaning
orderIdrequiredstring · max 100 charsYour 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.
currencyrequiredstring · max 3 chars3-letter ISO 4217 code, e.g. EUR. Uppercased server-side.
totalrequirednumber · >= 0MAJOR-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.
customerEmailrequiredstring · email · max 255 charsBuyer email. Resolves an existing contact or creates one.
customerPhonestring · max 50 charsApplied on contact CREATE only.
customerFirstNamestring · max 100 charsApplied on contact CREATE only.
customerLastNamestring · max 100 charsApplied on contact CREATE only.
lineItemsarray · 0-500 itemsPurchased 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.
clientIpAddressstring · max 64 charsThe BUYER's IP, not your server's. An ad-platform match key.
clientUserAgentstring · max 500 charsThe buyer's user agent. An ad-platform match key.
storeUrlstring · max 2000 charsThe 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.
placedAtstring · date-timeISO-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.
fbpstring · max 512 charsMeta `_fbp` cookie value.
fbcstring · max 512 charsMeta `_fbc` cookie value. A real cookie always wins over one constructed from `fbclid`.
fbclidstring · max 512 charsRaw Meta click id; used to construct `fbc` only when no real cookie was captured.
gclidstring · max 512 charsGoogle click id.
gbraidstring · max 512 charsGoogle app-to-web click id.
wbraidstring · max 512 charsGoogle web-to-app click id.
ttclidstring · max 512 charsTikTok click id.
gaClientIdstring · max 200 charsGA4 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.
gaSessionIdstring · max 200 charsGA4 session id from the `_ga_<container>` cookie; attributes the purchase to the session that produced it.
utmSourcestring · max 200 charsUTM source of the visit that led to the order. Stored lowercased on the order row for the channel reports.
utmMediumstring · max 200 charsUTM medium; stored lowercased.
utmCampaignstring · max 500 charsUTM campaign; stored as sent.
landingSitestring · max 2000 charsFull landing URL of the visit (query string included — that is where the UTMs and click ids live).
referringSitestring · max 2000 charsHTTP referrer of the landing visit.
aidstring · ^[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.
consentstring · granted | deniedWhat 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.
consentPurposesobjectPurpose-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.
consentDecisionIdstring · ^[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.

FieldTypeMeaning
orderIdrequiredstring · max 100 charsThe ORIGINAL order's id — what ties this refund to its sale.
currencyrequiredstring · max 3 chars3-letter ISO 4217 code.
refundedAmountrequirednumber · >= 0MAJOR-unit amount refunded, POSITIVE. A negative value is rejected rather than silently absolute-valued — send 12.50, never -12.50.
customerEmailrequiredstring · email · max 255 charsBuyer email. NO contact is created here — a refund for someone we have never seen buy anything answers 404 contact_not_found.
refundIdstring · max 100 charsYour 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.
remainingValuenumber · >= 0MAJOR-unit value the order retains after this refund. Omit only if genuinely unknown: the conversion is then fully retracted rather than adjusted.
refundedAtstring · max 64 charsISO-8601 refund time. Absent means now.
storeUrlstring · max 2000 charsThe 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.
aidstring · ^[A-Za-z0-9_.:-]{8,100}$Visitor id to stitch, exactly as on order-confirm. Proven trust; honour `rotate_aid` in the response.
consentstring · granted | deniedWhat 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.
consentPurposesobjectPurpose-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.
consentDecisionIdstring · ^[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

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.

FieldTypeMeaning
external_idrequiredstring,numberYour 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.
statusrequiredstring · pending | paid | fulfilled | delivered | cancelled | refunded | partially_refundedREQUIRED, 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_atrequiredstring · date-timeREQUIRED. 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.
currencyrequiredstring · 3 chars3-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_minorrequiredinteger · >= 0 · <= 100000000000Order 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_emailrequiredstring · email · max 255 charsBuyer email, lowercased server-side. Resolves an existing contact or creates one.
placed_atstring · date-timeISO-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_minorinteger · >= 0 · <= 100000000000Subtotal in minor units (cents).
tax_minorinteger · >= 0 · <= 100000000000Tax in minor units (cents).
shipping_minorinteger · >= 0 · <= 100000000000Shipping in minor units (cents).
discount_minorinteger · >= 0 · <= 100000000000Discount in minor units (cents), as a positive number.
payment_methodstring · cod | card | transfer | otherHow the buyer paid. One of cod, card, transfer, other.
customer_phonestring · max 50 charsApplied on contact CREATE only.
customer_first_namestring · max 100 charsApplied on contact CREATE only.
customer_last_namestring · max 100 charsApplied on contact CREATE only.
line_itemsarray · 0-500 itemsPurchased 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_urlstring · max 500 charsOrigin 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_ipstring · max 64 charsThe BUYER's IP address, not your server's. An ad-platform match key.
client_user_agentstring · max 500 charsThe buyer's browser user agent. An ad-platform match key.
fbpstring · max 512 charsMeta `_fbp` cookie value.
fbcstring · max 512 charsMeta `_fbc` cookie value. A real cookie wins over one built from fbclid.
fbclidstring · max 512 charsRaw Meta click id; used to build fbc only when no real cookie was captured.
gclidstring · max 512 charsGoogle click id.
gbraidstring · max 512 charsGoogle app-to-web click id.
wbraidstring · max 512 charsGoogle web-to-app click id.
ttclidstring · max 512 charsTikTok click id.
ga_client_idstring · max 200 charsGA4 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_idstring · max 200 charsGA4 session id from the `_ga_<container>` cookie.
utm_sourcestring · max 200 charsUTM source of the visit that led to the order.
utm_mediumstring · max 200 charsUTM medium.
utm_campaignstring · max 500 charsUTM campaign.
utm_contentstring · max 500 charsUTM content.
utm_termstring · max 500 charsUTM term.
landing_urlstring · max 2000 charsFull landing URL of the visit, query string included (that is where UTMs and click ids live).
referrer_urlstring · max 2000 charsHTTP referrer of the landing visit.
aidstring · ^[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.
consentstring · granted | deniedWhat 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_purposesobjectPurpose-specific consent: analytics, advertising, personalization, saleSharing, each granted, denied or unknown. Ad-platform conversions require advertising granted. An unstated purpose is unknown.
consent_decision_idstring · ^[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.

FieldTypeMeaning
product_idstring · max 200 charsYour product id. MUST equal the product's id in your product feed, or the item cannot be matched to the catalogue.
variant_idstring · max 200 charsVariant id, if the product has variants.
skustring · max 200 charsSKU.
namestring · max 500 charsProduct name.
categorystring · max 200 charsProduct category.
quantityinteger · > 0 · <= 100000Units bought, a positive integer.
unit_price_minorinteger · >= 0 · <= 100000000000Price 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.

FieldTypeMeaning
statusrequiredstring · cancelled | fulfilled | deliveredcancelled 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_atstring · date-timeISO-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_urlstring · max 500 charsOrigin 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.

FieldTypeMeaning
refunded_minorrequiredinteger · > 0 · <= 100000000000Amount refunded in MINOR units (cents), a positive integer. More than the order has left is a 409 refund_exceeds_remaining.
refund_idstring · min 1 chars · max 100 charsYour 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_minorinteger · >= 0 · <= 100000000000What 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_atstring · date-timeISO-8601 time of the refund. Absent means now.
store_urlstring · max 500 charsOrigin 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

StatusMeaningWhat you do
2xxAccepted. 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.
400Permanent. 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`.
401Permanent. 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.
404Permanent (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.
409Permanent 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.
413Permanent. Body over 256 KB (64 KB on refund).Split the batch. Never retry the same oversized body.
429Throttled.Honour the `Retry-After` header exactly; do not substitute your own schedule. Limits are charged per EVENT received, not per request.
5xxTransient.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