Every Meteor app we inherit has an HTTP surface nobody describes as one. There is a Stripe webhook bolted into WebApp.connectHandlers. There is a /api/v1/orders route added in 2017 for a partner integration that still runs nightly. There is a health check the load balancer calls, a CSV export that streams out of a collection, and an OAuth callback. None of it is in the architecture diagram, because the architecture diagram says "DDP."
That surface matters more than its size suggests. It is the part of the app that outside systems depend on, so it is the part you cannot break quietly. It is also the part that changed shape in Meteor 3, and the part a strangler migration leans on first. Worth taking an inventory of it before any of those things force the issue.
Why DDP left a gap in the first place
Meteor's default wiring is a WebSocket carrying DDP: methods for writes, publications for reads, Minimongo keeping the client in sync. Inside your own app that works well, and it is the reason the UI feels the way it does.
Nothing outside your app speaks it. A payment processor posting a webhook, a partner's integration team, a Zapier connector, a mobile client someone built in Swift, a monitoring probe, a reporting tool, an internal service in another language — all of them want HTTP and JSON. So every mature Meteor app grows an HTTP side door, usually one route at a time, usually by whoever needed it that week.
The result is predictable: three different styles of endpoint, three different auth schemes, and no single file that lists what exists.
Take the inventory first
Before changing anything, find every way an HTTP request reaches your server. The usual hiding places:
WebApp.connectHandlers.use(...)— the low-level Meteor hook. Grep for it everywhere, including inside packages underpackages/.Picker(meteorhacks:pickeror thecommunitypackages:pickerfork) — a router many apps adopted for cleaner route syntax.simple:rest,nimble:restivus, orrest-api-style Atmosphere packages — these auto-expose methods or collections over HTTP. Check what they expose, because "all methods" is a common default.- An Express or Koa app mounted inside the Meteor process — common in apps that outgrew the above.
Meteor.absoluteUrl()consumers — not a route, but a good way to find which external systems were told to call you back.- Your reverse proxy or load balancer config — sometimes a path is routed somewhere else entirely and no one on the app team knows.
For each route, write down four things: path and method, who calls it, how it authenticates, and whether anything breaks if it returns a 500 for an hour. That last column is the one that drives sequencing. A partner's nightly order sync and an unused 2019 debug endpoint do not deserve the same care.
Expect to find at least one endpoint with no authentication that reads data it should not. That is the normal outcome of an inventory, not a sign of a bad team — it is what happens when routes are added individually over eight years.
What Meteor 3 changed
Meteor 3 rebuilt WebApp on Express rather than Connect. For most apps this is good news and a small amount of work:
WebApp.connectHandlers.use(path, fn)still exists, and simple handlers generally keep working.WebApp.handlers(the Express app) is the forward-looking surface, andWebApp.expressgives you the Express instance when you need routers,express.json(), or anything else from that ecosystem.- Express 4-style middleware signatures are what the framework now expects. Handlers written against raw Connect semantics — especially ones that poke at internals, do their own body buffering, or depend on middleware ordering — are the ones to read carefully.
The bigger change is the same one that governs the rest of the Meteor 3 migration: no Fibers. HTTP handlers are ordinary async functions now. Any handler that called Collection.findOne synchronously, or ran a Meteor.call to do its work, has to become await findOneAsync / await Meteor.callAsync, and the handler itself has to be async. Two failure modes to watch for:
- A missing
awaitbeforeres.end(). The response goes out before the work finishes. In testing it looks fine — the write usually lands a few milliseconds later. In production under load, the caller gets a 200 for work that failed, and you find out from the partner, not from your logs. - Errors escaping an async handler. An unhandled rejection in an Express 4 handler does not become a 500 by itself; it becomes a hung request or a process-level warning. Wrap handler bodies in
try/catchand return explicit status codes. Be deliberate about it rather than relying on a framework default.
Anything that reached into Fiber-bound context inside a request — Meteor.userId() in a handler, for instance — was already a workaround. Make it explicit instead: read the token off the request and resolve the user yourself.
Auth on the HTTP side, stated plainly
DDP authentication does not carry over to HTTP requests. There is no session cookie doing it for you. Every endpoint needs an explicit answer, and in our experience there are only three good ones:
- Shared-secret / signature verification for webhooks. Verify the provider's signature against the raw body before parsing. This is the one detail that body-parsing middleware ruins: if
express.json()has already consumed and re-serialized the body, your signature check is computing a hash of something slightly different and will fail — or worse, someone "fixes" it by skipping verification. - API keys or service tokens for machine callers, stored hashed, scoped to a purpose, and revocable individually. Not one shared key in an environment variable that four partners use.
- Meteor login tokens for user-facing endpoints. The
userscollection stores hashed resume tokens inservices.resume.loginTokens. You can authenticate an HTTP request by hashing the presented token and looking it up — which is exactly what the REST packages do internally. Do it in one shared middleware, not per route.
Rate-limit the public ones. DDPRateLimiter does not cover HTTP, so this has to be handled at the proxy or in middleware.
Idempotency, because webhooks arrive twice
Payment and messaging providers retry. A webhook that charges credit, sends an email, or increments a counter will eventually run twice for the same event, and the way you find out is a customer complaining.
The fix is cheap and should be standard on every webhook route: take the provider's event ID, insert it into a webhookEvents collection with a unique index, and let the duplicate-key error be your "already processed" signal. Then acknowledge fast and do the real work in a job, so a slow handler never causes the provider to retry a request you are actually still processing. If you have already moved scheduled work onto a proper queue, reuse it here.
The strangler angle
If migrating off Meteor is on the table, this HTTP surface is where that migration starts — so it is worth cleaning up for reasons beyond hygiene.
An incremental migration puts a proxy in front and moves routes one at a time to a new application that shares the MongoDB database. The routes that move first are almost always the HTTP ones: they are stateless, they have no Minimongo or publication behavior to reproduce, and their contracts are already written down in whatever a partner integrated against. Moving /api/v1/orders to a Next.js route handler is a day of work and proves the proxy, the shared data layer, and the deployment path all function — before you touch anything reactive.
So an inventory done for operational reasons doubles as migration planning. The consolidation is worth doing even if you stay on Meteor for another five years: one router, one auth middleware, one error shape, one list of what exists.
Where to stop
Do not build an API gateway. Most Meteor apps need a dozen endpoints behaving consistently, not a platform. The target state is modest:
- One mounted Express router, with routes declared in files you can list.
- One auth middleware per caller class, applied explicitly.
- Raw-body access preserved on webhook routes.
- Idempotency keys on anything with side effects.
- Request logging with the same correlation ID you use for methods, so an HTTP request and the work it triggers show up together.
That is a week or two of work on a typical app, it does not block anything else, and it leaves you with a written contract for the part of the system other companies depend on. Whether that contract gets served by Meteor or by something else later becomes a deployment decision rather than a rewrite.