What a crypto payment gateway actually does for you
Before the steps, the shape of the thing. A crypto payment gateway sits between your application and a set of blockchains. You ask it to bill a customer; it gives you an address, an amount, and an expiry. The customer pays on-chain; the gateway watches the network, waits for enough confirmations, and tells your backend the money settled. You never run a node, hold a private key, or parse a block — that work lives behind the API.
halfin is that layer for the chains and assets where its gates actually run: Bitcoin, Ethereum and ERC-20 tokens, the EVM L2s Base, Arbitrum and Polygon, BNB Smart Chain, Tron (TRC-20), the XRP Ledger, and Solana (SOL and SPL). Stablecoins are the common case — USDT on Tron, Ethereum, and Solana; USDC on Ethereum, Base, and Solana. You price in the fiat you already think in, the customer pays in the asset they hold, and the gateway reconciles the two.
The setup below uses three surfaces: the merchant dashboard at dashboard.thehalfin.com for access, verification, keys, and webhooks; the REST API at api.thehalfin.com/api/v1 for programmatic calls; and the API reference at docs.thehalfin.com for the field-level schemas this guide summarizes but does not reproduce in full.
Step 1 — Request access and get a sandbox
Start in a sandbox, never against live funds. Contact the team at thehalfin.com/contact to start onboarding and get access — access is not self-serve. Once you are set up, the sandbox is where you build and break things without real money moving. Everything you do for the rest of this guide — keys, an invoice, a webhook, a test payment — happens here first, and the live switch in Step 6 is the same integration pointed at production credentials.
Treat the sandbox as a faithful rehearsal of production, not a toy. The invoice lifecycle, the webhook envelope, the signature scheme, and the per-chain confirmation behavior all match live. What differs is that you are paying test invoices on test rails rather than moving customer money, so you can exercise the awkward paths — an underpayment, an expiry, a redelivered webhook — before any of them can cost you a real order.
Once you are in, you have an operator UI for the whole flow. You can create invoices by hand, watch them resolve, configure webhook endpoints, and manage API keys. The dashboard and the API operate on the same objects, so anything you do by clicking, you can later do by calling — which is exactly the progression this guide follows.
Step 2 — Complete KYB so you can transact
Before a payment gateway will settle real money to you, it has to know who you are. halfin onboarding includes KYB — know-your-business verification — where you supply the details of the legal entity behind the account. This is a process step, not a certification you receive: it is how the platform satisfies its anti-money-laundering obligations and how the line between your business and the payment rail stays auditable.
Do this early. You can build and test the entire integration in the sandbox before KYB clears, but settling live funds depends on it, so starting verification while you write code means the two finish around the same time rather than KYB becoming a launch-day surprise. Have the business documentation ready when you begin; the dashboard walks you through what is needed.
Keep the responsibilities straight while you are here. halfin provides the payment infrastructure with KYB onboarding and AML-aware handling; your business still owns its own customer relationships, its own records, and any obligations specific to where and to whom it sells. None of this is legal or tax advice, and none of it makes your product regulated or licensed by association — it is a process boundary, and a clean one.
Step 3 — Mint a scoped API key (and keep it scoped)
Programmatic access runs on API keys, and the discipline that matters from the first key is scope. A key carries permissions; give each one only the permissions the service using it needs. The service that creates invoices for your storefront needs an invoicing-capable key. A reporting job that only reads data gets a read-only key. The worker that sends money out — if you ever build one — holds a payouts-scoped key, separate from everything else, so a leaked reporting credential can never move funds.
Create the key in the dashboard and store it like any other secret: in your secret manager, injected as an environment variable at runtime, never committed to source control. You authenticate REST calls by sending it in the X-API-Key header. The table below is the mental model to set up keys against — match a key to a job, not a job to a key.
The same model gives you a clean revocation story. Because each service holds its own narrow key, rotating or revoking one when you suspect exposure takes down exactly that integration and nothing else. A single all-powerful key is the credential you least want to have to rotate under pressure.
| Service in your stack | Key scope | Why it is separate |
|---|---|---|
| Storefront / billing backend | Invoicing | Creates invoices; cannot read payout history or move money |
| Analytics / reporting job | Read-only | Reads invoices and balances; can never create or pay anything |
| Payout worker (if you send money out) | Payouts | The only key that can move funds; isolated from billing and reporting |
Step 4 — Create your first invoice
Now prove the path. The core object is an invoice, and the cleanest first call is a fiat-anchored one: you state the amount in the fiat you bill in, and halfin computes the payable crypto amount and locks the rate when the invoice activates. The customer pays the dollar (or euro) value even if the asset's price moves while they have the checkout open. Send the amount as a string — monetary values are strings end to end, never floats — and set fiat_currency to your fiat anchor.
The curl below creates a fiat-anchored invoice against the public API. Note the request shape carefully, because the two most common mistakes both live here. The fiat amount goes in amount_fiat with its currency in fiat_currency — you do not put a fiat code in a currency field. The idempotency_key field in the request body makes a retried create return the existing invoice instead of billing the customer twice. The response carries the invoice id and a hosted checkout URL on checkout.thehalfin.com; store the id, and present the URL to the customer.
If you instead want to bill a fixed amount of a specific asset — say exactly 0.01 BTC rather than a dollar figure — you send amount with currency set to the crypto code (BTC, USDT, USDC, ETH, SOL, and so on). That is the fixed-asset shape. For a gateway that prices in fiat, the fiat-anchored call above is the one you will reach for almost every time; the fixed-asset form is there for the cases where the asset amount is the thing that is contractually fixed.
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",
"description": "Order #10482",
"idempotency_key": "order_10482"
}'
# halfin locks the rate at activation, pins the payable asset amount, and
# returns an invoice id plus a hosted checkout URL on checkout.thehalfin.com.
# Store the id, send the customer the URL, and wait for the signed webhook
# in Step 5. The full request and response schema is at docs.thehalfin.com.Step 5 — Register a webhook and verify the signature
An invoice you cannot hear about is half a gateway. Crypto payments are asynchronous — the customer broadcasts a transaction, the network confirms it over some blocks, and only then is the invoice paid. None of that happens inside your application. The webhook is how your backend learns the money arrived: register an endpoint URL in the dashboard, and halfin POSTs an HMAC-signed event to it every time an invoice changes state.
Verify before you trust. Your endpoint URL is reachable by anyone the moment it exists, so the signature is the only thing separating a real halfin event from forged JSON. On receipt, recompute the HMAC over the exact raw request bytes using your endpoint's signing secret, compare it to the signature header with a constant-time comparison, and only then parse the body and act. Compute the HMAC before any framework middleware reserializes the body — a re-encoded payload changes whitespace and key order and will fail an otherwise-valid signature. The handler below does exactly that, then routes on the event type.
Build the handler to be idempotent from day one. Delivery is at least once, so the same paid event can arrive twice with the same stable event id; dedupe on that id and make the side effect a no-op the second time, or you will fulfill an order or credit a balance twice. Acknowledge fast with a 2xx and push slow work — fulfillment, email, ledger writes — onto a queue, because a slow handler reads as a failed delivery and gets retried.
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
const app = express();
const SIGNING_SECRET = process.env.HALFIN_WEBHOOK_SECRET!;
// Capture the RAW body — the HMAC must be computed over the exact bytes
// received, not over a re-serialized JSON object.
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) // req.body is a Buffer here
.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");
}
// Only now is it safe to parse and act.
const event = JSON.parse(req.body.toString("utf8"));
switch (event.type) {
case "invoice.paid":
// fulfill the order tied to event.data.id (idempotent — dedupe on event id)
break;
case "invoice.underpaid":
// hold the order; surface the shortfall for follow-up
break;
case "invoice.expired":
// cancel; let the customer start a fresh invoice
break;
}
// Acknowledge fast; defer slow work to a queue.
return res.status(200).send("ok");
},
);The invoice states your gateway has to handle
Your webhook handler is only finished when it answers for the states a real payment can land in, not just the happy path. A naive integration treats anything that is not an exact, instant payment as a failure; a gateway treats each state as a defined outcome. The events below are the canonical invoice events — note that there is no separate "activated" event, and that crediting is reorg-aware and waits for the chain's confirmation threshold before an invoice is reported paid.
The middle rows are the ones that separate a toy from a gateway. An underpayment is a real on-chain payment that came up short — often because the customer paid the network fee out of the same amount — and the invoice records the shortfall so you can ask for the remainder or void it. An overpayment records the excess. A late deposit or a reversed deposit can arrive after the fact; both are first-class events precisely so your finance flow learns about them from a signal rather than from a reconciliation report a week later.
| Invoice event | What it means | What your handler does |
|---|---|---|
| invoice.confirming | A matching deposit is on-chain, confirmations accumulating toward the threshold | Tell the customer it is in flight; do not release goods yet |
| invoice.paid | Confirmed past the chain's threshold; the billed fiat amount settled | Fulfill the order, idempotently |
| invoice.underpaid | A real payment arrived but below the amount due | Hold the order; request the remainder or void the cycle |
| invoice.overpaid | Settled with a surplus over the amount due | Fulfill and flag the surplus for refund or credit |
| invoice.expired | The payment window closed before sufficient payment arrived | Cancel; let the customer start a fresh invoice at the current rate |
| invoice.late_deposit / invoice.deposit_reversed | Funds arrived after expiry, or a credited deposit was unwound by a reorg | Reconcile against the affected order; adjust fulfillment or balance |
Step 6 — Test end to end, then go live
Before you flip the switch, run the whole path once in the sandbox. Create an invoice with the call from Step 4, pay it on the test rail, and watch your endpoint receive the confirming and paid events. Then deliberately break things: flip a byte in a webhook body and confirm your handler returns a 4xx instead of acting; let an invoice's window lapse and confirm you get invoice.expired; pay one short and confirm invoice.underpaid lands and your order stays unfulfilled. A gateway that only handles the happy path is not done.
Going live is a credential swap, not a rewrite. The same integration — same create call, same webhook handler, same signature check — points at a live API key and a live webhook signing secret instead of the sandbox ones. Because keys and secrets are read from your secret manager at runtime, promotion is configuration, and the code that passed your sandbox tests is the code that runs in production. The checklist below is the gate.
After launch, keep an eye on webhook delivery the way you watch any critical dependency. Log the event id and the verification result for every request, so when finance asks why an order did not fulfill you can point to the exact event and whether it was received, verified, and acted on. An endpoint that starts returning non-2xx will pile up redeliveries; catching that early is the difference between a blip and a backlog.
- KYB is complete, so live settlement is unblocked.
- Live API key minted with the narrowest scope the service needs; stored in your secret manager.
- Live webhook endpoint registered with its own signing secret; signature verification tested against a genuine and a tampered event.
- Handler is idempotent on the stable event id and acknowledges with a fast 2xx.
- Underpaid, overpaid, and expired paths each tested in the sandbox before launch.
- The only thing that changed from sandbox to production is credentials — not code.