Revolut Merchant API on Vercel: Wallets, 3D Secure, and the Sync vs Async Trap
The Revolut Merchant API is a clean way to take card payments and wallet payments, and Vercel is a comfortable place to run the small serverless backend it needs. The catch is that most of the hard-won lessons live in two places the happy-path tutorials gloss over: how 3D Secure actually surfaces, and the difference between what your front end thinks happened and what truly happened. This is a practical walk through a real integration, with the load-bearing claims checked against Revolut's official documentation so you can verify them rather than trust a screenshot.
Setup and authorization
Per the Merchant API documentation, you work with two keys: a public one used in the browser at checkout, and a secret one used only on your server, sent as a bearer token on every server-to-server call.
Authorization: Bearer <your-secret-key>
Revolut-Api-Version: 2024-09-01
Two things to get right immediately. The API is versioned with a dated Revolut-Api-Version header, so pin a version explicitly rather than drifting with whatever the default becomes (see the API versions page). And sandbox and production are entirely separate environments with separate credentials and separate hosts: https://sandbox-merchant.revolut.com/ for testing and https://merchant.revolut.com/ for live. You switch by changing the host, nothing else, but objects do not cross between them.
The order-first model
Revolut is order-centric. Before any money moves, your server creates an order, documented under create order, and the response gives you a permanent id for later management plus a short-lived token for 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 detail that causes the most embarrassing bug is the very first field. The amount is in the minor currency unit, so 7034 means seventy euros and thirty-four cents, not seven thousand. Send a decimal or forget the factor of one hundred and you have a real charge that is wrong by two orders of magnitude. The capture_mode is either automatic (the default, capture follows authorization) or manual (you capture later through the order's id). Following the order payment flow, the order moves through states pending, processing, authorised, completed, cancelled, and failed. With automatic capture you fulfil on completed; with manual capture you fulfil on authorised, then capture. One more thing learned the hard way: the browser token expires the moment the payment is authorized, while the permanent id is what every server-side call uses afterward.
The client and the wallets
On the browser you use 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 offers a pop-up flow, an embedded card field, and a unified embedded checkout that aggregates methods. Apple Pay and Google Pay come through the SDK's wallet entry point, built on the browser Payment Request API, and there are two rules from the wallets guide that you cannot skip:
- Always gate the wallet button on availability. Call
canMakePayment()and only render the button when a wallet is actually present, otherwise you show a dead button to half your visitors. - Apple Pay needs domain verification. You host Revolut's validation file at
/.well-known/apple-developer-merchantid-domain-associationon your production domain, then register the domain through the Merchant API (register domain for Apple Pay). Google Pay needs no extra registration step.
The pitfall that costs an afternoon: Apple Pay is not available in the sandbox, so it can only be exercised in production. Plan a careful production smoke test rather than expecting to validate it in test like everything else. On Vercel, make sure your routing serves that .well-known file as a static asset and that it stays reachable, since Apple revalidates it.
How 3D Secure actually behaves
Strong customer authentication is mandatory, and the good news is that when you use the SDK widget, 3D Secure is handled for you. Per the 3D Secure overview, the widget collects device data, runs the frictionless path when the issuer allows it, and renders the bank challenge (one-time password or bank-app approval) inside the iframe when a full challenge is required. You implement nothing extra for the common case. You can force a challenge at order creation with enforce_challenge: forced (cards only), and the default automatic lets the fraud engine decide.
Two realities to design for. First, issuers can soft-decline and ask for re-authentication, and there is a soft_declined state, so a failed first attempt is not always a dead end. Second, if you bypass the widget and drive payments server-to-server, you inherit the challenge handling yourself: the payment surfaces an authentication-challenge state with a challenge object you must act on and then poll for the result. For most teams the widget is the right call precisely because it keeps the challenge, including any redirect-style rendering, out of your code.
The sync versus async trap
This is the part worth slowing down on, because it is where confident integrations ship a real bug. There is no magic async flag on the order or payment that flips the behavior. Instead, two different things are synchronous and asynchronous, and conflating them is the trap.
What is synchronous: your create-order API call returns immediately, and the widget's onSuccess callback fires in the browser when the customer finishes. What is asynchronous: the authoritative payment outcome. Revolut delivers the final result through webhooks, and its own guidance, in working with webhooks, is to treat the webhook as the source of truth and not rely solely on the front-end callback. Fulfil on the ORDER_COMPLETED webhook for automatic capture, or ORDER_AUTHORISED for manual capture, not on onSuccess.
Two further facts make this concrete:
- Event order is not guaranteed. Revolut states you might receive
ORDER_COMPLETEDbeforeORDER_AUTHORISED. Your handler must be idempotent and must not assume a sequence. - "Synchronous webhooks" are a different, narrow feature. Revolut does document a synchronous webhook, but per the synchronous webhook reference it exists only for real-time address validation during fast checkout. It is not a way to receive payment results synchronously. If you went looking for "sync payments" and found this, it is not what you wanted.
So the correct mental model is: synchronous request and synchronous front-end signal for UX, asynchronous webhook for truth, with polling the order status as a documented fallback if a webhook is delayed or missed.
Webhooks on Vercel: get the signature exactly right
A serverless function on Vercel is a fine webhook receiver, and Vercel serves it over HTTPS by default, which Revolut requires. The work is in verification, and one mistake invalidates all of it. 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
}
The serverless-specific rules that follow from this:
- Use the raw body. The signature is computed over the exact bytes Revolut sent. Any automatic JSON parsing breaks it, so disable the body parser for that route (App Router: read
request.text(); Pages Router:export const config = { api: { bodyParser: false } }). This is the single most common reason a correct-looking verifier fails. - Accept multiple signatures. During a signing-secret rotation the header can carry several comma-separated signatures, so match any of them.
- Respect the timestamp tolerance. Validate the timestamp within five minutes of now, and remember serverless clock skew is real.
- Be idempotent and fast. Revolut retries three more times at ten-minute intervals on failure, and because delivery order is not guaranteed, key your processing off the
order_idand ignore anything you have already handled.
A short field guide to the bugs that bite
Drawn from the behavior above, these are the ones that actually show up:
- Amount off by one hundred, from treating
amountas a decimal instead of minor units. - Treating
onSuccessas payment confirmation and fulfilling before the webhook, which over-fulfils on edge cases. - A webhook verifier that always fails because the framework parsed the JSON before you hashed the raw body.
- Assuming
ORDER_AUTHORISEDarrives beforeORDER_COMPLETED, when the order is not guaranteed. - Hunting for an
asyncswitch to get synchronous results, when the asynchrony is the design and the webhook is the truth. - Trying to test Apple Pay in the sandbox, where it does not exist, instead of a controlled production check.
How we ship this without drama
Payments earn caution, so the process is the point:
- Sandbox first, end to end, including forced 3D Secure via
enforce_challenge, declines, and webhook verification against the raw body, before any live key exists. - A fixed-scope first phase, usually one clean card payment reconciled by webhook, before wallets and manual capture get layered on.
- A deliberate production pass for the production-only paths, Apple Pay and any live-only method, on a controlled test order.
- You own the keys, the function, and the data. It is your Revolut account and your Vercel project, with no lock-in, which is what makes it safe to trust and extend.
Proof, not promises
We run real serverless backends on Vercel for production software, including the customs-invoice.com compliance platform, and we ship headless commerce with full payments such as the LeO-Optic store. Money-sensitive logic, verified webhooks, and careful handling of the asynchronous truth are the everyday substance of that work, which is exactly what a clean Revolut integration needs. The same webhook discipline shows up in our companion guide to embedding Stripe in a headless WooCommerce and Next.js store, and the integration mindset behind both is covered in why connecting your stack beats copy and paste.
If you are wiring Revolut into a serverless backend and want the wallets, the 3D Secure handling, and the async reconciliation done so it is correct and calm in production, tell me what you are building and I will give you a straight read on the cleanest first phase.
Have a project in mind?
Let's turn it into custom software that moves your business forward.