Teardown

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

RuntimeAdapterImport
Bun, Hono, Deno, Cloudflare Workers, Next.js, any edgefetch@teardown/server/http/fetchjump
Node / Expressfetch (via a Web Request shim)@teardown/server/http/fetchjump
ElysiaElysia plugin@teardown/server/http/elysiajump

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:

MethodPathHandler behaviour
POST/v1/identifyExtract headers → authenticate → validate body → services.identify.identify(headers, body)
POST/v1/eventsExtract 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:

FieldTypeSurfaced byDefault
versionstringGET / versionthe @teardown/server package version
serviceNamestringGET / message"Teardown Ingest API"
buildIdstringGET /health build_idundefined
serviceIdstringGET /health service_idundefined
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 Request into a normalized request (lowercased headers, parsed query, lazy JSON body),
  • short-circuits OPTIONS preflight to 204 with the CORS headers,
  • runs the router and serializes the result to a JSON Response with the status, CORS headers, and content-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 so req.body is 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, OPTIONS
  • access-control-allow-headers: the td-* 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) plus Content-Type and Authorization
  • access-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.

HeaderIdentifyEventsDescription
td-api-keyHosted onlyHosted onlyPublishable API key (Bearer prefix tolerated). Not required in single-tenant mode
td-org-idHosted onlyHosted onlyOrganization id. Not required in single-tenant mode
td-project-idHosted onlyHosted onlyProject id (must match the key's project). Not required in single-tenant mode
td-environment-slugRequiredRequiredEnvironment slug (resolved to an environment id)
td-device-idRequiredOptionalClient device id
td-session-idOptionalOptionalSession id
td-sdk-versionOptionalOptionalClient 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:

StatusCodeWhen
400MISSING_*A required td-* header is absent (the code names the header)
400VALIDATION_ERRORThe JSON body failed schema validation (fetch adapter / generic hosts)
400IDENTIFY_FAILEDservices.identify.identify returned a failure
400EVENTS_PROCESSING_FAILEDservices.events.processEvents returned a failure
403FORBIDDEN(Hosted mode) missing/invalid API key, or td-project-id did not match the key's project. Not raised in single-tenant mode
404NOT_FOUNDNo 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 the 400 VALIDATION_ERROR the fetch adapter 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) });