S2S Postback v2 Protocol Reference
How an advertiser backend reports a final conversion to the network: one signed JSON request per conversion. This page is a static reference. It does not send requests and shows no simulated results.
Endpoint
POST https://affnet.net/api/postback/v2 Content-Type: application/json X-Postback-Signature: <lowercase hex HMAC-SHA256 of the raw request body>
- POST only. GET is not supported because there is no query-string form of a signed body.
- The maximum body size is 16 KiB. A larger body is rejected before any database access.
- The body must be strictly valid UTF-8 without a byte-order mark. A violation is not reported as an encoding error; it surfaces as
bad_signature. - Each advertiser signs with an HMAC secret issued by the network administrator when the advertiser is onboarded. Several credentials can be active at once to allow rotation.
Request body
| Field | Type | Rule |
|---|---|---|
| click_id | string (UUID) | The click_id the network issued at click time. A value that is not a well-formed UUID is rejected as malformed. |
| nonce | string | Any non-empty string, unique per new send attempt. Retrying the same attempt after a timeout reuses the same nonce and the same body. |
| ts | integer (Unix seconds) | Send time, embedded in the body (not a header). It must be within 300 seconds of server time in either direction; build it when you send. |
| event | string | The final billing event of the offer: qualified_lead for a CPL offer, approved_sale for an approved CPA offer. Any other value is decided as rejected. |
Example body (placeholder values)
{
"click_id": "5c9c6f1e-2f1b-4d9a-9d1a-1f7f2b8b9a3f",
"nonce": "a-value-unique-per-attempt",
"ts": 1756000000,
"event": "qualified_lead"
}Signing
The signature covers the exact raw request body bytes you send, not a re-serialized or canonicalized form. Key order and whitespace are whatever your serializer produced, so sign the same string you transmit.
signature = lowercase_hex( HMAC_SHA256(secret, raw_body_bytes) )
Node.js sketch (no real secret, illustration only)
import { createHmac } from "node:crypto";
// secret: the HMAC secret issued for your advertiser account (never ship it to a browser)
const body = JSON.stringify({
click_id: clickId,
nonce: crypto.randomUUID(),
ts: Math.floor(Date.now() / 1000),
event: "qualified_lead",
});
const signature = createHmac("sha256", secret).update(body, "utf8").digest("hex");
const response = await fetch("https://affnet.net/api/postback/v2", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Postback-Signature": signature,
},
body, // send exactly the string that was signed
});Responses
A failed verification returns ok: false and the verdict in result. Only 502 and the 503 consumption case are worth retrying; every other verdict is final for that exact request.
| HTTP | result | Meaning | Retry |
|---|---|---|---|
| 200 | accepted | Signature, nonce and click verified. The body carries decision (payable or rejected), replay, and for a payable decision a consumption object. | No |
| 400 | malformed | Empty or oversized body (over 16 KiB), missing signature header, invalid JSON, non-UUID click_id or empty nonce. | No |
| 400 | stale_timestamp | ts is more than 300 seconds from server time. The timestamp is fixed in the body, so resending it cannot succeed. | No, send a new body |
| 401 | bad_signature | The advertiser has an active credential but the signature does not match the raw body. | No |
| 401 | unknown_credential | The advertiser has no active credential. | No |
| 403 | advertiser_paused | The advertiser account is paused. | No |
| 403 | ip_not_allowed | An offer-scoped IP allowlist is configured and the sending address is not on it. | No |
| 409 | duplicate_nonce | This advertiser and nonce pair was already used with a different body. | No, use a new nonce |
| 422 | unbound_click | The click exists but was never bound to an offer version; it can never be completed this way. | No |
| 502 | processing_error | Transient infrastructure or database error. | Yes, same nonce and body |
| 503 | accepted (consumption rejected) | Payable, but consumption.reason_code is capacity_exhausted or beneficiary_not_resolvable. The body sets consumption.retryable to true. | Yes, later, same nonce and body |
200, payable (shape only; identifiers are placeholders)
{
"ok": true,
"result": "accepted",
"decision": "payable",
"replay": false,
"consumption": {
"result": "consumed",
"replay": false,
"conversion_id": "<uuid>",
"snapshot_event_id": "<uuid>",
"journal_id": "<uuid>"
}
}200, decided as rejected (example reason_code)
{
"ok": true,
"result": "accepted",
"decision": "rejected",
"reason_code": "event_mismatch",
"replay": false
}Failed verification (example)
{ "ok": false, "result": "bad_signature" }replay: true means this click already had a permanent decision from an earlier request and the verdict shown is that earlier decision echoed back; the current event was not evaluated. A fresh consumed result is the only outcome that records a conversion and moves funds. Approved conversions are final and cannot be cancelled by the advertiser.
Spread calculator
Illustrative arithmetic on the values you enter. On each final conversion the network spread is the advertiser charge minus the cumulative Affiliate entitlement recorded for that conversion. The figures below are your own inputs, not actual offer terms or payouts.
Enter the three values above to see a result.