
Publisher docs
Integrate your app with 
The same integration guide publishers see inside the dashboard.
Security
Securing your endpoint with a shared secret, and HMAC-signed postbacks.
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. A fixed address is available as an optional per-account feature: turn on Static outbound IPs in Publisher Settings and every delivery we send you (postbacks and webhooks alike) leaves from a stable set of addresses you can allowlist at your edge; the exact addresses are listed next to the switch. Payloads, retries and signing are unchanged; only the source address is. Add the addresses to your allowlist before flipping the switch, so nothing we send you lands in the gap. Either way, a source address proves where a request came from, not who sent it, so make a shared secret (or a signature, below) your actual check, not the address:
- 1. Put a secret token in your URL. You own the template URL, and LootHQ leaves any params it doesn't recognize untouched. The default template already carries the
key=param. ReplaceYOUR_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. For cryptographic proof on top, so that a leaked URL is useless to a forger, turn on Signed postbacks as well.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 Dedupe keys per channel. - 3. Verify users. Confirm the
user_idbelongs to a valid account before crediting. - 4. Postbacks are approvals only.
{status}is alwaysapprovedon 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 “When you earn” 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 signedconversion.reversedevent (HMAC via thex-loothq-signatureheader) 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.
Validate before you credit
Every param in that URL is untrusted input, ours included. user_id decides who gets paid and points decides how much, so check that the user exists and that the amount is a number in a range you expect before either one reaches your ledger.
Signed postbacks
A static key= token proves the caller once knew your URL. A signature proves each individual request came from LootHQ and that nothing in it was changed, so a leaked or logged URL is useless to a forger. Turn it on in Settings → Signed postbacks: you get a signing secret (shown once), and from then on every postback we send you, on the primary and reversal URLs alike, prize redemptions and the test tools included, carries two extra query params:
ts: our server time in unix seconds, fresh on every attemptsig: a lowercase-hex HMAC-SHA256 over the canonical param string, keyed with your signing secret
The same two values also arrive as headers, x-loothq-timestamp and x-loothq-signature (the header carries a sha256= prefix), so you can verify from whichever your stack reads more easily. While signing is on, ts and sig are reserved names: don't use them as your own params in a saved template, because the signer sets both on every delivery. Your key= token keeps working unchanged and is itself covered by the signature.
Not the webhook computation
Webhooks and signed postbacks share the x-loothq-signature header name but sign DIFFERENT input. Webhooks (POST) sign timestamp + "." + rawBody; postbacks (GET) have no body, so they sign the canonical param string below, and each channel has its own secret. If you implement both, keep the two verifiers separate: a copy-pasted webhook verifier will reject every signed postback, and vice versa. Postback signing secrets start with pbsec_, webhook secrets with whsec_.
The canonical string, exactly
- Take every query parameter of the request except
sig.tsis included, your own params are included, duplicate keys are included once per occurrence, and a present-but-empty value contributesname=. - Form
name=valuestrings from the DECODED name and value: what your framework hands you inreq.query/$_GET/request.args, with%XXdecoded and+decoded to a space. Never re-encode. - Sort those strings as UTF-8 byte sequences. That is what PHP's
sort($pairs, SORT_STRING)and Python'ssorted()already do; in JavaScript compareBuffers, because the default string sort disagrees on rare characters. - Join with
&, then compute HMAC-SHA256 with your signing secret (both UTF-8). Compare againstsigin constant time, lowercase hex.
The clock rule
ts is OUR server clock, and we re-sign with a fresh ts on every delivery attempt, retries and manual resends included. Reject a request whose ts is more than 5 minutes from your own clock, and keep your server on NTP: signatures that fail intermittently for no visible reason are almost always clock skew on the receiving side, not a bad secret.
Validate your verifier against constants first
Before touching a live delivery, run these frozen vectors through your implementation. All three use the dummy secret lhq_test_secret_0123456789abcdef. If your canonical string matches byte for byte, your HMAC will too; if it doesn't, the mismatch names your encoding bug (vector 2 exists to trip re-encoders, locale sorts, and dropped empty values).
query : user_id=u-123&tx=68b1c2d3e4f5a6b7c8d9e0f1&payout=1.50&status=approved&ts=1756800000 canonical : payout=1.50&status=approved&ts=1756800000&tx=68b1c2d3e4f5a6b7c8d9e0f1&user_id=u-123 sig : 99247d2788a132734e7d8183971eb2d6446a97b52ce5e9590e09844048ce6e68
query : key=MY+TOKEN&offer_name=Caf%C3%A9%20Run%20%2B%20Bonus%2050%25&event=&tag=b&tag=a&ts=1756800000&user_id=u1 canonical : event=&key=MY TOKEN&offer_name=Café Run + Bonus 50%&tag=a&tag=b&ts=1756800000&user_id=u1 sig : fa40de20e23ca1d8195ba490e4ca9fbfd500aeff603b5f0ccb975d6201fbc81e
query : alpha=2&Zeta=1&sig=0000000000000000&ts=1756800000&user_id=u2 canonical : Zeta=1&alpha=2&ts=1756800000&user_id=u2 sig : 39a326f955512a92b0bcbc2254a63f151ab1d2c6b9c4685cffea5a274d2e8ff4
Reference verifiers
const crypto = require("crypto");
// Verify a signed LootHQ postback (GET). Work from the DECODED params your
// framework already gives you; never re-encode anything.
function verifySignedPostback(query, secrets /* array: [current, previous] during rotation */) {
// 1) Freshness: ts is LootHQ server time, unix seconds, re-signed fresh on
// every retry. Reject beyond 5 minutes of your own clock.
if (Math.abs(Date.now() / 1000 - Number(query.ts)) > 300) return false;
// 2) Canonical string: every param except sig (ts and your own params
// included, duplicates included), as "name=value" with decoded values,
// sorted as UTF-8 bytes, joined with "&".
const pairs = [];
for (const [k, v] of Object.entries(query)) {
if (k === "sig") continue;
for (const val of Array.isArray(v) ? v : [v]) pairs.push(k + "=" + val);
}
pairs.sort((a, b) => Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8")));
const canonical = pairs.join("&");
// 3) Constant-time compare against each accepted secret. Accepting a list
// is what makes rotation seamless: add the new secret here, deploy,
// activate it in Settings, then remove the old one.
const provided = Buffer.from(String(query.sig ?? ""), "utf8");
return secrets.some((secret) => {
const expected = Buffer.from(
crypto.createHmac("sha256", secret).update(canonical, "utf8").digest("hex"), "utf8");
return provided.length === expected.length && crypto.timingSafeEqual(provided, expected);
});
}<?php
// $_GET is already decoded ("+" arrives as a space). One caveat: PHP keeps
// only the LAST value of a duplicated key in $_GET, so keep param names
// unique in your template (the defaults already are).
function verify_signed_postback(array $get, array $secrets): bool {
// 1) Freshness: unix seconds, LootHQ server clock, 5-minute window.
if (abs(time() - (int)($get['ts'] ?? 0)) > 300) return false;
// 2) Canonical string: every param except sig, "name=value" decoded,
// sorted byte-wise, joined with "&".
$pairs = [];
foreach ($get as $k => $v) {
if ($k === 'sig') continue;
foreach ((array)$v as $val) $pairs[] = $k . '=' . $val;
}
sort($pairs, SORT_STRING); // byte-wise on UTF-8 strings
$canonical = implode('&', $pairs);
// 3) Constant-time compare against each accepted secret (rotation list).
$provided = (string)($get['sig'] ?? '');
foreach ($secrets as $secret) {
if (hash_equals(hash_hmac('sha256', $canonical, $secret), $provided)) return true;
}
return false;
}import hashlib, hmac, time
def verify_signed_postback(args, secrets):
"""args: request.args (Flask/Werkzeug MultiDict), already decoded.
secrets: list of accepted secrets, [current, previous] during rotation."""
# 1) Freshness: unix seconds, LootHQ server clock, 5-minute window.
try:
ts = int(args.get("ts", ""))
except ValueError:
return False
if abs(time.time() - ts) > 300:
return False
# 2) Canonical string: every param except sig, decoded "name=value"
# pairs (duplicates included via getlist), sorted as UTF-8 bytes.
pairs = []
for k in args.keys():
if k == "sig":
continue
for v in args.getlist(k):
pairs.append(f"{k}={v}")
pairs.sort(key=lambda s: s.encode("utf-8"))
canonical = "&".join(pairs)
# 3) Constant-time compare against each accepted secret.
provided = args.get("sig", "")
return any(
hmac.compare_digest(
hmac.new(s.encode(), canonical.encode(), hashlib.sha256).hexdigest(), provided)
for s in secrets
)Rotating your secret, with zero dropped deliveries
- Press Rotate secret in Settings. You get the new secret once; deliveries keep signing with the CURRENT one until you say otherwise.
- Add the new secret to your verifier's accepted list (the reference verifiers above take a list for exactly this reason) and deploy.
- Press Start signing with the new secret. Because every attempt is signed at send time, even deliveries already mid-retry switch to the new secret on their next attempt. Then remove the old secret from your list.
There is no forced window anywhere in that sequence: the cutover happens when you press the button, and a pending secret you never activate changes nothing.