Server-side tracking
Some events never happen in a browser — the payment confirmation the gateway posts to your server, an automatic renewal, an order support enters by hand. Your backend sends those directly with a server key: same data model, same identity, same reports.
When to go server-side#
The browser tag sees what happens on the page. Part of your business reality happens off the page: the payment gateway posts the result to your server, a subscription renews overnight, a support agent enters an order by hand, or the customer closes the browser before the gateway's response arrives.
| Subject | Browser tag | Server-side send |
|---|---|---|
| Page views, navigation, engagement | Yes | No |
| Add to cart, begin checkout | Yes | Possible, usually unnecessary |
| A purchase confirmed by the gateway | Incomplete and unreliable | Yes |
| Renewals, recurring invoices, refunds | No | Yes |
| An order entered by an operator | No | Yes |
| An event behind an ad blocker | Lost | Yes |
Both paths write to the same event table and feed the same identity graph. You do not have to give up one for the other — but do not send one event down both, because the two paths use different IDs and you will get two independent rows.
On WHMCS you do not need to write any code — the AdPix addon calls this same API. Get it from Admin → Integrations and CRM modules, and create the server key in the same place.
Create a server key#
A server key is bound to a property and is created from any data stream's panel.
sk_… value straight into your backend's secret store.Only the key's eleven-character prefix is retained; the secret itself is stored hashed and cannot be recovered. If you lose it, create a new key and revoke the old one. Both creation and revocation are recorded in the audit log.
There are four scopes. Grant the smallest set that works:
| Scope | What it opens |
|---|---|
events |
sending events and orders (/events and /events/batch) |
identify |
linking an anonymous visitor to a known user (/identify) |
read:attribution |
reading a user's live attribution or an order's snapshot |
read:identity |
reading a user's identity graph (linked IDs, accumulated traits) |
Creating a key requires editor level or above; listing keys needs only report-read access — but the list shows the prefix, never the secret.
Every data stream also has a write key, meant for the browser path and Tag Gateway. The server-to-server API does not accept it. Only a key beginning with sk_ works here.
Send your first event#
The base address is https://api.adpix.io/api/v1/s2s. Every request carries two things: the key in the Authorization header and the property ID in the X-Sov-Site header.
curl -X POST https://api.adpix.io/api/v1/s2s/events \
-H 'Authorization: Bearer sk_...' \
-H 'X-Sov-Site: <property_id>' \
-H 'Content-Type: application/json' \
-d '{
"event": "purchase",
"event_id": "order_10482",
"order_id": "10482",
"email": "ali@example.com",
"value": 4900000,
"currency": "IRR",
"properties": { "gateway": "saman" }
}'
{
"accepted": true,
"event_id": "order_10482",
"global_user_id": "...",
"anonymous_linked": true,
"attribution_snapshot": { "channel": "Paid Search", "attribution_model": "last_non_direct" },
"request_id": "req_..."
}
Before wiring everything up, call POST /api/v1/s2s/verify once. If the key and the property match, the response returns the property's name and timezone, the key's scopes, the attribution window length, and whether HMAC is required — exactly what you need to debug a configuration.
| Header | Required | Notes |
|---|---|---|
Authorization: Bearer sk_… |
Yes | The server key. Missing or malformed returns a 401. |
X-Sov-Site |
Yes | The ID of the property the key was created for. |
Content-Type: application/json |
Yes | The body is read up to a maximum of one megabyte. |
X-Sov-Timestamp |
With HMAC only | Unix time in seconds. |
X-Sov-Signature |
With HMAC only | Signature in the form t=<ts>,v1=<hex>. |
Of the body fields, only event and event_id are required. value and currency carry revenue (the currency code must be three uppercase letters), items builds the order lines, and anything else goes in properties to be available in reports as event parameters.
event_id — the guarantee an order is not counted twice#
This is the most important rule on the page. You choose the event_id, and it must stably identify that same event in your system — order_10482, not a fresh random number on each attempt.
AdPix keeps the first response for each event_id along with a fingerprint of the request body. After that:
| Re-sending with… | Result |
|---|---|
the same event_id and the same body |
the previous response is returned with a 200; no new event is written |
the same event_id and a different body |
a 409 with idempotency_conflict; nothing is written |
a fresh event_id |
a new event |
So a network retry, a re-run of a job queue and a restart mid-way are all safe. This is the only collection path in AdPix that offers that guarantee; the browser does not.
Link the visitor to the real user#
Every event has to say who it is about. You have four ways, and you can send more than one together:
anonymous_id— the anonymous ID the browser created. Send it and the behaviour from before the user signed in attaches to the same person.external_id— the user's ID in your own system (a CRM customer ID, a panel user ID).email— deterministic.phone— deterministic and at parity with email. The number is normalised to international form using the property's default calling code, so the national form 0912… and the international form of the same number reach one user.
Email, phone and external ID are deterministic links and are never overwritten by a browser-fingerprint guess. For the moment of sign-in or sign-up — where you have no business event yet — call POST /identify; it establishes the same link and returns the global user ID, the linked anonymous IDs and their current attribution. The full logic is in How AdPix identifies visitors.
Orders and the attribution snapshot#
If you send an order_id (or name the event purchase, order_updated or order_refunded and include the order ID), AdPix builds and freezes an attribution snapshot for that order: channel, source, campaign, first and last touch, days to conversion and the touch count.
The snapshot's model is last non-direct: if the last touch is direct or empty, the first touch takes its place.
The snapshot is written once. A later update to the same order brings the value and status but leaves attribution untouched — so your report is not rewritten by the customer's later behaviour. To read it:
Batch sending#
For a backfill or an overnight queue, send POST /events/batch with a body of {"events":[…]}. The per-request cap is 500 events; more returns a 413.
The response is a results array, in input order, one result per event. One invalid event does not take down the rest — its own result carries the error and the others are accepted. So always read results; the request's overall status code is not enough.
HMAC signing#
If you ticked "require HMAC" when creating the key, every request must also carry a signature. The signing string is <timestamp>.<raw body> and the signing key is the sk_… secret itself:
A clock difference of more than 300 seconds from the server returns stale_timestamp — so keep your server's clock in sync. The signature is computed over the raw body bytes, not over re-serialised JSON; if you re-serialise the body, the signature will not match.
If you send the signature headers, they are verified even when the key does not require HMAC.
What these events look like in reports#
There are a few deliberate differences from browser events. Not knowing them looks like missing data.
- They have no session. A server-side event is not a browser session and AdPix does not create one for it. If it did, every CRM order would become a new "session" and would understate conversion rate. The consequence: these events are not counted in session-based metrics (engagement rate, sessions per user), but they are fully present in event counts, key events, revenue and user attribution.
- They have no page URL. Page-based reports do not show them.
- Their attribution comes from that user's history. If you send
first_touchorlast_touchincontext, that is used; otherwise AdPix falls back to the durable first touch it stored for that visitor from the browser path. This is why sendinganonymous_idmakes a difference: without it, the order is not connected to the campaign that actually produced it. - Geography and device come from
context. Sendcontext.ipandcontext.user_agentto populate country and device type; omit them and those stay empty, because your server's IP is not a substitute for the user's.
Create-event and Modify-event rules are applied to browser events at collection time, and the server-to-server path does not go through them. Send the event with exactly the name and parameters you want to see in reports. If you have a Create-event rule for the payment success page and you also send that purchase from your server, two independent conversions are recorded; pick one of the two paths. The full explanation is in Create and Modify event rules.
Errors#
Every error returns JSON and always carries a request_id. Include it if you open a ticket.
| Code | Error | Meaning |
|---|---|---|
| 401 | invalid_key |
the Authorization or X-Sov-Site header is missing, or the key is unknown or revoked |
| 401 | bad_signature / stale_timestamp |
the HMAC signature does not match, or the clock difference exceeds 300 seconds |
| 403 | property_mismatch |
the key was not created for this property |
| 403 | insufficient_scope |
the key lacks the scope this path requires |
| 400 | bad_request |
the body is not valid JSON |
| 422 | validation_error |
event or event_id is missing, or the currency code is not three uppercase letters |
| 409 | idempotency_conflict |
the same event_id was already recorded with a different body |
| 413 | too_many_events |
the batch holds more than 500 events |
| 429 | rate_limited |
the key's rate limit is exhausted; see the Retry-After header |
The rate limit is counted against the key itself. If you have a heavy backfill job, create a separate key for it so it cannot block your live shop traffic.
The legacy /api/v1/track endpoint#
There is an older path that accepts the browser tag's event shape with a server key. It takes no property header, builds no attribution snapshot, and gives no guarantee about re-sending. It is kept only for older integrations; write new work against /api/v1/s2s/events.
The complete request and response shapes, for when you are writing your own client, are in the Server-to-server API reference.
Frequently asked questions#
When should I reach for server-side sending?
When the event does not happen in the browser, or cannot be trusted there — a payment confirmation the gateway posts to your server, renewals and recurring invoices, cancellations and refunds, and orders an operator enters from an admin panel. For page views and on-site behaviour, the browser tag is still the correct source.
If I send the same request twice, is the order counted twice?
No, as long as the event_id is the same. AdPix keeps the first send's response and returns it again with a 200 for the second, without creating a new event. If you send the same event_id with a different body you get a 409 and nothing is written.
Is a server key different from a stream's write key?
Yes, and they are not interchangeable. The write key is for the browser path and Tag Gateway; the server-to-server API accepts only a server key (sk_). Putting a write key in the Authorization header returns a 401.
Why don't server-side events show up in session-based reports?
Because a server-side event has no browser session, and AdPix deliberately does not fabricate one. If it did, every CRM order would become a new session and would distort conversion rate. These events are fully present in event counts, key events, revenue and user attribution; they are only absent from metrics whose unit is the session.
Thanks — your feedback helps us improve the docs.