Volumedocs

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 as cloudflared tunnel --url http://localhost:3000 and register the tunnel's address.
  • Send test in the dashboard, or POST /v1/webhook_endpoints/{id}/test, sends a webhook_endpoint.test event 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

EventWhendata
checkout.payment_detectedA transfer to the checkout's address was seen. It does not count yet.checkout_session, payment
payment.confirmedA transfer was accepted and credited to your balance.checkout_session, payment, credited_usd, fee_usd
invoice.paidThe invoice is paid. Act on this one.checkout_session
invoice.underpaidWhat has arrived falls short. The payer can top up.checkout_session, amount_received, amount_remaining
invoice.overpaidMore arrived than was due. Sent after invoice.paid.checkout_session, amount_received, amount_excess
invoice.expiredA checkout expired with nothing paid. The invoice stays open.checkout_session
payout.sentA payout reached your bank account.payout
payout.failedA payout failed. The money is back in your balance.payout
webhook_endpoint.testYou 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:

signature.js
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' }), not express.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_received only grows, and a paid invoice never goes back to unpaid.
  • Check the mode. livemode says 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}.

On this page