Look, Wise's docs are decent but they don't tell you about the stupid edge cases that'll waste your weekend. I've been through six months of production hell with this API - webhook signatures failing mysteriously at 3am, users abandoning flows because quotes expired, and rate limits hitting just when you need them not to.
Here's the real integration playbook - the gotchas that'll save your sanity and your sleep schedule.
Webhook Signature Verification Will Fuck You
The Problem: You parse the request body as JSON before verifying the signature. Wise uses RS256 and needs the raw body. This breaks silently in production.
How to Not Hate Yourself:
// This will break - you parse body first
app.use(express.json());
app.post('/webhooks', (req, res) => {
const signature = req.headers['x-signature'];
const isValid = verifySignature(JSON.stringify(req.body), signature); // NOPE
// Signature fails because JSON.stringify != original raw body
});
// This actually works
app.post('/webhooks', express.raw({type: 'application/json'}), (req, res) => {
const signature = req.headers['x-signature'];
const isValid = verifySignature(req.body, signature); // req.body is Buffer
if (!isValid) {
return res.status(401).send('Invalid signature');
}
const payload = JSON.parse(req.body);
// Now process your webhook...
});
I spent 4 hours on this because the error message "Invalid signature" tells you nothing useful. The real kicker? Express.js 4.18.2 specifically broke raw body parsing for webhook routes when they "improved" middleware handling. Every Stack Overflow answer from 2020-2022 is now wrong. Even worse: if you put express.json()
before your webhook route, it'll parse the body and break signature verification for ALL routes. Learned this the hard way when our entire payment system went down during Black Friday testing.
Production gotcha: If you're using nginx as a reverse proxy, proxy_request_buffering off;
is required or nginx will modify the request body and break signatures intermittently. Took our team a week to debug this because it only failed under load. The Wise signature examples work fine but don't mention these deployment gotchas.
Quote Expiration Will Ruin Your Day
The Problem: Quotes die after exactly 30 minutes. Your user gets distracted, comes back, clicks "Complete Transfer" and gets a cryptic error. Now they have to start over and you look incompetent.
Nuclear Option:
// Just refresh the damn quote when it's about to expire
function isQuoteExpiringSoon(quote) {
const expirationTime = new Date(quote.createdAt).getTime() + (30 * 60 * 1000);
const fiveMinutesFromNow = Date.now() + (5 * 60 * 1000);
return fiveMinutesFromNow > expirationTime;
}
// Check before every operation
if (isQuoteExpiringSoon(currentQuote)) {
currentQuote = await wise.quotes.create(quoteRequest);
}
Don't overthink this. Show a countdown timer in your UI and auto-refresh at 5 minutes remaining. Users hate starting over more than they hate seeing a brief loading spinner.
Bank Details Validation is a Nightmare
The Problem: Sandbox accepts garbage bank details that production rejects. Your beautiful demo breaks when users enter real routing numbers.
Reality Check:
// US ACH routing numbers: 9 digits, no dashes
// UK sort codes: 6 digits with dashes (XX-XX-XX)
// IBAN: varies by country, has check digits
const routingValidation = {
'USD': (routing) => /^\d{9}$/.test(routing),
'GBP': (routing) => /^\d{2}-\d{2}-\d{2}$/.test(routing),
'EUR': (iban) => iban.length >= 15 && iban.length <= 34
};
// Always validate before creating recipient
if (!routingValidation[currency](bankDetails.routing)) {
throw new Error(`Invalid routing format for ${currency}`);
}
The error "recipient_account_validation_failed" usually means wrong routing format - but the API response body will be empty or just say "Bad Request". Wise's validation rules are stricter in production than sandbox, and their error messages are about as helpful as a chocolate teapot. Validate everything client-side first.
Rate Limits Are Undocumented and Annoying
The Problem: Hit rate limits with no warning. The API returns 429 but doesn't tell you when to retry. Documentation is vague about actual limits.
What Actually Works:
// Exponential backoff when you hit 429
async function callWiseAPI(requestFn, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await requestFn();
} catch (error) {
if (error.status === 429 && attempt < maxRetries - 1) {
const delay = Math.min(1000 * Math.pow(2, attempt), 10000);
console.log(`Rate limited, waiting ${delay}ms`);
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
throw error;
}
}
}
Don't get fancy. When you hit 429, back off exponentially starting at 1 second. Most rate limit errors clear up quickly if you're not an asshole about retrying.
Production reality: Wise's rate limits reset every 5 minutes, not hourly like most APIs. The X-RateLimit-Remaining
header tells you how many requests you have left, and X-RateLimit-Reset
shows when the window resets. But here's the catch: these headers aren't always present in 429 responses, so your retry logic needs to handle missing headers gracefully. We found this out during a $500K transfer batch job that got rate limited and our naive retry logic hammered their API for 2 hours straight. Got a very polite but firm email from Wise's engineering team.
Environment Differences Will Bite You
The Problem: Sandbox behaves differently than production. Account creation is instant in sandbox but takes 1-3 days in production. Transfer statuses change differently.
Learn This the Easy Way: Always test account creation flows with real timelines in your staging environment. Mock the delays so your UX doesn't promise instant activation when it takes days.
These are the big five that'll wreck your integration timeline. But every developer hits different edge cases depending on their stack and requirements. The questions below cover the other gotchas you'll probably hit.