Gareth Wilson Gareth Wilson

Guide to Revolut Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationMerchant API only (Revolut-Api-Version header); max 10 webhook URLs
Signature headerRevolut-Signature (v1=<hex>)
Timestamp headerRevolut-Request-Timestamp (milliseconds)
Signature schemeHMAC-SHA256 hex over v1.{timestamp}.{raw body} (period-separated)
Signing secretwsk_... prefix, returned on creation, rotatable
Replay protection5-minute timestamp tolerance vs UTC
RotationMultiple comma-separated signatures; accept any match
Retries3 more attempts at 10-minute intervals
Hostsmerchant.revolut.com (prod) / sandbox-merchant.revolut.com
SDKNo official npm/pip SDK with webhook helpers

Common events

Revolut Merchant webhook events cover the order lifecycle:

EventFires when
ORDER_COMPLETEDAn order is completed (payment captured)
ORDER_AUTHORISEDAn order is authorised
ORDER_PAYMENT_DECLINEDA 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.

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:

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.

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.

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.

Make Revolut webhooks production-ready

Hookdeck verifies the Revolut-Signature, handles rotation and freshness, deduplicates, and durably queues every order 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 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 for free and handle Revolut webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.