Last week we renamed Happening to Frae. The headline promise we made to users was that nothing would break: same accounts, same events, and crucially, every link anyone had ever shared would keep working. This post is the engineering behind that promise, including the twenty minutes where I personally broke it.
A rename sounds like a find-and-replace. It is not. The find-and-replace is the easy 90%. The hard 10% is everything that has your old domain baked into it and is now out in the world, on other people's phones, beyond your reach.
Rule one: both domains serve, forever
The first decision was that the old domain does not get switched off. It gets demoted. Both the old domain and frae.app point at the same servers. The new one is canonical: it is what we advertise, what the apps talk to, what the canonical tags declare. The old one stays alive purely to catch traffic and forward it.
There is no date in the calendar where we turn the old domain off, because there is no date where we can prove the last old link has stopped being tapped. Someone screenshotted an invite in 2025 and will send it to a friend in 2027. Old links are forever, so the old domain is forever. Budgeting for a domain you will pay to keep alive indefinitely is part of the true cost of a rename, and it is worth every penny.
308, not 301: the redirect that keeps POST bodies
The obvious way to forward the old domain is a 301 Moved Permanently. That is what everyone reaches for, and for a plain web page it is fine. It is the wrong choice here, and the reason is the shipped native clients.
Older versions of our app are installed on phones right now, and some of them have the old API domain compiled in. Those clients make real POST requests: marking availability, creating an event, signing in. Here is the trap. When a browser or HTTP client follows a 301 or a 302 on a POST, the long-standing behaviour is to retry the request as a GET and drop the body. Your carefully constructed POST with a JSON payload arrives at the new domain as a bodyless GET, matches nothing, and the user's tap silently does nothing.
308 Permanent Redirect and 307 Temporary Redirect are the two status codes that are contractually required to preserve the method and the body. We used 308: permanent, method-preserving. Old clients POST to the old domain, receive a 308, replay the exact same POST to frae.app, and it works. The user never knows there was a hop.
If you take one thing from this post: the difference between 301 and 308 is invisible in a browser address bar and catastrophic for a shipped mobile client. Test the redirect with an actual POST, not a page load.
The files you cannot redirect: .well-known
Deep links are what make an invite open the app instead of the browser. On iOS that is Universal Links, backed by a file at /.well-known/apple-app-site-association. On Android it is App Links, backed by /.well-known/assetlinks.json. Both files tell the operating system "this domain and this app belong to each other".
We assumed, briefly and wrongly, that the 308 redirect would cover these too. Redirect everything on the old domain to the new one, including the .well-known files, and let Apple and Google follow the hop. They do not follow the hop. The app-link verifiers fetch the association file with a direct request and treat a redirect as a failure. A redirected association file is, as far as the verifier is concerned, no association file at all.
So both domains serve their own real .well-known files, directly, with no redirect. The old domain keeps a valid apple-app-site-association and assetlinks.json so that links already associated with it keep opening the app. The new domain serves its own copies so new links work. This is one of the few places in a rename where you genuinely keep the old identity running rather than forward it. The association files are not something you migrate; they are something you keep alive in parallel.
The twenty minutes I broke the guest page
Now the mistake. The guest invite page, the no-account web page a friend taps to mark availability, is a small static HTML page with a script that talks to the API. It needs to know which API to talk to. Rather than hard-code the domain, the page carries the base URL as a data attribute and the script reads it off that attribute at load time. Clean, until the domain changes.
During the cutover I updated the base URL the page hands to the script, and I tightened the Content Security Policy at the same time to name the new domain in the connect-src directive. I updated one and not the other. The page was serving the new frae.app base URL to the script, while the CSP connect-src still whitelisted only the old domain. The browser did exactly what a CSP is supposed to do: it blocked every fetch to frae.app as a policy violation. No visible error on the page, just a guest tapping dates while nothing happened, and a wall of CSP violations in a console that no guest will ever open.
It was live for about twenty minutes before a test invite on my own phone showed me a dead page. The fix was one line, adding frae.app to connect-src, but the lesson is bigger. The base URL and the CSP that permits connections to it are two halves of one fact, stored in two different files. Changing one without the other is a silent break, and it is silent precisely on the one page that has no logged-in user to complain to you. Guest pages fail quietly. Save your extra paranoia for the surfaces where the user has no account and no reason to tell you anything is wrong.
What we deliberately did not rename
The instinct during a rebrand is to make everything say the new name. Resist it. A pile of internal identifiers are load-bearing precisely because they are opaque and stable, and renaming them ranges from pointless to genuinely dangerous.
- The app bundle identifier stays exactly as it was. Changing the bundle ID does not rename your app; it publishes a brand new app that shares nothing with the old one: no reviews, no install base, no update path for existing users. The name on the icon changed. The identifier the stores key off did not.
- Message queue names stayed frozen. The queues have the old brand in their names. Who cares. A queue name is a contract between a producer and a consumer, and renaming it during a live cutover buys you nothing but a window where in-flight messages fall on the floor. They keep the old name today, and they will keep it.
- Cookie names stayed frozen. Rename the session or refresh cookie and every currently-logged-in user is instantly logged out, because their browser is still sending the old cookie name that your server no longer reads. A silent mass logout is a great way to make a "nothing changes" rename feel like everything broke.
- Database identifiers, JWT claims, internal package names. All invisible to users, all frozen. Our Java package is still com.happening.server. It compiles, it runs, nobody sees it, and renaming it would have been a thousand-file diff in exchange for zero user-visible benefit and a fresh opportunity to introduce a bug.
The rule we followed: rename what users read, freeze what machines read. If a string is only ever seen by another computer, its job is to be stable, not to be on-brand.
Email: the cutover you have to start early
Our transactional email goes through Resend, sending from a subdomain of the brand. New brand, new sending domain: notifications.frae.app, which meant new DKIM and SPF records and waiting for verification before a single email could leave from it. The trap with email is that deliverability is earned slowly and lost instantly. You cannot flip the from-address to a brand new domain with no sending reputation and expect inboxing to hold.
So the new sending domain was verified before the cutover, not during it. We stood it up in Resend, got the DNS records green, and only then pointed the from-address at it. Inbound support mail moved the same way: MX records for the new domain to Resend, with both old and new support addresses accepted during the overlap so a reply to an old thread still lands in the right place. The one hard rule with email is that you only ever send from a domain whose DKIM and SPF you have already verified, because the alternative is your invite emails landing in spam during the exact week you most want them delivered.
Did we keep the promise?
Mostly. Every shared link still resolves, old clients still work, nobody lost an account or an event, and the deep links still open the app. The guest page was down for twenty minutes for the specific people who tapped a brand new invite inside that window, which is twenty minutes more than "not a single link" is supposed to allow. So the honest headline is: we broke one thing, briefly, and it was a CSP typo.
If you are about to rename a live app, here is the whole playbook on one line. Keep both domains forever, redirect with 308 and not 301, serve your .well-known files directly on both domains, freeze every identifier a machine reads, verify your email domain before you cut over, and test the guest page on a real phone before you tell anyone you are done. That last one would have saved me twenty minutes.
