We had one Stripe account, in Australia, taking USD off American customers and settling it out as AUD. Roughly 2% of every US transaction went to the international card surcharge and the conversion, before whatever FX did between authorization and settlement. Tax season was worse than it needed to be, and per-region revenue was something you assembled by hand rather than read off a dashboard. The CEO wanted clean financials and lower fees with nothing visible to customers. How we do this, was for me to figure out.
So: a second Stripe account in the US, and everything American moves onto it.
Two products. Tutoring had 2,500 US customers, most of them actively paying, billed per lesson. Many with credits and coupons. Schools had 20,000 US customers and a few dozen subscriptions. Almost all of the risk was in the smaller number. Tutoring went first.
A customer, for the purposes of this, is a Stripe customer object, one or more saved cards, sometimes a cash balance in USD, sometimes a coupon with a promo code attached, sometimes a subscription, and sometimes an invoice that hasn't settled yet.
Six things. Stripe gives you a self serve PAN copy tool that moves two of them.
What the tool does
Card data can only move between Stripe accounts through their self-serve PAN copy tool. It's the only PCI-compliant path, so there's nothing to decide here, you must use it.
It copies the customer objects and preserves the customer IDs. That's the part that makes the whole migration tractable, and it's worth being specific about why. We store the Stripe customer ID on our own customer row, and everything else we hold against Stripe hangs off it. If the tool had reissued customer IDs, every reference in our database would have needed remapping, in the right order, with a lookup table that had to survive the whole cutover. Instead cus_... on the new account is the same string it was on the old one, and the join we already had keeps working.
It copies the attached payment methods, under new IDs, and hands you a CSV mapping every old payment method ID to its new one.
That's the feature. It does not move credits. It does not move coupons or promo codes. It does not move subscriptions or invoices, and it does not move any metadata beyond the customer record itself.
The scripts
Everything was written in Go, one script per object type, all the same shape: default to a dry run that prints what it would change and against which records, take a flag to actually write. Nothing in the set mutates anything without the flag. They're rate limited as well, with enough headroom left under Stripe's limit for production traffic to carry on unaffected, because the migration is sharing that API with the live product the entire time it runs.
The payment method remap is the CSV. For each row, find our record by the customer ID, swap the old payment method ID for the new one. It's the easiest script in the migration precisely because of the ID preservation above.
Credits are a read from the old account and a write to the new one. Shortest script of the lot. This one gave me some pain, but let's save the interesting parts for later.
Coupons
We issue per-customer coupons with a promo code attached to each one. Moving a coupon means recreating it on the new account with the same discount, the same customer restriction, and however many uses that customer has left.
This one is where things go slightly wrong. Stripe lets you put a usage limit on the coupon or on the promo code, and sales had used both, at different times, for reasons that made sense to whoever was doing it. Sometimes the limit is on the coupon. Sometimes on the code. Sometimes there's one on each and they disagree, and then you have to decide which one the customer would consider correct, which is not a technical question.
There's no rule I could write down. Per customer, the number of uses left is wherever the person who set it up happened to put it, so the script reads both sides and works out which one is real. After a lot of pain and a lot of unit tests, I was confident this was now correct.
The invoices we didn't touch
Open and failed invoices are still moving while you migrate. Stripe retries a failed payment on its own for several days. A customer pays an open invoice whenever they get round to reading the email, or whenever Stripe feels like charging their card. Both of those have days left to run and a migration is an instant.
The obvious move is to void them all at cutover and recreate them on the new account, and I didn't want to. You'd be cancelling invoices that were an hour from settling by themselves, then sending someone a second invoice with a different number for money they already owe, and each one of those becomes a conversation with support about a billing relationship that was fine before we came along.
So we left them. 48 hours on the old account, let the retries and the human payments clear whatever they were going to clear, then a script two days later at midnight that voided what was still open and recreated it on the new side. What was left needing a person was a much shorter list than what we started with.
Which meant the old account was still alive for two days, and still sending us webhooks about customers who didn't live there any more.
Webhooks
The backend is two regional clusters, sharded by region. One Stripe account made this easy. Everything went to AU and AU forwarded whatever wasn't its own. With two accounts neither cluster owns the truth, because the account an event came from no longer tells you which cluster the customer is in.
The design I want is each Stripe account pointed straight at the cluster that owns its customers. Both backends already have endpoints that take Stripe webhooks natively, signature verification and all. Nothing in the middle, nothing extra to operate, maybe a day of work.
We shipped a Cloud Function instead. One entry point in front of both accounts, verifying the incoming signature against both accounts' signing secrets, looking up which region the customer belongs to, forwarding to that cluster. Another senior engineer wrote the forwarding half of it while I was on the scripts.
The reason is those 48 hours. The AU account is still emitting events about customers who now live in the US cluster, every time one of those old invoices settles or fails again. An endpoint per region assumes the account that sent the event owns the customer it's about, and for two days that isn't true. The function is the only thing in the system that routes on the customer rather than on the sender.
It comes out when the last invoice on the old account closes. I'd rather run the ugly one with a date on it than the clean one that drops events during the week it matters.
Two smaller ones
Stripe won't let you archive a customer, and deleting them meant losing their history and their data, so the old account keeps all records that look exactly as real as the live ones, forever. A script went through and put (ARCHIVED) on the end of every name. Finance and sales work out of that dashboard all day and nobody is going to operate on a row with that on it.
Subscriptions were a schools problem. Most of them are annual and some had been paid days earlier. Create the subscription the normal way on the new account and Stripe raises an invoice for another year straight away, so a script pulled status, trial state, renewal date and applicable coupons for every active subscription, recreated each one on a trial ending at its existing renewal date, then cancelled the original on the AU side. The trial does nothing except keep Stripe from billing until the date the customer has already paid through.
Midnight
A few hours before the window I ran everything dry against production one more time and read the output. Nothing new in it.
Payment button off on the US app, which nobody was pressing at that hour anyway. Start the copy.
Stripe's docs say it can take up to three days. The writeups I could find said a couple of hours for under 2,000 customers. We'd budgeted three, with something written down for what to do if it ran past that, and really the whole window existed for this one step.
Ten minutes.
Then two hours and fifty minutes of metadata scripts, the cloud function, the backend deploy, live keys and QA, most of which I'd rehearsed enough times that afternoon that I was mostly reading output and checking it said what it had said the last four times.
Schools went three weeks later. Same scripts, same order, eight times the customers, an hour and a half instead of ten minutes. No failures, nothing rate limited. That's the whole story of the second one.
Two audits ran afterwards, both times. For every migrated customer: exists on the new account, has the expected number of payment methods, they're attached, and the IDs match what's in our database. For every customer with a non-zero balance on the old account: the same balance exists on the new one. On the schools side a third checked that every active subscription had been copied, that nobody had been charged twice, and that the original on the AU account was cancelled.
The payment methods were never a problem, for what it's worth. Clean in both audits, both times.
Wrong balance
Reading a customer's balance off the customer object is the obvious way to read a customer's balance. It's what the field is for. Stripe lets a customer hold balances in more than one currency, and the customer object gives you a balance, and we took it.
For five customers it gave us the AUD one, which was zero. The USD balance was sitting right there, funded, but we wrote a zero to their balance.
The credit audit read the same field.
So it passed. Two scripts asking one API the same question agree with each other whatever the answer is, and everything we could see said the migration was fine, and all of it was coming from the same place. The audit checked every customer with a balance, on both accounts, and reconciled them. It just asked the question the same way twice.
An account manager caught it, about ninety minutes after we finished. She'd added credits to one of her accounts the week before, went looking for them on the new account, and knew what the number should have been.
I still don't know why the API did that, and I didn't chase it very far. The fix was to stop reading balances that way, query the cash balance API for AUD and USD explicitly, and run a correction over the five. All of them were right again the same day, before any of them logged in.
Those five accounts were the only thing that went wrong in either migration. Nobody was billed wrong, no ticket was opened, and we never wrote it up. What I'd change is the audit. Check the number against something that didn't come out of the same call, and run it before the cutover rather than after.
Five days of building. A month of waiting for Stripe to verify the new US business account before any of it could run, which there was nothing to do about.
Top comments (1)
The webhook routing during the settlement tail is the part most people wouldn't see coming until it broke in prod. Routing by customer gets the event to the right cluster, but I'd pair it with handlers that are idempotent on the business action, not just the Stripe event id, for that window. Both accounts can emit for the same customer within the same few days (a final AU invoice.paid landing right as the first US one does), and if "grant access" or "add credit" keys off the event you can double-apply. Keying on customer + invoice + purpose makes the overlap harmless. Clean writeup, and the (ARCHIVED)-suffix trick is a nice touch.