Why a sandbox pass comes before live keys
A crypto payment flow has more moving parts than a card charge: an invoice with a locked rate and an expiry, an on-chain deposit that confirms over time, a signed webhook your backend has to verify, and a settlement that lands on a balance you can later pay out. A bug in any one of those is cheap to fix while you are testing and expensive once it is moving customer money. The sandbox exists so the first time your code runs the full loop, nothing on-chain is at stake.
The sandbox mirrors the production contract. The same REST endpoints under api.thehalfin.com/api/v1, the same request and response shapes, the same webhook envelope and event names — everything matches what you will see live. That is the point: you harden the integration against the real API surface, and when you flip to live keys, your code does not change. You are not testing against a stub that drifts from production behaviour.
Keep the two worlds strictly separate from the start. Sandbox access and test API keys come from the dashboard; live keys are a distinct set you create later. A test key must never be able to reach live funds, and a live key must never sit in your test config. Treat them as different credentials with different blast radii, store both in a secret manager, and never commit either to source control.
Step 1 — Get sandbox access and a test API key
Start in the merchant dashboard. Switch into the sandbox environment and create a test API key there. That key is what authenticates your sandbox requests, and it is scoped the same way a live key is — so this is also where you decide which permissions the integration actually needs. The service that creates invoices does not need to move money; the service that runs payouts does. Mint keys narrowly now and your live setup inherits the same discipline.
Authentication is one header. Every merchant request carries an X-API-Key header with your key; there is no separate login handshake for the API. Load the key from your environment, not a literal in code, so swapping sandbox for live later is a config change rather than a code change.
Pick your client. Raw curl or any HTTP library works against the REST API directly; if your backend is TypeScript, the @halfin/sdk-merchant package gives you a typed client over the same endpoints. The examples below use curl so the request shape is explicit, but every call maps one-to-one to an SDK method.
- Create the test key inside the dashboard's sandbox environment — not alongside live keys.
- Scope the key to what the integration does (invoicing vs. payouts), the same as you will live.
- Authenticate every request with the X-API-Key header; load the key from your secret store.
- Use raw REST or the typed @halfin/sdk-merchant client — both hit the same api.thehalfin.com/api/v1 surface.
Step 2 — Create your first sandbox invoice
The first thing to prove is that you can create a payable invoice. Post your fiat amount and currency, and halfin computes the payable asset amount and hands back an invoice you can present through hosted checkout or render yourself. Amounts are strings end to end — monetary values are never JSON floats — and the currency on a fiat-anchored request is your fiat anchor, not a crypto code.
There are two request shapes, and they are mutually exclusive. A fiat-anchored invoice carries amount_fiat and fiat_currency (with deferred when you want to let the customer pick the asset at pay time); halfin locks the rate at activation so the fiat figure you billed is the figure you reconcile. A fixed-asset invoice instead carries amount and currency, where currency is the crypto code — you are quoting an exact token amount. Send an idempotency_key on the create call so a retried request from your worker returns the existing invoice instead of issuing a second one.
Run both shapes in the sandbox at least once so you have seen the response for each. Store the returned invoice id against your own order, and note the hosted checkout URL on checkout.thehalfin.com if you plan to redirect customers there. The exact response schema lives in the API reference at docs.thehalfin.com; the point of this step is that one authenticated call gives you a payable invoice with no on-chain transfer involved yet.
# Fiat-anchored invoice — the customer picks the asset at pay time.
curl -X POST https://api.thehalfin.com/api/v1/invoices \
-H "X-API-Key: $HALFIN_TEST_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount_fiat": "49.00",
"fiat_currency": "USD",
"deferred": true,
"description": "Sandbox test — Pro plan",
"idempotency_key": "order-test-0001"
}'
# Fixed-asset variant — quote an exact crypto amount instead.
# The two shapes are mutually exclusive; currency here is a CRYPTO code:
# -d '{ "amount": "0.01", "currency": "BTC", "description": "Sandbox test" }'
#
# Response carries the invoice id and a hosted checkout URL on
# checkout.thehalfin.com. See docs.thehalfin.com for the full response schema.Step 3 — Drive the invoice through its lifecycle
Creating an invoice is the easy part; what you are really testing is how your code reacts as that invoice changes state. In the sandbox you exercise the full progression without an on-chain transfer mattering, which means you can deliberately hit the awkward middle cases — underpaid, overpaid, expired — that a naive paid/unpaid model ignores and that production will eventually throw at you.
Walk each state your integration cares about and confirm your backend does the right thing. The table below maps the canonical invoice events to what they mean and the action your handler should take. Note the live invoice event is invoice.confirming, not an 'activated' event — wire against the real names so your switch statement matches production exactly. Crediting is reorg-aware and respects each chain's confirmation threshold, so paid reflects a settlement halfin will stand behind, not a first-seen deposit.
Pay particular attention to the states that are easy to skip. An underpaid invoice has a real but insufficient payment recorded against it — your handler should hold the order and surface the shortfall, never extend access. An overpaid invoice records the surplus for refund or credit. An expired invoice means the rate-lock window closed before payment, and your code should re-issue rather than honour a stale quote. Rehearsing these now is what stops them becoming a production incident later.
| Invoice event | What it means | What your handler should do |
|---|---|---|
| invoice.confirming | A matching deposit is on-chain, confirmations accumulating toward the threshold | Show the customer it is in flight; do not fulfil yet |
| invoice.paid | Confirmed past the chain's threshold; the billed amount settled | Fulfil the order idempotently and reconcile against the fiat figure |
| invoice.underpaid | A real payment arrived but is below the amount due | Hold the order; record the shortfall for follow-up |
| invoice.overpaid | Settled with a surplus over the amount due | Fulfil and flag the surplus for refund or credit |
| invoice.expired | The rate-lock window closed before sufficient payment | Re-issue at the current rate if the customer still wants to pay |
Step 4 — Point a webhook endpoint at your test build and verify signatures
The redirect a customer returns to after paying can be missed — a phone wallet foregrounds, the browser tab is gone, the success page never loads. The reliable signal is the webhook: halfin POSTs your server an HMAC-signed event when an invoice changes state, independent of whatever the browser did. The sandbox is where you prove your handler treats that event as the source of truth and not the redirect.
Register a webhook endpoint and its signing secret from the dashboard, pointed at your test build — a tunnelled localhost URL is fine for development. Then test the one check that matters most: verify the HMAC signature over the raw request bytes, with a constant-time comparison, before you parse or act on anything. Compute the HMAC before any JSON middleware reserializes the body, because a re-encoded body changes whitespace and key order and will fail an otherwise-valid signature. The most valuable thing you can do in the sandbox is the negative test: flip one byte in a captured event body, replay it, and confirm your endpoint returns a 4xx and does nothing. If a tampered event can extend a plan, you have found the bug here instead of in production.
Delivery is at least once, so make your handler idempotent. The event id is stable across redeliveries; record processed ids and make the side effect a no-op the second time you see one. Acknowledge fast with a 2xx and push slow work — fulfilment, email, ledger writes — onto a queue, because a slow synchronous handler risks a timeout that halfin reads as a failed delivery and retries. Test all three properties in the sandbox: verify, dedupe, and acknowledge quickly.
- Verify the HMAC over the raw bytes with a constant-time compare before parsing — always.
- Run the negative test: tamper one byte, replay, and confirm a 4xx with no side effect.
- Dedupe on the stable event id so a redelivered event is a no-op.
- Return 2xx fast and defer fulfilment, email, and ledger writes to a background queue.
- Key entitlement off the signed event, never off the success redirect.
Step 5 — Rehearse a payout before you send live money
If your integration sends money out — affiliate payments, marketplace settlements, refunds to a wallet — rehearse that path in the sandbox too, because a payout bug spends real funds in a direction you cannot easily reverse. A single payout is one approved transfer to one destination; a mass payout fans many lines out over the same POST /api/v1/payouts endpoint, each line carrying its own currency, amount, destination, and idempotency_key. There is no separate batch endpoint — you submit the lines and halfin executes them.
The per-line idempotency_key is the property to test hardest. It is what makes a retried or duplicated submission safe: re-sending a run settles only the lines that did not already go out, so a worker that runs twice never double-pays a payee. In the sandbox, deliberately submit the same run twice with the same keys and confirm the second submission is a no-op for the already-sent lines. Hold a payouts-scoped key separate from your invoicing key while you do this, so you rehearse the real privilege separation rather than testing everything with one all-powerful credential.
Reconcile payout outcomes through the same signed-webhook stream you already wired in Step 4. The terminal payout events are payout.completed and payout.failed — handle both in the sandbox so your reconciliation logic has seen a failure, not just the happy path. The mental model is symmetric: invoices and webhooks bring money in, payouts and webhooks send it out, and both halves close the loop through the same verified event spine.
# Mass payout — fan lines out over POST /api/v1/payouts (no batch endpoint).
# Each line carries its own idempotency_key; resubmitting settles only
# the lines that did not already go out, so a retried run never double-pays.
curl -X POST https://api.thehalfin.com/api/v1/payouts \
-H "X-API-Key: $HALFIN_TEST_PAYOUTS_KEY" \
-H "Content-Type: application/json" \
-d '{
"payouts": [
{
"currency": "USDT",
"amount": "25.00",
"destination": "TXsandboxDestinationAddressOne",
"idempotency_key": "affiliate-9001-2026-06"
},
{
"currency": "USDT",
"amount": "40.00",
"destination": "TXsandboxDestinationAddressTwo",
"idempotency_key": "affiliate-9002-2026-06"
}
]
}'
# Reconcile via payout.completed / payout.failed webhooks. Field names and
# the full request schema live in the reference at docs.thehalfin.com.Step 6 — Run the go-live checklist and switch keys
Going live is not a rewrite — it is a key swap plus a short audit. Because the sandbox and production contracts are identical, the only thing your code loads differently is the credential. The checklist below is what you confirm before you point live keys at production, so the switch is boring rather than nerve-wracking.
Keep the two environments separated by configuration, not by editing code. The same handler that passed every sandbox test should run in production unchanged, reading a live key from the same secret-store slot a test key used in development. If anything other than the key has to change to go live, that difference is a place a sandbox test did not cover — close it before you flip.
| Before you flip to live | Confirmed in sandbox? |
|---|---|
| Invoice create returns a payable invoice for both request shapes | Step 2 |
| Handler reacts correctly to confirming, paid, underpaid, overpaid, expired | Step 3 |
| HMAC signature verified over raw bytes before any business action | Step 4 |
| Tampered event is rejected with a 4xx and no side effect | Step 4 |
| Webhook handler is idempotent on the stable event id | Step 4 |
| Payout idempotency_key prevents a double-pay on resubmission | Step 5 |
| payout.completed and payout.failed both handled | Step 5 |
| Live and test keys are distinct, scoped, and held in a secret manager | Steps 1, 5 |