Product

Webhooks: signed payment events you can act on

A customer pays an invoice on-chain and your order is still marked unpaid until something tells your backend the money arrived. halfin webhooks are that signal: an HMAC-signed HTTP POST for every invoice, deposit, and payout state change, sent to your endpoint the moment the platform records it. You verify the signature, then move your own state — fulfil the order, credit the balance, release the goods.

01

The problem: your backend can't see the blockchain

Crypto payments are asynchronous. The customer broadcasts a transaction, the network confirms it over some number of blocks, and only then is the invoice actually paid. None of that happens inside your application. Without a push channel you are left polling the API on a timer — fast enough to feel responsive means hammering the endpoint, slow enough to be polite means customers stare at a "waiting" screen long after their money has settled.

Webhooks invert that. Instead of your server asking "is it paid yet?" every few seconds, halfin tells your server the instant an invoice, a deposit address, or a payout changes state. The event carries the full object, so a single POST is usually enough to fulfil an order or update a ledger row without a follow-up read.

The events map to the lifecycles you already model. Invoice events track an invoice from activation through underpaid, paid, overpaid, and expired. Deposit events fire when an on-chain deposit lands on one of your static addresses and again as it confirms. Payout events follow a single or batch payout from submission to its terminal state. Crediting is reorg-aware and respects each chain's confirmation threshold, so a "paid" event reflects a credit the platform is willing to stand behind, not an optimistic first-seen.

02

The signed event envelope

Every webhook is a JSON envelope with a stable event id, an event type, a timestamp, and a typed data object for the resource that changed. The body is signed with HMAC using the signing secret tied to your endpoint, and the signature travels in a request header. Your job on receipt is short and strict: recompute the HMAC over the raw request body, compare it to the header in constant time, and only then deserialize and act.

Verify before you trust. The endpoint URL is public the moment you register it, so anyone can POST arbitrary JSON to it. The signature is what separates a real halfin event from a forged one. Treat an unsigned or mismatched request as hostile — return a 4xx and do nothing else. Never fulfil an order, credit a balance, or release a payout off the strength of the payload alone.

Compute the HMAC over the exact bytes you received, before any JSON parsing or framework middleware reserializes them. A re-encoded body changes whitespace and key order and will fail an otherwise-valid signature. In most stacks that means reading the raw request buffer and disabling automatic body parsing for the webhook route.

import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";

const app = express();
const SIGNING_SECRET = process.env.HALFIN_WEBHOOK_SECRET!;

// Capture the RAW body — HMAC must be computed over the exact bytes received,
// not over a re-serialized JSON object.
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");
    }

    // Only now is it 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 (idempotent — see below)
        break;
      case "payout.completed":
        // reconcile the payout in your ledger
        break;
    }

    // Acknowledge fast; defer slow work to a queue.
    return res.status(200).send("ok");
  },
);
03

Which events fire, and when

Webhooks cover the three resource lifecycles a payment integration cares about: invoices, deposits to static addresses, and payouts. Subscribe to the ones your flow needs. A storefront usually only needs invoice events; a desk that receives to persistent addresses also wants deposit events; anyone sending money out wants payout events for reconciliation.

The data object on each event is the same shape you get from the corresponding REST resource, so you can act on the webhook directly or use its id to read the canonical object back from the API if you prefer a single source of truth.

Event familyFires whenTypical action
invoice.*An invoice is activated, partially paid (underpaid), fully paid, overpaid, or expiresMark the order paid; flag under/overpayment for review
deposit.*An on-chain deposit lands on a static address and as it reaches its confirmation thresholdCredit the depositing account once confirmed
payout.*A single or batch payout is submitted, broadcast, completed, or failsReconcile the payout against your ledger and notify the payee
04

At-least-once delivery means you must be idempotent

halfin delivers each event at least once, not exactly once. A network blip, a slow response from your endpoint, or a 5xx will cause a redelivery, and the same logical event can legitimately arrive more than once. The event id is stable across those retries: the third copy of "invoice X is paid" carries the same id as the first.

Build your handler around that id. Record processed event ids and make the side effect a no-op the second time you see one. Fulfilling an order twice, double-crediting a balance, or sending a confirmation email twice are exactly the bugs at-least-once delivery will surface if you assume each POST is unique.

Acknowledge quickly and do the heavy lifting elsewhere. Return 2xx as soon as you have verified the signature and durably recorded the event; push order fulfilment, emails, and ledger writes onto a background queue. A handler that does slow synchronous work risks timing out, which halfin reads as a failed delivery and retries — multiplying the work it was already struggling to finish.

  • Dedupe on the stable event id; treat a repeat as a successful no-op.
  • Verify the HMAC signature before parsing or acting on the body.
  • Return 2xx fast; defer fulfilment, email, and ledger writes to a queue.
  • Persist the raw event before acknowledging so a crash can't lose it.
  • Re-read the resource from the API by id if you want a definitive state.
05

State you can drive from invoice events

For a storefront, the invoice lifecycle is the one that matters. The events let you move your order through the same states halfin tracks, including the awkward middle cases — a customer who sends slightly too little, or slightly too much — that a naive paid/unpaid model ignores.

Underpayment and overpayment are first-class. An underpaid invoice has received a real but insufficient on-chain payment; you decide whether to wait for a top-up, refund, or treat it as a partial. An overpaid invoice has been settled with a surplus you can refund or credit. Webhooks surface both so your support and finance flows can act on them instead of discovering them in a reconciliation report a week later.

Invoice eventMeaningWhat your handler does
invoice.confirmingInvoice is live with a locked quote and an expiryShow the address/amount; start your own waiting state
invoice.underpaidA real payment arrived but is below the amount dueHold the order; surface the shortfall for follow-up
invoice.paidThe full amount confirmed under the chain's thresholdFulfil the order (idempotently)
invoice.overpaidSettled with a surplus over the amount dueFulfil and flag the surplus for refund or credit
invoice.expiredThe quote window closed before full paymentCancel the order; let the customer start a new invoice
06

Register, test, and operate your endpoint

You configure a webhook endpoint and its signing secret from the dashboard or the API. The secret is what your handler uses to verify signatures, so store it the way you store any other credential — in your secret manager, never in source control, and rotate it if you suspect exposure.

Before you go live, exercise the path end to end. Create an invoice against the sandbox, pay it, and watch your endpoint receive the activation and paid events. Confirm your signature check passes for a genuine event and fails for a tampered one — flip a byte in the body and make sure you return a 4xx. The curl below shows the request shape halfin sends so you can model a local fixture against it.

In production, keep an eye on delivery. A handler that starts returning non-2xx responses will see redeliveries pile up; an endpoint that's unreachable will too. Log the event id and the verification result for every request, so when finance asks why an order didn't fulfil you can point to the exact event and whether it was received, verified, and acted on.

# The request shape halfin POSTs to your endpoint. Use this to build a local
# fixture; the X-Halfin-Signature header is the HMAC of the raw body.
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": {
      "invoice_id": "00000000-0000-0000-0000-000000000002",
      "external_id": null,
      "status": "paid",
      "environment": "live",
      "currency": "USDT",
      "amount_requested": "100.00",
      "amount_paid": "100.00",
      "amount_fiat": "100.00",
      "fiat_currency": "USD",
      "paid_at": "2026-06-10T12:00:00Z"
    }
  }'