Guide to Volume Webhooks: Features and Best Practices
Volume webhooks tell your backend when an open banking payment reaches its final state: the customer's bank transfer completed, the funds settled, or the payment failed. If you take pay-by-bank payments through Volume, webhooks are how your order system learns the outcome without polling the payments API after every checkout.
This guide covers what Volume is and how its webhooks work, the payment statuses they report, how to verify the RSA signature with Volume's public key, the platform's delivery quirks, and the best practices for production.
What are Volume webhooks?
Volume is a UK open banking payments provider. Instead of taking card details, Volume lets customers pay by bank: they approve a transfer in their own banking app and the money moves directly from their account to the merchant's. Its API lives at volumepay.io, with separate sandbox and live environments.
Volume webhooks are HTTP callbacks that notify your application when a payment reaches a final status. Volume sends them as HTTP PUT requests (not POST) to the webhook URL in your application configuration. The JSON body is the payment record itself, with no event name or envelope. A paymentStatus field tells you what happened, alongside the payment ID, your own merchantPaymentId, the amount and currency, and any metadata you attached when creating the payment.
Volume webhook features
| Feature | Details |
|---|---|
| Configuration | One webhook URL per application, set in the application configuration in the Volume merchant dashboard; sandbox and live are configured separately |
| HTTP method | PUT |
| Events | Final payment statuses only: COMPLETED, SETTLED (virtual accounts only) and FAILED |
| Envelope | Flat JSON payment object; branch on the paymentStatus field, as there is no event-type header or event ID |
| Authentication | SHA256withRSA (RSA PKCS#1 v1.5 with SHA-256) over the raw body, base64-encoded in the Authorization header as SHA256withRSA <signature> |
| Public keys | Sandbox: https://api.sandbox.volumepay.io/.well-known/signature/pem; live: https://api.volumepay.io/.well-known/signature/pem |
| Retries | Resent until your endpoint returns 200; any other response is treated as a failure |
| Source IPs | Sandbox: 52.30.246.188; live: 52.56.123.234, 18.175.86.214, 3.11.7.150 |
Common events
Volume has no event names. The paymentStatus values act as the event types:
| Event | Fires when |
|---|---|
COMPLETED | The payment succeeded; use it to fulfil the order and notify the customer |
SETTLED | The funds settled (virtual-account payments only); intended for internal reconciliation, not customer messaging |
FAILED | The payment was rejected; errorDescription explains why |
Branch on the top-level paymentStatus field. With a virtual account, the same payment produces a COMPLETED webhook and later a SETTLED one, so treat them as two separate events for the same paymentId.
See Volume webhook payloads in action. Inspect and replay sample Volume webhook payloads in the Hookdeck Console — no account or setup required.
Setting up Volume webhooks
Volume does not have per-event subscriptions or a webhook management API. Each application has a single webhook URL, and every final payment status for that application is sent to it.
- Sign in to the Volume merchant dashboard for the environment you're setting up (sandbox or live).
- Open your application's configuration.
- Set the webhook URL to your endpoint, for example
https://your-app.com/webhooks/volume. - Save. Repeat in the other environment when you're ready, since sandbox and live are configured separately.
There is no signing secret to copy. Volume signs every webhook with its RSA private key and publishes the matching public key for each environment, so the only thing your endpoint needs to know is which environment it receives webhooks from.
Make sure your route accepts PUT. A route that only handles POST returns 404 or 405, and Volume keeps resending the webhook because it never received a 200.
To test, Volume's webhook docs publish ready-made, sandbox-signed curl calls for a COMPLETED and a FAILED payment. Point one at your endpoint with your verifier set to the sandbox key:
curl --request PUT 'https://your-app.com/webhooks/volume' \
-H 'Content-Type: application/json' \
-H 'Authorization: SHA256withRSA hnHI6qoo7p37NwtBFj332TWC9UUHFiMlwgKsI2XV+L1xKbIK4Vp+3b3bczrdM+8bLXNTRMvJJJ+5zr5uBXBhl9enN3Sfq/4q3gmdq1pGd0Gz0YaRUZxhNG2tkVq7LGtKeeWzg5PxfCy7PeD3D71C+SnUYa7fwT+KzKyPCMqk+uWjLws6pKysinOzh3aYmVhaW9DhH6gZtV2LLGQFHUsqtYClzOkQRxDePhJU8kf8tu8FyTYxJgN4+CZ7vXrD162L0zrcsHXZX1VvVS0GbguHz/JHIFRzqu+o3QpHoidnU+reXPoCQOBV420NaWwVy3Op5o3rFSAZvSwjwAczoQRfnw==' \
--data-raw '{"paymentId":"3f2a2b69-6d42-4050-9c4f-7e8849bf683c","merchantPaymentId":"806","paymentStatus":"COMPLETED","errorDescription":null,"paymentRequest":{"amount":24.23,"currency":"GBP","reference":"payment-reference"},"paymentRefundData":null,"paymentMetadata":{"some-data":"some-value"}}'
Copy the body byte for byte: any whitespace change invalidates the signature. You can also complete a sandbox payment to trigger a real webhook.
For local development, use the Hookdeck CLI: hookdeck listen 3000 volume --path /webhooks/volume gives you a public HTTPS URL that forwards to your local server, plus a web UI for inspecting and replaying deliveries, with no account required. Set the generated URL as the webhook URL in your sandbox application configuration.
Securing Volume webhooks
Every Volume webhook carries an Authorization header in the format {algorithm} {signature}:
Authorization: SHA256withRSA hnHI6qoo7p37NwtBFj332TWC9UUHFiMl...fnw==
The signature is an RSA signature (PKCS#1 v1.5 padding, SHA-256 digest, the scheme Java calls SHA256withRSA) over the raw request body, encoded as standard base64. Nothing else is signed: no timestamp, no message ID. You verify it with Volume's public key for the right environment. The key URLs return the base64 body of an SPKI public key without the -----BEGIN PUBLIC KEY----- and -----END PUBLIC KEY----- lines, so decode it as DER (or wrap it in PEM lines yourself) before loading it.
Verify against the raw request body; in Express that means express.raw() on the route, and verifying before you parse. Load the key that matches the environment: sandbox and live keys differ, and a sandbox-signed webhook will not verify against the live key. Cache the key rather than fetching it on every request, and fail closed if it can't be fetched:
const crypto = require("crypto");
const PEM_URLS = {
sandbox: "https://api.sandbox.volumepay.io/.well-known/signature/pem",
live: "https://api.volumepay.io/.well-known/signature/pem",
};
const KEY_CACHE_TTL_MS = 60 * 60 * 1000; // re-fetch the public key hourly
let keyCache = null;
async function getVolumePublicKey() {
const url = PEM_URLS[process.env.VOLUME_ENV || "sandbox"];
if (keyCache && keyCache.url === url && keyCache.expiresAt > Date.now()) {
return keyCache.key;
}
const res = await fetch(url);
if (!res.ok) throw new Error(`Fetching Volume public key failed: HTTP ${res.status}`);
// The endpoint returns bare base64 SPKI, with no BEGIN/END lines
const key = crypto.createPublicKey({
key: Buffer.from((await res.text()).replace(/\s+/g, ""), "base64"),
format: "der",
type: "spki",
});
keyCache = { url, key, expiresAt: Date.now() + KEY_CACHE_TTL_MS };
return key;
}
function verifyVolumeSignature(rawBody, authorization, publicKey) {
if (!authorization) return false;
// "SHA256withRSA <signature>": scheme token, then a standard-base64 signature
const [scheme, signature, ...rest] = authorization.trim().split(/\s+/);
if (scheme !== "SHA256withRSA" || !signature || rest.length > 0) return false;
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(signature)) return false;
try {
// PKCS#1 v1.5 is Node's default padding for RSA keys
return crypto.verify("sha256", rawBody, publicKey, Buffer.from(signature, "base64"));
} catch {
return false;
}
}
async function volumeWebhook(req, res) {
let publicKey;
try {
publicKey = await getVolumePublicKey();
} catch (err) {
// Fail closed; a non-200 makes Volume send the webhook again later
return res.status(503).json({ error: "Signature key unavailable" });
}
if (!verifyVolumeSignature(req.body, req.get("authorization"), publicKey)) {
return res.status(400).json({ error: "Invalid signature" });
}
const payment = JSON.parse(req.body.toString("utf8"));
// Reconcile amount, currency and merchantPaymentId against your own
// payment record before acting, and dedupe on paymentId + paymentStatus
switch (payment.paymentStatus) {
case "COMPLETED":
// Fulfil the order, notify the customer
break;
case "SETTLED":
// Virtual accounts only: internal reconciliation
break;
case "FAILED":
// Mark the order failed; see payment.errorDescription
break;
}
res.status(200).json({ received: true });
}
// Volume delivers with PUT; accepting POST as well is harmless
const rawJson = express.raw({ type: "*/*" });
app.put("/webhooks/volume", rawJson, volumeWebhook);
app.post("/webhooks/volume", rawJson, volumeWebhook);
Note the scheme: this is RSA with PKCS#1 v1.5 padding, not HMAC (there is no shared secret) and not PSS, and only the part after the SHA256withRSA prefix is the base64 signature.
Make Volume webhooks production-ready. Hookdeck Event Gateway verifies deliveries at the edge, deduplicates, and durably queues every event with replay for anything that fails.
Volume webhook limitations and pain points
Deliveries arrive as PUT, not POST
The Problem: Most webhook tooling and framework examples assume webhooks arrive as POST. A Volume webhook hitting a POST-only route gets a 404 or 405, and because Volume resends until it receives a 200, the failure repeats rather than surfacing as a single error.
Why It Happens: Volume's webhook notification is defined as a REST PUT call to the URL in your application configuration.
Workarounds:
- Register the handler for
PUTexplicitly, and acceptPOSTtoo if that keeps your routing consistent. - Check that any proxy, API gateway or WAF in front of your app allows
PUTon the webhook path. - When testing, use Volume's published
curl --request PUTcalls rather than a genericPOSTtest.
How Hookdeck Can Help: Hookdeck's Volume source accepts both PUT and POST out of the box, so deliveries are ingested without custom method configuration, and every request is logged with its method, headers and body for inspection.
A byte-exact signature that middleware breaks
The Problem: Volume signs the raw body bytes, so anything that changes them before verification breaks the signature: JSON body parsers, framework DTO mapping (enum handling, number formatting, key order), compression or pretty-printing proxies. A common pattern is that Volume's sample curl calls pass while real deliveries fail, or the reverse.
Why It Happens: The signature covers the payload exactly as sent, and the scheme is asymmetric RSA with a per-environment key, so there are more ways to get it wrong than with a shared-secret HMAC: wrong padding, wrong key environment, a bare key loaded as PEM, or the whole Authorization header passed as the signature.
Workarounds:
- Read the raw body (
express.raw()or the equivalent in your framework), verify, then parse. - Pin the verifier to PKCS#1 v1.5 padding and the key for the environment the endpoint serves.
- Keep Volume's sandbox-signed test call in your test suite as a known-good fixture.
How Hookdeck Can Help: Hookdeck verifies the SHA256withRSA signature at the edge using Volume's public key. You pick Production or Sandbox when you configure the source, and Hookdeck fetches and caches the matching key, so deliveries with a missing or invalid signature never reach your handler.
Retries with no documented limit or schedule
The Problem: Volume resends a webhook until it receives a 200 and treats every other response as a failure. Its docs don't publish a retry schedule or a cap, so you can't plan around a recovery window, and a handler that returns non-200 for a legitimate reason keeps receiving the same webhook.
Why It Happens: Volume's delivery contract is simply "send until 200", which guarantees redelivery but makes duplicates a normal part of the integration.
Workarounds:
- Return
200as soon as the signature checks out and process the payment asynchronously. - Return
200for webhooks you've decided not to act on (for example, a reconciliation mismatch you've flagged for review) so they stop repeating. - Monitor non-
200responses on the endpoint so a broken deploy is caught before redeliveries pile up.
How Hookdeck Can Help: Hookdeck acknowledges Volume immediately and durably queues each event ahead of your endpoint, then delivers with automatic retries on a schedule you control, raises Issues when deliveries fail, and lets you replay any event once your service recovers.
No event ID, and two webhooks for one payment
The Problem: Volume sends no event ID, so you can't dedupe on a delivery identifier. With virtual accounts, a single payment also produces two legitimate webhooks, COMPLETED and later SETTLED, so deduplicating on paymentId alone drops the second one.
Why It Happens: The webhook body is the payment record itself, keyed by paymentId, with paymentStatus as the only indicator of what changed.
Workarounds:
- Build the idempotency key from
paymentIdpluspaymentStatus. - Store processed keys in a database or Redis rather than in memory, so they survive restarts and scale across instances.
- Log and acknowledge unknown
paymentStatusvalues instead of failing on them.
How Hookdeck Can Help: Hookdeck's deduplication can drop repeat deliveries based on the paymentId and paymentStatus fields before they reach your service, and filters can route SETTLED events to a reconciliation service while COMPLETED and FAILED go to order handling.
Best practices
Verify the raw body against the right environment's key
Verify the SHA256withRSA signature over the raw body before parsing, using the sandbox key for sandbox deployments and the live key for production. Cache the key (the skill's examples refresh it hourly) and reject the webhook if the key can't be loaded, rather than skipping verification.
Reconcile before you fulfil
Volume's docs say to check all webhook data, including amount, merchantPaymentId and currency, against the payment record you created, and to stop processing on any mismatch. A valid signature proves Volume sent the message; it doesn't prove the payment matches the order you're about to ship. Note that paymentRequest.amount is in major units: 24.23 means £24.23, not pence.
Make handlers idempotent
Volume's docs require consumers to handle more than one webhook call for the same payment. Key your processing on paymentId plus paymentStatus so a resent COMPLETED never triggers a second fulfilment, while SETTLED still gets through. See our guide to webhook idempotency.
Acknowledge fast, process asynchronously
Volume keeps resending until it gets a 200, so a slow or failing handler turns into a stream of duplicates. Return 200 once the signature checks out and hand the payment to a queue or background job. See why to process webhooks asynchronously.
Use the published IPs as an extra layer
Volume publishes static source IPs for sandbox and live. Allowlisting them at your firewall or load balancer adds defense in depth alongside signature verification, which you still need. If a proxy such as Hookdeck sits in front of your app, the source IP your app sees is the proxy's, so filter at the edge or rely on signatures.
Conclusion
Volume webhooks report the final status of open banking payments (COMPLETED, FAILED and, for virtual accounts, SETTLED) in PUT requests carrying the full payment record. Security rests on an RSA signature over the raw body, verified with Volume's public key for the matching environment, with no shared secret to manage.
The delivery contract is simple (resend until 200), which means duplicates are expected and reconciliation against your own records is part of the job. Hookdeck Event Gateway puts signature verification, deduplication and a durable queue with replay in front of your endpoint, so your handlers only process trustworthy, unique payment events.
Get started with Hookdeck for free and handle Volume webhooks reliably in minutes.