The five rules, and why they exist
A crypto payment integration touches money over an asynchronous, occasionally-reorging channel, with at-least-once webhooks and clients that retry. That combination breaks naive code in predictable ways. The rules below are not style preferences — each one closes a specific failure that costs money or strands a customer.
Read them as a checklist. If your integration already does all five, the rest of this guide is confirmation. If it skips one, that is the line item to fix before you go live, because every one of them surfaces under real load rather than in your happy-path test.
- Verify the webhook HMAC over the raw bytes before you act on anything — the endpoint is public, the signature is the only thing that makes an event real.
- Put idempotency_key in the request body as a snake_case field — it is not an Idempotency-Key header, and a retried create must reuse the same value.
- Lean on the rate lock instead of re-pricing yourself — the invoice fixes the payable amount at activation, so you reconcile the fiat figure you billed.
- Reconcile against the API by resource id — the webhook is the trigger, the REST resource is the source of truth before any irreversible action.
- Handle underpaid and overpaid as first-class outcomes — a real-but-wrong amount is not a failure to discard, it is a state to act on.
Rule 1 — Verify the webhook HMAC before you act
The moment you register a webhook endpoint, its URL is reachable by anyone. Nothing stops a stranger from POSTing a JSON body that says invoice.paid to it. The only thing that separates a genuine halfin event from a forged one is the HMAC signature in the request header, so verification is not an optional hardening step — it is the gate that every event must pass before it touches your order state, your balance, or a payout.
Compute the HMAC over the exact raw bytes you received, using the signing secret tied to your endpoint, and compare it to the header with a constant-time comparison. The order matters: verify first, parse second, act third. If you let a framework deserialize and re-serialize the body before you hash it, key order and whitespace change and an otherwise-valid signature fails — so read the raw request buffer and disable automatic body parsing on the webhook route.
A mismatch is hostile input, not a soft error. Return a 4xx and do nothing else: do not fulfil the order, do not credit a balance, do not log it as a near-miss you will look at later. Only an event whose signature verifies is allowed to move your state. The dedicated guide on verifying webhook signatures walks the byte-level mechanics if you want the long form.
Rule 2 — Put idempotency_key in the body, never in a header
halfin's request canon is deliberately small: the only request headers you set are X-API-Key for authentication and Content-Type for the JSON body. The idempotency key is a snake_case field inside the JSON body — idempotency_key — not an Idempotency-Key header. Reaching for the header out of habit from other gateways means the field silently goes unset, and your retry safety quietly disappears.
The key is what makes a retried create idempotent. If a POST to create an invoice or a payout times out, you do not know whether it landed; resubmitting the identical request with the same idempotency_key either creates the resource once (if the first attempt never arrived) or returns the existing one (if it did). That turns 'I don't know if that went through' into a non-event. For it to work, the key must be reused on the retry — a fresh UUID per attempt defeats the entire mechanism and can create a duplicate invoice or pay someone twice.
Derive the key from data you can reproduce, not from a send-time clock. A stable identifier you already store — an order id, an account id joined with a billing period — re-derives to the same string on the second pass. The curl below shows the canonical shape: two headers, and idempotency_key as a body field alongside the amount and currency.
# Canonical halfin request: ONLY X-API-Key + Content-Type headers.
# idempotency_key is a snake_case BODY field, not an Idempotency-Key header.
curl -X POST https://api.thehalfin.com/api/v1/invoices \
-H "X-API-Key: $HALFIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount_fiat": "49.00",
"fiat_currency": "USD",
"deferred": true,
"description": "Pro plan — March",
"idempotency_key": "order_8821:2026-03"
}'
# Retrying this exact request after a timeout is safe: the same
# idempotency_key returns the original invoice instead of creating a second.Rule 3 — Lean on the rate lock instead of re-pricing
You price in fiat; the customer pays in a volatile asset. The wrong move is to quote a token amount yourself at draft time, watch the rate drift while the customer finds their wallet, and then eat the difference or chase a top-up. halfin removes that exposure by locking the conversion rate when the invoice activates and pinning the payable asset amount for the life of that invoice.
So the practice is to let the invoice own the rate. Create it with amount_fiat and fiat_currency (with deferred for a quote at activation), or with a fixed asset amount plus its currency if you are billing a token figure directly. From there the payable amount is fixed and stamped with an expiry — the customer pays that exact figure inside the window, and it settles to the fiat number you billed. If the window lapses, the invoice expires rather than silently re-pricing against a newer rate; you re-issue at the current rate on your terms.
Two anti-patterns to avoid. Do not cache a rate in your own code and present your own token amount — you will diverge from the locked quote the customer actually pays. And do not treat an expired invoice as a payment you can still honor at yesterday's rate; expiry is explicit precisely so nobody is exposed to a stale quote.
- Create with the fiat anchor (amount_fiat + fiat_currency + deferred) or a fixed asset amount + currency — let halfin compute and pin the payable amount.
- Show the customer the locked figure and the expiry; never substitute a rate you computed yourself.
- Treat an expired invoice as expired — re-issue at the current rate instead of honoring a stale quote.
- Reconcile against the fiat amount you billed, since the lock guarantees it maps back cleanly.
Rule 4 — Reconcile via the API, don't trust the webhook as the ledger
A verified webhook is a reliable trigger, but delivery is at least once, not exactly once. The same logical event can arrive more than once, carrying the same stable event id across redeliveries. Build your handler around that id: record processed ids and make the side effect a no-op the second time you see one, so a redelivered invoice.paid fulfils an order once, not twice.
The webhook tells you something changed; the API tells you the definitive current state. The data object on an event matches the corresponding REST resource, so for most flows acting on the verified payload is enough. But before any irreversible action — releasing goods, signing off a payout, issuing a refund — read the resource back from the API by its id and confirm the state, rather than acting on the strength of a single message. That read is your reconciliation point.
Drive settlement state from events, not from a polling loop. Hammering the API on a timer to ask 'is it paid yet?' is the pattern webhooks exist to replace; it wastes calls and still lags. Acknowledge the webhook fast with a 2xx once you have verified the signature and durably recorded the event, then do fulfilment, email, and ledger writes on a background queue — slow synchronous work risks a timeout, which halfin reads as a failed delivery and retries, multiplying the work.
| Concern | Anti-pattern | Best practice |
|---|---|---|
| Authenticity | Act on the POST body directly | Verify the HMAC over raw bytes (constant-time) before parsing |
| Duplicate delivery | Assume each POST is unique | Dedupe on the stable event id; treat a repeat as a no-op |
| Source of truth | Treat the webhook payload as the ledger | Read the resource back by id before an irreversible action |
| Liveness | Poll the API on a timer for status | React to signed events; ack 2xx fast, defer slow work to a queue |
Rule 5 — Handle underpaid and overpaid as real states
Customers send from exchanges that skim a withdrawal fee, fat-finger an amount, or pay a slightly stale quote. A model that recognizes only paid and unpaid throws those real payments into an unmatched-deposit pile and strands the money. halfin instead tracks the amount expected against the amount actually received on-chain and surfaces the gap as a first-class outcome you can act on.
An invoice.underpaid event means a genuine payment arrived but fell short of the amount due. Hold the order and decide per your own policy: request a top-up, settle partially, or refund. An invoice.overpaid event means it settled with a surplus — fulfil the order and flag the excess for refund or credit so it is visible and accountable, not lost. Wire both into your support and finance flows so they are caught at payment time, not in a reconciliation report a week later.
Underpaid and overpaid sit alongside the rest of the lifecycle your handler already models. Remember that crediting is reorg-aware and respects per-chain confirmation thresholds: an invoice is marked paid only after confirmations reach the chain's depth, and a reorg that unwinds a transaction is reflected rather than ignored. The amount you see as settled is an amount that actually held.
| Webhook event | Meaning | What your handler does |
|---|---|---|
| invoice.confirming | A matching deposit is seen and confirmations are accumulating toward the threshold | Show a waiting state; do not release goods yet |
| invoice.paid | The full amount confirmed under the chain's threshold | Fulfil the order — idempotently, keyed on the event id |
| invoice.underpaid | A real payment arrived but is below the amount due | Hold the order; request a top-up, partial-settle, or refund per policy |
| invoice.overpaid | Settled with a surplus over the amount due | Fulfil and flag the surplus for refund or credit |
| invoice.expired | The payment window closed before a sufficient payment arrived | Cancel the order; let the customer start a fresh invoice at the current rate |
Operational habits around the five rules
Two practices sit underneath all five and are worth stating on their own. The first is API-key hygiene: scope keys to the job. The service that stages payouts should hold a payouts-scoped key, separate from the read-only key your reporting uses and the invoicing key your storefront uses, so a leaked analytics credential can never initiate a payout. Store the webhook signing secret the same way you store any credential — in a secret manager, never in source control — and rotate it if you suspect exposure.
The second is to exercise the whole path before you trust it. Create an invoice against the sandbox, pay it, and watch your endpoint receive invoice.confirming through invoice.paid. Confirm your signature check passes for a genuine event and fails for a tampered one — flip a byte in the body and verify you return a 4xx. Confirm a retried create with the same idempotency_key returns the original resource rather than a duplicate. The full request and response schema lives at docs.thehalfin.com; the discipline that matters is that you have seen each rule hold against a real round-trip, not just in theory.