LootHQ

Publisher docs

Integrate your app with LootHQ

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. Replace YOUR_SECRET with 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_id belongs to a valid account before crediting.
  • 4. Postbacks are approvals only. {status} is always approved on 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 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.

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 attempt
  • sig: 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

  1. Take every query parameter of the request except sig. ts is included, your own params are included, duplicate keys are included once per occurrence, and a present-but-empty value contributes name=.
  2. Form name=value strings from the DECODED name and value: what your framework hands you in req.query / $_GET / request.args, with %XX decoded and + decoded to a space. Never re-encode.
  3. Sort those strings as UTF-8 byte sequences. That is what PHP's sort($pairs, SORT_STRING) and Python's sorted() already do; in JavaScript compare Buffers, because the default string sort disagrees on rare characters.
  4. Join with &, then compute HMAC-SHA256 with your signing secret (both UTF-8). Compare against sig in 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).

Vector 1: Typical approval delivery
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
Vector 2: Encoding traps: + vs %20, percent-decoding, UTF-8, empty value, duplicate keys
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
Vector 3: sig excluded from its own input; byte-wise sort (uppercase before lowercase)
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

Node.js
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
<?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;
}
Python (Flask)
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

  1. Press Rotate secret in Settings. You get the new secret once; deliveries keep signing with the CURRENT one until you say otherwise.
  2. Add the new secret to your verifier's accepted list (the reference verifiers above take a list for exactly this reason) and deploy.
  3. 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.