Verify a signed postback with HMAC-SHA256
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.
See also: Signed postbacks
On this page
- Your 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.
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
verifierparameter out and keep its value. Leave every other parameter where it is, includingtestand 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):
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:
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_idandstatustogether: a reversal repeats theaction_idof the conversion it takes back, so deduping onaction_idalone drops the reversal.Check it worked
Choose Send test postback on a saved row (networks: Test Postback). Your server should accept the
verifierand seetest=true. Then change one character of the secret in your config and send it again: the check must fail.
- The scheme or host changed on the way in. Behind a load balancer or proxy, your framework can report
httpor 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 tohttpsfor that reason. - A framework decoded the query.
+and%20hash 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.
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.
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.