LootHQ

Publisher docs

Integrate your app with LootHQ

The same integration guide publishers see inside the dashboard.

LootHQ Publisher Integration

Connect your app, game, or rewards site to LootHQ with a hosted link, iframe or script embed, and secure server-to-server postbacks.

For publishers

Create placements, embed the wall, and track earnings in real-time.

For engineers

Simple integration via URL params, iFrames, and webhooks.

Time to live

Most teams go live in under a day once postbacks are wired.

How the coins model works

A Hosted Wall integration involves two currencies. Being precise about each one saves confusion later.

  • LootHQ Coins: the wall's native currency. Every offer pays out in coins at a consistent rate, which is what lets the wall run its own mechanics on top: leaderboards, lotteries, prize pools, streak bonuses. Coins are the user's score on the wall; the spendable reward lives in your app, in your currency.
  • Your currency: gems, points, credits, whatever your app calls it. You set the points-per-coin rate, so your economy stays yours. Your postback fires the moment a conversion is approved and carries {points}already converted at that rate, so the user's reward shows up in your app with no action on their part.

Why two layers

  • Rate control. Set the coin-to-currency rate that fits your economy, and change it without touching your integration.
  • Retention mechanics you don't have to build. Leaderboards and lotteries give users a reason to come back to the wall. More completions, more revenue, no engineering on your side. Leaderboards run network-wide across all LootHQ publishers, not per-app. That means the board is competitive from day one regardless of your traffic volume: your users compete for a prize pool LootHQ funds, not one drawn from your revenue. No publisher names, branding, or attribution appear on the board; users see display names and avatars only. Network Leaderboard ships off by default; toggle it on per placement under Dashboard → Placements → your placement.
  • Reversals are mirrored to you at full value. When an advertiser reverses a conversion, we deduct the coins from the user's wall balance (it can go negative; the deduction comes out of their next earnings), and your reversal postback asks you to take back exactly what the approval postback credited. Prefer never to claw back from a user? Hold risky offers on your own side and release them after your own window; see .

Negative balances work because the user's earning relationship lives on the wall. An SDK or iframe wall, where the publisher holds that relationship, can't do this at all.

Worked example

  1. An offer lists a payout of $1.40, the amount you earn per conversion. It's the number shown in your dashboard, and the Offers API returns it as payoutUsd.
  2. Your placement margin is your cut of that payout. At the 25% default you keep $0.35.
  3. The remaining $1.05 is the user payout, credited as 1,050 LootHQ Coins (1,000 coins per USD).
  4. At your 1 coin = 1 gem rate, those 1,050 coins are 1,050 gems to credit.
  5. The moment the conversion is approved, LootHQ fires your postback with {coins}=1050, {points}=1050, {payout}=1.05, {currency}=gems.

This example is about what the user receives. See below for how your own wallet is credited (it's a different number, on a different schedule). Both levers are yours, in Publisher Dashboard → Placements: the points-per-coin rate is set when you create the placement, and the margin is an editable percentage on the same page: your cut, the share of each payout you keep before paying out to your users. If you'd rather skip the coin layer and render offers in your own UI with USD postbacks, see the below.

Integration flow

At a high level, this is how traffic and rewards move through LootHQ:

  1. 1

    User opens your app or site

    You
  2. 2

    Your app loads the LootHQ wall

    You

    iframe, script embed, modal, or hosted link, always with your sub_id

  3. 3

    User clicks an offer on the wall

    LootHQ

    LootHQ records the click, then redirects to the advertiser

  4. 4

    User completes the offer at the advertiser

    Offer network

    install, sign-up, purchase…

  5. 5

    The advertiser/network posts the conversion back to LootHQ

    Offer network

    the tracking link is ours, so we always see it first. This is also the moment YOUR wallet is credited in full (see “When you earn” below)

  6. 6

    LootHQ credits the user in LootHQ Coins

    LootHQ

    1 USD of payout = 1,000 coins, minus your placement margin; coins drive the wall's leaderboards and prizes

  7. 7

    The completion is approved → LootHQ fires your postback

    LootHQ

    {points} arrives here: the amount to credit, already in your currency

  8. 8

    Your backend credits the user and shows the completion

    You

Quickstart: wall in 5 minutes

Everything you need to get a hosted wall live, in order, with the exact things to copy. You need one thing before you start: a placement id (step 1 gets you one).

  1. 1Create a placement. Dashboard → Placements → New placement → integration type Hosted Wall. Copy its placement id; the snippets below call it YOUR_PLACEMENT_ID.
  2. 2Embed the wall. Paste this where the wall should render, filling in both placeholders:
    Script embed (recommended)
    <div id="loothq-wall"></div>
    <script
      src="https://www.loothq.net/wall.js"
      data-placement-id="YOUR_PLACEMENT_ID"
      data-sub-id="{USER_ID}"
      data-container="loothq-wall"
    ></script>
    <!-- Replace YOUR_PLACEMENT_ID with the id from Dashboard → Placements, and
         render {USER_ID} server-side as the signed-in user's STABLE id. -->

    data-sub-id must be a stable id for the signed-in user, unique across all your placements. It is the id your postback will echo back as {user_id}, so it decides who you credit. Prefer an or a ? Both work identically; the script tag just wires the resize and click plumbing for you.

  3. 3Save your postback template. In Settings → Publisher Settings, save this as your Postback URL (swap in your own domain, path and a real secret for YOUR_SECRET; the {macros} stay exactly as written; we fill them in):
    Default postback template
    https://your-site.com/postback?key=YOUR_SECRET&user_id={user_id}&tx={transaction_id}&points={points}&payout={payout}&currency={currency}&offer_id={offer_id}&offer_name={offer_name}&status={status}&event={event_id}

    Your endpoint credits the user {points} (already in your currency) and returns HTTP 200. Saving warns you, without blocking, if the template is missing {status} or {offer_id}, or if you have no reversal URL yet. Full handler samples are in .

  4. 4Run the Integration check. Open Dashboard → Integration check and press Run. One click verifies your saved URLs, the attribution macros, and a real delivery to each endpoint that writes no delivery, conversion, stat or ledger row. Every failing row comes with the concrete fix.
  5. 5Fire a test. On the same Settings page, run Test delivery in the Postback Tester and confirm your endpoint answers 200. Run it twice: it reuses the same {transaction_id} on purpose, so the second run proves your duplicate check works. Then go live and watch clicks, conversions and revenue in the dashboard.

Answer 200 first, then validate

The delivery test sends a {user_id} and {transaction_id} that do not exist in your database yet. That is deliberate: it is the only way to prove your endpoint is reachable without inventing a real conversion. If your handler looks the user up and returns 400 or 500 when it cannot find them, the test fails even though your server is working perfectly.

Return HTTP 200 for anything you have decided about, including "unknown user" and "already processed". Reserve non-2xx for the cases you want us to retry, such as your database being briefly unreachable. The full handler samples in do this already:

const user = await db.Users.findById(user_id);
if (!user) {
  return res.status(200).send("unknown user");   // not 404
}

When you are ready for reversal handling in code (recommended before launch), add the separate reversal URL; see . Prefer to render offers in your own UI instead of embedding the wall? Start at the .

Hosted link

The simplest integration, and the only genuinely zero-code one, is to send users to your hosted LootHQ offerwall by URL. One link: no JavaScript, no iframe, works in webviews and email. We handle tracking, device detection, and localization for you.

Basic wall URL
https://www.loothq.net/wall?placement_id=YOUR_PLACEMENT_ID&sub_id={USER_ID}

Required query parameters

ParamDescriptionExample
placement_idThe placement ID from your publisher dashboard → Placements. Each placement represents one integration point (app, site, etc).placement_id=64f3b...
sub_id / user_idStable user identifier from your system, used to attribute conversions to the correct user. sub_id and user_id are interchangeable. Must be unique across ALL your placements, not just within one. See the note below.sub_id=USER_987

Use the same identifier for both the offerwall (subID) and your postback handler so that conversions always credit the right account.

sub_id must be unique across ALL of your placements, not just within one. If you run multiple apps or sites, don't reuse a per-app numeric ID (e.g. a plain database row ID) as-is across them. Two different placements handing us the same sub_id looks like the same person to us and merges their coin balances and history. Prefix or namespace your IDs per placement (e.g. app1_4821 vs. app2_4821) if there's any chance of collision.

iFrame

Use an iFrame to keep users inside your own layout while LootHQ handles the offerwall content.

Basic iFrame embed
<iframe
  id="loothq-wall"
  src="https://www.loothq.net/wall?placement_id=YOUR_PLACEMENT_ID&sub_id={USER_ID}"
  width="100%"
  height="700"
  frameborder="0"
  scrolling="no"
  allow="clipboard-write"
  sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
  style="border: none; width: 100%; min-height: 700px; border-radius: 12px;"
></iframe>

<script>
  window.addEventListener('message', function (e) {
    var frame = document.getElementById('loothq-wall');
    // Only act on messages that actually came from the wall iframe you
    // embedded: check e.origin before touching e.data. Never trust a
    // postMessage's origin implicitly, and never reply with '*'.
    if (!frame) return;
    var wallOrigin;
    try { wallOrigin = new URL(frame.src, window.location.href).origin; } catch (err) { return; }
    if (e.origin !== wallOrigin) return;
    if (!e.data) return;
    // The wall asks whether a host will open offers on its behalf. Answer, or
    // it opens them itself, which your sandbox may block.
    if (e.data.type === 'loothq:wall-ready' && e.source) {
      e.source.postMessage({ type: 'loothq:host-ready' }, wallOrigin);
    }
    // Auto-resize iframe to fit wall content
    if (e.data.type === 'loothq:wall-height' && frame) {
      frame.style.height = Math.max(e.data.height, 500) + 'px';
    }
    // Open offer links in a new tab (required when iframe has sandbox restrictions)
    if (e.data.type === 'loothq:open-url' && e.data.url) {
      window.open(e.data.url, '_blank', 'noopener,noreferrer');
    }
  });
</script>

<!-- Prefer not to hand-roll this? Drop in https://www.loothq.net/wall.js instead; it does all
     of the above (and the modal) for you. See the Script section. -->

We recommend a dedicated "Earn" or "Offerwall" page with a minimum height of 650–800px. You can wrap the iFrame in your own navigation and branding.

If your site sends a Content-Security-Policy header

You must allow LootHQ in your frame-srcdirective, or the browser will refuse to load the wall and your users will see "This content is blocked. Contact the site owner":

Content-Security-Policy: frame-src https://www.loothq.net;

This failure happens entirely inside the browser. It never reaches our servers, so it won't appear in your LootHQ logs or ours. An empty space where the wall should be, with a CSP violation in the browser console, is the signature.

What your users see in the address bar

The address bar always shows your site. That is how iframes work, and it is the point: the wall is part of your product, not a redirect away from it. The only LootHQ-branded URL in the whole flow is the sign-in popup, and that is deliberate. Credentials are only ever entered on a window whose address bar visibly reads loothq.net, so a user can always tell a genuine LootHQ sign-in from a page imitating one. We never ask for a password inside the iframe.

Script

One tag, and we manage the frame. The script mounts the same canonical wall URL as the method, then does the plumbing the hand-rolled version asks you to write yourself. This is the embed the recommends.

Script embed
<div id="loothq-wall"></div>
<script
  src="https://www.loothq.net/wall.js"
  data-placement-id="YOUR_PLACEMENT_ID"
  data-sub-id="{USER_ID}"
  data-container="loothq-wall"
></script>
<!-- Replace YOUR_PLACEMENT_ID with the id from Dashboard → Placements, and
     render {USER_ID} server-side as the signed-in user's STABLE id. -->

Attributes

AttributeDescription
data-placement-idRequired for the inline mount. A tag without it mounts nothing and only installs the window.LootHQ API (with its legacy alias and message listener). That is the modal-only usage below, not an error.
data-sub-idYour stable id for the signed-in user (alias: data-user-id). Same rules as sub_id on the wall URL: stable, and unique across all your placements.
data-containerId of the element to mount into (alias: data-container-id). Defaults to loothq-wall; if no such element exists, the script creates a <div> right after the script tag.
data-base-urlOverride the wall origin (alias: data-origin). Defaults to the origin the script was loaded from; you almost never need this.

What it handles for you

  • Auto-resize. Listens for the wall's height messages and sizes the frame to fit (the inline mount keeps a 600px minimum), so there's no clipped, unscrollable wall to debug.
  • The ready handshake. Answers the wall's wall-ready announcement with host-ready, which tells the wall to delegate offer-opening to the page. Without a host, the wall opens offers itself, which works only as long as your sandbox allows popups; a stricter sandbox (no allow-popups) silently swallows the click. The script never depends on your sandbox choices.
  • Offer opening. Opens each offer in a new tab with noopener,noreferrer, and dedupes the wall's double-fired click messages so one click never opens two tabs.

What the script does on your page

The answer for your security review: it reads its own data- attributes, creates iframes pointed only at https://www.loothq.net (one per inline mount or modal open), installs window.LootHQ (plus a legacy window.RewardsRiver alias), and registers a single postMessage listener. That listener checks e.origin and accepts messages only from origins of frames the script itself created (normally exactly https://www.loothq.net), and every reply is targeted at that origin, never "*". It sets no cookies, and apart from looking up its mount container (plus an Escape-key listener while the modal is open) it reads nothing else from your page. Rewards are server-to-server via postbacks, so the script exposes no events or callbacks to the host page; there is nothing else to wire up.

Modal mode

Prefer the wall as an overlay instead of a page? Load the tag with no data-placement-id and open it on demand:

Modal mode
<script src="https://www.loothq.net/wall.js"></script>
<script>
  // e.g. from your "Earn coins" button:
  document.getElementById("earn-btn").addEventListener("click", function () {
    LootHQ.open("YOUR_PLACEMENT_ID", getCurrentUserId());
  });
</script>

LootHQ.open(placementId, subId) shows the wall in a full-screen overlay; Escape, a click on the backdrop, or the ✕ closes it, and LootHQ.close() closes it programmatically. Same wall, same attribution, same CSP requirements as the inline mount.

If your site sends a Content-Security-Policy header

The tag loads JavaScript from our origin and mounts an iframe pointed at it, so a strict CSP needs script-src as well as frame-src:

Content-Security-Policy: script-src https://www.loothq.net; frame-src https://www.loothq.net;

Like the iFrame case, this failure happens entirely inside the browser and never reaches our servers, so it appears in neither your LootHQ logs nor ours. The signature is a CSP violation in the browser console and no wall.

When to pick which: iFrame means you manage the frame: sizing, placement, and the short message listener. Script means we manage the frame: the tag mounts it, sizes it, and handles offer opening, with modal mode when you want an overlay. Both render the same wall from the same canonical URL.

Setting the wall URL after login

A companion pattern for the method, not an integration method of its own: leave the iframe's src unset and fill it in client-side once you know who the user is.

Set the src after auth
const userId = getCurrentUserId(); // your logic
const placementId = "YOUR_PLACEMENT_ID"; // from publisher dashboard → Placements

const wallUrl = "https://www.loothq.net/wall"
  + "?placement_id=" + encodeURIComponent(placementId)
  + "&sub_id=" + encodeURIComponent(userId);

document.getElementById("loothq-wall").src = wallUrl;

This pattern is useful if you render your app client-side or need to inject the wall URL after authentication.

Conversion postbacks (server-to-server)

You save a template URL in Settings → Publisher Settings. When a conversion is approved, LootHQ replaces the {macros} below with real values and calls your URL server-to-server. You choose the param names; just point each one at the macro you want.

Example template URL (save this in Publisher Settings)
https://your-site.com/postback?key=YOUR_SECRET&user_id={user_id}&tx={transaction_id}&points={points}&payout={payout}&currency={currency}&offer_id={offer_id}&offer_name={offer_name}&status={status}&event={event_id}

Start from that exact template, including {offer_id} and {status}. They cost you nothing and you will want both the first time you reconcile: without {offer_id} your reporting can't say which offer paid, and without {status} your handler has nothing to assert on. The placeholder in the Settings field shows this exact template; copy it in and save (the field itself starts empty). Settings checks this for you at save time: it warns (it never blocks the save) when your template is missing {status} or {offer_id}, or when you have saved a primary URL with no reversal URL alongside it.

Available macros

MacroDescriptionExample
{user_id} / {sub_id}The sub_id you passed to the offerwall (or to the Offers API feed), i.e. your internal user identifier. Use it to credit the right account. Sent on every channel, reversals included.USER_987
{transaction_id} / {click_id}The click id behind this credit, identical on the hosted-wall and Offers API channels. It is unique per credit on prize redemptions and on any offer with a SINGLE goal, but NOT on a MULTI-GOAL (milestone/CPE) offer: every goal reached on one click credits separately while repeating this id, so deduping on it alone silently drops every goal after the first. Dedupe credits on the ({transaction_id}, {event_id}) pair, which is unique per credit; the conversion.approved webhook's conversionId is a signed alternative. (On a conversion with no click — a survey completion, for example — the conversion's own id stands in, on the approval and the reversal alike, so the join below still holds.) On the REVERSAL URL it is the ORIGINAL conversion's id — repeated on every reversal of that conversion, so it is NOT a reversal dedupe key; dedupe reversals on {event_id}.b3f1c9a2-7e44-4f1a-9c2d
{payout} / {amount}USD value of this credit, already net of your placement margin — the conversion's user payout on both primary channels. Reversal URL: the full USD value credited at approval, now being reversed. Never negative — a reversal is never signalled by sign. Credit {points} on the wall channel; {payout} is the same value in USD.1.20
{publisher_payout} / {pub_payout}USD you earn on this conversion, BEFORE your own placement margin. This is the one money macro on this page that is about you rather than your user: {payout} and {points} are both what the USER gets, net of your margin, and the difference between the two is your margin. Sent on both primary channels and on the reversal URL, where it is the amount being taken back. Never negative. EMPTY on prize redemptions, which are a Prize Coin payout to the user and earn you nothing. Take care before forwarding this to your own users alongside {payout}: together the pair discloses your margin.1.50
{points}Amount to credit in YOUR currency: this conversion's coins × your points-per-coin rate. This is the value you credit the user on the hosted wall. Hosted wall, prize redemptions and reversals only — EMPTY on Offers API postbacks, which have no coin step (use {payout} there).1200
{coins}LootHQ coins behind this credit (1,000 coins per USD of user payout; on the reversal URL, the coins reversed). EMPTY on Offers API postbacks, which have no coin step.1200
{offer_id}The LootHQ offer ID. Sent on every conversion postback (hosted wall and Offers API) and on reversal postbacks — key your own reporting and any hold policy on it, and dedupe/join on it rather than on {offer_name}, which is mutable. The offer IMAGE resolves by this id: GET /api/v1/conversions returns name and image inline on every conversion row, and the conversion.approved webhook carries offerName/offerImage. EMPTY on prize redemptions, which no single offer produced.42
{offer_name} / {offer_title}The offer's display name at dispatch time, URL-encoded (e.g. "Lucky7"). Made for your completions feed and notifications: print it, never join on it — names can change, and {offer_id} is the stable key. Sent on conversion postbacks (both channels) and reversal postbacks; EMPTY on prize redemptions.Lucky7
{currency}Currency of this credit. Hosted-wall postbacks send YOUR currency name (e.g. "gems") because {points} is the amount you credit; Offers API postbacks send "USD". EMPTY on the reversal URL — read {coins}/{points}/{payout} there instead.USD
{status}On the PRIMARY postback URL this is always "approved", because reversals are never sent there, by design. On the optional REVERSAL postback URL it is always "reversed". A primary-URL handler therefore never sees a reversal, a negative {payout}, or any status other than "approved" — do not write code that waits for one.approved
{reason}Reversal postback only. Why the conversion was reversed (chargeback, fraud, advertiser void, manual, …).chargeback
{event_id}Which event this delivery is about — the meaning is per-channel. Conversion postbacks (both channels): the goal/event id being credited ("signup", "deposit", …; "conversion" on a single-goal offer) — dedupe on the ({transaction_id}, {event_id}) pair, which is unique per credit. Reversal URL: a fresh UUID per reversal DELIVERY — dedupe reversals on it alone. EMPTY on prize redemptions, whose {transaction_id} is already unique per credit.signup
{source}Set to "prize" on a leaderboard prize-coin redemption, and EMPTY on every other postback (a normal conversion does not set it). Optional, so leave it out and nothing changes; you are funded at face value either way. Branch on {source} === "prize", never on it equalling "offer".prize

What each macro carries, per callback

Four things ever call your URLs: a hosted-wall conversion approval, an Offers API conversion approval, a prize redemption (all three on your primary URL), and a reversal (on the separate reversal URL). A dash means the macro is not supplied there and arrives present-and-empty, never missing.

MacroHosted wall conversionOffers API conversionPrize redemptionReversal URL
{user_id}your sub_idyour sub_idyour sub_idyour sub_id
{transaction_id}the CLICK id — repeats across goalsthe CLICK id — repeats across goalsprize_<id> — unique per creditthe ORIGINAL conversion's click id
{payout}user payout, USDuser payout, USDface-value USDfull credited USD
{publisher_payout}your payout, USDyour payout, USDyour payout, reversed
{points}coins × your ratecoins × your ratefull credited points
{coins}coins for this conversioncoins redeemedcoins reversed (0 on Offers API)
{offer_id}the offer idthe offer idthe offer id
{offer_name}the offer namethe offer namethe offer name
{currency}your currency name"USD"your currency name
{status}"approved""approved""approved""reversed"
{reason}why it was reversed
{event_id}goal/event id ("signup", …)goal/event id ("signup", …)fresh UUID per delivery — the dedupe key
{source}"prize"

An unused macro arrives empty, not absent

We substitute every macro we recognize. One we have no value for on a particular postback becomes the empty string: the param is still there, it just has nothing in it. Treat empty as "not applicable to this event", never as an error, and never require it to be non-empty before you credit.

  • {offer_id} is populated on every conversion postback, hosted wall and Offers API alike, and on reversals. It is empty on a prize redemption, which no single offer produced. Key your per-offer reporting, and any hold policy of your own, on it.
  • {points} and {coins} are present on hosted-wall conversions, prize redemptions and reversals; empty on Offers API postbacks, which have no coin layer. Use {payout} there.
  • {currency} is not sent on the reversal URL.
  • {event_id} is populated on every conversion approval, both channels: the goal id, e.g. signup, or conversion on a single-goal offer. On reversals it is a fresh per-delivery UUID. It is empty on prize redemptions, whose {transaction_id} is already unique per credit.
  • {source} is set only on a prize redemption, where it reads prize. On every ordinary conversion it is empty. Test it for prize; it never equals offer.

Print {offer_name}, join on {offer_id}

{offer_name}is the offer's display name at the moment the postback fires, made for your completions feed and notifications. Names are mutable: an advertiser can rename a campaign, and the string you stored at credit time then stops matching the one in newer deliveries. So show it, but key your reporting, dedupe and any hold policy on {offer_id}, which never changes.

The offer image, and names for older credits, resolve from the same id: the is keyed by it, GET /api/v1/conversions returns name and image inline on every conversion row (offers that have since left the feed included), and the conversion.approved webhook carries offerName and offerImage.

{currency} means something different per integration

Hosted Wall postbacks send your own currency name, whatever you named it in Placements (e.g. "gems"), because {points}is already converted into that currency at your placement's rate. Offers API postbacks always send USD. There's no coin layer in that model, so {payout} is the number to use instead. Branch on your placement's integration type, not on the value of {currency} alone.

Required: idempotent processing

Store the ({transaction_id}, {event_id}) pair and credit each pair exactly once. If you receive the same pair again, return HTTP 200 without re-crediting. This protects you from duplicate deliveries: every retry and every manual Resend reuses both values verbatim.

The pair works on every channel. Conversion approvals, hosted wall and Offers API alike, send the click id plus the goal id, and only the pair is unique per credit. Prize redemptions send a {transaction_id} that is unique per credit and an empty {event_id}, so the pair degrades to the transaction id alone there; see .

Never dedupe on user + offer instead. A user can legitimately earn more than once on the same offer, and a user+offer key silently swallows those separate, legitimate rewards.

Watch out: offers with more than one goal

A milestone (CPE) offer pays several times for one click: "install", then "reach level 10", then "first purchase". Each goal is a separate credit and fires its own postback, but on every conversion postback {transaction_id} is the click id (which is why {click_id} is an alias for it), so every goal on that click repeats the same value. Dedupe on it alone and you will credit the first goal and silently discard the rest, with no error on either side.

What tells the goals apart is {event_id}: the goal id (signup, deposit, …, and conversionon a single-goal offer; a supply network's multi-tier offers carry the network's tier id). So key idempotency on the ({transaction_id}, {event_id}) pair: it is unique per credit, and it is the same key our own database enforces. If you prefer a signed source of truth, the conversion.approved webhook's conversionId is also one per credit and works for both integration types.

Single-goal offers are unaffected: one click, one credit, and the pair is just ({transaction_id}, "conversion"). If you are not sure which your offers are, dedupe on the pair and you never need to know: the failure mode this prevents is invisible in your logs.

Sample postback handlers

Node.js / Express handler
// Example: Node.js / Express (plain JS, runs as-is under `node`)
// The param names below are the ones YOU chose in your template URL.
app.get("/postback", async (req, res) => {
  const { user_id, tx, points, payout, offer_id, currency, status } = req.query;
  const event = req.query.event || ""; // the conversion's goal id; empty on prize redemptions

  // 0) Auth – your template URL hard-codes &key=...; reject anything without it
  if (req.query.key !== process.env.LOOTHQ_POSTBACK_SECRET) {
    return res.status(403).send("forbidden");
  }

  // 1) Basic sanity checks
  if (!user_id || !tx) {
    return res.status(400).send("missing params");
  }

  // 2) Idempotency – credit each (tx, event) pair exactly once. On wall and
  //    Offers API conversions alike, tx is the CLICK id and repeats for
  //    every goal of a multi-goal offer; only the pair is unique per
  //    credit. On a prize redemption, event is "" and tx alone is unique,
  //    so the pair degrades to tx. For strict safety under concurrent
  //    duplicates (two retries landing at once), also add a UNIQUE INDEX on
  //    the (tx, event) columns; a findOne-then-create check alone still has
  //    a race window.
  const existing = await db.Conversions.findOne({ tx, event });
  if (existing) {
    return res.status(200).send("already processed");
  }

  // 3) Confirm the user belongs to a real account
  const user = await db.Users.findById(user_id);
  if (!user) {
    return res.status(200).send("unknown user");
  }

  // 4) Credit the user.
  //    Hosted wall → {points} is ALREADY in your currency (coins x your rate).
  //                  Credit it as-is. Do NOT credit {payout}, which is USD.
  //    Offers API  → no coins; {payout} is USD, convert it yourself.
  //    Test for "" and not for null: a macro we don't substitute on this
  //    channel arrives PRESENT AND EMPTY, so `points != null` is true on an
  //    Offers API postback and Number("") would credit 0.
  const amount = points !== undefined && points !== ""
    ? Number(points)
    : usdToYourCurrency(Number(payout));

  await creditUser(user_id, amount);

  // 5) Store the conversion for reporting
  await db.Conversions.create({
    tx,
    event,
    user: user_id,
    credited: amount,
    payoutUsd: payout != null ? Number(payout) : null,
    currency: currency || "USD",
    offerId: offer_id || null,
    status: status || "approved",
  });

  return res.status(200).send("ok");
});

Dedupe keys, per channel

The single most expensive class of integration bug is deduping on the wrong key: too weak and retries double-credit, too strong and legitimate credits are silently dropped. This is the whole truth in one table.

DeliveryWhat {transaction_id} isWhat {event_id} isDedupe on
Conversion approval (hosted wall or Offers API)The click id (the conversion's own id when there is no click, e.g. a survey completion); it repeats across the goals of a multi-goal offerThe goal id (signup, deposit, …; conversion on single-goal offers)The ({transaction_id}, {event_id}) pair
Prize redemptionprize_…, unique per creditEmpty{transaction_id} alone
Reversal (separate URL)The original conversion's id; it repeats per reversal and per retryA fresh UUID per delivery{event_id} alone
  • On the primary URL, the pair is always safe. Prize redemptions send an empty {event_id}, so storing the ({transaction_id}, {event_id}) pair degrades to the transaction id alone there, so one dedupe rule covers every placement type you will ever run.
  • On conversions, the pair is required. {transaction_id} is the click id on both integration types, and a milestone (CPE) offer credits once per goal on one click: each goal repeats the transaction id and changes the event id. Dedupe on the transaction id alone and goals 2..n are silently discarded.
  • Retries never mint new identities. Every automatic retry and every manual Resend of a delivery reuses the exact same {transaction_id} and {event_id} as its first attempt, so the pair you stored keeps matching.
  • Reversals dedupe on {event_id} alone. On that channel it is a per-delivery UUID, while {transaction_id}is the original conversion's id and repeats. See .

When you earn

Your LootHQ wallet is credited the full listed payout for that conversion the moment it is approved, for both Hosted Wall and Offers API placements (each goal of a multi-goal offer credits separately as it lands). There's no proration and no waiting on the user.

  • The postback is about your user, not your wallet. The approval-time postback tells you what to credit the user in your own currency. It has no effect on your LootHQ wallet; that is settled separately, at the same approval.
  • Coins are a score, not a debt. A user's coin balance on the wall drives leaderboards and prizes. Nothing about it delays, reduces, or claws back what you're owed.
  • Only an explicit reversal claws back. A genuine reversal (advertiser rejection, chargeback, fraud, void) is the one thing that debits your wallet. See below for exactly how that's delivered.

Users see earned coins instantly, but some categories and new accounts hold coins pending for a short window before they count as available. See . That window gates only the user's coin display on the wall; it never delays your postback, which fires at approval regardless, and it never delays or reduces your wallet credit.

Track your running balance any time in Publisher Dashboard → Earnings.

Pending and available coins

Coins are credited to the user instantly and visibly the moment a conversion is approved. The wall always shows them right away, split into available (cleared) and pending(clearing soon). Some offer categories and brand-new accounts carry a short clearing window; logged-in users clear faster. The window gates only the user's coin display on the wall. It never touches your side: your postback fires at approval regardless, and your wallet is paid in full at approval regardless.

Clearing windows vary by offer category. A user's trust tier(built from account age, history, and a clean reversal record) shortens them over time, and a verified login shortens them immediately, which is the reason to prompt sign-in. Coins credited on a hosted wall count toward the user's lifetime and leaderboard totals immediately; only availability is gated.

Handling reversals

Read this before you write reversal handling

A reversal never arrives as a negative {payout} on your primary postback URL. It never arrives on that URL at all. {payout}, {points} and {coins} are never negative there. A reversal is never signalled by sign (a tiny credit can legitimately round {payout} down to 0.00, so don't gate on > 0 either), and {status} is always approved. Code that watches the primary URL for a negative amount, or for status=reversed, will revoke nothing, forever, and will look like it works, because approvals keep flowing through it normally.

Reversals travel their own channels: a separate reversal postback URL (Tier 1, below), the dashboard and email (Tier 0, always on), and the signed conversion.reversed webhook (Tier 2). If you have set none of them up, reversals are still handled (they are settled in coins on our side); you simply are not told in code.

Your primary postback URL is approvals-only and never carries a reversal or a negative. That promise is permanent. A reversal is netted from your unpaid balance, always, on every account; the balance can dip negative and is recovered from future earnings, never billed. Reversals are delivered on their own channels. Pick what suits you:

Tier 0: Dashboard & email (always on, zero setup)

Every reversal appears in your reversal ledger and (unless you opt out) triggers an email, a daily digest by default, or one-per-event. Set the cadence in Settings → Publisher Settings → Reversal notifications.

Tier 1: Reversal postback URL (optional, one field)

A separate URL fired only on reversals, using the same macro engine. {status} is always reversed. Keeping it separate is the opt-in to reversal-aware code: a reversal must never hit your approvals handler, which would credit {points} and swallow the event on its idempotency check.

Reversal postback template (save in Publisher Settings)
https://your-site.com/reversal?key=YOUR_SECRET&user_id={user_id}&tx={transaction_id}&event={event_id}&coins={coins}&points={points}&payout={payout}&reason={reason}&status={status}
Reversal macroMeaning
{reason}Reversal postback only. Why the conversion was reversed (chargeback, fraud, advertiser void, manual, …).
{event_id}On this channel: a fresh UUID per reversal delivery, the reversal dedupe key. Every retry and manual Resend of the same reversal reuses it verbatim, so store it and process each value once. (On the primary URL the same macro means something else: the goal id of the conversion being credited.)
{transaction_id}The ORIGINAL conversion id (its click id): the same id your approval postback carried, so it joins straight to the credits you stored. Not unique per reversal: it repeats for every reversal of that conversion and every retry of this one, so it is never the dedupe key. Dedupe on {event_id}.
{user_id}Your sub_id for the affected user.
{coins}LootHQ coins reversed. 0 on an Offers API conversion, which has no coin layer.
{points} / {payout}The full value credited at approval, in your currency / in USD: exactly what the approval postback told you to credit, now to be taken back. {points} is 0 on an Offers API conversion, which has no coin layer; use {payout} there. Both are positive numbers; a reversal is signalled by the channel, never by a sign.
{offer_id}The offer whose conversion was reversed. Always populated here, including for hosted-wall reversals.
{status}Always reversed on this channel.
{currency}, {source}Not sent on this channel. If your template references them they arrive as empty strings, so don't branch on them here.

Never decide "is this a reversal?" from the amounts

On an Offers API conversion the reversal arrives with {points}=0 and {coins}=0 (there is no coin layer on that channel); the value is in {payout}. A tiny credit can also round {payout} down to 0.00.

A handler written as if (points > 0) or if (payout > 0) silently drops real reversals. That is a real bug even when nothing needs debiting: the reversal is still the event that tells you the conversion is dead, and it is what your records, your fraud signals and your own user-facing history should reflect. It is also the only signal you get: there is no second delivery later.

Branch on {status} (always reversed on this channel) or simply on receiving the delivery at all. Use the amounts to decide how much to take back (including zero), never to decide whether this is a reversal.

What to match a reversal against

{transaction_id}is the same id your approval postback carried, on both integration types, so you can match the reversal straight to the credits you issued. (On a conversion with no click, a survey completion for example, the conversion's own id stands in on both channels, so the join still holds.)

One thing to keep straight on multi-goal offers: a reversal reverses a single conversion, but its {transaction_id} (the click id) matches every goal you credited on that click, and its {event_id} is a delivery UUID, not the goal id. Take back the amount the delivery carries ({points}, or {payout} converted), never everything stored under that transaction id.

Prefer never to claw back? Hold risky offers on your side

The industry-standard pattern for reversal-prone categories (casino, trials, deposits) is a hold on your side: credit the reward into a held state keyed by {offer_id} (and {event_id} for per-goal holds), release it to the user after your own window, and cancel the held credit instead of debiting when a reversal for that conversion arrives first. Your users never see money taken away, and you decide per offer how long to wait.

Reversal postbacks use the same durable retry schedule as the primary channel and appear in Postback Deliveries. Every retry and every manual Resend reuses the same {event_id}, which is what makes it a safe dedupe key.

Prefer one endpoint? Paste the same URL into both fields, keep &status={status} in both templates, and branch on its value: it is approved on every primary delivery and reversed on every reversal, with no third value. (Do not branch on whether &event={event_id} is filled in: it also resolves on conversion approvals, where it is the goal id.)

Single endpoint, both channels (Node.js / Express)
// ONE handler for both channels. Save the SAME base URL in BOTH Settings
// fields; the payload's {status} says which channel is calling:
//   Postback URL : https://your-site.com/postback?key=…&user_id={user_id}&tx={transaction_id}&points={points}&payout={payout}&offer_id={offer_id}&status={status}&event={event_id}
//   Reversal URL : https://your-site.com/postback?key=…&user_id={user_id}&tx={transaction_id}&points={points}&payout={payout}&offer_id={offer_id}&status={status}&event={event_id}&reason={reason}
app.get("/postback", async (req, res) => {
  if (req.query.key !== process.env.LOOTHQ_POSTBACK_SECRET) {
    return res.status(403).send("forbidden");
  }
  const { user_id, tx, reason, status, points, payout } = req.query;
  const event = req.query.event || "";

  // A macro we don't substitute on a given channel arrives PRESENT AND EMPTY
  // ("", with the param still there), never missing. So "points ?? payout" and
  // "points != null" both pick the empty string and quietly credit 0.
  const has = (v) => v !== undefined && v !== "";

  // Branch on {status}: it is "approved" on every primary-URL delivery and
  // "reversed" on every reversal-URL delivery, with no third value. Do NOT
  // branch on whether `event` is set: it is also set on approvals (the
  // goal id there). And what does NOT work at all, and what a first
  // integration usually assumes, is waiting for a negative payout on the
  // primary URL. It never arrives.
  if (status === "reversed") {
    // ---- reversal ----
    // Dedupe on {event_id}: on THIS channel it is a fresh UUID per delivery.
    // NOT on tx: on a reversal, tx is the ORIGINAL conversion's id, so it
    // repeats for every reversal of that conversion and every retry of this
    // one.
    if (await db.Reversals.findOne({ event })) {
      return res.status(200).send("already reversed");
    }
    await db.Reversals.create({ event, tx, user: user_id, reason: reason || "unknown" });

    // {points}/{payout} carry the FULL value the approval postback told you
    // to credit: {points} in your currency on wall conversions, 0 on Offers
    // API conversions (no coin layer, so take {payout} in USD instead).
    // Take back exactly that amount. Your own wallet is settled by us
    // either way (see "When you earn").
    const undo = Number(points) > 0
      ? Number(points)
      : usdToYourCurrency(Number(payout));
    if (undo > 0) await debitUser(user_id, undo);
    return res.status(200).send("ok");
  }

  // ---- approval: the only thing the primary URL ever sends ----
  // WHAT TO CREDIT depends on the placement, and the payload tells you which:
  //   hosted wall / prize → {points} is ALREADY your currency. Credit as-is.
  //   Offers API          → no coin step, so {points} is empty and {payout}
  //                         is USD. Convert it yourself.
  const amount = has(points)
    ? Number(points)
    : usdToYourCurrency(Number(payout));

  // IDEMPOTENCY: the (tx, event) pair, unique per credit on every channel.
  // Wall and Offers API conversions alike: tx is the CLICK id and repeats
  // for every goal of a multi-goal offer; event (the goal id) is what
  // separates them, so keying on tx alone would drop every goal after the
  // first. Prize redemptions: tx is unique by itself and event is "", so
  // the pair degrades to tx.
  if (await db.Conversions.findOne({ tx, event })) {
    return res.status(200).send("already processed");
  }
  await db.Conversions.create({ tx, event, user: user_id, credited: amount, status });
  await creditUser(user_id, amount);
  return res.status(200).send("ok");
});

Tier 2: Webhooks (signed events)

Subscribe to the conversion.reversed event on the Webhooks page for an HMAC-signed JSON payload with the full reversal detail. See .

Worked example, end to end

One user, one offer, every request you would actually receive, with real-shaped values you can trace from step to step. This example is an Offers APIplacement running a two-goal offer ("signup", then "deposit") and the shipped default templates. The cast:

  • Your user: u123 (the user_id you sent on the feed call).
  • The offer: id 64f3b2a1c9d4e5f601234567, paying you $2.50 for signup and $7.50 for deposit; your placement margin leaves the user reward at $1.25 / $3.75.
  • Your shared secret: kq8Zw3vN7Rp2 (what you swapped in for YOUR_SECRET when you saved the templates).
  1. 1u123 opens the offer.You linked them to the feed's trackingUrl; we record the click and mint its id: b3f1c9a2-7e44-4f1a-9c2d-0a6e8d5b21c7. Everything that follows hangs off that one id.
  2. 2The signup goal is approved → we GET your primary postback URL. With the default template, every param is present; note points= arriving empty (no coin step on this channel) and event=signup:
    Primary postback: goal 1 (signup)
    https://your-site.com/postback?key=kq8Zw3vN7Rp2&user_id=u123&tx=b3f1c9a2-7e44-4f1a-9c2d-0a6e8d5b21c7&points=&payout=1.25&currency=USD&offer_id=64f3b2a1c9d4e5f601234567&status=approved&event=signup
    Your handler: status is approved, the pair (b3f1c9a2…, signup) is new → store it, convert payout=1.25 into your currency, credit u123, answer 200.
  3. 3A week later u123 deposits → a second, separate credit on the same click. Same tx, different event:
    Primary postback: goal 2 (deposit)
    https://your-site.com/postback?key=kq8Zw3vN7Rp2&user_id=u123&tx=b3f1c9a2-7e44-4f1a-9c2d-0a6e8d5b21c7&points=&payout=3.75&currency=USD&offer_id=64f3b2a1c9d4e5f601234567&status=approved&event=deposit
    The pair (b3f1c9a2…, deposit) is new → credit again. A handler deduping on txalone would have answered "already processed" here and silently kept u123's deposit reward. That is the bug the pair exists to prevent.
  4. 4The signup conversion is charged back → a reversal arrives, on your reversal URL only, never on the URL above. With the default reversal template:
    Reversal postback (separate URL)
    https://your-site.com/reversal?key=kq8Zw3vN7Rp2&user_id=u123&tx=b3f1c9a2-7e44-4f1a-9c2d-0a6e8d5b21c7&event=d9c1e6b0-2f7a-4c3e-8a11-6f2b8c4d7e90&coins=0&points=0&payout=2.00&reason=chargeback&status=reversed
    Read it carefully against step 2: tx is the same click id, so you can join it to the credits you stored, but event is now a fresh delivery UUID, not "signup", and coins=0 and points=0 because an Offers API conversion has no coin layer; payout=2.00 carries the full USD value credited at approval, which is the amount to take back. Your handler: status is reversed → take the reversal branch, check the UUID d9c1e6b0… is unseen (dedupe key on this channel), record it, take back what you credited for the signup, answer 200. If the delivery is retried, the same UUID arrives again and your check swallows it.

On a hosted wall the ids are identical; only the amounts change: points arrives filled (the amount in your currency), coins shows the coin grant, and currencyis your currency's name instead of USD. Same click id in tx, same goal id in event, and a reversal's tx joins to your stored credits the same way. See .

Prize Coin redemption

LootHQ runs a network-wide monthly leaderboard and awards winners Prize Coins. Users redeem those Prize Coins on your wall for your own currency, and you need to change nothing to support it.

You are paid, not charged. LootHQ funds every prize. When a user redeems Prize Coins on your wall, we credit your wallet the full face value of those coins (1,000 coins = $1.00) at the moment of redemption. You are never made to fund a prize you didn't offer; you are reimbursed for the points you issue.

It arrives as a normal postback. Same URL, same macros, a fresh transaction_id. Your handler does not need to distinguish it. The one difference is behind the scenes: we retry a prize redemption for about 3.7 days rather than the usual ~41 hours, because unlike a conversion there is no other record for you to reconcile it from afterwards.

Prize redemptions are reviewed before issuance and need no special handling on your side.

Turn the board on and we pay you 5% more

Prize Coin redemption works on every active placement whether or not you do anything; there is no switch to receive it. But if you display the leaderboard, every prize redemption on that wall pays you 105% of face value instead of 100%. The extra 5% is funded by LootHQ and lands as a separate Leaderboard bonus line in your earnings.

Dashboard → Placements → your placement → toggle Network Leaderboardon. It adds a Leaderboard tab to your wall so your users can see where they rank and what they're competing for, and it takes effect on the next wall load, with no cache to wait out and nothing to redeploy. Off by default on every placement, and not available on Offers API placements (the board is a wall UI surface).

The board never names another site. It shows handles and avatars only, with no publisher, no placement and no cross-site comparisons, so it cannot send your users anywhere.

Optional: telling the two apart

If you want to distinguish a prize redemption from an ordinary conversion postback (to label it differently in your own ledger, say), add the optional {source} macro to your postback URL. It resolves to prize on a prize redemption and is empty on every other postback. Branch on source === "prize"; it never resolves to offer. It is purely additive: leaving it out changes nothing, and existing handlers are unaffected.

Prize Coins have no cash value and cannot be withdrawn; redeeming them on a wall is the only way they can be spent. Full rules are in the contest terms.

Delivery and retries

A postback attempt succeeds when your endpoint returns an HTTP 2xx status within 10 seconds. Anything else (a non-2xx status, a timeout, or a connection error) is retried automatically on a fixed schedule:

+1m+5m+15m+1h+4h+12h+24h

Each value is the wait before the NEXT attempt, so that's 7 retries, or 8 attempts in total, with the last landing about 41 hours after the first. Then we give up and mark the delivery failed. Every retry (and a manual Resend) reuses the exact same {transaction_id} and {event_id} from the first attempt, which is why idempotent processing matters.

Every attempt (HTTP status, latency, a response snippet, and a timestamp) is logged and visible in Publisher Dashboard → Postback Deliveries. Deliveries that are still retrying or have failed outright can be re-fired on demand with the Resend button, without waiting for or restarting the schedule. A Resend never uses up one of those 8 attempts: it's counted separately on the delivery row, and a delivery that is still retrying keeps its full remaining budget and its existing next-retry time. Resend (and each remaining scheduled retry) re-reads your current saved postback URL first, so correcting a broken endpoint in Settings fixes the deliveries already in flight instead of replaying the old address. Only the endpoint changes: the {transaction_id} and every other value are carried over untouched, so your idempotency check still sees the same transaction.

Outbound policy: redirects, timeout, HTTPS

Every attempt has a 10-second budget (redirect hops included) and requires HTTPS.

We follow at most 2 redirect hops, and only when every hop is:

  • https:// (a 30x to plain http is refused as a downgrade, never followed);
  • on the same host, or its www/apex variant (example.com www.example.com). A redirect to any other host is refused;
  • still resolving to a public IP; each hop is re-checked and re-pinned, so we never follow a Location header on trust.

Anything outside that (a 3rd hop, a different host, a URL shortener, a missing Location) is a failed attempt, retried on the schedule above.

Configure the FINAL URL, not one that redirects to it. A followed hop still costs a full extra round-trip on every single delivery, and it leaves you one infrastructure change (a host move, a forced-http rule) away from silent failure. When we do follow one, we say so on the delivery row in Postback Deliveries and print the address to switch to. That note always describes the LATEST attempt, so it clears itself as soon as an attempt no longer meets a redirect. If a row starts failing for some other reason (a timeout, a 500), the redirect note goes away rather than sitting there misdiagnosing it.

Fastest way to check any of this before you go live: Test delivery in the Postback Tester on Settings → Publisher Settings. See .

If deliveries to one of your postback endpoints fail 3 or more times within 6 hours, we'll email you (at most once per 24h per endpoint) with which endpoint it was, its last result, and a link back to Postback Deliveries. An endpoint that silently rots stops crediting your users without either of us noticing, so this is how we make sure you find out.

Postbacks vs. webhooks

Two channels, two jobs. Postbacks credit users: a URL template with macros, the industry-standard rewarded channel. Webhooks are for reversals, reconciliation, and automation: signed JSON events, the Stripe/GitHub model.

 PostbackWebhook
ShapeURL template with {macros}Signed JSON event body
AuthSecret token in the URL (over TLS)HMAC signature + timestamp (x-loothq-signature)
Events per endpointOne shape per URLMany event types on one endpoint
Replay protectionYour idempotency keySigned timestamp (reject stale)
Best forCrediting users in real timeReversals, reconciliation, evolving payloads

Verify a webhook by recomputing the HMAC over timestamp + "." + rawBody with your signing secret and comparing in constant time:

Webhook signature verification (Node.js)
const crypto = require("crypto");

// Mount with the RAW body so bytes match what we signed:
//   app.post("/webhooks", express.raw({ type: "application/json" }), handler)
function verifyLootHQWebhook(req, secret) {
  const sig = req.get("x-loothq-signature") || "";   // "sha256=<hex>"
  const ts  = req.get("x-loothq-timestamp") || "";
  const raw = req.body.toString("utf8");             // the raw bytes, not re-stringified JSON

  const expected = "sha256=" + crypto
    .createHmac("sha256", secret)
    .update(ts + "." + raw)
    .digest("hex");

  const a = Buffer.from(sig), b = Buffer.from(expected);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return false;

  // Reject stale deliveries (replay protection): 5-minute window.
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  return true;
}

Securing your postback endpoint

LootHQ postbacks are fired from cloud infrastructure with dynamic egress IPs by default, so there is no standing IP list to whitelist. If your edge needs a fixed address before it will accept us, we can arrange one for your account on request. Either way, make a shared secret your actual check, not the address:

  • 1. Put a secret token in your URL (recommended). You own the template URL, and LootHQ leaves any params it doesn't recognize untouched. The default template already carries the key= param. Replace YOUR_SECRETwith an unguessable token of your own (saving the literal placeholder gets you a warning, and your handler will reject every delivery until it's replaced) and reject any request that doesn't carry it. That alone stops strangers from crediting your users.
    Template with a hard-coded secret (save in Publisher Settings)
    https://your-site.com/postback?key=YOUR_SECRET&user_id={user_id}&tx={transaction_id}&points={points}&payout={payout}
  • 2. Idempotency. Only process each ({transaction_id}, {event_id}) pair once. Store the pair and return HTTP 200 for repeats without re-crediting. On prize redemptions {event_id} is empty and the pair degrades to the transaction id; on offers with more than one goal, only the pair is unique per credit. See .
  • 3. Verify users. Confirm the user_id belongs to a valid account before crediting.
  • 4. Postbacks are approvals only. {status} is always approvedon this channel; negative values and reversals are never sent here, by design. That doesn't mean reversals don't happen: if a conversion is later rejected by the advertiser (chargeback, fraud, void), we don't rewrite this postback; we claw the earning back on our side instead, per above. The reversed value is netted from your unpaid balance; the balance can dip negative and is recovered from future earnings, never billed. If you're subscribed on the Webhooks page you'll get a cryptographically signed conversion.reversed event (HMAC via the x-loothq-signature header) with the exact split, so you can optionally debit your own user too.
  • 5. Logging. Log rejected postbacks with a reason so you can debug campaigns quickly.

Important

Never trust query parameters blindly. Treat them as untrusted input and validate / sanitize them before using them in your business logic.

Testing your setup

Use the built-in tools in the publisher dashboard to verify your integration before sending live traffic.

Run the Integration check first. One click runs every readiness check: URL saved, attribution macros present, a real delivery probe on each channel (it writes no delivery, conversion, stat or ledger row), and Offers API sub-id usage, each with a concrete fix. The steps below are the manual versions of the same checks.

  1. 1. Configure your postback(s). Go to Settings → Publisher Settings and set your postback URL. If you want Tier 1 reversal notifications, set your reversal postback URL too.
  2. 2. Open the Postback Tester. On the same Settings page, expand the Postback Tester section. It has three modes:
    • Test delivery (run this one first): a health check of the URL you just saved. It fires that URL through the exact outbound policy real deliveries use (HTTPS, the 10-second budget, and the 2-hop same-host redirect rule) and names the stage that failed: DNS, TLS, redirect, timeout, or the HTTP status itself. If it followed a redirect it prints the final address so you can save that instead. It writes no delivery, conversion, stat or ledger row on our side.It also sends the same {transaction_id} on every run (a fixed looptest-delivery-… value, alongside test=1), so running it twice is how you prove your duplicate check works: a correct handler processes the first and ignores the second. That id is not in the format we use for real conversions, so it can never collide with, or be mistaken for, a live transaction.That proves the retry case, where the id repeats and the credit must not. It cannot prove the multi-goal case, where the same {transaction_id} arrives with a different {event_id} for genuinely different credits. Passing this test does not mean your handler is safe for milestone offers. See .
    • Conversion: the original free-form tester. Build a URL against any endpoint (not necessarily your saved one) with custom values and fire it.
    • Reversal: fires your SAVED reversal postback URL through the real delivery pipeline, with a synthetic original {transaction_id} and a fresh {event_id}, clearly labeled as a test. A successful reversal test is how you prove your notification path before a real reversal needs it. Netting itself is not something you opt into: a reversal is netted from your unpaid balance always, on every account; this test is how you make sure you hear about it when it happens.
  3. 3. Send a test. Choose a sample payout and subID, send a test, and confirm your endpoint returns HTTP 200.
  4. 4. Verify credit. Check that your own app credits the test user with the expected reward.
  5. 5. Test a webhook (optional). If you've subscribed an endpoint on the Webhooks page, the tester also has a Send test webhook button per endpoint: a real, signed conversion.reversed-shaped payload (marked test: true) so you can verify your signature-checking code against a genuine request.

Common errors

Here are some typical integration issues and how to fix them:

Error / behaviourCauseFix
"This content is blocked. Contact the site owner" where the wall should beYour page's Content Security Policy doesn't allow framing loothq.net. The browser blocks the iframe before it loads, so this never reaches us.Add https://www.loothq.net to your frame-src directive (see the iFrame section).
Script embed: no wall appears, and no LootHQ request is madeYour page's Content Security Policy doesn't allow loading scripts from loothq.net. The browser refuses to fetch wall.js before anything runs, so this never reaches us; the signature is a script-src CSP violation in the browser console.Add https://www.loothq.net to your script-src directive as well as frame-src (see the Script section).
Wall shows "Missing placement_id"You didn't pass a placement ID, or it's invalid / inactive.Add ?placement_id=YOUR_PLACEMENT_ID. Get the ID from dashboard → Placements.
Users don't get rewardsPostback not configured or returning non-200.Set your postback URL in Settings and ensure it returns 200 OK.
Every attempt fails with Redirect (30x) to … — it points at a different host (or it downgrades to plain http, it exceeds the 2-hop redirect limit)We do follow redirects on postback deliveries, but only up to 2 hops, HTTPS-only, and only to the same host or its www/apex variant. This error means the redirect left that envelope: a different hostname (a CDN or a link shortener), plain http, or a 3rd hop. A plain apex⇄www redirect is followed and never produces it.Save the FINAL address as your postback URL (the delivery row prints it), then confirm with Test delivery on the Settings page.
Deliveries fail with a TLS or certificate errorExpired certificate, missing intermediate chain, or a certificate that doesn't cover the hostname you saved (very common on the apex when only www is covered).Fix the chain, or save the hostname the certificate actually covers. Test delivery reports the exact TLS reason.
Deliveries retried although your endpoint is up; every attempt shows a timeoutEach attempt has a 10-second budget, redirect hops included. A handler that credits synchronously against a slow database, or calls a third-party API before answering, blows it, and the delivery is retried, so your slow path runs again.Answer 200 first and do slow work after responding (queue it). The retry schedule is in .
Every delivery to your endpoint fails with 401 / 403Your own auth is rejecting us: usually the key=… secret is missing from the saved template (it is a literal param, not a macro, so it is easy to lose when editing the URL), still the literal YOUR_SECRET placeholder from the default, or your endpoint expects a header or IP allowlist we cannot satisfy.Re-save the template with your key= param intact and set to your real token (not YOUR_SECRET), and auth on that alone. Postbacks carry no custom headers, and come from dynamic IPs unless we have put your account on a fixed relay address. Confirm with Test delivery.
Your call to /api/v1/offers answers 401 or 403401: invalid or missing API key. 403: your account is not active, API access is not enabled on it, the placement is inactive, or the placement is a Hosted Wall one, which the feed refuses by design.Send Authorization: Bearer <API_KEY> with the key from the Offers API page, and use a placement whose integration type is Offers API. If the account itself is blocked, the error body says to contact support.
Feed works, clicks flow, but nothing ever credits (Offers API)You are calling the feed without user_id/sub_id, so the tracking URLs carry no sub-id. Depending on your account's rollout state, such clicks are either not recorded at all ("grace") or refused outright ("enforced"). Either way they can never be credited to anyone. The response told you: its warning and sub_id_enforcement fields are non-null.Pass user_id=<your end-user id> on every feed call and assert warning === null in your own build. See the .
Duplicate rewardsPostbacks re-processed without checking the ({transaction_id}, {event_id}) pair. Retries and manual Resends reuse both values verbatim, so an unchecked handler credits every repeat.Store each pair; return 200 for repeats without re-crediting. See .
Milestone offers only ever credit their first goalYou are deduping on {transaction_id} alone, which on every conversion postback is the click id. Every goal of a multi-goal offer repeats it, so your handler answers 200 already processed and discards goals 2..n. No error is logged on either side.Dedupe on the ({transaction_id}, {event_id}) pair: {event_id} carries the goal id there, so the pair is unique per credit. (The conversion.approved webhook's conversionId is a signed alternative.) See .
Your reversal handling never fires, and nothing looks wrongYou are waiting for a negative {payout} or status=reversed on the primary postback URL. Neither is ever sent there. Approvals keep arriving normally, so the integration looks healthy while revoking nothing.Set a reversal postback URL in Settings (or subscribe to conversion.reversedon the Webhooks page), then prove it with the tester's Reversal mode. See .
{offer_id} is empty on a postbackIt is a prize redemption: {source} reads prize, and no single offer produced the credit, so there is no offer id to send. Every conversion postback, hosted wall and Offers API alike, carries {offer_id}.Nothing to fix. Branch on source === "prize" if you want to label these separately; per-offer reporting is unaffected.
Users keep clawed-back rewards; your reversal ledger fills up but your code never hears about itNo reversal postback URL is saved. Reversals never arrive on the primary URL, so with no reversal URL (and no webhook) they are settled on our side only. Settings warns about exactly this when you save a primary URL alone.Save a reversal postback URL in Settings (the same endpoint is fine; see the single-endpoint pattern in ), then prove it with the tester's Reversal mode.
Empty values, or literal {braces}, in your postback paramsTwo distinct signatures. A param carrying the literal macro text (e.g. payout={payoutt}) means the macro name is misspelled: we only substitute names we recognize and pass anything else through untouched. A param that is present but empty is usually normal: that macro simply is not supplied on this channel (see the per-channel matrix in ).For braces: fix the spelling against the macro table. For empties: check the matrix before treating it as a bug, and never reject a request because an inapplicable macro is empty. Use the Postback Tester to confirm.

FAQ

Can I use multiple apps / sites under one publisher account?

Yes. You can create multiple placements for different apps, sites, and GEO splits under a single publisher account.

How often are stats updated in the dashboard?

Clicks and conversions are streamed in real-time or near real-time, depending on the upstream network. Balances and wallet figures update as new conversions are processed.

What should I use as the user / subID?

Use a stable, unique identifier for each user (internal user ID, UUID, or wallet address). Do not use sensitive data like emails in plain text. It must be unique across ALL your placements, not just one. Reusing a plain per-app numeric ID across multiple apps or sites will merge those users' coin balances and history. Namespace it per placement if there's any chance of collision.

Can I disable certain GEOs or offer types?

Yes. Reach out to support or your LootHQ rep to adjust GEOs, categories, or traffic restrictions on your account.

Can I whitelist your postback IPs?

Not by default. Postbacks fire from cloud infrastructure with dynamic IPs, so there is no standing list to publish. If your edge runs a default-deny filter and you cannot add us without an address, ask your LootHQ contact: we can move your account onto a fixed relay address and give you the value to allowlist. It is set up per publisher on request, so tell us before you build against it.

Even then, do not make the IP your only check. A source address proves where a request came from, not who sent it. Authenticate with a . It travels over TLS, proves the sender holds your secret, and you can rotate it yourself any time by editing the URL. Combined with idempotent {transaction_id} handling, replayed or forged postbacks credit nothing. If you want cryptographic verification on top, subscribe on the Webhooks page. Every delivery is HMAC-signed and timestamped (x-loothq-signature), the same model Stripe and GitHub use.

A user says their offer didn't credit. Who handles it?

LootHQ does. The coin ledger is ours, not yours, so credit disputes don't route through your support queue. Users raise them directly in the wall, where the support entry point links the report straight to their click, so our team can check it against the original postback receipt. You don't need to build anything for this; just point confused users back to the wall.

Do you have a leaderboard?

Optionally, per placement: a single network-wide top-25, monthly season board. Every user sees their own rank and distance to #25 even when they're not in the 25. Login is optional and only prompted aftera user's first conversion; signing in counts their prior earnings retroactively (nothing is lost) and clears their pending coins faster. Anonymous sub_id earning is unaffected, and no publisher attribution is ever shown. It has no effect on your integration; full leaderboard docs are separate.

Offers API: render offers in your own UI

Prefer your own design instead of our hosted wall? Pull the offers feed and render it yourself. This is the direct model: conversions credit your wallet (rev-share) and fire your postback at approval, exactly like the wall, but with no coin layer: {points} arrives empty and {payout} carries USD for you to convert.

Offers API quickstart

  1. 1Authenticate. Every call carries your API key (from the Offers API page) as a header: Authorization: Bearer <YOUR_API_KEY>. You also need a placement whose integration type is Offers API.
  2. 2Pull the feed, always with user_id.

    GET /api/v1/offers?placement_id=YOUR_PLACEMENT_ID&user_id=u123&geo=US&device=mobile

    Authorization: Bearer <YOUR_API_KEY>

    user_id (alias sub_id) is your stable id for the end user, and it is required for attribution: omit it and the returned links carry no sub-id, and clicks on them can never credit anyone: not your wallet, not the user. The response says so out loud (warning and sub_id_enforcement non-null); treat that as a build failure.

  3. 3Send the user to trackingUrl exactly as returned. It is already scoped to your placement and that user, so don't rebuild it. We record the click and redirect to the advertiser.
  4. 4Receive the postback. On approval we credit your wallet and call your saved postback URL with {payout} in USD, {user_id} = the id you sent, and {event_id} = the goal that converted. Credit the user, dedupe on the ({transaction_id}, {event_id}) pair, and return 200. See the for a fully-filled request.
  5. 5Honour completed_offer_ids. It lists the offers this user has already completed or is capped out of. They are removed from offers because a click on them can never earn that user anything again. Grey those cards out rather than hiding them (details in the panel below).
  • Requires a placement whose integration type is Offers API. A Hosted Wall placement is rejected (its conversions pay out in coins through the wall instead).
  • Requires API access to be enabled on your account by an admin; ask support if you get a 403.
  • Always send user_id (alias sub_id), your stable id for the end user making the request. It scopes the feed to that person and it is what makes the returned links clickable. It is becoming mandatory; see the migration note below.
  • Each offer includes a ready-to-use trackingUrl (already scoped to your placement + user), payoutUsd (what you earn) and userPayoutUsd (suggested reward for your user).
  • Rate limit: 120 requests/minute.

The feed is scoped to the user you name

With user_id set, offers that user has already converted on, or is capped out of, are left out of offers and listed in completed_offer_ids instead. They can never earn that user another cent, so serving them is pure wasted traffic.

completed_offer_ids is a string[] of offer ids, always present (empty when you send no user_id). Use it to grey out rather than hide: keep your own offer cache and render those cards disabled, so a user who finished something sees it marked done instead of watching it vanish. If you'd rather not cache, add include_completed=1 and they come back inline flagged "completed": true with "trackingUrl": null.

Omit user_id and the response is the old user-agnostic one plus a warning, but its tracking URLs then carry no sub-id. Without a sub-id nothing can be credited to anyone: not your wallet, and not the user, because your postback has no {user_id} to send you.

Migration: user_id is becoming mandatory

Every response tells you where your account stands, in sub_id_enforcement:

  • null: you sent a user_id. Nothing to do.
  • "grace": clicks on those links still reach the advertiser, but no click is recorded and no conversion on them can ever be credited.
  • "enforced": clicks are refused outright with a branded "this link can't be tracked" page, and the user never reaches the advertiser.

"grace" does not mean it is working. You earn nothing in either state; the only difference is whether your user wastes their time on the offer first. Accounts created from now on start enforced; existing accounts start in grace and are switched over per account (ask support to flip yours once you are sending user_id, so you see the failure in staging instead of production), then globally once everyone has migrated.

To verify you are done: call the feed the way your production code does and check that user_id in the response is NOT null and warning is null. It is worth failing your own build on that.

Displaying completed and started offers: names, images, values

Your postback identifies the offer by {offer_id}only. To render a user's completed offers, their in-progress offers, or a "recently completed" ticker with the offer's name and image, pull the conversion history:

GET /api/v1/conversions?user_id=u123&status=completed&include_started=1

Authorization: Bearer <YOUR_API_KEY>

  • Every row carries offer: {id, name, image, category} inline, plus payoutUsd / userPayoutUsd, status and timestamps. This works for offers that have since left the feed, so old completions always render.
  • With user_id: that user's history (their profile page). Without it: all your conversions, newest first (your admin panel or a completions ticker; status=completed keeps it to credited ones).
  • include_started=1 adds the offers that user clicked in the last 30 days with no conversion yet, same inline metadata.
  • Rows join to your postback records on the (clickId, eventId) pair, the same values your postback received as ({transaction_id}, {event_id}).
  • Prefer push? The conversion.approved and conversion.reversed webhooks now include offerName and offerImage alongside offerId.

Full quickstart, response schema and your API key live on the Offers API page in your dashboard.

Brand assets for your own site

Listing LootHQ alongside other offerwalls, or putting a header above your embedded wall? Use the card below rather than screenshotting a logo off our marketing pages. It is sized for an offerwall row and carries its own background, so it works on a light theme too.

LootHQ offerwall card

Worth knowing before you reach for a transparent logo: only the HQ is gold, and Loot is white. On a light background a transparent mark loses half its lettering, which is what the cards exist to solve.

Square tiles, app icons, transparent logos in WEBP and PNG, our hex colours and the usage rules are on the brand assets page. It needs no login, so you can send it straight to a designer.