LootHQ

Publisher docs

Integrate your app with LootHQ

The same integration guide publishers see inside the dashboard.

Reversals

How a reversal reaches you, and the handler shapes that silently miss it.

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. A separate field does not require a separate endpoint: save the same URL in both fields and branch on {status}; the single-endpoint handler at the bottom of this section is the complete pattern.

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, …).
{original_event_id}Reversal postback only. The goal id of the credit being reversed ("signup", "deposit", or "conversion" on a single-goal offer) — the same value {event_id} carried on the approval postback that credited it. Pair it with {transaction_id} to find the exact credit to claw back; on a multi-goal offer that pair is the only thing that identifies which goal was reversed. Still dedupe reversals on {event_id}, which is this delivery's own UUID.
{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}, {event_name}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.

Holding risky offers on your side, instead of clawing back

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.

One endpoint is fine. 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 Postbacks vs webhooks.