Card payments come with test card numbers that fail on demand. Crypto payments don't. The money moves on a public network, the confirmation arrives from someone else's server, and the interesting failures (a duplicate callback, a payment that lands after the invoice expired, a webhook that never shows up) are exactly the ones you can't trigger by clicking around a checkout page.
So most teams test the happy path once, see an order turn green, and ship. The edge cases get tested in production by customers.
This guide sets up a local workflow that covers both halves. Sandbox mode and a tunnel prove that your real integration talks to the real provider correctly. A small replay script then lets you fire any webhook you want at your own server, correctly signed, as many times and in whatever order you like. The examples use Node.js 20 with Express and the OxaPay API, but the approach carries over to any stack and any provider that signs its webhooks with an HMAC.
What sandbox mode is for
The Generate Invoice endpoint takes a sandbox boolean. Set it to true and the invoice is created in the test environment, so no real funds are involved. Here is the client we'll use throughout, with the flag driven by an environment variable so it can never be left on by accident in production config:
// src/oxapay.js
const API = 'https://api.oxapay.com/v1';
export async function createInvoice({ amount, orderId, callbackUrl, returnUrl }) {
const res = await fetch(`${API}/payment/invoice`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
merchant_api_key: process.env.OXAPAY_MERCHANT_KEY,
},
body: JSON.stringify({
amount,
currency: 'USDT',
lifetime: 60,
order_id: orderId,
callback_url: callbackUrl,
return_url: returnUrl,
sandbox: process.env.OXAPAY_SANDBOX === 'true',
}),
});
const body = await res.json();
if (!res.ok) {
throw new Error(body?.message ?? `Invoice request failed with ${res.status}`);
}
return body.data; // { track_id, payment_url, expired_at, date }
}
# .env
OXAPAY_MERCHANT_KEY=your_merchant_api_key
OXAPAY_SANDBOX=true
PUBLIC_URL=https://your-tunnel-url.example
Node 20.6 and later read this file natively with node --env-file=.env, so there's no need for dotenv.
Sandbox is good at answering one question: does my real integration talk to the real provider correctly? It catches a wrong key, a malformed request, a callback URL your server never receives, a redirect that drops the customer somewhere unexpected. That's worth a lot, and it's the only part of testing that has to involve the provider at all.
Sandbox invoices go through the full flow. You can simulate the payment on the invoice page without moving real funds, and the provider sends real webhooks to your callback_url, paying first and then paid. What sandbox doesn't give you is control over the awkward cases. There's no documented way to make a sandbox invoice end up underpaid, deliver the same webhook twice, or send paying after paid, and those are the cases that actually break integrations. One more limit: the flag applies to invoice creation, and lookups such as Payment Information are separate calls, so check how they behave for sandbox invoices before your tests depend on them.
So we split the work. Sandbox and a tunnel prove the plumbing once. Everything after that, we drive ourselves.
Get real webhooks to your laptop
The provider won't deliver callbacks to localhost or any private address, so a webhook from a sandbox invoice has nowhere to go until you give it a public URL. Do this in two steps.
First, look at a real payload before you write code against it. Create one sandbox invoice with callback_url pointing at a request bin such as webhook.site, which the webhook documentation itself suggests for this. Complete the simulated payment and read what arrives: the body, the field names, the casing of status, and the HMAC header. Ten minutes here saves you from writing a parser for the payload you imagined instead of the one you get. Request bins are public, so use them with sandbox invoices only.
Then, point the callback at your own machine. Run your app and open a tunnel to it:
npm start # Express on port 3000
ngrok http 3000 # or: cloudflared tunnel --url http://localhost:3000
Put the HTTPS URL the tunnel prints into PUBLIC_URL, restart the app, and create an invoice through your own checkout route:
// src/app.js (excerpt)
app.post('/orders/:id/pay', async (req, res, next) => {
const order = orders.get(req.params.id);
if (!order || order.status !== 'pending') {
return res.status(409).send('Order is not payable');
}
try {
const invoice = await createInvoice({
amount: Number(order.total),
orderId: order.id,
callbackUrl: `${process.env.PUBLIC_URL}/webhooks/oxapay`,
returnUrl: `${process.env.PUBLIC_URL}/orders/${order.id}`,
});
payments.set(String(invoice.track_id), {
trackId: String(invoice.track_id),
orderId: order.id,
amount: order.total,
currency: 'USDT',
status: 'new',
});
res.redirect(303, invoice.payment_url);
} catch (err) {
next(err);
}
});
Three things about tunnels catch people out.
Free tunnel URLs usually change every time you restart the tunnel. The callback URL is baked into each invoice when it's created, so an invoice from yesterday's session will keep sending webhooks to a URL that no longer exists. When a callback seems to vanish, check which URL that invoice was created with before you debug anything else.
ngrok's local inspector at http://127.0.0.1:4040 shows every request that came through, with headers and body, and has a Replay button. Replaying a real delivery is the quickest way to see what your handler does with an exact duplicate.
Your endpoint has to return HTTP 200 with the body ok. Anything else is treated as a failed delivery and retried up to five times, roughly 1 minute, 3 minutes, 30 minutes and 3 hours apart. You can watch this happen: stop your app, let a webhook fail, start it again and see the retry arrive in the inspector. It's the cheapest way to confirm that your handler copes with a payment it first hears about late.
Replay signed webhooks yourself
Once the plumbing works, you no longer need the provider to send you anything. A webhook is a JSON body plus an HMAC header containing the HMAC-SHA512 of that exact body, keyed with your merchant API key. You have the key, so you can produce valid webhooks for any status, in any order, as often as you like.
First, the receiving side. The handler verifies the signature against the raw bytes, stores each distinct body once, and applies it:
// src/oxapay.js (continued)
import { createHmac, timingSafeEqual } from 'node:crypto';
export function hasValidSignature(rawBody, receivedHmac, merchantKey) {
if (!receivedHmac || !merchantKey) return false;
const expected = createHmac('sha512', merchantKey).update(rawBody).digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(String(receivedHmac), 'utf8');
return a.length === b.length && timingSafeEqual(a, b);
}
// src/app.js (excerpt)
// express.raw keeps the exact bytes the signature was computed over.
app.post('/webhooks/oxapay', express.raw({ type: 'application/json' }), (req, res) => {
const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
if (!hasValidSignature(raw, req.get('HMAC'), process.env.OXAPAY_MERCHANT_KEY)) {
log.warn('Rejected webhook with a missing or invalid signature');
return res.status(400).send('invalid signature');
}
const hash = createHash('sha256').update(raw).digest('hex');
if (!events.has(hash)) {
const data = JSON.parse(raw.toString('utf8'));
events.set(hash, data);
applyEvent(data, log);
}
res.send('ok');
});
The one mistake that wastes the most time here is letting a JSON body parser run before the signature check. express.json() gives you a parsed object, and serialising it back with JSON.stringify won't reproduce the original bytes: key order, spacing and number formatting can all differ. The signature is over the bytes, so verify the bytes. timingSafeEqual also throws if the two buffers differ in length, which is why the length check comes first.
applyEvent holds the business rules: a transition table so a status can only move forward, and an exact amount check before anything is marked paid. It's the same model as in a typical production setup, kept small here so the tests stay readable. In a real app this is where you'd hand off to a queue instead of working inside the request.
Now the sending side. This script signs a payload with your key and posts it to your server:
// scripts/replay.js
// node --env-file=.env scripts/replay.js paid --track 184747701 --order 1001 --times 2
import { createHmac } from 'node:crypto';
import { parseArgs } from 'node:util';
const { values, positionals } = parseArgs({
allowPositionals: true,
options: {
track: { type: 'string', default: '184747701' },
order: { type: 'string', default: '1001' },
amount: { type: 'string', default: '49.99' },
currency: { type: 'string', default: 'USDT' },
times: { type: 'string', default: '1' },
'bad-signature': { type: 'boolean', default: false },
url: { type: 'string', default: 'http://localhost:3000/webhooks/oxapay' },
},
});
const status = positionals[0] ?? 'paid';
const body = JSON.stringify({
type: 'invoice',
track_id: values.track,
status,
order_id: values.order,
amount: Number(values.amount),
currency: values.currency,
date: Math.floor(Date.now() / 1000),
});
const key = values['bad-signature'] ? 'not-the-real-key' : process.env.OXAPAY_MERCHANT_KEY;
const hmac = createHmac('sha512', key).update(body).digest('hex');
for (let i = 1; i <= Number(values.times); i++) {
const res = await fetch(values.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', HMAC: hmac },
body,
});
console.log(`${status} #${i}: ${res.status} ${await res.text()}`);
}
Use the track_id from a sandbox invoice you created earlier, so your local data has a matching payment. A typical session looks like this:
$ node --env-file=.env scripts/replay.js paying
paying #1: 200 ok
$ node --env-file=.env scripts/replay.js paid --times 2
paid #1: 200 ok
paid #2: 200 ok
$ node --env-file=.env scripts/replay.js paying
paying #1: 200 ok
$ node --env-file=.env scripts/replay.js paid --bad-signature
paid #1: 400 invalid signature
Every response is ok except the forged one, which is what the provider needs to see. The interesting part is your own state afterwards: the order should be paid exactly once, and the late paying should have changed nothing.
Notice the date field. Two sends inside one --times loop carry an identical body, so they test exact-duplicate protection. Two separate runs carry different timestamps, so they test the harder case: a different payload that describes the same change. Your handler needs to survive both.
The edge cases worth testing
These are the scenarios that show up in real support queues. Each one takes a single replay command to reproduce:
| Scenario | How to simulate it | What should happen |
|---|---|---|
| Exact duplicate delivery | replay.js paid --times 2 |
Stored once, order fulfilled once |
| Retry with a different body |
replay.js paid, then run it again |
Second event stored, but nothing changes |
paying arrives after paid
|
replay.js paid, then replay.js paying
|
Payment stays paid
|
| Forged or corrupted request | replay.js paid --bad-signature |
HTTP 400, nothing stored |
| Paid amount doesn't match | replay.js paid --amount 10 |
Not fulfilled, flagged for review |
| Partial payment | replay.js underpaid |
Order stays pending, customer and support notified |
| Money arrives after expiry |
replay.js expired, then replay.js paid
|
Order paid, flagged for a human to look at |
Unknown track_id
|
replay.js paid --track 999 |
Logged, answered with ok
|
| Your server was down | Stop the app during a sandbox payment, then restart | The retry arrives and is processed normally |
Two rows deserve a word.
An unknown track_id still gets ok. If you answer with an error, the provider retries for hours about a payment you'll never recognise, and your logs fill with noise. Log it loudly, return ok, and investigate from the log.
Payment after expiry is a judgment call. Blockchain transactions don't respect your invoice timer, so a customer can send funds at minute 59 that confirm at minute 62. Rejecting that payment means holding a customer's money against an order you've closed. Most merchants would rather accept it and review it, and the rules below do exactly that.
Here are the rules all those rows are testing:
// src/payments.js
import { orders, payments } from './store.js';
const SETTLED = new Set(['paid', 'manual_accept']);
const TRANSITIONS = {
new: ['waiting', 'paying', 'paid', 'underpaid', 'expired'],
waiting: ['paying', 'paid', 'underpaid', 'expired'],
paying: ['paid', 'underpaid', 'expired'],
underpaid: ['paid', 'manual_accept', 'expired'],
expired: ['paid', 'manual_accept'],
paid: ['refunding', 'refunded'],
manual_accept: ['refunding', 'refunded'],
refunding: ['refunded'],
refunded: [],
};
// Compare decimal strings exactly, without going through floats.
function toUnits(value, scale = 8) {
const [int, frac = ''] = String(value).split('.');
return BigInt(int + frac.padEnd(scale, '0').slice(0, scale));
}
export function applyEvent(data, log = console) {
const payment = payments.get(String(data.track_id));
if (!payment) {
log.warn(`Webhook for unknown track_id ${data.track_id}`);
return 'unknown';
}
const next = String(data.status ?? '').toLowerCase();
if (next === payment.status || !TRANSITIONS[payment.status]?.includes(next)) {
return 'ignored'; // duplicate or out of order, e.g. 'paying' after 'paid'
}
if (SETTLED.has(next)) {
const matches =
String(data.order_id) === String(payment.orderId) &&
String(data.currency).toUpperCase() === payment.currency &&
toUnits(data.amount) === toUnits(payment.amount);
if (!matches) {
log.error(`Paid webhook for ${payment.trackId} does not match the local payment`);
return 'needs_review';
}
}
const previous = payment.status;
payment.status = next;
const order = orders.get(payment.orderId);
if (SETTLED.has(next) && order.status === 'pending') {
order.status = 'paid';
if (previous === 'expired') {
log.warn(`Order ${order.id} was paid after its invoice expired`);
}
return 'fulfilled';
}
return 'updated';
}
The statuses come from the provider's payment status table. toUnits exists because 49.99 === 49.99 is true in JavaScript but arithmetic on floats isn't, and an amount check is exactly where you don't want to discover that. Scaling both values to integer units with BigInt makes the comparison exact.
Turn the table into automated tests
The replay script is for poking at a running app. The same idea, signing your own payloads, turns every row of that table into a test that runs in under a second with Node's built-in test runner and no extra dependencies.
The examples keep state in a tiny module so they stay short. Swap the Maps for real tables later; nothing around them changes:
// src/store.js
export const orders = new Map(); // orderId -> { id, status, total }
export const payments = new Map(); // trackId -> { trackId, orderId, amount, currency, status }
export const events = new Map(); // sha256 of raw body -> payload
export function reset() {
orders.clear();
payments.clear();
events.clear();
}
// test/webhooks.test.js
import { test, beforeEach, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { createHmac } from 'node:crypto';
import { createApp } from '../src/app.js';
import { events, orders, payments, reset } from '../src/store.js';
process.env.OXAPAY_MERCHANT_KEY = 'test-merchant-key';
const quiet = { info() {}, warn() {}, error() {} };
let server;
let url;
before(async () => {
server = createApp({ log: quiet }).listen(0);
await new Promise((resolve) => server.once('listening', resolve));
url = `http://127.0.0.1:${server.address().port}/webhooks/oxapay`;
});
after(() => server.close());
beforeEach(() => {
reset();
orders.set('1001', { id: '1001', status: 'pending', total: '49.99' });
payments.set('184747701', {
trackId: '184747701', orderId: '1001', amount: '49.99', currency: 'USDT', status: 'waiting',
});
});
async function send(status, { amount = 49.99, key = 'test-merchant-key', date = Date.now() } = {}) {
const body = JSON.stringify({
type: 'invoice', track_id: '184747701', status,
order_id: '1001', amount, currency: 'USDT', date,
});
const hmac = createHmac('sha512', key).update(body).digest('hex');
return fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', HMAC: hmac }, body });
}
test('an exact duplicate delivery is stored once', async () => {
const first = await send('paid', { date: 1 });
const second = await send('paid', { date: 1 });
assert.equal(await first.text(), 'ok');
assert.equal(second.status, 200);
assert.equal(events.size, 1);
assert.equal(orders.get('1001').status, 'paid');
});
test('a retry with a different body does not fulfil twice', async () => {
await send('paid', { date: 1 });
await send('paid', { date: 2 });
assert.equal(events.size, 2);
assert.equal(payments.get('184747701').status, 'paid');
assert.equal(orders.get('1001').status, 'paid');
});
test('a late paying callback cannot undo paid', async () => {
await send('paid');
await send('paying');
assert.equal(payments.get('184747701').status, 'paid');
});
test('a bad signature is rejected and changes nothing', async () => {
const res = await send('paid', { key: 'wrong-key' });
assert.equal(res.status, 400);
assert.equal(events.size, 0);
assert.equal(orders.get('1001').status, 'pending');
});
test('a paid amount that does not match is held for review', async () => {
await send('paid', { amount: 10 });
assert.equal(payments.get('184747701').status, 'waiting');
assert.equal(orders.get('1001').status, 'pending');
});
test('payment after expiry still marks the order paid', async () => {
await send('expired');
await send('paid');
assert.equal(orders.get('1001').status, 'paid');
});
test('an unknown track_id is acknowledged so the provider stops retrying', async () => {
payments.clear();
const res = await send('paid');
assert.equal(await res.text(), 'ok');
assert.equal(orders.get('1001').status, 'pending');
});
Run them with npm test (the script is just node --test):
$ npm test
# tests 7
# pass 7
# fail 0
A few choices make these tests trustworthy rather than decorative. They go through real HTTP on an ephemeral port, so the raw-body handling and the signature check are exercised exactly as in production; a test that calls applyEvent directly would skip the part most likely to be wrong. The app takes a logger as a parameter, so tests stay quiet without mocking console. And each test resets the store, so order doesn't matter and one failure can't cascade into the next.
When the in-memory store becomes a real database, keep these tests and point them at a test database. The scenarios don't change, and they are exactly the regressions you want to catch the next time someone refactors the webhook handler.
Before you go live
Local testing covers your logic. A few things can only be checked on the real deployment:
- Set
OXAPAY_SANDBOX=falseand use the live merchant key - Make sure the callback URL is your production HTTPS domain, not a tunnel left over from development
- Allow the provider's IPs through your WAF or CDN on the webhook route; their support can give you the list
- Create one small real invoice, pay it, and watch the webhook arrive and the order update end to end
- Keep the replay script out of production builds; it signs requests with your live key if you let it
That's the whole workflow: sandbox and a tunnel to prove the integration talks to the provider, a request bin to see the real payload once, and a replay script plus a handful of tests for everything the provider can't produce on demand. The part customers would otherwise test for you, duplicates, late callbacks, mismatched amounts, now runs in under a second on every commit. The API reference for everything used here is in the OxaPay documentation.
Top comments (0)