Conversion postbacks

One signed server-to-server request per conversion, and one more if that conversion is later reversed. This is the only channel that is real-time, and the only one you should pay a user from.

Setting your URL

Put it on the placement in your dashboard. Use {macros} and we substitute them; use none and we append every field as a query string.

https://you.example/offerwall/callback?user={userId}&amount={currencyAmount}
    &tx={transactionId}&status={status}&sig={signature}

These aliases are understood too, so an integration you already have for another network usually needs no changes at all:

{user_id} {subid} {amount} {points} {reward} {txid} {trans_id} {offer_id} {sig} {hash}

Prefer POST? Choose it on the placement and the fields arrive as application/x-www-form-urlencoded with your URL left untouched.

The fields

FieldTypeWhat it isExample
userId string The id you sent us in the wall URL. Yours, unchanged. user-1234
transactionId string Our id for the conversion. Unique and stable, and the same on its reversal. e794d89342e0c468f6dd81205999425b
offerId string The network's own offer id. 32-21022-15575424
offerName string For your reward feed and your support desk. World of Sea Battle
goalId string Which step of a multi-step offer completed. Empty for single-step. (empty)
currencyAmount number What to credit, in your currency. Fractional, up to 4 decimals. Negative on a reversal. 1750.2857
currencyName string What you call it. Gems
payoutUsd number Your revenue for this conversion, USD. Negative on a reversal. Not covered by the signature — see below. 1.750000
status string credited or reversed. credited
test string 1 from the dashboard test button. Never credit on a test. 0
timestamp string Unix seconds at send time. 1756588800
signature string HMAC-SHA256 of userId:transactionId:currencyAmount. a967cb02a0…

Verify before you credit

A callback you do not verify is a callback anyone can forge. Your URL is guessable, the parameters are in the docs, and the only thing standing between a stranger and your currency is this check.

The signature is HMAC-SHA256 over exactly three fields joined with colons:

signature = hmac_sha256("userId:transactionId:currencyAmount", secretKey)

Three, not twelve, and in that order — so the check still works when those are the only macros you asked for. Compare it in constant time.

<?php
$expected = hash_hmac('sha256', "{$userId}:{$txId}:{$amount}", $secretKey);

if (!hash_equals($expected, $signature)) {
    http_response_code(403);
    exit('FORBIDDEN');
}

Recipes for Node and Python are in the integration guide.

What the signature does and does not cover

Those three fields are the ones you act on: currencyAmount is what you credit, and it is signed, so nobody can change the number you pay out. payoutUsd, offerName, goalId and timestamp are outside the digest.

Treat payoutUsd as reporting, not as an authoritative financial record. It is correct as we send it, and it is what shows on your dashboard — but if you post it to a revenue ledger, reconcile against GET /api/v1/conversions?transactionId=…, which is authenticated by your API key rather than by a digest over someone else's query string. Your balance with us is computed from our own ledger either way; nothing you receive here can change it.

Looking one up afterwards

GET /api/v1/conversions?transactionId=<id> returns the single conversion that id belongs to, with its current status and payoutUsd. Since a reversal reuses the id, this also answers "is this one still credited?" — which is the query you want when a support ticket names a reward.

Amounts are fractional

currencyAmount is payoutUsd × your units-per-USD, carried to four decimal places. It is not rounded to the Decimals shown setting on your placement — that setting controls how the wall prints a reward to your users and nothing else.

At 70 units per USD, a $0.33 payout is 23.1, not 23. Most real conversions land on a fraction; whole numbers are the exception.

If your own ledger is integer-only, round once, consistently, and the same way in both directions — the credit and its reversal must produce the same magnitude, or a rounded-up credit and a rounded-down reversal leave the user permanently ahead. Rounding the credit down and the reversal up is the safe pairing. Better still, store the fraction we send and round only when you display it.

Answering

Any 2xx, or a body of OK or 1. Anything else is a failure and we retry. Answer after you have durably recorded the transaction — a 200 means "I have this", and we stop asking.

Retries

We retry a failed delivery on this schedule, up to 6 attempts in total:

After attemptNext try in
11 min
25 min
315 min
41 hr
53 hrs
66 hrs

Every attempt and its response is logged and visible on the placement, so a conversation about a missing reward starts from the delivery record rather than from guesswork.

Be idempotent

transactionId is unique and stable. You will see the same one twice: we retry when your endpoint is slow, and a network can legitimately resend a conversion. Credit each id once.

Read the status before you dedupe. A reversal arrives with the same transactionId as the credit it cancels — see below. An endpoint that checks "have I seen this id?" first, and stops there, answers 200 to every reversal it will ever receive and claws nothing back. Key your idempotency on (transactionId, status), or branch on status before the duplicate check.

Make it a unique constraint on (transactionId, status) in your database rather than a lookup before the write — two retries can arrive at once, and a check-then-insert loses that race.

Reversals

When a network charges back a conversion — fraud, a refunded purchase, a cancelled trial — we send a second callback for the same conversion with:

The status and the sign are both sent so you can act on whichever your system already reads. There is no separate "original transaction" field, and there does not need to be: the id is the reference. One conversion has one id for its whole life, and its status moves from credited to reversed on our side too.

Make the debit conditional on the credit still standing — reverse only a conversion you currently hold as credited. That is what makes a redelivered reversal safe, and it is exactly how our own inbound pipeline handles it. Decide before launch whether a user's balance may go negative; the honest answer is usually yes, with the debt clearing against future earnings, because the alternative is paying for fraud yourself.

Testing it

There is a test button on the placement. It sends a real, correctly signed callback with test=1 — so it exercises your signature check, your parsing and your response without touching a balance. Never credit a user when test=1.

The Postman collection goes further: it fires a signed callback at your own URL, computing the signature the way we do. Prove your endpoint accepts it, then change one character of the amount and prove it rejects that.

When a reward does not arrive

Work down this list before opening a ticket — it is the same order we would:

  1. Is there a click? No click row, no conversion. Users often open the wall and never start.
  2. Did the network report it? Most offers settle in minutes; some take days, and a few hold until the advertiser confirms.
  3. Did we deliver? The placement shows every attempt and the response your endpoint gave.
  4. Did you record it? A 200 you sent before your own write completed looks identical to us.

The wall's own Support screen lets a user file a missing-reward claim against a specific click, which gives our support desk the exact row to trace rather than a free-text guess at an offer name. If you draw your own wall, link to https://offerwall.gg/wall/YOUR_PUBLIC_KEY/support?userId=USER_ID.