helipod
Core Concepts

HTTP and webhooks

Expose a plain HTTP endpoint for webhooks and REST calls with httpAction and a conventional http.ts router.

A webhook doesn't know or care about your sync protocol. Stripe, GitHub, and your email provider all want the same thing: POST some JSON at a URL and get a response back.

httpAction and a conventional http.ts router give you exactly that: a plain HTTP endpoint, wired into the same reactive engine as the rest of your app. This page walks through building one.

POST /webhooks/... ctx.runMutation data changed push update Third-party service (Stripe, GitHub, ...) httpAction Mutation Database Subscribed query Your app

Write the handler with httpAction

httpAction is an action that speaks raw HTTP instead of typed arguments. Its input is a Web Request, its output is a Response, but everything else about it is a regular action:

  • ctx.runQuery(ref, args), ctx.runMutation(ref, args), ctx.runAction(ref, args), each a fresh, independent top-level run, exactly as in an action.
  • Native fetch, Date.now(), Math.random(), timers all work normally.

No ctx.db

An httpAction isn't part of any transaction, so the only way it touches data is by calling a query, mutation, or action through the three methods above.

helipod/http.ts
import { httpAction, httpRouter } from "./_generated/server";

export const receiveWebhook = httpAction(async (ctx, request) => {
  const body = (await request.json()) as { author: string; body: string };
  await ctx.runMutation("messages:send", { author: body.author, body: body.body });
  return new Response(JSON.stringify({ ok: true }), {
    status: 200,
    headers: { "content-type": "application/json" },
  });
});

httpAction takes either a bare handler function or { handler }. There's no args/returns validator option the way query/mutation/action have one, because the input is a Request, not a validated argument object. Parse and validate the body yourself, the same as you would in any HTTP framework.

Because the handler has no ctx.db, its actual write goes through ctx.runMutation. That mutation is what produces a write set and fans out to any subscription reading the messages table (see Reactivity). This is the shape every webhook in helipod takes: HTTP in, mutation for the write, reactive fan-out for free.

Register it in http.ts

A conventional helipod/http.ts default-exports an httpRouter() populated with route() calls. This is how the engine discovers your endpoints at load time:

helipod/http.ts
import { httpRouter } from "./_generated/server";
import { receiveWebhook } from "./webhooks";

const http = httpRouter();

http.route({ path: "/webhooks/messages", method: "POST", handler: receiveWebhook });

export default http;

Each route() call takes exactly one of path or pathPrefix, plus a method:

  • { path, method, handler }: an exact match. The request's path must equal path character for character.
  • { pathPrefix, method, handler }: a prefix match. The request's path must start with pathPrefix (so pathPrefix: "/webhooks/" matches /webhooks/messages and /webhooks/anything).

Passing both, or neither, throws at registration time: http.route requires exactly one of `path` or `pathPrefix` .

The handler must be a named export of an app module, something httpAction(...) produced. Registering an inline arrow function or an unresolvable reference fails project loading with:

http.route handler for "/webhooks/messages" must be an exported httpAction
(declare it as a named export of an app module)

Understand match precedence

When a request could match more than one registered route, resolution is deterministic:

  1. Method must always match. A route registered for POST never matches a GET request to the same path, regardless of exact or prefix.
  2. An exact path match wins outright over every pathPrefix route, no matter how specific the prefix. This is checked first and returns immediately.
  3. Among pathPrefix routes, the longest matching prefix wins. pathPrefix: "/webhooks/stripe/" beats pathPrefix: "/webhooks/" for a request to /webhooks/stripe/invoice.
helipod/http.ts
http.route({ path: "/webhooks/stripe/health", method: "GET", handler: healthCheck });
http.route({ pathPrefix: "/webhooks/stripe/", method: "POST", handler: stripeHook });
http.route({ pathPrefix: "/webhooks/", method: "POST", handler: genericHook });

With those three routes registered:

requestwinning routewhy
GET /webhooks/stripe/healthhealthCheckexact path beats any prefix
POST /webhooks/stripe/invoicestripeHooklongest matching prefix wins
POST /webhooks/messagesgenericHookonly /webhooks/ matches

There's no support for named path parameters (/webhooks/:id). A route only ever knows the exact path or a prefix it starts with, so a handler that needs the rest of the path reads it itself, from new URL(request.url).pathname.

An unmatched {method, path} combination, whether there's no exact match, no prefix match, or one of the reserved built-ins below already claimed it, falls through to a plain 404.

Know the reserved paths

/api/* and any path whose first segment starts with _ (for example /_dashboard, /_admin/anything) belong to the engine: the sync WebSocket, /api/run, /api/health, file storage's /api/storage/*, the admin API, and the dashboard all live there.

route() rejects a conflicting registration at registration time, before your http.ts can ever shadow a built-in:

http.route({ path: "/api/foo", method: "GET", handler: whatever });
// throws: http.route path "/api/foo" is reserved (/api/* and /_* belong to the engine)

A malformed http.ts fails loudly and immediately this way, rather than silently never firing or, worse, intercepting traffic meant for the engine. The reservation is a single predicate: the path is /api, starts with /api/, or matches /^\/_/. So /apix is fine (it doesn't start with /api/), but /api, /api/, and anything under /api/... are all rejected.

Call it

The endpoint is just HTTP. No client SDK required:

curl -X POST https://your-deployment/webhooks/messages \
  -H "content-type: application/json" \
  -d '{"author": "webhook", "body": "hello from the webhook"}'

Any client already subscribed to a query over the messages table, a useQuery(api.messages.list) in a running app, for example, receives the update the moment this request's inner mutation commits. No polling, no extra wiring on the client side. This is the same reactive fan-out any mutation gets, whether it's called from the client SDK, another function, the scheduler, or, as here, a webhook.

Reading the caller's identity

No built-in verification

If the request carries an Authorization: Bearer <token> header, that raw token is passed straight through to the handler's context as its identity. helipod does not decode, validate, or look up the token for you. It's handed to your handler exactly as the caller sent it.

If you need to check a webhook's authenticity (an HMAC signature, a shared secret, a provider's own signing scheme like Stripe's Stripe-Signature header or GitHub's X-Hub-Signature-256), verify it yourself inside the handler, reading whatever header the provider actually signs with, before calling ctx.runMutation. This is exactly what you'd do in any other HTTP framework. httpAction doesn't add or remove anything here.

Hot reload

helipod dev's watch loop re-resolves http.ts on every save, exactly like it reloads your queries, mutations, and actions. The dev server exposes a setRoutes(routes) method that the reload path calls with the freshly-resolved route table, and the very next request sees the new routes. Nothing in the running WebSocket sync connections, active subscriptions, or in-flight mutations is disturbed. Only the HTTP route table itself is swapped in place.

You can add, remove, or repoint a http.route() call and see it live within one reload cycle, with no server restart. Same DX as editing a query or mutation.

Going deeper

What's not here

Non-goals

A few things a general-purpose HTTP framework offers are deliberately not part of httpAction and http.ts, and aren't planned:

  • Streaming request or response bodies. A handler reads the whole request body and returns a whole Response body. There's no chunked or streaming I/O in either direction.
  • Automatic CORS. If a browser needs to call your endpoint cross-origin, set the Access-Control-* headers yourself in the Response you return (and handle OPTIONS yourself if you need a preflight).
  • Named path parameters (/webhooks/:id). Routes match on an exact path or a prefix only. Read anything beyond the matched prefix from the request's own URL inside the handler.
  • Per-route middleware. There's no chain of route-scoped use() handlers. Cross-cutting concerns like auth checks, logging, or rate limiting go inside the handler itself, or as a shared helper function each handler calls.

If you need any of these, build it inside the handler body. The primitives (Request, Response, ctx.runQuery/runMutation/runAction) are the same ones a hand-rolled Node or Bun HTTP server would give you.

  • Actions: the non-deterministic execution model httpAction shares, including ctx.runQuery/runMutation/runAction semantics.
  • Mutations: the only way a webhook's data actually gets written.
  • Reactivity: why a write from a webhook shows up in a live query with no extra code.
  • File storage: _storage's own reserved /api/storage/* routes are an example of the same reserved-route mechanism, built into the engine rather than a component.

On this page