●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
Stop Spinning Up a Separate Server: Colocate Your Backend With Expo Router API Routes
Standing up yet another Worker just to receive a RevenueCat webhook gets old fast. Expo Router API Routes let you put the backend inside the same repo as your app. Here is the implementation, from signature verification to picking a deployment target.
Last week I was getting my seventh small app ready to ship, and I stopped mid-task. To receive a RevenueCat webhook, I was about to spin up another Cloudflare Worker, set its environment variables, note the deploy URL, and paste it into a dashboard. I had done this exact dance so many times that my hands knew it before my brain did.
Workers are a fine tool. But every new app meant two new repositories: the app itself, and a tiny server that existed only to serve that app. Versions drifted. I forgot which secret lived where. I would deploy one side and not the other, and things quietly fell out of sync.
That is when I finally took Expo Router's API Routes seriously — and the friction changed. You put the backend in the same repository as the app, under app/api/. This article walks through that implementation using a RevenueCat webhook as the working example, including signature verification and how to choose where to deploy.
The Invisible Cost of One More Standalone Worker
Let me be honest about why I switched to colocating.
As an indie developer running iOS and Android apps for years, the server-side work I actually need tends to be small. Verify an AdMob rewarded SSV callback. Receive a RevenueCat or App Store webhook and write it to my own database. Put a single proxy in front of an API so the key never ships to the client. Each of these is a few dozen lines.
For those few dozen lines, I was provisioning a separate repo and deploy pipeline every single time. Stacked across every app I ship, the cost is not the amount of code — it is the number of things to manage. I had small Workers scattered across six apps, and I could not tell which belonged to which without opening the README.
API Routes are a way to reduce that count. One repo per app. The backend lives inside it.
API Routes Are a Backend That Lives Inside the App
Expo Router API Routes work like this: put a file with the +api.ts suffix inside your app/ directory, and it becomes an endpoint that runs on the server. It is the same file-based routing convention you already use for screens, extended to the server side.
Create app/api/hello+api.ts, and you get an endpoint you can call at /api/hello. Inside, it is a function that takes a standard Web Request and returns a Response. If you have touched Next.js Route Handlers, this will feel almost identical.
There is one prerequisite that trips people up. API Routes need a server runtime, so you must set web.output to "server" in app.json. Miss this, and the build succeeds while every endpoint returns a 404 — a frustrating state to debug because nothing looks broken.
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Receive RevenueCat webhooks with a single app/api/webhook+api.ts file (copy-paste TypeScript included)
✦Signature verification, where to store secrets, and an EAS Hosting vs. Vercel comparison in one table
✦The exact rule I use across 6 apps to decide between a colocated route and a standalone Worker
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Start with the smallest possible thing so you can confirm the path is wired up. Three steps:
Create an app/api/ folder
Add hello+api.ts inside it
Run npx expo start and hit /api/hello
The file is just this:
// app/api/hello+api.tsexport function GET(request: Request) { return Response.json({ ok: true, message: "hello from the edge" });}
Export a function named GET, and it handles GET requests. Export POST, and you handle POST. The return value is a standard Response, so the Response.json() helper works directly.
Once this responds, the path is live. From here it is just a matter of growing that GET into the handler you actually need.
Receiving a RevenueCat Webhook
Now to the real work. RevenueCat POSTs subscription events — initial purchase, renewal, cancellation — to a URL you configure. Your job is to receive them and reflect the change into your own database or logs.
Create app/api/revenuecat+api.ts and write a POST handler.
// app/api/revenuecat+api.tsexport async function POST(request: Request) { // 1. Confirm authenticity via the Authorization header RevenueCat sends const auth = request.headers.get("authorization"); if (auth !== `Bearer ${process.env.REVENUECAT_WEBHOOK_SECRET}`) { return new Response("unauthorized", { status: 401 }); } // 2. Pull out the event body const payload = await request.json(); const event = payload.event; // 3. Branch by event type switch (event.type) { case "INITIAL_PURCHASE": case "RENEWAL": await grantEntitlement(event.app_user_id, event.expiration_at_ms); break; case "CANCELLATION": case "EXPIRATION": await revokeEntitlement(event.app_user_id); break; default: // Absorb unhandled events with a 200 so RevenueCat stops retrying break; } // 4. Always return 2xx. Return an error and RevenueCat retries forever return Response.json({ received: true });}
The line that matters most is step 4 in the comments. The rule for a webhook receiver is: if you received it, return 2xx. If you return a 500 because your processing failed, the sender assumes delivery failed and keeps retrying, and the same event arrives again and again. Catch database failures with your own queue or logs, and keep the outward response closed at 2xx. Separating those two concerns up front saves you later.
Signature Verification and Where Secrets Belong
The example above only checks the Authorization header, but some providers send an HMAC signature over the request body. In that case, compute the signature against the raw body string and compare it to the header value in constant time.
import { createHmac, timingSafeEqual } from "node:crypto";async function verifySignature(request: Request, secret: string) { const raw = await request.text(); // you need the raw body, pre-JSON const signature = request.headers.get("x-signature") ?? ""; const expected = createHmac("sha256", secret).update(raw).digest("hex"); const a = Buffer.from(signature); const b = Buffer.from(expected); // timingSafeEqual throws on length mismatch, so guard first if (a.length !== b.length) return { ok: false, raw }; return { ok: timingSafeEqual(a, b), raw };}
The easy thing to miss here is that signature verification needs the raw body before it is parsed as JSON. If you call request.json() first, the body is consumed and the string you need for the calculation is gone. Read the raw string with request.text() and run JSON.parse yourself.
Never put secrets in the repository. Reference them through process.env and register the values in your deployment target's environment variables — eas env:create on EAS Hosting, or the Environment Variables panel on Vercel. Never prefix a server-only secret with EXPO_PUBLIC_. That prefix bakes the value into the client bundle, where anyone who inspects the app can read it.
Deployment Target: EAS Hosting vs. Vercel
To ship an app that includes API Routes, you need hosting that can run a server. Here are the two I have actually used, laid out along the axes that help you decide.
Aspect
EAS Hosting
Vercel
Expo integration
First-party. One eas deploy
Needs a separate adapter
Deploy steps
expo export, then eas deploy
expo export, then vercel
Preview environments
Auto URL per branch
Auto URL per PR
Free tier
Plenty for a small indie app
Plenty for a small indie app
Best fit
You want to stay in Expo's world
You already use Vercel
For new apps, I lean toward EAS Hosting. The reason is simple: eas deploy sits in the same CLI lineup as building the app, so there is no context switch. If you already host a frontend on Vercel, align with that instead and you add nothing to manage. This is less about technical superiority than fitting your existing toolbox.
When to Use a Standalone Worker Instead
Colocating is not a cure-all. Here is the rule I actually apply across six apps.
Work whose lifetime matches the app gets colocated. Webhook receivers, auth proxies, aggregations specific to that one app — all of these become useless the moment the app dies, so they belong in the same repo.
Work shared across multiple apps stays standalone. My AdMob SSV verification uses the same logic across all six apps, so it lives in a single independent Worker. Copying that code into each app would mean fixing six places on every change, which flips the benefit of colocating on its head.
So the deciding question is: does this backend share a fate with this app? If yes, colocate. If it is a shared asset, keep it standalone. Drawing that one line made almost all of my hesitation disappear.
Three Things That Tripped Me Up in Production
Finally, three snags the docs do not put in bold. I hope they save the next person some time.
The first is the web.output: "server" setting I mentioned earlier. If it works on your local dev server but the API Routes vanish in a production build, suspect this first. I lost half a day to it.
The second is the order of request.json() and signature verification. Defer a check that needs the raw body, and the body is already consumed by the time you get there. Design for calling request.text() once for verification and parsing yourself from the start.
The third is webhook timeouts. If you synchronously await every database write and external API call, the response lags and the sender may treat it as a timeout. Hand heavy work off asynchronously after receiving, and close the receiver at 2xx immediately. In my setup, response time including cold start dropped from about 300ms to 80ms — roughly 4x faster.
Cutting one server means one fewer thing to monitor and one fewer place to forget. The next time you build a small app and wonder where the webhook receiver should live, I hope this helps you decide. Thank you for reading.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.