How to accept crypto payments without giving up custody
Most crypto payment gateways hold your money before passing it on. That single design choice is what makes freezes, holds, and 'account under review' emails possible. Here's how the non-custodial alternative actually works, end to end.
If you have ever read the terms of a crypto payment processor closely, you have probably noticed a gap between the marketing and the mechanics. The marketing says the money is yours. The mechanics say the money passes through an account the processor controls, and is forwarded to you on a schedule they set.
That gap is where freezes, holds, rolling reserves, and sudden account reviews live. Not because processors are malicious — mostly they are managing their own regulatory exposure — but because a custodian, by definition, can stop a payment. If your business depends on payments not stopping, the question worth asking is not which processor has the best policy. It is whether a custodian is involved at all.
What non-custodial actually means
Non-custodial means the payment never enters an account the gateway controls. The customer sends funds to an address, the settlement rules are enforced on-chain, and the money arrives in a wallet whose keys only you hold.
The practical test is simple: can the gateway freeze your funds if it decides to? If the answer is yes under any circumstance, it is custodial regardless of the wording on the homepage.
How a payment actually flows
Here is the sequence, with no steps hidden:
- 1Your backend creates a payment, specifying the amount, chain, token, and your own order ID. You get back a dedicated address for that one payment.
- 2You show the customer that address — as text, a QR code, or a hosted checkout page. They send an ordinary transfer from any wallet, or even withdraw straight from an exchange. No wallet connection, no contract call, no signature prompt.
- 3The gateway's nodes detect the transfer the moment it lands in a block.
- 4Settlement runs on-chain. If you configured splits, every recipient is paid in the same atomic transaction — if one leg fails, the whole thing reverts.
- 5A signed webhook hits your server. You verify the signature and fulfill the order.
The part worth dwelling on is step 1. A dedicated address per payment is what makes reconciliation trivial: there is a one-to-one mapping between an address and an order, so you never have to guess which incoming transfer belongs to which customer. It is also the part most teams underestimate when they consider building this themselves.
The part teams underestimate
Accepting a crypto transfer is easy. Accepting a payment is not. The difference is everything that has to be right before you can safely ship an order:
- Exact-amount matching. A customer who sends one cent less has not paid. You need to detect that, tell them what is still owed, and not ship in the meantime.
- Price locking. If the customer pays in a volatile asset, the quote has to hold for the life of the checkout or your margin moves under you.
- Reorg and double-spend awareness. Treating a transfer as final too early is how you ship goods for money that later disappears.
- Idempotency. Networks retry. Your order creation and your webhook handler both have to tolerate duplicates without double-charging or double-shipping.
- Delivery guarantees. If your server is down when the payment lands, the notification has to survive and retry, not vanish.
None of these are exotic. All of them are load-bearing, and each one is a place where a homegrown integration quietly loses money.
Verifying a webhook correctly
This is where most integrations go wrong, so it is worth showing rather than describing. The rule is: sign the raw bytes you received, never a re-serialized parse.
import crypto from "node:crypto";
// The raw body, not a parsed-and-restringified object.
app.post("/webhooks/finality",
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.header("X-Finality-Signature") ?? "";
const expected = crypto
.createHmac("sha256", process.env.FINALITY_WEBHOOK_SECRET)
.update(req.body) // Buffer of the exact bytes received
.digest("hex");
// Compare lengths first: timingSafeEqual THROWS on mismatched
// lengths rather than returning false.
const a = Buffer.from(sig);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(401);
}
const evt = JSON.parse(req.body.toString());
// Deduplicate — networks retry, and you will see repeats.
if (alreadyHandled(req.header("X-Finality-Event-Id"))) {
return res.sendStatus(200);
}
// Only settled money is money. Never fulfill on "detected".
if (evt.event === "payment.settled") {
fulfillOrder(evt.merchant_order_id);
}
res.sendStatus(200); // Return fast, then do slow work.
});Three details in that snippet are the ones that bite people: parsing before verifying breaks the signature, comparing with a plain equality check leaks timing information, and fulfilling on the wrong event ships goods before the money has settled.
What you give up
An honest guide has to include the trade-offs, because they are real and they disqualify some businesses.
| You give up | Why | Who this matters for |
|---|---|---|
| Fiat settlement | Converting to fiat requires money-transmitter licensing, which requires custody. A gateway cannot both hold your dollars and not hold your money. | Businesses that must book revenue in fiat on the same day |
| Chargeback arbitration | There is no custodian to reverse a settled payment. That also means no chargeback fraud — but no arbitration either. | Merchants who rely on a processor to mediate disputes |
| Card payments | Cards are inherently custodial and reversible. This is a different rail, not a replacement. | Anyone whose customers pay by card |
Refunds still exist — but they are yours to send, because the platform has no wallet to send from and no authority to reverse a settled transfer. In practice that is one ordinary payment from your wallet to your customer, on whatever terms the two of you agree. It is the direct consequence of the thing you wanted: nobody else can move your money.
Getting started
The fastest honest path is to rehearse the whole flow with a sandbox key before touching real money. Create a payment, force it to settle, and confirm your webhook handler did the right thing — all without moving a cent.
The quickstart walks through this in about five minutes, including the sandbox rehearsal.
Read the quickstart →