← halfin journalMay 26, 2026 · 7 min read
Engineering

Verify the signature before you act on a webhook

An unsigned webhook is an anonymous HTTP request that knows your invoice IDs — here is how to handle a halfin event so a forged one can't ship a customer their order.

RA
R. AdeyemiPayments Engineering
engineering · cover

A webhook is not a notification. It is an instruction from a system that controls money, arriving over an endpoint anyone on the internet can POST to.

Here is the failure mode we see most often in integration reviews. A merchant wires up an invoice.paid handler, parses the JSON, looks up the order by invoice_id, marks it fulfilled, and ships. It works in the sandbox. It works in production. It works right up until someone notices that your webhook endpoint is a public URL and the body is JSON they can guess.

There is exactly one thing standing between "we received an event" and "anyone who knows our endpoint can fulfill orders for free": the signature. Verify it first, before you parse anything as a business fact. Everything else in this post is downstream of that one rule.

What a halfin webhook actually is

When something happens to an invoice or a payout, halfin POSTs an event to the endpoint you registered. Each delivery carries the event payload as a JSON body and a signature in the headers, computed over the raw bytes of that body with a secret only you and halfin hold.

The canonical events — the complete list, not a sample — are:

  • invoice.confirming
  • invoice.paid
  • invoice.underpaid
  • invoice.overpaid
  • invoice.expired
  • payout.completed

If you receive a type that isn't one of those, treat it as noise, not as a future event you should helpfully pass through. An unknown event type reaching your switch statement is more likely a malformed or hostile request than a feature you forgot to read about.

The exact signing scheme — the header name, the canonical string, the algorithm, and how to source your signing secret — lives in the webhooks product page and the developer reference. Read the recipe there and copy it exactly. Do not reconstruct it from memory or from a blog post, including this one. The whole security property collapses if your verification disagrees with our signing by even a byte.

Verify on the raw body, not the parsed object

This is the part people get wrong even when they remember to verify at all.

Frameworks love to hand you a parsed object. Express gives you req.body already turned into a dictionary; FastAPI hands you a Pydantic model. The moment you re-serialize that object to check a signature, you have changed the bytes. Key order shifts. Whitespace vanishes. 0.01 becomes 0.010000000000000002 if a float snuck in. The signature was computed over what we sent, and what we sent is not what your JSON library reproduces.

So capture the raw bytes before any parser touches them:

// Express — verify against the raw buffer, parse only after.
app.post(
  "/webhooks/halfin",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const raw = req.body; // a Buffer, untouched by JSON.parse
    if (!isValidSignature(raw, req.headers, WEBHOOK_SECRET)) {
      return res.sendStatus(401); // do not parse, do not log as an event
    }

    const event = JSON.parse(raw.toString("utf8"));
    // only now is `event` allowed to influence anything
  },
);

The order is load-bearing: bytes in, signature check, then parse. If verification fails, you reject with a 4xx and you do not look at the contents. A request that failed the signature check is not an event. It is a stranger's POST, and the correct amount of attention to pay its invoice_id is none.

Make the handler idempotent, then verify, then act

Even a perfectly authenticated webhook will arrive more than once. Networks drop our delivery after your server already committed; our retry curve fires; you get invoice.paid for the same invoice twice. A handler that ships an order on every invoice.paid it sees will ship twice. (Our delivery side has its own war stories about retries and backoff — that's a separate post — but the receiving side has to be idempotent regardless of how careful the sender is.)

The fix is the same one you'd use for any at-least-once stream: dedupe on the event's identifier, not on the business entity.

function handle(event: HalfinEvent): void {
  // Already processed this exact delivery? Stop.
  if (events.has(event.id)) return;

  switch (event.type) {
    case "invoice.paid":
      fulfillOnce(event.data.invoice_id); // fulfillOnce is itself idempotent
      break;
    case "invoice.underpaid":
    case "invoice.overpaid":
      flagForReview(event.data.invoice_id);
      break;
    case "invoice.expired":
      releaseHold(event.data.invoice_id);
      break;
    case "payout.completed":
      reconcilePayout(event.data.payout_id);
      break;
    // no default that "passes through" — an unknown type is dropped
  }

  events.add(event.id);
}

Two details worth saying out loud. First, dedupe by the event's own id, not by invoice_id — one invoice legitimately produces several events over its life (activated, then paid, possibly overpaid), so keying on the invoice would swallow real transitions. Second, the business action inside each case should also be idempotent where it can be: fulfillOnce that checks order state is a second seatbelt for the moment your event store and your fulfillment write disagree after a crash.

Don't infer money from the event; trust the state machine

invoice.paid means the invoice reached a terminal paid state on a rail with the per-chain confirmations we require — it is not your cue to recompute what was owed from the payload and decide for yourself. The amount, the asset, the confirmation depth: those were settled before we emitted the event. Your handler's job is to react to the state we reached, not to re-adjudicate it.

invoice.underpaid and invoice.overpaid are the events people forget. They are not errors and they are not "paid." Underpaid means the customer sent less than the invoice asked for; the invoice is not satisfied and you should not fulfill. Overpaid means they sent more; the order is satisfied but there is a balance to reconcile, and a human probably wants to know. Route both to review rather than collapsing them into your paid branch because the word "paid" appears in the type.

The whole handler, in order

Strip away the framework specifics and a correct halfin webhook handler is five steps in a fixed order:

  1. Capture the raw bytes before any parser runs.
  2. Verify the signature against those bytes using the recipe in the developer reference. Fail closed: a bad or missing signature is a 4xx and a full stop.
  3. Dedupe on the event id. A repeat is a no-op, returned 2xx so we stop retrying.
  4. Dispatch on a closed set of known event types. Unknown types are dropped, not forwarded.
  5. Act idempotently, and treat underpaid / overpaid as their own outcomes, never as paid.

Return a 2xx only once the event is durably recorded — acknowledge after you've persisted, not before. If your processing is heavy, persist the verified event, return 2xx, and do the slow work on your own queue. We'll keep retrying until we get that 2xx, which is exactly the behavior you want when your downstream is briefly down — and exactly the behavior that doubles your side effects if step 3 isn't there.

The signature check is the load-bearing line. Get it right and the rest of this is ordinary, careful event handling. Skip it, and every other safeguard is protecting a door you left unlocked.

R. Adeyemi, halfin payments engineering

↳ end of articlehalfin journal · May 26, 2026