Mounting
The HTTP layer in depth — the router, the fetch and Elysia adapters, CORS, the request/response contract, and per-runtime mounting.
The HTTP layer reproduces Teardown's ingest wire contract so the React Native SDK works against your server unchanged. This page covers the router, the shipped adapters, CORS, the response envelopes and error codes, and how to mount on each runtime.
Pick your adapter
| Runtime | Adapter | Import |
|---|---|---|
| Bun, Hono, Deno, Cloudflare Workers, Next.js, any edge | fetch | @teardown/server/http/fetch → jump |
| Node / Express | fetch (via a Web Request shim) | @teardown/server/http/fetch → jump |
| Elysia | Elysia plugin | @teardown/server/http/elysia → jump |
The fetch adapter is the default — zero required peers, runs anywhere Web-standard
Request/Response exists. Use the Elysia plugin only on Elysia hosts.
The router
td.router(info?) builds a framework-agnostic TdRouter over four routes:
| Method | Path | Handler behaviour |
|---|---|---|
POST | /v1/identify | Extract headers → authenticate → validate body → services.identify.identify(headers, body) |
POST | /v1/events | Extract headers → authenticate → validate body → services.events.processEvents(headers, body) |
GET | /health | { status: "ok", timestamp, build_id?, service_id? } |
GET | / | { message, version } |
The optional info argument supplies runtime/build metadata for the two GET routes:
| Field | Type | Surfaced by | Default |
|---|---|---|---|
version | string | GET / version | the @teardown/server package version |
serviceName | string | GET / message | "Teardown Ingest API" |
buildId | string | GET /health build_id | undefined |
serviceId | string | GET /health service_id | undefined |
const router = td.router({ version: "1.4.0", buildId: process.env.GIT_SHA });A request that matches no route returns 404 { success: false, error: { code: "NOT_FOUND", message } }.
The fetch adapter
@teardown/server/http/fetch is the primary, universal adapter. toFetchHandler(router) returns (req: Request) => Promise<Response> using only Web-standard Request/Response/URL — no Node-only APIs. It:
- lowers the
Requestinto a normalized request (lowercased headers, parsed query, lazy JSON body), - short-circuits
OPTIONSpreflight to204with the CORS headers, - runs the router and serializes the result to a JSON
Responsewith the status, CORS headers, andcontent-type: application/json.
import { toFetchHandler } from "@teardown/server/http/fetch";
const handler = toFetchHandler(td.router());Bun
Bun.serve({ port: 4501, fetch: toFetchHandler(td.router()) });Hono
import { Hono } from "hono";
const app = new Hono();
const ingest = toFetchHandler(td.router());
app.all("/v1/*", (c) => ingest(c.req.raw));
app.get("/health", (c) => ingest(c.req.raw));Cloudflare Workers / Deno / edge
const handler = toFetchHandler(td.router());
export default {
fetch(request: Request): Promise<Response> {
return handler(request);
},
};Next.js route handlers
Mount the handler in an App Router catch-all route. Because the handler is a plain (Request) => Response, it maps onto the route methods directly:
// app/api/[...td]/route.ts
import { toFetchHandler } from "@teardown/server/http/fetch";
import { td } from "@/lib/teardown";
const handler = toFetchHandler(td.router());
export const GET = (req: Request) => handler(req);
export const POST = (req: Request) => handler(req);
export const OPTIONS = (req: Request) => handler(req);Node / Express
The fetch handler runs on Node 18+. Mount it on Express by shimming the Node request into a Web Request and writing the Web Response back:
import express from "express";
import { toFetchHandler } from "@teardown/server/http/fetch";
const handler = toFetchHandler(td.router());
const app = express();
app.use(async (req, res) => {
const url = `${req.protocol}://${req.get("host")}${req.originalUrl}`;
const body = ["GET", "HEAD"].includes(req.method) ? undefined : JSON.stringify(req.body);
const request = new Request(url, {
method: req.method,
headers: req.headers as Record<string, string>,
body,
});
const response = await handler(request);
res.status(response.status);
response.headers.forEach((value, key) => res.setHeader(key, value));
res.send(await response.text());
});Mount
express.json()before this middleware soreq.bodyis parsed.
The Elysia adapter
@teardown/server/http/elysia ships a first-class Elysia plugin (elysia is an optional peer). teardownElysia(deps) mounts the four routes and uses Elysia's native TypeBox headers / body guards to validate the request first — producing the 422 ValidationError the hosted ingest returns when you also mount @teardown/errors's ElysiaErrors handler — then delegates to the same handlers the fetch adapter uses. Auth failures throw ForbiddenError (rendered as 403).
import { Elysia } from "elysia";
import { ElysiaErrors } from "@teardown/errors/elysia";
import { teardownElysia } from "@teardown/server/http/elysia";
const app = new Elysia().use(ElysiaErrors).use(teardownElysia(td));
app.listen(4501);A Teardown instance satisfies the plugin's dependency type structurally, so you pass td directly. You can also pass { services, storage, clock, auth?, info? } if you build the pieces yourself.
CORS
The adapters apply the same CORS configuration as the hosted ingest so the React Native SDK's browser/preflight behaviour is unchanged:
access-control-allow-origin: *access-control-allow-methods: GET, POST, OPTIONSaccess-control-allow-headers: thetd-*allowlist (td-api-key,td-org-id,td-project-id,td-environment-slug,td-device-id,td-session-id,td-sdk-version,td-rn-version) plusContent-TypeandAuthorizationaccess-control-allow-credentials: true
These constants are exported from @teardown/server/http (CORS_HEADERS, CORS_ALLOWED_HEADERS, CORS_ALLOWED_METHODS) if you mount the routes manually and need to apply them yourself.
Request headers
Both POST routes read the same td-* headers the React Native SDK sends. In single-tenant mode (tenant passed to createTeardown) the three hosted-only headers are not read — the org/project come from the tenant and no API key is required; the client sends only td-environment-slug + td-device-id.
| Header | Identify | Events | Description |
|---|---|---|---|
td-api-key | Hosted only | Hosted only | Publishable API key (Bearer prefix tolerated). Not required in single-tenant mode |
td-org-id | Hosted only | Hosted only | Organization id. Not required in single-tenant mode |
td-project-id | Hosted only | Hosted only | Project id (must match the key's project). Not required in single-tenant mode |
td-environment-slug | Required | Required | Environment slug (resolved to an environment id) |
td-device-id | Required | Optional | Client device id |
td-session-id | Optional | Optional | Session id |
td-sdk-version | Optional | Optional | Client SDK name/version |
Request/response contract
Success
Identify returns the full session payload:
{
"success": true,
"data": {
"session_id": "...",
"device_id": "...",
"user_id": "...",
"token": "...",
"version_info": { "status": "UP_TO_DATE", "update": null }
}
}Events returns the batch result:
{
"success": true,
"data": { "event_ids": ["..."], "processed_count": 3, "failed_count": 0 }
}Errors
Failures use { success: false, error: { code, message } } with these codes:
| Status | Code | When |
|---|---|---|
400 | MISSING_* | A required td-* header is absent (the code names the header) |
400 | VALIDATION_ERROR | The JSON body failed schema validation (fetch adapter / generic hosts) |
400 | IDENTIFY_FAILED | services.identify.identify returned a failure |
400 | EVENTS_PROCESSING_FAILED | services.events.processEvents returned a failure |
403 | FORBIDDEN | (Hosted mode) missing/invalid API key, or td-project-id did not match the key's project. Not raised in single-tenant mode |
404 | NOT_FOUND | No route matched the method + path |
The Elysia adapter validates the body with Elysia's own TypeBox guard before the handler, so an invalid body there surfaces as a
422 ValidationError(matching the hosted ingest) rather than the400 VALIDATION_ERRORthefetchadapter returns. Both reject the same payloads.
Normalized primitives
If you write your own adapter, the normalized shapes are exported from @teardown/server/http:
interface TdRequest {
method: string;
path: string; // path only, no query string
headers: Record<string, string | undefined>; // lowercased keys
query: Record<string, string | undefined>;
json(): Promise<unknown>; // lazily parses + caches the body
}
interface TdResponse {
status: number;
body: unknown; // serialized to JSON by the adapter
headers?: Record<string, string>;
}
interface TdRouter {
readonly routes: TdRoute[];
handle(req: TdRequest): Promise<TdResponse>;
}Lower your framework's request into a TdRequest, call router.handle(req), and serialize the TdResponse — that is exactly what the fetch and Elysia adapters do.
Custom authenticator
The router builds an API-key authenticator over your storage.apiKeys by default. To override it (for example to delegate to an existing auth helper), pass a TdRouter's deps with your own auth:
import { buildIngestRouter, createApiKeyAuthenticator } from "@teardown/server/http";
const router = buildIngestRouter({
services: td.services,
storage: td.storage,
clock: td.clock,
auth: myCustomAuthenticator, // implements ApiKeyAuthenticator
});
Bun.serve({ port: 4501, fetch: toFetchHandler(router) });