How to accept crypto payments without giving up custody

·8 min read

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.

Contents
  1. 1What non-custodial actually means
  2. 2How a payment actually flows
  3. 3The part teams underestimate
  4. 4Verifying a webhook correctly
  5. 5What you give up
  6. 6Getting started

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.

A useful way to think about it: custodial gateways ask you to trust a policy. Non-custodial ones remove the need for the policy, because the capability to freeze does not exist.

How a payment actually flows

Here is the sequence, with no steps hidden:

  1. 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.
  2. 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.
  3. 3The gateway's nodes detect the transfer the moment it lands in a block.
  4. 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.
  5. 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:

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.

Node.js — correct verification
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 upWhyWho this matters for
Fiat settlementConverting 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 arbitrationThere 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 paymentsCards 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 →
Related reading