Integration guide
Most publishers are live on the first step. The rest of this page is what you need before you take real traffic.
Your two keys
publicKey-
YOUR_PUBLIC_KEY
Identifies the placement. It goes in the wall URL, so it is public by design — it identifies, it does not authenticate. secretKey-
YOUR_SECRET_KEY
Signs and verifies. Server-side only. Anything you put in browser JavaScript is public, and this key can read your revenue.
1 Embed the wall
One iframe. We render the offers, target them to the user's country and device, track the clicks, take the network callbacks and pay you.
<iframe src="https://offerwall.gg/wall/YOUR_PUBLIC_KEY?userId=USER_ID"
style="width:100%;height:800px;border:0"
allow="clipboard-write"></iframe>
USER_ID is your id for the user — whatever your database calls them.
It comes back on every callback unchanged, so you always know who to credit. Use a stable
id, not a session id: a user who comes back tomorrow has to be the same person, or their
pending conversions land on nobody.
Leave userId off entirely and the wall renders in preview mode — offers show,
clicking is refused. That is what to build against.
Make it fit
The wall is responsive down to phone widths, so give the iframe the height you can spare and let it scroll internally. Set the colour scheme, accent colour and heading on the placement — they are wall settings, not URL parameters, so they cannot be tampered with by whoever is looking at the page.
Lock it to your domain
Set allowed domains on the placement and we send
Content-Security-Policy: frame-ancestors naming them, so a browser refuses to
render your wall inside anyone else's page. Subdomains of a listed domain are covered.
Leave it empty and the wall embeds anywhere — fine while you are integrating, not fine
once you are earning.
2 Sign the entry link
Without a signature, anyone who sees a wall URL can change the user id in it and earn into somebody else's balance. Turn on require signature on the placement and build the URL server-side:
<?php
$publicKey = 'YOUR_PUBLIC_KEY';
$secretKey = 'YOUR_SECRET_KEY'; // never in client code
$userId = 'user-1234';
$params = ['appId' => $publicKey, 'userId' => $userId];
ksort($params); // sorted by name
$signature = hash_hmac('sha256', http_build_query($params), $secretKey);
$wallUrl = 'https://offerwall.gg/wall/' . $publicKey
. '?userId=' . urlencode($userId)
. '&signature=' . $signature;
The canonical string is every parameter sorted by name and joined k=v&k=v.
signature is never part of what it signs.
If expiring links are switched on for the platform, add ts (Unix seconds) to
the signed parameters and send it alongside the signature. The timestamp being inside the
signature is what makes expiry work — a link cannot be edited to look newer.
3 Receive the conversion
This is the part that pays your users, and the only channel that is real-time. Set your callback URL on the placement:
https://you.example/offerwall/callback?user={userId}&amount={currencyAmount}
&tx={transactionId}&status={status}&sig={signature}
Then verify the signature before crediting anybody. It is HMAC-SHA256 over exactly three fields joined with colons:
signature = hmac_sha256("userId:transactionId:currencyAmount", secretKey)
Only those three, in that order — so the check still works when those are the only macros you asked for.
PHP
<?php
$secret = getenv('OFFERWALL_SECRET');
$userId = $_GET['user'] ?? '';
$txId = $_GET['tx'] ?? '';
$amount = $_GET['amount'] ?? '';
$expected = hash_hmac('sha256', "$userId:$txId:$amount", $secret);
if (!hash_equals($expected, $_GET['sig'] ?? '')) {
http_response_code(403);
exit('FORBIDDEN');
}
if (($_GET['test'] ?? '0') === '1') {
exit('OK'); // a dashboard test — never credit
}
if (alreadyProcessed($txId)) {
exit('OK'); // idempotent: we retry, networks resend
}
creditUser($userId, (float) $amount); // negative on a reversal — subtract it
markProcessed($txId);
echo 'OK';
Node.js
const crypto = require('crypto');
app.get('/offerwall/callback', async (req, res) => {
const { user = '', tx = '', amount = '', sig = '', test = '0' } = req.query;
const expected = crypto
.createHmac('sha256', process.env.OFFERWALL_SECRET)
.update(`${user}:${tx}:${amount}`)
.digest('hex');
const a = Buffer.from(expected), b = Buffer.from(String(sig));
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(403).send('FORBIDDEN');
}
if (test === '1') return res.send('OK');
if (await alreadyProcessed(tx)) return res.send('OK');
await creditUser(user, Number(amount)); // negative on a reversal
await markProcessed(tx);
res.send('OK');
});
Python
import hmac, hashlib, os
from flask import request, abort
@app.get("/offerwall/callback")
def offerwall_callback():
user = request.args.get("user", "")
tx = request.args.get("tx", "")
amount = request.args.get("amount", "")
expected = hmac.new(
os.environ["OFFERWALL_SECRET"].encode(),
f"{user}:{tx}:{amount}".encode(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, request.args.get("sig", "")):
abort(403)
if request.args.get("test") == "1":
return "OK"
if already_processed(tx):
return "OK"
credit_user(user, float(amount)) # negative on a reversal
mark_processed(tx)
return "OK"
hash_equals,
timingSafeEqual, compare_digest. A plain
== on an HMAC leaks the answer one byte at a time.
There is more to the callback than this — retries, reversals, what to answer. The full contract is here.
4 Or build your own wall
If you want your own UI, take the inventory as JSON. Same targeting, same rewards, same click URLs — the API changes who draws the pixels, nothing else. You still receive the same signed callback.
curl "https://offerwall.gg/api/v1/offers?appId=YOUR_PUBLIC_KEY&userId=user-1234&limit=50" \
-H "X-Api-Key: YOUR_SECRET_KEY"
{
"success": true,
"data": {
"offers": [{
"id": 4021,
"name": "World of Sea Battle",
"requirements": "Reach level 10 within 7 days.",
"reward": 1176,
"rewardFormatted": "1,176 Coins",
"rewardIsVariable": false,
"clickUrl": "https://offerwall.gg/wall/YOUR_PUBLIC_KEY/click/4021?userId=user-1234"
}],
"pagination": { "page": 1, "pages": 11, "total": 546, "perPage": 50 },
"currency": { "name": "Coins", "perUsd": 1000 }
}
}
reward is already converted into your currency, at your rate, and is
already your earnings. You never do the arithmetic.
clickUrl, never to the advertiser.
The click row is written before the redirect, and it is the only thing that makes a
conversion attributable days later. A user sent straight to an advertiser earns nobody
anything.
Pass no userId and there is no clickUrl in the response —
an unattributed click cannot become a conversion, so we do not hand one out.
Before you take real traffic
- Verify, then credit
- An unverified callback is one anyone can forge. Check the HMAC first, every time.
- Be idempotent
- Store
transactionIdand ignore one you have seen. We retry failures; networks resend. - Handle negatives
- A chargeback arrives as
status=reversedwith a negative amount. Decide now whether a user can go below zero. - Ignore tests
test=1comes from the dashboard button. Answer 200, credit nobody.- Keep the secret server-side
- If it ever reaches a browser, rotate it in the dashboard immediately.
- Lock the domain
- Set allowed domains so your wall cannot be embedded on someone else's site.
- Show the requirements
- If you draw your own wall, show
requirements. Most missing-reward tickets are a user who did not read what the offer wanted.
Tooling
Full reference Swagger UI — try it live Postman collection
The OpenAPI document is at
/openapi.json and
/openapi.yaml (3.1, canonical), with a
3.0.3 build for tooling that cannot read 3.1 —
openapi-generator, older Swagger UI, most API gateways. Both are CORS-open and cached for
an hour, so your CI can fetch either without a credential.
The Postman collection includes a Postback folder that fires a correctly signed callback at your endpoint, so you can prove your verification works before you have any traffic.
Stuck on something the docs do not answer? Tell us — a question that reaches support is a page we should have written.