> AffiliateOS Help Center. Index of every article for AI tools: https://www.affiliateos.ai/help/llms.txt

# Verify a signed postback with HMAC-SHA256

Postbacks and conversions · For publishers, networks · Updated 30 October 2026
Web version: https://www.affiliateos.ai/help/postbacks/verify-postback-secret

Every postback we send a publisher carries a `verifier`: an HMAC-SHA256 of the full URL, keyed with your `ps_` secret. Remove `verifier`, hash the rest of the URL exactly as you received it and compare. Credit nothing that fails.

## Before you start

- Your [postback secret](https://www.affiliateos.ai/glossary#postback-secret). Publishers create it under **Postbacks** with **Create postback secret**; networks under **Workspace settings** with **Generate Webhook Secret**. It's shown once.
- Somewhere safe on your server to keep it, such as an environment variable. Never put it in an app or a web page.

## Steps

### Take the URL exactly as it arrived

Use the full URL of the request: scheme, host, path and query, in the order and encoding we sent. Don't rebuild it from parsed parameters. Rebuilding drops parameters you didn't expect and changes encoding, and the hash no longer matches.

### Remove the verifier

Take the `verifier` parameter out and keep its value. Leave every other parameter where it is, including `test` and any you don't use.

### Hash and compare

Compute HMAC-SHA256 of that URL with your secret as the key, as a lowercase hex string. Compare it with the value you kept, using a constant-time comparison. On a mismatch, answer 401 and credit nothing.

Node (Express):

```js
const crypto = require("crypto");

app.get("/afos", (req, res) => {
  const host = `https://${req.get("host")}`;
  const url = new URL(host + req.originalUrl);
  const got = url.searchParams.get("verifier") || "";
  url.searchParams.delete("verifier");
  const want = crypto
    .createHmac("sha256", process.env.AFOS_SECRET)
    .update(url.toString())
    .digest("hex");
  const ok =
    got.length === want.length &&
    crypto.timingSafeEqual(
      Buffer.from(got), Buffer.from(want));
  if (!ok) return res.status(401).send("Bad verifier");
  // Credit on completed, deduct on reversed.
  res.sendStatus(200);
});
```

Python:

```python
import hashlib, hmac, os

def is_from_affiliateos(full_url: str) -> bool:
    base, _, query = full_url.partition("?")
    kept, got = [], ""
    for pair in query.split("&"):
        if pair.startswith("verifier="):
            got = pair[len("verifier="):]
        else:
            kept.append(pair)
    rest = "&".join(kept)
    unsigned = f"{base}?{rest}" if rest else base
    key = os.environ["AFOS_SECRET"].encode()
    want = hmac.new(
        key, unsigned.encode(), hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(want, got)
```

The Python version works on the raw URL string, so nothing is decoded and encoded again on the way.

### Then dedupe and credit

A verified postback can still be a repeat. Dedupe on `action_id` and `status` together: a reversal repeats the `action_id` of the conversion it takes back, so deduping on `action_id` alone drops the reversal.

### Check it worked

Choose **Send test postback** on a saved row (networks: **Test Postback**). Your server should accept the `verifier` and see `test=true`. Then change one character of the secret in your config and send it again: the check must fail.

## Things that trip people up

- **The scheme or host changed on the way in.** Behind a load balancer or proxy, your framework can report `http` or an internal host. Hash the public URL we called: the URL in your postback row, with macros filled in and our parameters added. The Node sample above fixes the scheme to `https` for that reason.
- **A framework decoded the query.** `+` and `%20` hash differently. Read the raw query string.
- **A new parameter appeared.** Every parameter we send is inside the signature, so verifying the URL as received keeps working when we add one. Checking against a fixed list of names breaks.
- **Parameters in your own URL.** Your own parameters are part of the URL we sign, so they're covered too. Keep them as they are in your row.

## Rotating the secret

Publishers: **Postbacks**, then **Rotate secret**. Only an owner or admin sees it. The old secret stops working the moment the new one is created, so every postback after that fails your check until your server has the new value. Have the change ready to deploy, rotate, then paste the new secret in straight away.

Deliveries your server refused in between show as failed in **Flight Recorder**. Send us their action IDs and we can send the credits again, except Hosted Offerwall ones.

> **Lost the secret? Rotate it.** We never show a secret twice. Rotating is the only way to get a working one, and it ends the old one at once.

## Networks

Network postbacks carry a `verifier` only while a **Webhook Secret** exists. Without one, they're sent unsigned. **Revoke** removes the secret, and from then on postbacks arrive unsigned until you generate a new one, so generate the new secret straight after revoking.
