← halfin journalMar 25, 2026 · 10 min read
Engineering

A sandbox-first testing playbook: prove the lifecycle before you flip to live

Simulate the full invoice and payout lifecycle with test keys, drive the real webhook events, assert idempotency under replay, then promote. What we want green before a single mainnet cent moves.

RA
R. AdeyemiPayments Engineering
engineering · cover

The integration that goes live cleanly is never the one that "worked once." It's the one that survived a replay, an expiry, and an underpayment in sandbox first.

Most payment integrations fail in the same place: the happy path passed, the team shipped, and then a duplicate webhook double-credited an order on a Tuesday. Crypto makes this sharper, because the events you most need to handle — invoice.underpaid, invoice.late_deposit, invoice.deposit_reversed — are exactly the ones a quick demo never produces.

So we test them on purpose. This is the order we run things in, with a sk_test_ key, before any sk_live_ key touches the code.

Why sandbox-first, not sandbox-also

Sandbox is a full mirror of production: same API surface, same event names, same signature scheme, same status machine. The only difference is that nothing settles on a real chain. That property is the whole point — you can manufacture states that mainnet would charge you (and your patience) to reproduce.

A test key is the boundary. Everything keyed sk_test_ lives in its own world; flipping to sk_live_ is the promotion event, not a config tweak you make halfway through. If you find yourself reaching for a live key to "just check one thing," stop — that one thing belongs in sandbox too. The developer overview walks through where the keys live and how the environments separate.

Step 1 — Invoice lifecycle, every branch

Start with invoices, because they drive the most state. Create one with a sk_test_ key, and remember the request canon: the only headers you send are X-API-Key and Content-Type. There is no Idempotency-Key header — idempotency is the idempotency_key field in the JSON body. Get that wrong in sandbox and you'll get it wrong in production.

Drive each branch deliberately:

  • Paid clean. Confirm invoice.confirming arrives first, then invoice.paid. Your handler should treat confirming as "seen, not final" and only release goods on paid.
  • Overpaid. Pay more than the invoice asks. You get invoice.overpaid. Decide now whether that credits a balance, triggers a refund, or pages a human. Decide it in code, not in a support ticket later.
  • Underpaid. Pay less. invoice.underpaid fires. The order is not complete. The most common production bug we see is treating any deposit event as "done."
  • Expired. Let the clock run out. invoice.expired arrives. The deposit window closed; a late payer must not silently fulfill an order.
  • Late deposit. Pay after expiry. invoice.late_deposit is the event that exists precisely because chains don't respect your timeout. Have an answer for it.

Note the events that do not exist, so you don't write handlers for ghosts: there is no invoice.activated. The real set is invoice.confirming, invoice.paid, invoice.overpaid, invoice.underpaid, invoice.expired, invoice.late_deposit, invoice.deposit_reversed. Write to that list, not to one you imagined.

For a fiat-anchored invoice, sandbox is also where you confirm the rate-lock behaves the way you expect: the amount is quoted against fiat_currency, locked when the invoice activates, and the customer pays the crypto amount that lock implies. Watch one expire and re-create to feel how the lock window moves.

Step 2 — Webhooks: verify, then replay against yourself

Point the sandbox webhook at your real receiver — not a logging stub. The receiver has one non-negotiable first move: verify the HMAC over the raw request bytes before parsing anything. Parse-then-verify is how forged events get in. We covered the failure mode in detail in verify the signature before you act; the short version is that the bytes you verify must be the bytes you received, byte-for-byte.

Once verification is solid, attack your own handler:

  1. Deliver twice. Replay the same event. A correct handler no-ops the second time. If it double-credits, you found the bug in sandbox, which is the only acceptable place to find it.
  2. Deliver out of order. Send paid before confirming. State machines that assume strict ordering break here.
  3. Corrupt the signature. Flip one byte. The handler must reject, not "fail open."

If your receiver stays correct under duplicate, reorder, and corruption, the integration is real. If it only handles the clean single delivery, it isn't tested — it's demoed.

Step 3 — Assert idempotency, don't assume it

Idempotency is two-sided, and sandbox is where you prove both sides.

Outbound (your requests to us). Send the same create-invoice or create-payout call twice with the same idempotency key in the body. You get the same resource back, not two. Then send the same key with a different body — and confirm you get an error, not a silent overwrite. That second test is the one teams skip and regret.

Inbound (our events to you). This is the duplicate-delivery test from Step 2, framed as a property: processing the same event_id twice must leave your system in the same state as processing it once. Store the IDs you've handled; skip the ones you've seen.

These two assertions are cheap to write and they are the difference between "we think it's safe to retry" and "we know it is."

Step 4 — Payouts and the approval gate

Single and mass payouts have a wrinkle that surprises people in production if they didn't meet it in sandbox: a created payout enters pending approval. It is not sent the instant your API call returns. Release happens from the dashboard.

So your sandbox run must cover the full arc:

  • Create payouts (mass payouts fan out as many POST /api/v1/payouts calls — there is no batch endpoint, so your client loops, and every call carries its own idempotency_key).
  • See them sit in pending approval.
  • Release from the dashboard.
  • Receive payout.completed on success and payout.failed on the ones that don't land, and handle both. A payout.failed that your code ignores is money you think went out but didn't.

If your reconciliation logic assumes a payout is final at create-time, sandbox is where that assumption gets corrected — for free.

Step 5 — Pick the rails you'll actually run

Sandbox lets you exercise each chain's quirks without paying for them. Confirmations differ per chain, and reorg handling matters. Run a paid invoice on at least the rails you'll support in production — if that includes Base or Tron or Solana, prove the confirmation and credit timing there, not just on whatever was easiest to test. The networks overview lists what's live.

Then, and only then, flip

When every branch above is green — lifecycle, signed-and-replayed webhooks, two-sided idempotency, the payout approval arc, your real rails — promotion is the small step it should be: swap sk_test_ for sk_live_, repoint the webhook, and watch the first real invoice with the same care. The cutover itself has its own short list, which we keep in the sandbox-to-production checklist.

The discipline isn't "test in sandbox." Everyone says that. The discipline is manufacturing the unhappy paths on purpose — the underpayment, the late deposit, the duplicate delivery, the failed payout — and asserting your system stays correct through each one. Do that with a test key, and the live key is boring. Boring is the goal.

R. Adeyemi, halfin payments engineering

↳ end of articlehalfin journal · Mar 25, 2026