DocsRewarding users
View as MarkdownPostbacks
One URL on your server receives every event: credits and reversals. Here is what we send and how to handle it.
Setup
Add your postback URL in your dashboard under Apps, your app, Postback. We send a GET request to it for every event of every transaction, and we fill in the macros you put in the URL:
https://yoursite.com/postback?user={user_id}&tx={transaction_id}&event={event_id}&status={status}&reward={reward}&payout={payout}&hash={hash}
Use the macros you need, in any order and with any parameter names. Values are URL-encoded. The URL must use http or https on port 80, 443, 8080, or 8443, and point to a public server.
Macros
| Macro | Description |
|---|---|
{user_id} | The user ID you put in the offerwall link. |
{transaction_id} | Stays the same for the whole life of a transaction: the credit and a later reversal carry the same value. |
{event_id} | Unique for every event we send. Useful for logging. |
{status} | What happened, as a number: 1 or 2. See Statuses. |
{state} | The same as {status} in words: credited or reversed. |
{reward} | The reward in your currency, after your conversion rate, user split, bonus, and rounding. Never negative. See Rewards and currency. |
{currency} | The name of your currency, for example Coins. |
{payout} | What you earn for the transaction, in US dollars. Never negative. |
{offer_id} | The ID of the offer. |
{offer_name} | The name of the offer as your user saw it. |
{country} | Two-letter country code of the user. |
{device} | desktop, android, or ios. |
{ip} | The IP address the user started the offer from. |
{sub1} to {sub5} | The sub IDs you added to the offerwall link, the Offers API request, or the SDK session, or empty. See Track sources with sub IDs. |
{integration} | How the user reached the offer: offerwall (the offerwall, opened by link, iframe, or the floating button), api (Offers API), or sdk (the mobile SDKs for Android, iOS, Flutter, React Native, and Unity). |
{hash} | Signature to check that the postback comes from us. See Verify the hash. |
Statuses
| Status | State | What to do |
|---|---|---|
1 | credited | Add {reward} to the user. |
2 | reversed | Take {reward} back from the user, if you credited this transaction before. |
Rules that keep your balances right:
- Credit only on status 1, and only once per
transaction_id. - A reversal (status 2) always carries the
transaction_idof the credit it cancels. If you never credited that transaction, ignore it. - Amounts are never negative. The status tells you whether to add or take back.
- The same event can arrive twice, for example after a retry. Store
transaction_idwith the last status you processed and skip repeats.
Verify the hash
{hash} is the HMAC-SHA256, as lowercase hex, of the transaction ID, user ID, reward, and status joined with colons, signed with your postback secret key from the Integration tab. Use the values exactly as they arrive.
$expected = hash_hmac('sha256', $tx . ':' . $userId . ':' . $reward . ':' . $status, $postbackSecretKey);
if (!hash_equals($expected, $hash)) {
http_response_code(403);
exit;
}
const crypto = require('crypto');
const expected = crypto.createHmac('sha256', postbackSecretKey)
.update(`${tx}:${userId}:${reward}:${status}`)
.digest('hex');
const given = Buffer.from(String(hash));
const wanted = Buffer.from(expected);
const valid = given.length === wanted.length
&& crypto.timingSafeEqual(given, wanted);
if (!valid) return res.sendStatus(403);
import hmac, hashlib
expected = hmac.new(postback_secret_key.encode(), f"{tx}:{user_id}:{reward}:{status}".encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, hash_value):
abort(403)
The postback secret key is different from the link hash salt.
Our IP addresses
Postbacks come from these addresses. If your server only accepts requests from known addresses, allow both of them:
| Type | Address |
|---|---|
| IPv4 | 187.124.177.111 |
| IPv6 | 2a02:4780:79:782c::1 |
We connect over IPv4 whenever your host has one, and use IPv6 only for hosts without an IPv4 address. Every request has the user agent Moonetix-Postback/1.0. An IP check is an extra layer: always verify the hash as well. If these addresses ever change, we tell you in advance.
Responses and retries
- Answer with any 2xx status code within 6 seconds. We do not read the response body and we do not follow redirects.
- If your server fails or times out, we try again up to 5 times, after about 1 minute, 5 minutes, 30 minutes, 2 hours, and 6 hours.
- Events of one
transaction_idarrive in order. A reversal is never sent before the credit it cancels. - Every attempt, with the status code your server returned, is listed in your dashboard under Reports, Postbacks. If the last attempt fails too, we email you, at most once a day per app, and you can send the postback again from that report.
- To check your handler before going live, use Send a test postback in Integration status on the Integration tab. It sends a credit or a reversal for a user ID and payout you choose. A test always has a
transaction_idthat starts withtest_andoffer_id0, so your handler can tell tests from real results.
Example handler
A complete handler for the URL in Setup. Replace the four helper functions with your own storage.
$tx = $_GET['tx'] ?? '';
$userId = $_GET['user'] ?? '';
$reward = $_GET['reward'] ?? '0';
$status = (int)($_GET['status'] ?? 0);
$expected = hash_hmac('sha256', $tx . ':' . $userId . ':' . $reward . ':' . $status, $postbackSecretKey);
if (!hash_equals($expected, $_GET['hash'] ?? '')) {
http_response_code(403);
exit;
}
$last = get_last_status($tx);
if ($status === 1 && $last !== 1 && $last !== 2) {
add_to_balance($userId, $reward);
save_status($tx, 1);
} elseif ($status === 2 && $last === 1) {
subtract_from_balance($userId, $reward);
save_status($tx, 2);
}
http_response_code(200);
const crypto = require('crypto');
const express = require('express');
const app = express();
app.get('/postback', async (req, res) => {
const { tx = '', user = '', reward = '0', status = '', hash = '' } = req.query;
const expected = crypto.createHmac('sha256', process.env.MOONETIX_POSTBACK_KEY)
.update(`${tx}:${user}:${reward}:${status}`)
.digest('hex');
const given = Buffer.from(String(hash));
const wanted = Buffer.from(expected);
if (given.length !== wanted.length || !crypto.timingSafeEqual(given, wanted)) {
return res.sendStatus(403);
}
const s = Number(status);
const last = await getLastStatus(tx);
if (s === 1 && last !== 1 && last !== 2) {
await addToBalance(user, reward);
await saveStatus(tx, 1);
} else if (s === 2 && last === 1) {
await subtractFromBalance(user, reward);
await saveStatus(tx, 2);
}
res.sendStatus(200);
});
import hmac, hashlib, os
from flask import Flask, request, abort
app = Flask(__name__)
@app.get("/postback")
def postback():
tx = request.args.get("tx", "")
user = request.args.get("user", "")
reward = request.args.get("reward", "0")
status = request.args.get("status", "")
expected = hmac.new(os.environ["MOONETIX_POSTBACK_KEY"].encode(), f"{tx}:{user}:{reward}:{status}".encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.args.get("hash", "")):
abort(403)
s = int(status or 0)
last = get_last_status(tx)
if s == 1 and last not in (1, 2):
add_to_balance(user, reward)
save_status(tx, 1)
elif s == 2 and last == 1:
subtract_from_balance(user, reward)
save_status(tx, 2)
return "OK", 200