# 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.

> **Already have a module?**
>
> 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.

1. Open **Admin** in the console.
2. Under **Property settings**, select **Data streams** (or **Integrations and CRM modules** — the keys card is in both).
3. In the **Server keys** card, pick the stream, give it a name, and tick the scopes it needs.
4. Select **Create server key** and put the `sk_…` value straight into your backend's secret store.

> **The secret is shown once**
>
> 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.

> **A write key is not a server key**
>
> 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.

```bash
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" }
  }'
```

```json
{
  "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](concepts/foundations/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:

```bash
curl 'https://api.adpix.io/api/v1/s2s/attribution?order_id=10482' \
  -H 'Authorization: Bearer sk_...' \
  -H 'X-Sov-Site: <property_id>'
```

## 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:

```
X-Sov-Timestamp: 1767225600
X-Sov-Signature: t=1767225600,v1=<hex(HMAC-SHA256)>
```

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_touch` or `last_touch` in `context`, that is used; otherwise AdPix falls back to the durable first touch it stored for that visitor from the browser path. This is why sending `anonymous_id` makes a difference: without it, the order is not connected to the campaign that actually produced it.
- **Geography and device come from `context`.** Send `context.ip` and `context.user_agent` to populate country and device type; omit them and those stay empty, because your server's IP is not a substitute for the user's.

> **Event rules do not run on this path**
>
> 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](analytics/collect/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](analytics/developers/s2s-api).

## 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.

## Related

- [Server-to-server API reference](https://docs.adpix.io/en/developers/analytics/developers/s2s-api/)
- [E-commerce events](https://docs.adpix.io/en/analytics/collect/ecommerce-events/)
- [Integrations and CRM modules](https://docs.adpix.io/en/analytics/admin/integrations/)
- [How AdPix identifies visitors](https://docs.adpix.io/en/concepts/foundations/how-adpix-identifies-visitors/)

---

[Docs](https://docs.adpix.io/en/analytics/collect/server-side-tracking/) · AdPix
