# 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](https://getvolume.com) 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](https://console.hookdeck.com) — 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.

1. Sign in to the Volume merchant dashboard for the environment you're setting up (sandbox or live).
2. Open your application's configuration.
3. Set the webhook URL to your endpoint, for example `https://your-app.com/webhooks/volume`.
4. 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:

```bash
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](/docs/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:

```javascript
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](/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 `PUT` explicitly, and accept `POST` too if that keeps your routing consistent.
* Check that any proxy, API gateway or WAF in front of your app allows `PUT` on the webhook path.
* When testing, use Volume's published `curl --request PUT` calls rather than a generic `POST` test.

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 `200` as soon as the signature checks out and process the payment asynchronously.
* Return `200` for webhooks you've decided not to act on (for example, a reconciliation mismatch you've flagged for review) so they stop repeating.
* Monitor non-`200` responses 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](/docs/retries) on a schedule you control, raises [Issues](/docs/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 `paymentId` plus `paymentStatus`.
* Store processed keys in a database or Redis rather than in memory, so they survive restarts and scale across instances.
* Log and acknowledge unknown `paymentStatus` values instead of failing on them.

How Hookdeck Can Help: Hookdeck's [deduplication](/docs/deduplication) can drop repeat deliveries based on the `paymentId` and `paymentStatus` fields before they reach your service, and [filters](/docs/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](/webhooks/guides/implement-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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### 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](/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](https://dashboard.hookdeck.com/signup) for free and handle Volume webhooks reliably in minutes.