
Publisher docs
Integrate your app with 
The same integration guide publishers see inside the dashboard.
Postbacks
The template URL, what each macro carries, dedupe keys and delivery.
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.
The short version
- Credit the user
{points}(hosted wall, already in your currency) or{payout}(Offers API, USD), then return HTTP 200. - Dedupe on the (
{transaction_id},{event_id}) pair. The transaction id alone repeats on multi-goal offers. - This URL only ever receives
status=approved. Reversals go to the separate reversal URL, which may be the same endpoint: save the same URL in both fields and branch on{status}. - A macro we have no value for arrives present and empty, never missing. Empty is normal, not an error.
https://your-site.com/postback?key=YOUR_SECRET&user_id={user_id}&tx={transaction_id}&points={points}&payout={payout}¤cy={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.
Every macro, and what it carries per channel
The full reference lives on its own page: Macro reference. It lists all 15 macros with their aliases, filters by the channel you care about, and gives each one a link you can paste into a ticket. The one rule to carry over here: a macro we have no value for on a given channel arrives present and empty, never missing.
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, orconversionon 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 readsprize. On every ordinary conversion it is empty. Test it forprize; it never equalsoffer.
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 offers feed 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 Dedupe keys per channel.
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.
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 conversion on 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
// 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
More money is lost to the wrong dedupe key than to anything else in this guide. Too weak and retries double-credit; too strong and real credits are dropped with no error on either side. Every channel is in the table.
| Delivery | What {transaction_id} is | What {event_id} is | Dedupe 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 offer | The goal id (signup, deposit, …; conversion on single-goal offers) | The ({transaction_id}, {event_id}) pair |
| Prize redemption | prize_…, unique per credit | Empty | {transaction_id} alone |
| Reversal (separate URL) | The original conversion's id; it repeats per reversal and per retry | A 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 Handling reversals.
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:
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 plainhttpis 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
Locationheader 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 Testing your setup.
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
The two channels do different 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.
| Postback | Webhook | |
|---|---|---|
| Shape | URL template with {macros} | Signed JSON event body |
| Auth | Secret token in the URL (over TLS), plus optional HMAC signing (see Signed postbacks) | HMAC signature + timestamp (x-loothq-signature) |
| Events per endpoint | One shape per URL | Many event types on one endpoint |
| Replay protection | Your idempotency key | Signed timestamp (reject stale) |
| Best for | Crediting users in real time | Reversals, reconciliation, evolving payloads |
Verify a webhook by recomputing the HMAC over timestamp + "." + rawBody with your signing secret and comparing in constant time:
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;
}