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_PUBLIC_KEY and YOUR_SECRET_KEY are on your placement's page once you have an account. Create one, or sign in and this page fills itself in.

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"
Use a constant-time comparison — 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.

Send users to 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 transactionId and ignore one you have seen. We retry failures; networks resend.
Handle negatives
A chargeback arrives as status=reversed with a negative amount. Decide now whether a user can go below zero.
Ignore tests
test=1 comes 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

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.