Guide

How to reconcile crypto payments

Reconciliation is the step where your books and the blockchain are supposed to agree: every invoice you billed maps to money that actually settled, every payout you released maps to money that actually left, and nothing is double-counted or quietly missing. The mistake most integrations make is to reconcile by polling the API on a timer and hoping. The reliable design is the inverse — drive your ledger off signed webhooks as the real-time source of truth, and reach for the REST API only to backfill a gap or recover after an outage. This guide builds that two-layer reconciliation: webhooks as the live feed, the API as the catch-up.

01

The two-layer model: webhooks live, API for recovery

Crypto money movements are asynchronous and happen outside your application: a customer broadcasts a transaction, the chain confirms it over some number of blocks, and only then is an invoice paid or a payout settled. Your backend cannot see any of that directly. So reconciliation has exactly two jobs — learn about each terminal state the instant it happens, and prove afterward that you missed nothing.

halfin gives you a primitive for each job. Signed webhooks are the real-time source of truth: an HMAC-signed POST for every invoice and payout state change, delivered the moment the platform records it. The REST API is the recovery layer: you read back the canonical invoice or payout by id to confirm a webhook, to fill a window your endpoint was down, or to settle a 'did this actually land?' question. Lead with webhooks; use the API to make the webhook feed auditable rather than merely trusted.

Crucially, do not invert this and poll the API as your primary feed. Polling fast enough to feel live means hammering the endpoint; polling slow enough to be polite means your ledger lags reality by minutes. Webhooks remove that trade-off — you find out when something changes, not on your next scan — and the API stays in reserve for the cases webhooks alone cannot answer.

  • Webhooks = real-time source of truth: act the instant an invoice or payout reaches a terminal state.
  • REST API = recovery layer: backfill missed windows, confirm an event, resolve an ambiguous outcome by id.
  • Do not poll the API as the primary feed — webhooks remove the latency-vs-load trade-off.
  • Crediting is reorg-aware under per-chain confirmation thresholds, so a 'paid' event reflects a credit halfin stands behind.
02

Step 1 — Model your ledger so a payment can be matched

Reconciliation only works if every row you write can be matched back to a halfin object by a stable identifier. Before any event handling, make sure your ledger stores the halfin invoice id against the order it paid for, and the halfin payout id against the disbursement it settled. The id is the join key; without it you are reduced to matching on fuzzy amounts and timestamps, which breaks the moment two customers pay the same price in the same minute.

For invoices, also persist the fiat figure you billed alongside the asset and network the customer is expected to pay. A halfin invoice carries both the fiat anchor and the payable asset amount, and your reconciliation reads cleanest against the fiat number — every confirmed invoice maps back to the amount you booked, not to a token quantity you have to re-price. For payouts, store the idempotency_key you sent on each line; it is what lets you re-derive the payout deterministically if you ever need to.

Keep amounts as strings throughout. Monetary values cross the API boundary as JSON strings, and the moment you let one become a floating-point number you have invited rounding drift between your ledger and the chain — which is precisely the discrepancy reconciliation exists to catch. Carry the string from the event into your books unchanged.

Ledger columnSourceWhy it matters for reconciliation
invoice_id / payout_idThe id on the webhook event and the REST resourceThe join key — every match is by id, never by amount alone.
amount_fiat + fiat_currencyInvoice object (the fiat anchor you billed)Reconcile against the figure you booked, not a re-priced token amount.
currency + amount (string)Invoice / payout objectCarried exactly; a float here is the drift reconciliation must catch.
idempotency_keyWhat you sent when creating the payoutLets you re-derive and re-confirm a payout line deterministically.
last_event_id + statusThe stable event id on each webhookDedupe redeliveries and prove which event last moved this row.
03

Step 2 — Drive the ledger off signed webhooks

These are the events that close the loop. For invoices, invoice.paid is the settlement signal, with invoice.overpaid and invoice.underpaid flagging the amount mismatches you must reconcile by hand or policy, invoice.expired ending an unpaid invoice, and invoice.confirming marking a live invoice with a locked quote. For the awkward tail, invoice.late_deposit covers a payment that arrives after expiry and invoice.deposit_reversed covers a credit unwound by a reorg — both are reconciliation events, because both change what your books should say after you already thought the invoice was settled. For payouts, payout.completed and payout.failed are the two terminal outcomes. There is no invoice.activated event — do not wait on one.

On every event, verify before you trust. Recompute the HMAC over the exact raw request bytes using your endpoint's signing secret, compare it to the signature header with a constant-time comparison, and only then parse and act. The endpoint URL is public the moment you register it, so an unsigned or mismatched POST is hostile — return a 4xx and touch nothing. Reconciling off an unverified payload is how a forged 'paid' event marks an order settled that never was.

Then make the ledger write idempotent. halfin delivers at least once, so the same event can arrive more than once carrying the same stable event id. Record processed event ids and make the side effect a no-op on a repeat: a redelivered payout.completed must not settle the payout twice, and a redelivered invoice.paid must not credit the order twice. Acknowledge with a 2xx as soon as you have verified and durably stored the event; push the slower reconciliation write onto a queue so a slow handler is never read as a failed delivery and retried.

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

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

// HMAC must be computed over the RAW bytes — capture the buffer, do not
// let middleware reserialize the JSON first.
app.post(
  "/webhooks/halfin",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const got = req.header("x-halfin-signature") ?? "";
    const want = createHmac("sha256", SECRET).update(req.body).digest("hex");
    const a = Buffer.from(got);
    const b = Buffer.from(want);
    if (a.length !== b.length || !timingSafeEqual(a, b)) {
      return res.status(401).send("invalid signature");
    }

    const event = JSON.parse(req.body.toString("utf8"));

    // Dedupe on the stable event id — delivery is at-least-once.
    if (alreadyProcessed(event.id)) return res.status(200).send("ok");

    switch (event.type) {
      case "invoice.paid":
        markInvoiceSettled(event.data); // match by event.data.id to your ledger
        break;
      case "invoice.underpaid":
      case "invoice.overpaid":
        flagAmountMismatch(event.data); // resolve by policy, not silently
        break;
      case "invoice.deposit_reversed":
        unwindCredit(event.data); // a reorg took back a credit you booked
        break;
      case "payout.completed":
        markPayoutSettled(event.data);
        break;
      case "payout.failed":
        markPayoutFailed(event.data);
        break;
    }

    recordProcessed(event.id);
    return res.status(200).send("ok");
  },
);
04

Step 3 — Reconcile invoices, including the mismatches

A clean reconciliation is not just 'paid equals booked'. Real customers underpay because an exchange skimmed a withdrawal fee, overpay because they fat-fingered an amount, or pay a slightly stale quote. halfin records the amount expected against the amount received on-chain and surfaces the gap as a first-class event rather than stranding the money, so your reconciliation has to take a position on each case instead of treating anything that is not an exact match as a failure.

Match each invoice event to the order by id, then compare the received amount to the billed fiat figure and route by the outcome. An invoice.paid that matches is the easy path — fulfil and book it. An invoice.underpaid is a real but insufficient payment: hold the order and decide, by your own policy, whether to request a top-up, settle partially, or refund. An invoice.overpaid settled with a surplus you can refund or credit. An invoice.expired means the quote window closed unpaid — and an invoice.late_deposit means money showed up anyway after that, which is its own reconciliation decision.

Treat invoice.deposit_reversed as the case that protects your books from the chain. Crediting is reorg-aware, so a transaction that confirmed and then got unwound by a reorganization is reflected, not ignored. If you already booked that invoice as paid and fulfilled, a reversal event has to walk it back in your ledger — which is exactly why you reconcile off verified events and keep the writes idempotent, rather than assuming first-seen is final.

Invoice eventWhat reconciliation recordsDecision it forces
invoice.paidBilled fiat figure settled to your balance.Fulfil and book; match by id, not by amount.
invoice.underpaidA real payment below the amount due.Hold; request top-up, partial-settle, or refund per policy.
invoice.overpaidSettlement with a surplus over the amount due.Book the surplus for refund or credit; do not let it vanish.
invoice.expiredQuote window closed before sufficient payment.Cancel the order; re-issue at the current rate if asked.
invoice.late_depositA payment that arrived after expiry.Decide whether to honour it or refund the late funds.
invoice.deposit_reversedA reorg unwound a credit you already booked.Walk the booked invoice back; idempotency keeps it correct.
05

Step 4 — Reconcile payouts against what left your balance

The payout side mirrors the invoice side, in reverse. A payout you created does not move funds the instant your code submits it — each payout enters a pending-approval state and is released from the dashboard, so the reconciliation question is not 'did I call the API?' but 'did the money settle on-chain?'. The answer arrives as a webhook, not from staring at the API.

Two events are terminal. payout.completed fires when a payout settles under the chain's confirmation threshold with reorg-aware crediting — that is when you mark the disbursement settled in your ledger and, if you do it, notify the payee. payout.failed fires when one cannot settle. Match each to your records by the payout id, and keep the handler idempotent so a redelivered payout.completed settles the line exactly once and never fires a second payee notification.

A payout.failed is an operational outcome, not a dead end, and it is the discrepancy reconciliation is built to surface. Find the reason — an address that became invalid, an insufficient balance at release — fix the underlying issue, then re-run the line. If it never settled, re-run with the same idempotency_key and halfin returns the original rather than paying twice; if you are issuing a genuinely different payment, use a new key. The key is your contract with the platform about what counts as 'the same payout', which is what makes a recovery re-run safe.

  • Reconcile payouts on payout.completed and payout.failed — match by payout id.
  • A payout settles after release + on-chain confirmation, not when your loop submits it.
  • Re-run a failed-but-never-settled line with the same idempotency_key; halfin returns the original, no double-pay.
  • Use a new idempotency_key only for a genuinely different payment.
06

Step 5 — Backfill with the REST API after a gap

Webhooks are your live feed, but your endpoint will eventually be unreachable for a stretch — a deploy, an outage, a bad release that 5xx'd every delivery. halfin retries at-least-once, but you should not depend on retries to heal a long gap. The recovery layer is the REST API: read the canonical objects back by id and replay the state your handler missed, exactly as if the events had arrived.

Run a periodic backfill as a safety net rather than a primary path. For any invoice or payout your ledger still shows as non-terminal past the window you would expect it to settle, read it back from api.thehalfin.com/api/v1 by its id, take the current authoritative status, and apply the same idempotent reconciliation write your webhook handler would. Because the data object on an event matches the corresponding REST resource, the backfill and the live path converge on the same ledger state. The curl below is the shape of that recovery read.

Use a read-scoped API key for backfill. Reconciliation reads should hold a key that cannot create invoices or initiate payouts — separate from the payouts-scoped key that moves money and the invoicing key your storefront uses. A reconciliation job is the last place that should carry write authority, and a leaked read key cannot disburse funds.

# Recovery read: confirm an invoice's authoritative state by id after a gap.
# Use a READ-scoped key here — reconciliation never needs write authority.
curl -sS https://api.thehalfin.com/api/v1/invoices/00000000-0000-0000-0000-000000000002 \
  -H "X-API-Key: $HALFIN_READ_API_KEY" \
  -H "Content-Type: application/json"

# The returned object matches the webhook data shape, so the backfill applies
# the SAME idempotent reconciliation write as your live handler. Read payouts
# back the same way at /api/v1/payouts/<id>.
# See docs.thehalfin.com for the full response schema.
07

Step 6 — Close the period and surface the discrepancies

At period close, the goal is a short, honest exception list, not a green checkmark you cannot defend. With every row carrying a halfin id and a last-event id, the close is mechanical: group settled invoices by currency and confirm the count and per-currency total match what you booked; do the same for payouts against what left your balance. The figures should agree because both sides were driven off the same verified events.

What you are hunting is the residue: invoices your ledger shows non-terminal long past their expiry, payouts stuck without a completed or failed event, and any row whose amount received does not equal the amount you expected. Each of those is a real question — a missed webhook the backfill should have caught, an under/overpayment awaiting a policy decision, a reversal you have not walked back. Resolve them by reading the authoritative object from the API by id; do not paper over a mismatch by adjusting your own number to fit.

Keep the close auditable. Logging the event id and verification result for every reconciliation write means that when finance asks why an order fulfilled or a payout settled, you can point at the exact signed event and show it was received, verified, and applied once. That audit trail — verified events in, idempotent writes out, API reads to fill gaps — is what makes 'our books agree with the chain' a statement you can stand behind rather than hope for.

  • Match per-currency counts and totals: settled invoices vs booked, settled payouts vs disbursed.
  • Exception list = non-terminal-past-expiry invoices, payouts with no terminal event, amount mismatches.
  • Resolve every exception by reading the authoritative object by id — never by editing your own figure to fit.
  • Log event id + verification result per write so close-out is auditable, not just balanced.