Webhooks
Register an endpoint, check that each event came from Volume, and handle every event, retries and duplicates included.
Volume tells your server what happened by POSTing an event to a URL of yours. Every event is signed, retried until you answer, and may arrive more than once.
Register an endpoint
In the dashboard, open Developers, add your
endpoint's URL under Webhooks, and copy its signing secret (whsec_…). Volume shows
it this once. Keep it in your server's environment, beside your API key.
Or register it with your API key:
curl https://api.paywithvolume.com/v1/webhook_endpoints \
-H "Authorization: Bearer $VOLUME_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://api.example.com/webhooks/volume" }'The response's secret is the signing secret, again shown only this once. The endpoint
receives every event type unless you pass enabled_events.
- An endpoint belongs to one mode, the key's. Test and live endpoints are separate, with separate secrets, and a test endpoint never receives a live event.
- The URL must be
https, and reachable from the internet, in test mode as well. To receive events on your laptop, expose it through a tunnel such ascloudflared tunnel --url http://localhost:3000and register the tunnel's address. - Send test in the dashboard, or
POST /v1/webhook_endpoints/{id}/test, sends awebhook_endpoint.testevent to check the wiring.
What arrives
POST /webhooks/volume HTTP/1.1
Content-Type: application/json
User-Agent: Volume-Webhooks/1
Volume-Signature: t=1792055836,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Volume-Event-Id: evt_2Lk8Rv4QmW1nXb7TsH9dPz3C
Volume-Event-Type: invoice.paid{
"id": "evt_2Lk8Rv4QmW1nXb7TsH9dPz3C",
"object": "event",
"type": "invoice.paid",
"created_at": "2026-10-15T09:17:15.000Z",
"livemode": false,
"data": {
"checkout_session": {
"object": "checkout_session",
"id": "cs_9Wd4Tn2KxR7bLq1MhV5sZp8C",
"invoice_id": "inv_3Hf8QwZ1mKp7XnB2sLd9Vt4R",
"status": "paid",
"amount_usd": "25.00",
"asset": "USDT",
"network": "bsc",
"rate": "1",
"amount_due": "25.000000",
"amount_received": "25.000000",
"paid_at": "2026-10-15T09:17:15.000Z"
}
}
}data holds what the event is about, as it was when it happened. Every payment event
carries the checkout_session: its status, the invoice's price in dollars, the coin and
network the payer chose, the rate they were quoted (dollars per coin), what was due
and what has arrived so far, in the coin. amount_received counts only transfers the
custodian has accepted.
Events
| Event | When | data |
|---|---|---|
checkout.payment_detected | A transfer to the checkout's address was seen. It does not count yet. | checkout_session, payment |
payment.confirmed | A transfer was accepted and credited to your balance. | checkout_session, payment, credited_usd, fee_usd |
invoice.paid | The invoice is paid. Act on this one. | checkout_session |
invoice.underpaid | What has arrived falls short. The payer can top up. | checkout_session, amount_received, amount_remaining |
invoice.overpaid | More arrived than was due. Sent after invoice.paid. | checkout_session, amount_received, amount_excess |
invoice.expired | A checkout expired with nothing paid. The invoice stays open. | checkout_session |
payout.sent | A payout reached your bank account. | payout |
payout.failed | A payout failed. The money is back in your balance. | payout |
webhook_endpoint.test | You pressed Send test. | webhook_endpoint |
amount_received, amount_remaining and amount_excess are in the coin, like the
checkout's own amount_received. Multiply by the checkout's rate for dollars.
Grant what was paid for on invoice.paid, never on checkout.payment_detected: a
detected transfer can still be refused or fall short. And never on the payer reaching
your success_url, which anyone can open.
Check the signature
Volume-Signature is t=<unix seconds>,v1=<signature>, where the signature is the hex
HMAC-SHA256 of <t>.<raw body> under your endpoint's secret. Check it before you
trust anything in the body:
import { createHmac, timingSafeEqual } from 'node:crypto';
/**
* Whether `rawBody` is what Volume sent, signed with your endpoint's `whsec_` secret,
* in the last five minutes. Check the raw bytes, before parsing them.
*/
export function verifySignature(secret, rawBody, header, now = Date.now()) {
const parts = Object.fromEntries(
String(header ?? '')
.split(',')
.map((part) => part.split('=')),
);
const t = Number(parts.t);
if (!Number.isInteger(t) || typeof parts.v1 !== 'string') return false;
const expected = Buffer.from(
createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex'),
);
const given = Buffer.from(parts.v1);
const valid = given.length === expected.length && timingSafeEqual(given, expected);
return valid && Math.abs(now / 1000 - t) <= 300;
}- Check the raw body, exactly as it arrived. Parsing the JSON and serialising it
again changes the bytes, and the signature with them. In Express, take the route's
body with
express.raw({ type: 'application/json' }), notexpress.json(). - The timestamp is signed too, so refusing anything more than five minutes old stops someone replaying a delivery they captured.
- Answer a bad signature with
400, and do nothing else.
Answer quickly
Answer with any 2xx within ten seconds. A slower answer, a 3xx, 4xx or 5xx, or
no answer at all is a failure, and the delivery is tried again. If handling an event
takes longer, record it and answer first.
Failed deliveries are retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours
and 10 hours, then every 12 hours, for up to three days after the event. After that the
delivery is given up. Every attempt and its answer is listed under Developers in the
dashboard, and by GET /v1/webhook_deliveries.
Delivered at least once
A delivery can arrive twice: Volume may not hear your 200, and tries again. Events can
also arrive out of order, since a retried one can land after a newer one. So:
- Make handling idempotent. Remember the event
ids you have handled, or, better, make the effect itself safe to repeat. The Monierate guide credits a wallet "up to" a total, which is correct however often and in whatever order its events arrive. - Never let an older event undo a newer one. A checkout's
amount_receivedonly grows, and a paid invoice never goes back to unpaid. - Check the mode.
livemodesays whether the event is real. Your production server should hold your live secret only, so a test event cannot pass its signature check.
To read the latest state rather than an event's snapshot, fetch it:
GET /v1/checkout_sessions/{id} or GET /v1/invoices/{id}.