Revolut Merchant API on Vercel: Wallets, 3D Secure, and the Sync vs Async Trap
To run the Revolut Merchant API on Vercel, your serverless backend creates an order before any money moves, hands the browser a short-lived token, and lets Revolut's web SDK collect the payment, wallets included, with 3D Secure handled inside the widget. The outcome you can trust is not the browser's success callback; it is the signed webhook Revolut sends your function afterward, and that event is what should trigger fulfillment. The secret key stays on the server, the amount is always in minor units, and Apple Pay only works in production. Get the order-first flow, the wallet gating, and the asynchronous webhook right, and the integration is calm. What follows is the field-tested version, with each load-bearing claim checked against Revolut's own documentation so you can verify it rather than trust a screenshot.
The short version
- Revolut is order-first. Nothing is charged until your server creates an order and receives a permanent
idfor management plus a short-livedtokenfor the browser. - Amounts are in minor units.
7034means seventy euros and thirty-four cents; a stray decimal or a missing factor of one hundred produces a real charge wrong by two orders of magnitude. - Wallets need gating and, for Apple Pay, a verified domain. Render the wallet button only when
canMakePayment()says one is present, and host Revolut's validation file plus register the domain before Apple Pay works at all. - The widget handles 3D Secure for you. Use it and the frictionless path, the bank challenge, and any redirect rendering stay out of your code; bypass it and you inherit all of that.
- The webhook is the truth, the callback is only UX. Fulfill on
ORDER_COMPLETEDorORDER_AUTHORISED, never on the browser'sonSuccess, and never assume the two events arrive in order. - Verify the signature over the raw body. The HMAC is computed on the exact bytes Revolut sent, so any automatic JSON parsing on that route breaks verification.
How do you authenticate and choose the right environment?
Per the Merchant API documentation, you hold two keys: a public one for the browser at checkout and a secret one used only server to server, sent as a bearer token on every backend call.
Authorization: Bearer <your-secret-key>
Revolut-Api-Version: 2024-09-01
Two settings need to be right from the first request. The API is versioned through a dated Revolut-Api-Version header, so pin a version deliberately instead of drifting along with whatever the default becomes, which the API versions page spells out. And sandbox and production are wholly separate worlds, each with its own credentials and its own host: https://sandbox-merchant.revolut.com/ for testing and https://merchant.revolut.com/ for live. Flip between them by changing only the host, but understand that an object created in one will never appear in the other.
Why is Revolut order-first, and what does that change?
Everything centers on the order object. Before a cent moves, your server creates an order, documented under create order, and the response returns a permanent id you use for all later management plus a short-lived token you pass to the browser.
const res = await fetch('https://merchant.revolut.com/api/orders', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.REVOLUT_SECRET_KEY}`,
'Revolut-Api-Version': '2024-09-01',
'Content-Type': 'application/json'
},
body: JSON.stringify({ amount: 7034, currency: 'EUR', capture_mode: 'automatic' })
});
const order = await res.json(); // order.id, order.token, order.checkout_url
The first field is the one that produces the most humiliating bug. That amount is in the minor currency unit, so 7034 is seventy euros and thirty-four cents. Send a decimal, or forget the hundredfold, and you have charged a customer a hundred times too much or too little. The capture_mode is either automatic, where capture follows authorization, or manual, where you capture later through the order's id. Following the order payment flow, an order travels through pending, processing, authorised, completed, cancelled, and failed. Automatic capture means you fulfill on completed; manual capture means you fulfill on authorised and then capture. And a detail learned the hard way: that browser token dies the moment the payment is authorized, while the permanent id is what every server-side call leans on afterward.
How do the wallets actually work on Vercel?
On the browser you load the official @revolut/checkout SDK, initialized with the order token and the environment.
import RevolutCheckout from '@revolut/checkout';
const instance = await RevolutCheckout(order.token, 'prod');
instance.payWithPopup({ onSuccess() { /* show pending, do NOT fulfil here */ }, onError(e) {} });
The SDK ships a pop-up flow, an embedded card field, and a unified embedded checkout that aggregates methods. Apple Pay and Google Pay arrive through the SDK's wallet entry point, built on the browser Payment Request API, and the wallets guide sets two rules you cannot skip:
- Gate the wallet button on availability. Call
canMakePayment()and render the button only when a wallet is genuinely present, or you serve a dead button to the visitors who have none. - Apple Pay demands domain verification. Host Revolut's validation file at
/.well-known/apple-developer-merchantid-domain-associationon your production domain, then register that domain through the Merchant API (register domain for Apple Pay). Google Pay needs no such step.
The cost-you-an-afternoon surprise is that Apple Pay does not exist in the sandbox, so it can only be exercised for real in production. Plan a careful live smoke test rather than expecting to validate it alongside everything else in test. On Vercel specifically, confirm your routing serves that .well-known file as a reachable static asset, because Apple revalidates it after you go live. Getting the wallet experience to feel native on the storefront is a close cousin of the branding problems we tackled on the WooCommerce side in why I built a custom Revolut checkout.
How does 3D Secure behave in practice?
Strong customer authentication is mandatory, and the relief is that the SDK widget handles 3D Secure for you. Per the 3D Secure overview, the widget gathers device data, runs the frictionless path when the issuer permits, and renders the bank challenge, a one-time password or an in-app approval, inside its own iframe when a full challenge is required. For the common case you build nothing extra. You can force a challenge at order creation with enforce_challenge: forced (cards only), while the default automatic leaves the decision to the fraud engine.
Two realities deserve design attention. Issuers can soft-decline and ask for re-authentication, exposed as a soft_declined state, so a failed first attempt is not necessarily the end of the road. And if you skip the widget to drive payments server to server, the challenge becomes your problem: the payment surfaces an authentication-challenge state with a challenge object you must act on and then poll to resolve. For most teams the widget is the right call precisely because it keeps that machinery, including any redirect-style rendering, out of your codebase.
The sync versus async trap
This is the part worth slowing down for, because it is where a confident integration ships a genuine bug. There is no async flag on the order or payment that toggles behavior. Instead, two separate things are synchronous and asynchronous, and blurring them is the trap.
Synchronous: your create-order call returns immediately, and the widget's onSuccess fires in the browser when the customer finishes. Asynchronous: the authoritative payment result. Revolut delivers that verdict through webhooks, and its own guidance, in working with webhooks, is to treat the webhook as the source of truth rather than leaning on the front-end callback. Fulfill on the ORDER_COMPLETED webhook for automatic capture, or ORDER_AUTHORISED for manual capture, not on onSuccess.
Two more facts make the trap concrete. Event ordering is not guaranteed, and Revolut states you might receive ORDER_COMPLETED before ORDER_AUTHORISED, so your handler must be idempotent and must never assume a sequence. And "synchronous webhooks" are an unrelated, narrow feature: per the synchronous webhook reference they exist only for real-time address validation during fast checkout, not for receiving payment results in line. If you went hunting for "sync payments" and landed there, it is not the thing you wanted. So the accurate mental model is a synchronous request and a synchronous front-end signal for the interface, an asynchronous webhook for the truth, and polling the order status as a documented fallback when a webhook is late or lost.
How do you verify a Revolut webhook on Vercel?
A serverless function is a fine receiver, and Vercel serves it over HTTPS by default, which Revolut requires. The care is all in verification, and one slip invalidates the whole thing. From the signature verification guide, Revolut sends a Revolut-Request-Timestamp header and a Revolut-Signature header, signs with HMAC-SHA256, and the string you sign is the version, the timestamp, and the raw payload joined by full stops.
import crypto from 'node:crypto';
function isValid(rawBody, headers, secret) {
const ts = headers['revolut-request-timestamp'];
const payloadToSign = `v1.${ts}.${rawBody}`; // version . timestamp . raw body
const expected = 'v1=' + crypto.createHmac('sha256', secret).update(payloadToSign).digest('hex');
const sent = (headers['revolut-signature'] || '').split(',').map(s => s.trim());
const withinTolerance = Math.abs(Date.now() - Number(ts)) < 5 * 60 * 1000;
return withinTolerance && sent.includes(expected); // accept any active signature
}
Four serverless rules follow directly. Read the raw body, because the HMAC covers the exact bytes Revolut sent, so disable the body parser for that route (App Router reads request.text(); Pages Router sets export const config = { api: { bodyParser: false } }), the single most frequent reason a correct-looking verifier fails. Accept multiple signatures, because during a signing-secret rotation the header can carry several comma-separated values and any active one is valid. Respect the timestamp tolerance of five minutes, and remember serverless clock skew is real. And stay idempotent and fast, because Revolut retries three further times at ten-minute intervals on failure and delivery order is not guaranteed, so key your processing off the order_id and ignore anything already handled. This is the same webhook hygiene we treat as non-negotiable in the reliable webhooks guide.
How do you take a Revolut integration live, step by step?
Sequence matters, because each step is only trustworthy once the previous one is proven.
- Wire the secret key server-side and the public key to the browser. Confirm from the client that the secret never appears in any bundle or network response.
- Build the create-order endpoint. Compute the amount in minor units on the server from the real cart, never from a total the browser supplies, and pin the API version.
- Render the widget with the order token. Drive one sandbox card payment through
payWithPopupend to end, showing a pending state ononSuccessrather than fulfilling. - Add the webhook receiver with signature checking. Verify against the raw body, make the fulfillment update idempotent on
order_idfrom the start, and log rejects. - Force a 3D Secure challenge. Create an order with
enforce_challenge: forcedand confirm the bank challenge renders and resolves inside the widget. - Test the unhappy paths. Run declines, a soft decline, and a delayed webhook, and confirm your status polling fallback recovers the order state.
- Do a deliberate production pass for Apple Pay. With the domain verified and the
.well-knownfile live, run one controlled real order to exercise the wallet that the sandbox cannot.
What should you check before going live?
- Is the secret key confirmed absent from the browser, with only the public key client-side?
- Does the server compute every amount in minor units from the authoritative cart?
- Is the
Revolut-Api-Versionheader pinned to a specific date? - Does the wallet button render only after
canMakePayment()confirms a wallet is present? - Is the Apple Pay domain verified and the
.well-knownfile reachable on production? - Does fulfillment fire on the webhook, never on
onSuccess? - Is the signature verified over the raw, unparsed body?
- Is the handler idempotent on
order_idand tolerant of out-of-order events?
Common pitfalls
Most Revolut integration failures are a handful of the same mistakes, and they follow directly from the behavior above.
- Amount off by one hundred. Treating
amountas a decimal instead of minor units, which turns seventy euros into seven thousand or seventy cents. - Fulfilling on
onSuccess. Reading the browser callback as confirmation and shipping before the webhook, which over-fulfills on exactly the edge cases that matter. - A verifier that always rejects. The framework parsed the JSON before you hashed the raw body, so the HMAC can never match.
- Assuming event order. Building logic that expects
ORDER_AUTHORISEDbeforeORDER_COMPLETED, when Revolut does not promise the sequence. - Hunting for an async switch. Searching for a flag to make results synchronous, when the asynchrony is the design and the webhook is the answer.
- Testing Apple Pay in the sandbox. Where it simply does not exist, instead of a controlled production check.
A concrete case. A team we advised had a Revolut integration that passed every sandbox test and then behaved strangely on launch day. A small fraction of live orders were being marked complete twice, occasionally triggering a duplicate fulfillment email and, once, a double dispatch. The cause was two compounding assumptions. Their handler treated each webhook as a fresh, ordered event, and it fulfilled on the front-end onSuccess as well, "to be safe." So a customer whose ORDER_COMPLETED arrived before ORDER_AUTHORISED, combined with a callback that had already nudged fulfillment, produced two triggers for one payment. The fix was unglamorous and permanent: fulfillment moved exclusively into the webhook handler, keyed on order_id, idempotent by construction, with the callback demoted to a "thank you, we're confirming your payment" screen and nothing more. The double-dispatch stopped the moment there was exactly one path to fulfillment and it ignored anything it had already seen.
How we build it so production stays calm
Payments earn caution, so the process is the point. We prove the whole flow in the sandbox first, forced 3D Secure and declines and raw-body webhook verification included, before any live key exists; we ship a fixed-scope first phase, usually one clean card payment reconciled by webhook, before wallets and manual capture get layered on; and we run a deliberate production pass for the paths the sandbox cannot reach, Apple Pay chief among them. You own the Revolut account, the Vercel project, the keys, and the data throughout, with no lock-in, which is exactly what makes it safe to trust and cheap to extend. This is the everyday substance of our work: we run production serverless backends on Vercel, including the customs-invoice.com compliance platform, and we ship headless commerce with full payments such as the LeO-Optic store, where verified webhooks and careful handling of the asynchronous truth are ordinary practice. The same discipline runs through the companion piece on embedding Stripe in a headless WooCommerce and Next.js store, and the integration mindset behind both is the subject of why connecting your stack beats copy and paste. If you are wiring Revolut into a serverless backend and want the wallets, the 3D Secure, and the async reconciliation done so it is correct and quiet under load, tell me what you are building and I will give you a straight read on the cleanest first phase.
FAQ
Why is my Revolut charge off by a factor of one hundred?
Because the amount field is denominated in the minor currency unit, not the major one. A value of 7034 is not seven thousand and something; it is seventy euros and thirty-four cents. If you pass a decimal like 70.34, or send 70 intending seventy euros, you will charge a wildly wrong figure. Compute the amount as an integer number of the smallest unit (cents, pence, and so on) on your server, and validate it before creating the order so a formatting slip never reaches a real card.
Should I fulfill the order when the browser's onSuccess fires?
No. The onSuccess callback is a user-experience signal that the customer finished the widget, not a confirmation that the payment settled. Revolut delivers the authoritative result asynchronously through webhooks, so fulfillment belongs in your webhook handler, on ORDER_COMPLETED for automatic capture or ORDER_AUTHORISED for manual capture. Use onSuccess only to show a pending or thank-you state. Fulfilling on the callback is what produces double dispatches and shipments against payments that ultimately failed.
Why does my Revolut webhook signature verification fail?
Nearly always because the request body was parsed before you computed the HMAC. Revolut signs the exact bytes it sent, so if your framework deserializes the JSON and your code then hashes a re-encoded version, the signatures will never line up. Disable the body parser for that route and hash the raw payload, joined as version, timestamp, and body with full stops. Also accept multiple comma-separated signatures during a secret rotation, and reject anything outside the five-minute timestamp tolerance.
Can I test Apple Pay in the Revolut sandbox?
You cannot; Apple Pay is not available in the sandbox environment, which trips up teams expecting to validate every method in test. Everything else, card payments, forced 3D Secure, declines, and webhook verification, you can and should exercise in the sandbox. Apple Pay requires a verified production domain, the hosted .well-known validation file, and a domain registered through the Merchant API, so plan a single controlled real order in production to confirm the wallet works rather than assuming it will.
Is there a synchronous way to get Revolut payment results?
Not for payment outcomes. Revolut does document a "synchronous webhook," but per its own reference that feature exists narrowly for real-time address validation during fast checkout, not for returning payment results in the same request. The payment verdict is asynchronous by design and arrives through the standard webhook. The right pattern is to accept that asynchrony, treat the webhook as truth, and poll the order status as a fallback if a webhook is delayed, rather than searching for a flag that makes the result synchronous.
How is this different from the standard Revolut WooCommerce plugin?
This guide is about running the Merchant API directly from your own serverless backend on Vercel, where you own the order flow, the wallet gating, and the webhook reconciliation. The standard plugin instead lives inside WooCommerce and manages that lifecycle for you, which is the right call for many stores. The reasons a brand-sensitive store might rebuild the gateway experience inside WooCommerce, unreliable wallets, a jumpy checkout, and card fields that undermine trust, are a separate story told in why I built a custom Revolut checkout.
Have a project in mind?
Let's turn it into custom software that moves your business forward.