# Guide to Revolut Webhooks: Features and Best Practices

Revolut Merchant webhooks notify your backend when a payment changes state: an order completes, an order is authorised, a payment is declined. If you're fulfilling orders or reconciling payments through Revolut, webhooks are the signal that money moved.

This guide covers how Revolut webhooks work, the events you'll handle, how to verify the `Revolut-Signature` (including the timestamp and rotation details), the retry behavior, and the best practices for production.

## What are Revolut webhooks?

Revolut Merchant webhooks are HTTP POSTs sent to a URL you register, each carrying a JSON payload about an order event. You configure webhooks through the Merchant API, and Revolut signs each delivery with a scheme that binds the signature to a timestamp, which gives you replay protection if you check it.

## Revolut webhook features

| Feature | Details |
| --- | --- |
| Configuration | Merchant API only (`Revolut-Api-Version` header); max 10 webhook URLs |
| Signature header | `Revolut-Signature` (`v1=<hex>`) |
| Timestamp header | `Revolut-Request-Timestamp` (milliseconds) |
| Signature scheme | HMAC-SHA256 hex over `v1.{timestamp}.{raw body}` (period-separated) |
| Signing secret | `wsk_...` prefix, returned on creation, rotatable |
| Replay protection | 5-minute timestamp tolerance vs UTC |
| Rotation | Multiple comma-separated signatures; accept any match |
| Retries | 3 more attempts at 10-minute intervals |
| Hosts | `merchant.revolut.com` (prod) / `sandbox-merchant.revolut.com` |
| SDK | No official npm/pip SDK with webhook helpers |

## Common events

Revolut Merchant webhook events cover the order lifecycle:

| Event | Fires when |
| --- | --- |
| `ORDER_COMPLETED` | An order is completed (payment captured) |
| `ORDER_AUTHORISED` | An order is authorised |
| `ORDER_PAYMENT_DECLINED` | A payment on an order is declined |

Subscribe to the events you process, and consult Revolut's webhook reference for the full enum. (Revolut's Business API has a separate webhook system using the same `v1` signature scheme.)

## Setting up Revolut webhooks

Webhooks are configured via the Merchant API (include the `Revolut-Api-Version` header). You can register up to 10 webhook URLs. Creation returns a signing secret with a `wsk_` prefix, which you can rotate later via the rotate-signing-secret endpoint. Production and sandbox use different hosts (`merchant.revolut.com` and `sandbox-merchant.revolut.com`).

## Securing Revolut webhooks

Each delivery carries a `Revolut-Signature` header (in the form `v1=<hex>`) and a `Revolut-Request-Timestamp` header. To verify, build the signed payload `v1.{Revolut-Request-Timestamp}.{raw body}` (period-separated), compute an HMAC-SHA256 with your `wsk_` signing secret, hex-encode it, and compare against the signature. During rotation the header may contain multiple comma-separated signatures; accept the delivery if any matches. Also check that the timestamp is within 5 minutes of now (it's a 13-digit millisecond timestamp) to reject replays.

```javascript
const crypto = require("crypto");

const SECRET = process.env.REVOLUT_SIGNING_SECRET; // wsk_...

function verify(rawBody, timestamp, signatureHeader) {
  // Reject stale deliveries (timestamp is in milliseconds)
  if (Math.abs(Date.now() - Number(timestamp)) > 5 * 60 * 1000) return false;

  const payload = `v1.${timestamp}.${rawBody}`;
  const expected = "v1=" +
    crypto.createHmac("sha256", SECRET).update(payload).digest("hex");

  // Header may hold multiple comma-separated signatures during rotation
  return (signatureHeader || "").split(",").some((sig) => {
    const a = Buffer.from(sig.trim());
    const b = Buffer.from(expected);
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  });
}

app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const timestamp = req.headers["revolut-request-timestamp"];
  const signature = req.headers["revolut-signature"];
  if (!verify(req.body.toString("utf8"), timestamp, signature)) {
    return res.sendStatus(401);
  }

  res.sendStatus(200); // acknowledge fast
  processQueue.add(JSON.parse(req.body)); // process asynchronously
});

```

The same check in Python:

```python
import hashlib
import hmac
import os
import time

SECRET = os.environ["REVOLUT_SIGNING_SECRET"].encode()  # wsk_...

def verify(raw_body: str, timestamp: str, signature_header: str) -> bool:
    if abs(time.time() * 1000 - int(timestamp)) > 5 * 60 * 1000:
        return False
    payload = f"v1.{timestamp}.{raw_body}".encode()
    expected = "v1=" + hmac.new(SECRET, payload, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, s.strip())
               for s in (signature_header or "").split(","))

```

## Revolut webhook limitations and pain points

### The signed payload format is specific

The Problem: The signature is over `v1.{timestamp}.{raw body}` (period-separated, with the `v1` prefix), and the header value is `v1=<hex>`. Sign only the body, or use the wrong separators, and verification fails.

Why It Happens: Revolut binds the signature to a version tag and the request timestamp.

Workarounds:

* Build the exact `v1.{timestamp}.{body}` string and compare against the `v1=` value.
* Verify against the raw body; re-serialized JSON breaks it.

How Hookdeck Can Help: Hookdeck verifies the Revolut signature at the edge, including the payload construction and rotation, so your app receives pre-verified events.

### Millisecond timestamps and the 5-minute window

The Problem: The timestamp is a 13-digit millisecond value, even though it's described as a UNIX timestamp, and deliveries outside a 5-minute tolerance should be rejected. Treating it as seconds, or skipping the check, breaks either verification or replay protection.

Why It Happens: Revolut uses millisecond precision and a freshness window for replay resistance.

Workarounds:

* Compare against `Date.now()` in milliseconds (don't divide by 1000).
* Acknowledge fast and process asynchronously so you verify inside the window.

How Hookdeck Can Help: Hookdeck verifies signatures and freshness at the edge and durably queues events, so a downstream backlog doesn't risk deliveries aging out.

### Rotation produces multiple signatures

The Problem: During secret rotation the `Revolut-Signature` header carries multiple comma-separated signatures. Code that checks only the first rejects valid deliveries signed with the other secret.

Why It Happens: Overlapping signatures let you rotate the `wsk_` secret without dropping in-flight deliveries.

Workarounds:

* Split on commas and accept the delivery on any match.
* Coordinate rotation so old and new secrets overlap.

How Hookdeck Can Help: Hookdeck handles the rotation case at the edge, so your app never has to reason about multiple signatures.

### Limited retries

The Problem: Revolut retries a failed delivery only 3 more times at 10-minute intervals. A short outage past that window drops the event.

Why It Happens: Revolut's retry budget is small.

Workarounds:

* Acknowledge fast so transient issues don't consume the retry budget.
* Reconcile against the Merchant API for anything you can't afford to miss.
* Dedupe on the order/event identifier since retries bring duplicates.

How Hookdeck Can Help: Hookdeck accepts every delivery and retries to your downstream on a schedule you control, so Revolut's 3-attempt limit isn't your only safety net, and it deduplicates so retries don't double-process. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

## Best practices

### Verify over `v1.{timestamp}.{body}` with the wsk_ secret

Build the exact period-separated payload, HMAC-SHA256 hex with your `wsk_` secret, compare against the `v1=` value in constant time, and accept any signature during rotation.

### Check the millisecond timestamp

Reject deliveries more than 5 minutes off, comparing in milliseconds.

### Acknowledge fast, process asynchronously

Return 200 quickly and defer work to a queue, both for the freshness window and the small retry budget. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Dedupe and reconcile

Dedupe on the order/event identifier, make side effects idempotent, and reconcile against the Merchant API for critical payment state.

### Confirm before fulfilling

After verifying, confirm the order state via the API before granting value, as you would for any payment event.

## Conclusion

Revolut Merchant webhooks are the signal your backend acts on when a payment moves, and the verification is specific: an HMAC-SHA256 over `v1.{timestamp}.{raw body}`, compared against a `v1=` header value, with a millisecond timestamp you check against a 5-minute window and multiple signatures to accept during rotation. Add a small 3-attempt retry budget, and reliability rests on getting all of it right.

That leaves correct verification, freshness checks, deduplication, and reconciliation on you. [Hookdeck](https://hookdeck.com) verifies the signature, handles rotation and freshness, deduplicates, and durably queues every event at the edge, so your backend only ever acts on verified, unique order events.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Revolut webhooks reliably in minutes.