Webhooks
Revolut webhooks
Revolut delivers payment lifecycle events to POST https://billing-api.pbx.lt/v1/webhooks/revolut. We verify the signature, dedupe by event id, and update local state. Outbound webhooks to
customer systems are not part of this surface today.
Event types we accept
| Event | Effect |
|---|---|
| ORDER_COMPLETED | Hosted-checkout order succeeded. We capture the saved-card token (if requested) and credit the prepaid balance on a topup. |
| ORDER_AUTHORISED | Card was authorised but not yet captured. We capture asynchronously after subscription-side validation; if we never capture, the auth lapses. |
| ORDER_CANCELLED | Customer cancelled at the hosted-checkout screen. We flag the in-flight order as `cancelled`. No money moves. |
| ORDER_FAILED | Card issuer declined. We surface the localised problem-details code to the SPA. No money moves. |
| ORDER_REFUNDED | Manual refund issued via the Revolut dashboard or our admin endpoint. We reconcile the invoice and lower the prepaid balance if applicable. |
| PAYMENT_AUTHENTICATED | 3DS step-up completed. Informational; we wait for ORDER_COMPLETED before doing anything financially meaningful. |
Signature verification
Revolut signs every delivery with an HMAC-SHA256 over the raw request body, using the webhook
secret we provisioned in their dashboard. The signature lands in the Revolut-Signature header as a timestamp + hex pair. Verify both before trusting the payload.
// Node 18+, no extra deps
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verifyRevolutSignature(
rawBody: string,
header: string,
secret: string,
maxAgeSeconds = 300,
): boolean {
// Header shape: "v1=<hex>,t=<unix-ts>"
const parts = Object.fromEntries(
header.split(',').map((p) => p.trim().split('=')),
)
const ts = Number(parts.t)
const sig = parts.v1
if (!ts || !sig) return false
if (Math.abs(Date.now() / 1000 - ts) > maxAgeSeconds) return false
const expected = createHmac('sha256', secret)
.update(`${ts}.${rawBody}`)
.digest('hex')
// Constant-time compare
const a = Buffer.from(expected, 'hex')
const b = Buffer.from(sig, 'hex')
return a.length === b.length && timingSafeEqual(a, b)
}
Note: read the body as a raw buffer/string before any framework parses it as JSON; once
a router consumes the stream, the bytes change and the signature no longer matches. Hono on Workers
exposes the raw body via c.req.raw.clone().text().
Idempotency
Revolut may redeliver the same event up to 5 times over 24 hours. We deduplicate by the event_id field in the body. Persist the id, return HTTP 200 on replay without side effects, and treat the
first delivery as authoritative.
Always return HTTP 200 (even on validation failure) so Revolut stops retrying; log the failure on our side and surface it via the admin dashboard. Returning a 5xx puts the delivery into Revolut's retry queue and bloats their dashboard.