What a confirmation actually is
When a customer sends a payment, their wallet broadcasts a transaction to the network. For a short moment that transaction is unconfirmed — it is sitting in the mempool, seen by nodes but not yet written into a block. At this stage it is a request, not a settled fact: it can be replaced, dropped, or out-priced by a higher fee. Treating an unconfirmed transaction as paid is the single most common way crypto integrations lose money.
A confirmation is one block that contains the transaction, plus the work of every block built on top of it. The first confirmation is the block that includes the transaction. The second is the next block mined or finalized after it, and so on. Each additional block makes the transaction harder to undo, because reversing it would mean re-doing all the work layered above it. "How many confirmations" is really asking "how many blocks deep is this payment buried, and is that deep enough that no plausible reorganization can pull it back out?"
This is why "is it paid yet?" has no instant answer. The honest answer is "it has N confirmations, and we credit at the threshold this chain requires." halfin tracks that depth for every incoming payment and only marks an invoice paid once the count clears the threshold for the asset's chain.
- Unconfirmed (in the mempool) is not paid — it can still be replaced or dropped.
- One confirmation = one block containing the transaction; depth accumulates block by block.
- More confirmations = harder to reverse, because every block above must be re-done to undo it.
- halfin credits at a per-chain threshold, not on first-seen.
Why the wait is different on every chain
There is no universal number of confirmations, and any guide that gives you one is wrong. Each chain produces blocks at its own cadence and offers its own kind of settlement guarantee, so the threshold that makes a payment safe is a property of the chain, not a global constant. halfin sets the confirmation threshold per chain to match how that chain settles — you do not configure it, and you should not hard-code a number of your own on top of it.
Two chain characteristics drive the difference. The first is block production: some networks add blocks quickly, others slowly, so the same number of confirmations represents a very different amount of elapsed time. The second is the settlement model. Some chains offer probabilistic finality — a transaction is never theoretically impossible to reverse, just exponentially less likely the deeper it goes, which is why proof-of-work chains wait for several blocks. Others offer a stronger, near-deterministic finality once a block is finalized, so far fewer confirmations are needed to be safe.
The practical takeaway for your integration: do not promise customers a fixed wait time, and do not write "paid" logic keyed to a confirmation count you picked yourself. Let the chain's threshold — enforced by halfin — be the source of truth, and present the customer a waiting state until the paid event arrives.
| Chain characteristic | What it changes | Effect on the wait |
|---|---|---|
| Block production speed | How quickly new blocks stack on top of the payment | Faster blocks reach a given depth sooner; slower blocks take longer for the same count |
| Finality model | Whether settlement is probabilistic or near-deterministic | Probabilistic chains need more confirmations; strong-finality chains need fewer |
| Reorg likelihood | How often the chain rewrites recent blocks | Higher reorg risk means a deeper threshold before crediting is safe |
| Network conditions | Mempool congestion and fee market at the time of payment | A low-fee transaction can sit unconfirmed longer before its first confirmation |
Reorg-aware crediting: when the chain rewrites history
Blockchains are not strictly append-only in the short term. A chain reorganization — a reorg — happens when two valid blocks are produced at nearly the same height and the network briefly disagrees about which is canonical, then converges on one and discards the other. Any transaction that lived only in the discarded block is unwound as if it never happened. Reorgs of a block or two are a normal, expected event on several chains; deep reorgs are rare but are exactly what the confirmation threshold exists to outlast.
This is the failure mode that catches naive integrations. If you credit a payment the instant you see it in a block and that block is then reorged away, you have shipped goods against money that no longer exists on-chain. halfin is built for this: crediting is reorg-aware. The platform does not treat a payment as final the moment it appears in a block — it waits for the per-chain threshold of confirmations, and if a reorg unwinds a transaction before that threshold is met, the unwind is reflected rather than ignored.
The webhook layer carries this honesty through to you. The lifecycle includes events for the awkward edges of confirmation: an invoice.late_deposit when a matching payment arrives after the invoice's window, and an invoice.deposit_reversed when a previously seen deposit is unwound by a reorg before it was final. You do not have to detect reorgs yourself or run your own chain monitor — you react to verified events, and the amount you see settled is an amount that held.
- A reorg can unwind a transaction that was already in a block — first-seen is never final.
- halfin waits for the per-chain threshold, so a shallow reorg can't trigger a false credit.
- invoice.late_deposit surfaces a payment that arrived after the invoice window.
- invoice.deposit_reversed surfaces a deposit unwound by a reorg before it was final.
The states a payment passes through while it confirms
From your application's point of view, an invoice does not jump from unpaid to paid. It moves through a confirming phase that you can show the customer and react to, so the wait feels deliberate instead of broken. Each transition is observable the same way across the dashboard, the REST API, and signed webhooks, so you are never guessing where a payment is in its climb to finality.
The model below is the same one halfin uses internally to decide when to credit. The key boundary is between confirming and paid: confirming means a real payment is on-chain but has not yet reached the chain's threshold, and paid means it has — under reorg-aware crediting — so it is safe to act on.
| State | What it means on-chain | What you show / do |
|---|---|---|
| Awaiting payment | Invoice is live and the rate is locked; nothing seen yet | Show address, amount, network, and the expiry countdown |
| Payment seen | A matching transaction is in the mempool or a recent block, not yet at threshold | Tell the customer it's in flight; do not release goods |
| Confirming (invoice.confirming) | Confirmations are accumulating toward the per-chain threshold | Keep the waiting state; crediting is reorg-aware and not yet final |
| Paid (invoice.paid) | Threshold met; the credit held through reorg-aware checks | Fulfil the order — this is the event you act on |
| Expired (invoice.expired) | The window elapsed before a sufficient confirmed payment arrived | Offer to re-issue at the current rate |
Don't poll for confirmations — react to the paid event
It is tempting to write a loop that reads the invoice every few seconds and counts confirmations yourself. Resist it. Polling either hammers the API to feel responsive or lags to be polite, and it pushes the reorg-and-threshold logic — the hard part — into your code. halfin already does that work and emits a signed webhook at each transition. Your job is to wait for invoice.confirming to drive a UI waiting state, and to fulfil on invoice.paid.
Treat the paid event as the green light and nothing earlier. invoice.confirming tells you a real payment is on its way through the threshold, which is perfect for telling the customer "we see your payment, hang tight" — but it is not authorization to ship. Only invoice.paid means the credit cleared the chain's confirmation threshold and survived reorg-aware checks. Wiring fulfilment to confirming instead of paid re-introduces exactly the reorg risk the threshold exists to remove.
And always verify the signature first. Recompute the HMAC over the raw request bytes with the endpoint's signing secret, compare it in constant time, and only then act. An unsigned or mismatched POST is not a halfin event — it must never move your order forward. The handler below does the minimum correct thing: verify, then branch on the event type, treating only paid as final.
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
const app = express();
const SIGNING_SECRET = process.env.HALFIN_WEBHOOK_SECRET!;
// Raw body — the HMAC must be computed over the exact bytes received.
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) // Buffer
.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");
}
const event = JSON.parse(req.body.toString("utf8"));
switch (event.type) {
case "invoice.confirming":
// Real payment seen, still climbing to the chain's threshold.
// Show "payment received, confirming" — DO NOT ship yet.
break;
case "invoice.paid":
// Threshold met under reorg-aware crediting. Safe to fulfil.
// fulfilOrder(event.data.invoice_id) — idempotently
break;
case "invoice.deposit_reversed":
// A previously seen deposit was unwound by a reorg. Roll back
// any optimistic state for this invoice.
break;
}
return res.status(200).send("ok");
},
);Set the right expectation for the customer
Most "why is my payment stuck?" support tickets are really a UX problem: the customer was never told the wait was normal. The fix is cheap. On the payment screen, state plainly that the network needs a short time to confirm and that the order completes automatically once it does. When you receive invoice.confirming, flip the screen to a confirming state — "we've seen your payment, finalizing on-chain" — so the customer knows the money arrived and the system is working, not frozen.
Let the rate lock and expiry carry the timing risk for you. The payable asset amount is fixed when the invoice activates and the invoice carries an expiry window, so a customer who pays inside the window settles at the amount they were shown — the confirmation wait does not re-price the invoice. If the window lapses before a confirmed payment lands, the invoice expires rather than crediting against a stale quote, and you re-issue. Pair that with a creation call that hands you a payable invoice in one request, and the only thing left to do is wait for the signed paid event.
# Create a fiat-anchored invoice; the customer settles in any supported asset.
# The confirmation wait is the chain's, not something you configure here.
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": "00000000-0000-4000-8000-000000000010"
}'
# halfin locks the rate at activation and returns a payable invoice. Track it
# via signed webhooks: invoice.confirming while it climbs the chain's
# threshold, invoice.paid once the credit is final under reorg-aware checks.
# See docs.thehalfin.com for the full response schema.