Publisher postbacks

How we notify your server of approved and reversed conversions: URL placeholders, the HMAC-SHA256 signature, retries, idempotency and testing.

When one of your users' conversions is approved, or an approved one is reversed, we call your postback URL. That call is the only reliable signal to credit or remove a reward.

When we send one

StatusWhen
approvedThe advertiser confirmed the conversion. Credit the user.
reversedThe advertiser took back a conversion that was approved. Remove the reward.

Conversions the advertiser reports as pending are not sent until they are approved. Multi-step offers send one postback per completed step.

Your URL

Set it under Postbacks in the dashboard. It must be an http or https URL and must contain {user_id}. We call it with GET and fill in the placeholders, URL-encoded. Unknown placeholders are left as they are.

PlaceholderValue
{user_id}Your user's ID, from the click link.
{reward}Points to credit, or to remove on a reversal.
{payout}What you earn for this conversion, USD, four decimals.
{status}approved or reversed.
{conversion_id}Our ID for the conversion. The same on retries and on its reversal.
{event}The step's event name, e.g. install or level_10.
{event_title}The step as the user sees it, e.g. Reach level 10.
{offer_id}Our ID for the offer, as id in the offers API.
{offer_name}The offer's name.
{click_id}The click the conversion came from.
{country}Two-letter country of the click, or empty.
{signature}See below.

The signature

{signature} is the HMAC-SHA256, as lowercase hex, of:

{conversion_id}:{user_id}:{reward}:{status}

keyed with the secret shown on your Postbacks page. Compute it yourself and compare it to the value you received using a constant-time comparison. Reject the request if it doesn't match.

JavaScript
import { createHmac, timingSafeEqual } from "node:crypto";
 
export function isGenuine(q, secret) {
  const expected = createHmac("sha256", secret)
    .update(`${q.id}:${q.user}:${q.points}:${q.status}`)
    .digest("hex");
  return typeof q.sig === "string" && q.sig.length === expected.length &&
    timingSafeEqual(Buffer.from(q.sig), Buffer.from(expected));
}
PHP
$expected = hash_hmac('sha256', "{$_GET['id']}:{$_GET['user']}:{$_GET['points']}:{$_GET['status']}", $secret);
if (!hash_equals($expected, $_GET['sig'] ?? '')) { http_response_code(403); exit; }
Python
import hmac, hashlib
 
def is_genuine(q, secret):
    msg = f"{q['id']}:{q['user']}:{q['points']}:{q['status']}".encode()
    expected = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, q.get("sig", ""))

These examples assume the URL from the quick start, where id, user, points, status and sig carry the placeholders.

Replacing your secret on the Postbacks page takes effect immediately. Postbacks already queued for a retry still carry the old signature.

Responses and retries

Answer with any 2xx status within 10 seconds once you have recorded the postback. Redirects are not followed.

Anything else counts as a failure, and we try again after 1, 5, 30, 120 and 720 minutes. After the last attempt the postback is marked failed, you get a notification in the dashboard, and our team is told. Every attempt and your server's response is listed on your Postbacks page.

Crediting exactly once

Retries carry the same conversion_id, so the same postback can arrive more than once. Store each conversion_id and status with a unique constraint, in the same database transaction that changes the user's balance. If the insert fails because you have seen it before, answer 200 and do nothing else.

A reversal has the same conversion_id and status=reversed, so it is a new event under that rule. Decide what to do when the user already spent the reward: a negative balance, a hold on withdrawals, or flagging the account. Your own earnings for a reversed conversion are taken back regardless; see payouts.

Testing

Press Send a test postback on the Postbacks page. We call your URL once with user_id=test_user, reward=1000, payout=1.0000, status=approved, event=install, country=US and a conversion_id starting with test_, signed with your real secret. The result shows the status code your server returned. Test postbacks are not retried.