Affnet/Developer Tools/S2S Postback v2

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

FieldTypeRule
click_idstring (UUID)The click_id the network issued at click time. A value that is not a well-formed UUID is rejected as malformed.
noncestringAny non-empty string, unique per new send attempt. Retrying the same attempt after a timeout reuses the same nonce and the same body.
tsinteger (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.
eventstringThe 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.

HTTPresultMeaningRetry
200acceptedSignature, nonce and click verified. The body carries decision (payable or rejected), replay, and for a payable decision a consumption object.No
400malformedEmpty or oversized body (over 16 KiB), missing signature header, invalid JSON, non-UUID click_id or empty nonce.No
400stale_timestampts 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
401bad_signatureThe advertiser has an active credential but the signature does not match the raw body.No
401unknown_credentialThe advertiser has no active credential.No
403advertiser_pausedThe advertiser account is paused.No
403ip_not_allowedAn offer-scoped IP allowlist is configured and the sending address is not on it.No
409duplicate_nonceThis advertiser and nonce pair was already used with a different body.No, use a new nonce
422unbound_clickThe click exists but was never bound to an offer version; it can never be completed this way.No
502processing_errorTransient infrastructure or database error.Yes, same nonce and body
503accepted (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.

S2S Postback v2 Protocol Reference — Affnet Developer Tools | Affnet