docs.pbx.lt

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.