# Postbacks

> Have STX call your tracker when a player you referred signs up, verifies, first deposits or first trades. Each call is signed.

Source: https://docs.stxapp.io/affiliates/postbacks/

> **Availability.** Available on US demo now. Live access is coming soon in the US and Ontario. See [Environments](https://docs.stxapp.io/environments/).

A postback is a URL on your own tracker that STX calls when a player you referred reaches a step. Your tracker sees each conversion shortly after it happens, with your own click ID, so you can match it to the click that brought the player in.

Owners, admins and marketers set postbacks up in STX Apps, under your affiliate's **Postbacks** tab. Everyone in your organization can see them and their delivery log.

## Set one up

1. Write your tracker's URL with the macros you want filled in, for example:

   ```
   https://tracker.example/pb?cid={click_id}&e={event}&s={sub}&amt={amount}&ref={player_ref}&ts={ts}
   ```

2. Pick the steps to send on.
3. Save it, and copy the signing secret. It starts with `pbsec_` and is shown once.
4. Choose **Send test postback** to check your tracker receives it.

Your postback URL must be a public `https` address. Private and internal network addresses are refused, and redirects are not followed, so give the address your tracker answers on directly.

A new postback starts from the moment you save it. Conversions from before then are not sent.

## Steps

| Step | `{event}` | Sent when |
| --- | --- | --- |
| Sign-up | `sign_up` | A player signs up through your link |
| Verified | `verified` | The player verifies their email |
| First deposit | `first_deposit` | The player deposits for the first time. `{amount}` is the deposit |
| First trade | `first_trade` | The player's first trade |

Each player reaches each step once, so each step is sent at most once per player and postback.

## Macros

STX replaces each macro with its value, URL-encoded. A macro with no value is left empty, and anything else in braces is left as you typed it.

| Macro | Value | Example |
| --- | --- | --- |
| `{click_id}` | The `click_id` on the link the player came through | `abc123` |
| `{sub}` | The sub-ID on that link | `nfl-wk7` |
| `{link}` | The link's code | `ip7owude` |
| `{event}` | The step, from the table above | `first_deposit` |
| `{amount}` | The first deposit's amount; empty for other steps | `50.0000` |
| `{currency}` | The amount's currency | `USD` |
| `{player_ref}` | The player's anonymous reference, the same as in your Players report | `plr_q4z7d2k9m3x8a6bt` |
| `{ts}` | When the step happened, in Unix seconds | `1792504991` |
| `{conversion_id}` | A unique ID for this conversion. Use it to ignore a repeat | `1042` |

With the template above, a first deposit calls:

```
https://tracker.example/pb?cid=abc123&e=first_deposit&s=nfl-wk7&amt=50.0000&ref=plr_q4z7d2k9m3x8a6bt&ts=1792504991
```

## The call

Each postback is an HTTP `GET` of your URL, with these headers:

| Header | Value |
| --- | --- |
| `x-stx-signature` | `t=1792505021,v1=e721b0c9a20e20bdd6696199227ea5d585ad1bb4254aed67b323ecbcaec34826` |
| `x-stx-event` | The step, as in `{event}` |
| `x-stx-conversion-id` | The conversion's ID, as in `{conversion_id}` |
| `user-agent` | `STX-Apps-Postback/1` |

Answer with any `2xx` within 5 seconds. Redirects are not followed.

## Check the signature

`x-stx-signature` is `t=<unix seconds>,v1=<signature>`, where the signature is the hex HMAC-SHA256 of `<t>.<url>` under your postback's secret, and `<url>` is the full URL STX called, exactly as sent. To check it:

1. Rebuild the URL STX called: your postback's scheme, host and path, then the query string exactly as it arrived, without decoding or reordering it.
2. Compute the HMAC-SHA256 of `t`, a dot and that URL, with your secret as the key.
3. Compare it with `v1` in constant time, and reject a `t` more than a few minutes from your clock, so a captured call can't be replayed later.

With the secret `pbsec_3f9a1c7e5b2d8f4a6c0e9b1d3f5a7c9e2b4d`, the call above signed at `t=1792505021` gives `v1=e721b0c9a20e20bdd6696199227ea5d585ad1bb4254aed67b323ecbcaec34826`.

**Node.js**

```js
import crypto from 'node:crypto'

// base is your postback URL up to the '?', e.g. "https://tracker.example/pb".
// rawQuery is the query string as received, e.g. req.originalUrl.split('?')[1].
function verifyPostback(secret, base, rawQuery, header, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=', 2)))
  const t = Number(parts.t)
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false
  const url = rawQuery ? `${base}?${rawQuery}` : base
  const expected = crypto.createHmac('sha256', secret).update(`${t}.${url}`).digest('hex')
  const given = Buffer.from(parts.v1 ?? '', 'utf8')
  return given.length === expected.length && crypto.timingSafeEqual(given, Buffer.from(expected, 'utf8'))
}
```

**Python**

```python
import hashlib
import hmac
import time

# base is your postback URL up to the "?", e.g. "https://tracker.example/pb".
# raw_query is the query string as received, before any decoding.
def verify_postback(secret: str, base: str, raw_query: str, header: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    try:
        t = int(parts["t"])
    except (KeyError, ValueError):
        return False
    if abs(time.time() - t) > tolerance:
        return False
    url = f"{base}?{raw_query}" if raw_query else base
    expected = hmac.new(secret.encode(), f"{t}.{url}".encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))
```

## Retries

A call that gets no `2xx` is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, about 9 hours in all. If the last try fails too, the call is marked failed. Use `{conversion_id}` or the `x-stx-conversion-id` header to ignore a call you have already counted.

## Held, paused and test calls

- **Held.** A conversion from a player whose sign-up is under STX review (see [Self-referrals](/affiliates/#self-referrals)) is held and never sent.
- **Paused.** While a postback is paused, its conversions are skipped, not queued, and are not sent when you resume it.
- **Test.** **Send test postback** calls your URL once, straight away, even while the postback is paused, with made-up values: `{event}` `test`, `{click_id}` `test-click`, `{sub}` `test`, `{link}` `test`, `{amount}` `0`, `{currency}` `USD` and `{player_ref}` `plr_testtesttesttest`. It is signed like a real call and is not retried.

## Delivery log

The **Delivery log** shows the latest 50 calls across your postbacks: when, which tracker, the step, the conversion, its status (delivered, pending, failed, held or skipped), your tracker's HTTP response code, the number of tries and the last error. Times are UTC.
