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
| Field | Type | What it is | Example |
|---|---|---|---|
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
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.
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 attempt | Next try in |
|---|---|
| 1 | 1 min |
| 2 | 5 min |
| 3 | 15 min |
| 4 | 1 hr |
| 5 | 3 hrs |
| 6 | 6 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.
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:
status=reversed- a negative
currencyAmountandpayoutUsd - the same
transactionIdas the original credit — that is what tells you which conversion is being taken back
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:
- Is there a click? No click row, no conversion. Users often open the wall and never start.
- Did the network report it? Most offers settle in minutes; some take days, and a few hold until the advertiser confirms.
- Did we deliver? The placement shows every attempt and the response your endpoint gave.
- 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.