Why verification is not optional
A webhook is an HTTP request from halfin to a URL you control. That URL is not a secret — it travels in your dashboard config, your logs, and sometimes your client code. Treat it as public, because it effectively is. Anyone who learns it can send your endpoint a request shaped exactly like a halfin event, including a body that claims an invoice was paid.
What an attacker cannot do is sign that request. Every genuine halfin webhook is signed with HMAC using a secret shared only between halfin and your endpoint. The signature is a keyed hash of the request body; without the secret you cannot produce one that matches. Verification is the single check that turns "some JSON arrived" into "halfin sent this, and it was not altered in transit."
So the rule is blunt: verify first, act second, with nothing in between. Do not fulfil an order, credit an account, extend a subscription, or release a payout on the strength of the payload until the signature has checked out. A handler that parses the body and acts before verifying has no security at all — the signature header it skipped was the entire defense.
- The endpoint URL is public; the signing secret is what proves authenticity.
- An unverified body is attacker-controlled input — never a business fact.
- Verify before any side effect: no fulfilment, credit, or payout on an unverified event.
- A mismatched or missing signature is a hostile request — reject it and stop.
Step 1 — Capture the raw request bytes
HMAC is computed over the exact bytes halfin signed. If you let your web framework parse the JSON and then re-serialize it before you hash, you are hashing a different byte sequence — different whitespace, different key order, a trailing newline gone — and an otherwise-valid signature will fail. This is the single most common reason a correct secret still produces a verification failure.
The fix is to read the raw body for the webhook route and disable automatic JSON parsing on it. In Express that means mounting `express.raw()` on the webhook path so `req.body` is a Buffer, not an object. In other stacks it is the equivalent: a raw-body reader, a request stream you drain yourself, or a framework flag that hands you the unparsed payload. Capture the bytes once, hash those bytes, and only parse JSON after the signature passes.
Order matters here. Put the raw-body middleware on the webhook route specifically, before any global body parser can touch it. A global `express.json()` mounted earlier will consume and discard the raw bytes, and you will be left re-encoding the parsed object — back to the mismatch problem.
Step 2 — Recompute the HMAC with your signing secret
halfin sends the signature in a request header alongside the event. Your job is to recompute the same HMAC locally and check that the two agree. Read your endpoint's signing secret from your secret manager (never from source), feed it and the raw body into an HMAC, and produce a digest in the same encoding the header uses.
The Node handler below does the full sequence on an Express route: raw body in, HMAC out, constant-time compare, then — and only then — parse and dispatch on the event type. The exact header name and digest algorithm come from the docs; the structure of the check is what does not change. Note that the success path returns 2xx fast and defers slow work, and the failure path returns 4xx and does nothing else.
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
const app = express();
const SIGNING_SECRET = process.env.HALFIN_WEBHOOK_SECRET!;
// Raw body on THIS route only — HMAC is over the exact bytes received,
// not over a re-serialized JSON object. See docs.thehalfin.com for the
// header name and digest algorithm.
app.post(
"/webhooks/halfin",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.header("x-halfin-signature") ?? "";
const expected = createHmac("sha256", SIGNING_SECRET)
.update(req.body) // req.body is a Buffer here
.digest("hex");
const a = Buffer.from(signature);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return res.status(401).send("invalid signature");
}
// Verified. Now it is safe to parse and act.
const event = JSON.parse(req.body.toString("utf8"));
switch (event.type) {
case "invoice.paid":
// fulfil the order tied to event.data.id (idempotently)
break;
case "payout.completed":
// reconcile the payout in your ledger
break;
}
return res.status(200).send("ok");
},
);Step 3 — Compare in constant time
Do not compare the signatures with `===` or a plain string equality. A naive comparison returns as soon as it hits the first differing byte, and the time it takes leaks how many leading bytes matched. An attacker who can measure that timing can recover a valid signature byte by byte. The defense is a constant-time comparison whose duration does not depend on where the strings differ.
In Node that primitive is `crypto.timingSafeEqual`, used above. It requires the two buffers to be the same length, so check lengths first and treat a length mismatch as a failure — a wrong-length signature is wrong regardless. Other languages have the same tool: Python's `hmac.compare_digest`, Go's `hmac.Equal`, PHP's `hash_equals`. Reach for the one your runtime ships; do not hand-roll the loop.
Here is the equivalent verification in Python with Flask, for a backend that is not on Node. Same shape: raw body, recomputed HMAC, `compare_digest`, reject on mismatch, parse only after the check passes.
import hashlib
import hmac
import os
from flask import Flask, request, abort
app = Flask(__name__)
SIGNING_SECRET = os.environ["HALFIN_WEBHOOK_SECRET"].encode()
@app.post("/webhooks/halfin")
def halfin_webhook():
# request.get_data() returns the raw bytes — hash these, not a
# re-encoded JSON object.
raw = request.get_data()
sent = request.headers.get("X-Halfin-Signature", "")
expected = hmac.new(SIGNING_SECRET, raw, hashlib.sha256).hexdigest()
# Constant-time compare — never use == on a signature.
if not hmac.compare_digest(sent, expected):
abort(401)
# Verified. Parse and act.
event = request.get_json()
if event["type"] == "invoice.paid":
pass # fulfil event["data"]["id"] idempotently
return ("ok", 200)Step 4 — Act only on canonical events, idempotently
Once the signature verifies, dispatch on the event type — but only on the names halfin actually sends. Hard-coding the canonical set means a typo'd or unknown type falls through instead of triggering the wrong branch. The table below is the authoritative list; treat any type outside it as something to log and ignore rather than handle.
Delivery is at least once, not exactly once. A timeout, a transient 5xx from your endpoint, or a slow acknowledgement causes halfin to redeliver, and the redelivered copy carries the same stable event id. So make every side effect idempotent: record processed event ids and turn the second occurrence into a no-op. Fulfilling an order twice or double-crediting a balance are exactly the bugs at-least-once delivery surfaces if you assume each POST is unique. Verification and idempotency are separate concerns — one proves the event is real, the other makes acting on a real event safe to repeat.
| Canonical event | What it signals | What a verified handler does |
|---|---|---|
| invoice.confirming | A matching deposit is on-chain, confirmations accumulating toward the threshold | Show an in-flight state; do not release goods yet |
| invoice.paid | Confirmed past the chain's threshold; the billed amount settled | Fulfil the order, keyed on the event id (once) |
| invoice.underpaid | A real payment arrived below the amount due | Hold the order; surface the shortfall for follow-up |
| invoice.overpaid | Settled with a surplus over the amount due | Fulfil and flag the surplus for refund or credit |
| invoice.expired | The quote window closed before sufficient payment | Cancel the order; let the customer start a fresh invoice |
| invoice.late_deposit | Funds landed after the invoice had already expired | Reconcile out-of-band; the deposit is recorded, not silently dropped |
| invoice.deposit_reversed | A previously seen deposit was unwound (e.g. a reorg) | Reverse any provisional grant tied to that deposit |
| balance.credited | A confirmed deposit credited your balance | Update your own ledger entry for the account |
| payout.completed | A single or batch payout reached its terminal success state | Mark the payout settled; notify the payee; reconcile |
| payout.failed | A payout did not go through | Flag for retry or manual review; do not assume it sent |
Step 5 — Test the path before you go live
A signature check is easy to get subtly wrong and hard to notice, because the happy path and a broken check both look like "requests arrive." Exercise both outcomes deliberately. First, confirm a genuine event verifies: create an invoice against the sandbox, pay it, and watch your endpoint receive `invoice.confirming` and `invoice.paid` and pass the check. Then prove the negative: flip a single byte in the body before your handler runs, or send a request with a wrong signature, and confirm you return a 4xx and take no action.
Build a local fixture against the request shape halfin sends so you can run these tests without hitting the network every time. The curl below models that shape — a JSON envelope with a stable event id, a canonical type, a timestamp, and a typed data object, plus the signature header carrying the HMAC of the raw body. Drive both a valid and a tampered version through your handler in CI so a future refactor that breaks verification fails the build instead of shipping.
Two failures to rule out explicitly, because they account for most "my secret is right but it still fails" reports: a body that was parsed and re-serialized before hashing (Step 1), and a non-constant-time comparison that you replaced with `===` during a cleanup (Step 3). If verification fails on a request you believe is genuine, check those two before suspecting the secret.
# An illustrative request shape halfin POSTs to your endpoint: a signed
# envelope with a stable event id, a canonical type, a timestamp, and a typed
# data object. Use it to build a local fixture and to test BOTH a valid and a
# tampered body. The signature header is the HMAC of the exact raw body below.
# See docs.thehalfin.com for the authoritative header name, digest algorithm, and
# the full data schema for each event type.
curl -X POST https://your-app.example.com/webhooks/halfin \
-H "Content-Type: application/json" \
-H "X-Halfin-Signature: 9f86d081884c7d659a2feaa0c55ad015a..." \
-d '{
"id": "evt_00000000-0000-0000-0000-000000000001",
"type": "invoice.paid",
"created_at": "2026-06-10T12:00:00Z",
"data": {
"id": "inv_00000000-0000-0000-0000-000000000002"
}
}'
# Then flip one byte in the body and re-send: your handler must return 4xx
# and do nothing. If it acts anyway, verification is not gating the side effect.Operating the endpoint safely
The signing secret is a credential, so store it the way you store any other one — in your secret manager, never in source control, scoped to the service that runs the webhook handler. If you suspect it leaked, rotate it from the dashboard and update the handler; a rotated secret means old forged requests signed with the previous value stop verifying.
Log the event id and the verification result for every request — received, verified-or-rejected, acted-on. When finance asks why an order did not fulfil, that line tells you whether the event arrived, whether the signature passed, and whether your handler ran, without guessing. Pair it with the idempotency record from Step 4 so a redelivery is visibly a no-op rather than a mystery second fulfilment.
Keep the handler fast on the success path: verify, durably record the raw event, return 2xx, and push fulfilment, email, and ledger writes onto a background queue. Slow synchronous work risks a timeout, which halfin reads as a failed delivery and retries — multiplying the load on a handler that was already struggling. A 4xx, by contrast, is your deliberate "this request is not a valid halfin event" and is exactly what an unverified request should get.