Where the real risk is — and where it isn't
It helps to be precise about your threat model before writing any code. The wallets and private keys that hold value, the blockchain monitoring, the reorg handling, and the signing of outbound transactions live inside halfin — your application never touches a private key, and it never talks to a blockchain node. That removes a whole category of mistakes you would otherwise own: you are not storing seed phrases, not deciding how many confirmations are enough, not racing a reorg.
What stays your responsibility is the boundary between halfin and your own systems: the webhook endpoint that tells your backend money arrived, the API keys your services authenticate with, the payout requests your code submits, and the moment you decide a payment is final enough to act on. Every item below sits on that boundary. The blockchain is not where you'll get burned — your own trust decisions are.
One honest limit up front: halfin's custody story is signing, scoped permissions, and an audit trail, not a custody guarantee beyond those controls. The job of this guide is to make sure the controls that do exist are actually wired into your integration, because an unused control protects nothing.
Step 1 — Verify every webhook before you act on it
Your webhook endpoint is a public URL. It sits in your dashboard config, your logs, sometimes your client code — treat it as known to the world. Anyone who learns it can POST a body that says "invoice.paid" to it. The HMAC signature is the only thing that separates a genuine halfin event from a forgery, so verification is not a hardening nicety; it is the load-bearing control on the inbound side.
The discipline is fixed and short. Read the raw request bytes before any framework middleware reparses them. Recompute the HMAC with the signing secret tied to your endpoint. Compare your computed value against the signature header using a constant-time comparison, never plain string equality — a byte-by-byte equals check leaks timing information an attacker can use to forge a valid signature. Only after the comparison passes do you parse the JSON and treat it as a business fact. A request that fails the check gets a 4xx and nothing else: no order fulfilment, no balance credit, no payout release.
Compute over the exact bytes you received. A re-encoded body — one your JSON middleware already parsed and re-serialized — changes whitespace and key order and will fail an otherwise-valid signature. In most stacks that means disabling automatic body parsing on the webhook route and reading the raw buffer. The full signing recipe, including the header name and hashing algorithm, is in the docs at docs.thehalfin.com; the code below shows the shape of a correct handler.
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
const app = express();
const SIGNING_SECRET = process.env.HALFIN_WEBHOOK_SECRET!;
app.post(
"/webhooks/halfin",
// Raw body — the HMAC must be computed over the exact bytes received.
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.header("x-halfin-signature") ?? "";
const expected = createHmac("sha256", SIGNING_SECRET)
.update(req.body) // Buffer, not a parsed object
.digest("hex");
const a = Buffer.from(signature);
const b = Buffer.from(expected);
// Constant-time compare — never a plain === on the signatures.
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return res.status(401).send("invalid signature");
}
// Only now is the body trustworthy.
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 2 — Trust confirmed state, not first sight
The signed events you can act on are a fixed set, and acting on the wrong one is a security mistake dressed up as a logic bug. The canonical invoice events are invoice.confirming, invoice.paid, invoice.overpaid, invoice.underpaid, invoice.expired, and the deposit-anomaly pair invoice.late_deposit and invoice.deposit_reversed. The settlement events are balance.credited, payout.completed, and payout.failed. There is no invoice.activated event — if your handler is branching on one, it is branching on something that will never arrive.
Grant value on the final event, not the first one. invoice.paid means the payment confirmed past the chain's threshold under reorg-aware crediting — halfin waited the chain-appropriate number of confirmations before standing behind it. invoice.confirming means a deposit was seen but is not yet final; releasing goods on it is how you occasionally hand out a paid order against a transaction that gets reorged away. The platform absorbs the per-chain waiting and the reorg handling, but only if your code keys entitlement off the confirmed state it emits.
Two later events exist precisely because a payment that looked settled can move. invoice.late_deposit fires when funds arrive after the invoice's window, and invoice.deposit_reversed fires when a previously credited deposit is unwound — typically by a reorg. If you acted on a credit, a reversal event is your signal to claw the entitlement back. Build a defined response to both; an integration that has no handler for deposit_reversed has a hole that opens exactly when the chain is least stable.
| Event | What it means | Safe action |
|---|---|---|
| invoice.confirming | A deposit was seen but has not reached the chain's confirmation threshold. | Show a waiting state — do not release goods or credit anything. |
| invoice.paid | Confirmed past the per-chain threshold, reorg-aware. | Fulfil the order, idempotently. |
| invoice.underpaid / invoice.overpaid | A real payment arrived, but short of or over the amount due. | Hold for review; apply your shortfall / surplus policy. |
| invoice.deposit_reversed | A previously credited deposit was unwound, typically by a reorg. | Reverse the entitlement you granted; flag for finance. |
| payout.completed / payout.failed | An outbound payout reached its terminal state. | Reconcile against your ledger; never re-submit on a bare timeout. |
Step 3 — Scope every API key to one job
A single all-powerful API key is the integration equivalent of a master password taped to the monitor. The service that renders your storefront, the worker that pays your affiliates, and the dashboard that runs your weekly reporting do not need the same authority — and when they share a key, a leak in the least-careful one inherits the power of the most-dangerous one. halfin issues API keys with scoped permissions for this reason: a key carries only the operations its holder needs.
Map keys to services, not to convenience. The storefront that creates invoices holds an invoicing key with no payout authority. The reconciliation job that lists balances and reads invoices holds a read-only key — if that credential leaks from a log or a dashboard screenshot, it cannot move a cent. The payout worker holds a payouts-scoped key, isolated from everything else, because it is the only place where a leaked credential turns into stolen funds. The point of the split is blast radius: a compromised reporting key should be an annoyance, not an incident.
Operate keys like the secrets they are. Keep them in your secret manager, never in source control or client-side bundles, and inject them as environment variables at runtime. Rotate on any suspicion of exposure, and rotate on staff turnover the same way you rotate other credentials. Use separate keys per environment so a staging leak can never touch production money. Pair scoped keys with idempotency keys on every create and payout call — the same idempotency_key on a retried request returns the original result instead of issuing a duplicate, which closes the door on a retry storm accidentally double-charging or double-paying.
| Service | Key scope | Worst case if it leaks |
|---|---|---|
| Storefront / checkout | Invoicing only | An attacker can create invoices — but cannot pay out or read balances. |
| Reporting / reconciliation | Read-only | Read access to your own data; no ability to move money. |
| Payout / settlement worker | Payouts only | The real money-moving credential — isolate it and watch it closely. |
Step 4 — Gate payouts behind approval and audit
Money leaving the system deserves more friction than money arriving. A payout is irreversible once it confirms on-chain, so the controls around it should reflect that. halfin's payout flow is built on approval and audit: a payout is a deliberate, recorded action, and the dashboard keeps an audit trail of what was approved, by whom, and when. Use that trail rather than treating payouts as fire-and-forget API calls your code makes unsupervised.
Keep payee validation and approval inside your own controls, layered on top of halfin's. Validate the destination address for the asset and network before you ever submit — a typo or a swapped-network address sends real funds to a place you can't recall them from. For mass payouts, the safety property that matters is idempotency: a batch is a fan-out of payout lines over a single request, each line carries its own idempotency_key, and re-submitting the same batch after a timeout settles only the lines that did not already go out. That is what stops a retried payout job from paying every affiliate twice. There is no separate batch-status endpoint to poll; you reconcile each line through the same signed payout events, keyed by your idempotency_key.
Make a bare timeout a non-event for payouts. If a submission times out, you do not know whether it went through, so re-submitting the identical request — same idempotency_key per line — is safe by design and is the only correct retry. Re-submitting with a fresh key is how you double-pay. Treat payout.completed and payout.failed as the truth about a payout's outcome, and let the audit trail plus your ledger be the record you reconcile against, not the HTTP response of a call that may or may not have landed.
# A mass payout is a fan-out of lines in ONE request. Each line carries
# its own idempotency_key — re-submitting the same batch after a timeout
# settles only the lines that did not already go out. There is no
# /payouts/batches endpoint; reconcile each line via signed payout events.
curl -X POST https://api.thehalfin.com/api/v1/payouts \
-H "X-API-Key: $HALFIN_PAYOUTS_KEY" \
-H "Content-Type: application/json" \
-d '{
"payouts": [
{
"currency": "USDT",
"amount": "120.00",
"destination": "TRON_DESTINATION_ADDRESS",
"idempotency_key": "affiliate_4821:2026-06"
},
{
"currency": "USDC",
"amount": "75.00",
"destination": "SOLANA_DESTINATION_ADDRESS",
"idempotency_key": "affiliate_5190:2026-06"
}
]
}'
# See docs.thehalfin.com for the full request and response schema. Use a
# payouts-scoped key here, never your invoicing or reporting key.Step 5 — Make every handler idempotent
Webhook delivery is at-least-once, not exactly-once. A network blip, a slow response, or a 5xx from your endpoint causes halfin to redeliver, 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. An integration that assumes each POST is unique will fulfil an order twice, double-credit a balance, or send a second confirmation email, and those duplicate side effects are a security and finance problem, not just a cosmetic one.
Build the handler around that stable id. Record processed event ids and make the side effect a no-op the second time you see one. Verify the signature first, persist the raw event durably, then apply the state change exactly once. Acknowledge with a 2xx as soon as the event is verified and recorded, and push the slow work — fulfilment, email, ledger writes — onto a background queue. A handler that does heavy synchronous work risks timing out, which halfin reads as a failed delivery and retries, multiplying the very work it was already struggling to finish.
Idempotency is the same property on both sides of the flow. On the inbound side it is dedupe-on-event-id so a redelivered paid event grants a cycle once. On the outbound side it is the per-line idempotency_key so a retried payout settles once. Treat them as one discipline: every action that moves value should be safe to attempt more than once, because at some point it will be attempted more than once.
- Dedupe inbound events on the stable event id; a repeat is a successful no-op.
- Verify the HMAC, persist the raw event, then act — in that order.
- Return 2xx fast; defer fulfilment, email, and ledger writes to a queue.
- Carry a per-line idempotency_key on every payout so a retry settles once.
- Re-read the resource from the API by id when you need a definitive state.
Step 6 — Know the custody boundary
A clear division of responsibility is itself a security control, because it tells you exactly what you are accountable for. halfin owns the payment rail: it holds the keys, monitors the chains, applies reorg-aware crediting under per-chain confirmation thresholds, signs outbound payouts, enforces scoped permissions, and keeps the audit trail. Your application owns the customer relationship and the entitlement decision: which order to ship, which subscription to extend, which payee to approve. Neither side reaches into the other's job, and that separation is what keeps the whole flow auditable.
Be accurate about what that boundary does and does not promise. It is signing, scoped permissions, and an audit trail — not a custody guarantee beyond those controls, and nothing here is legal, tax, or compliance advice for your business. Onboarding involves KYB and the platform operates with AML awareness, but those are process boundaries, not a certification or license that covers your product. Your business remains responsible for its own customer onboarding, its own records, and any obligations specific to where and to whom it sells.
The practical payoff of respecting the boundary is that your security story becomes something you can show rather than reconstruct. Every credit has a verified, confirmed event behind it. Every payout has an approval and an audit record. Every key is scoped to one job and rotated like a secret. When a reviewer or a finance lead asks why an order shipped or where a payout went, you point at the event id, the signature check, and the audit trail — not at a guess. Review the broader operational controls on the security page, and lock the inbound mechanics with the webhook-signature guide before you go live.