Custom routes
Custom routes let you ship server-side logic that runs alongside the records API — think webhook receivers, third-party integrations, server-only math, or any HTTP endpoint where the built-in CRUD doesn’t fit.
For the high-level overview see Hooks · routes · cron.
Mount path
Section titled “Mount path”Every custom route lives under /api/custom/<your-path>. The leading
/api/custom is fixed; whatever you put after is the route path you author
in the admin Hooks → Custom routes tab.
admin path: GET /health/:servicepublic URL: GET /api/custom/health/:serviceCustom routes match before built-in routes, so they can’t be shadowed
by /api/<collection> patterns.
Editor
Section titled “Editor”The Hooks → Custom routes tab in the admin UI provides:
- Method picker (
GET/POST/PATCH/PUT/DELETE) - Path input with
:namesyntax for params - Monaco editor with TypeScript IntelliSense over
ctx - “Save & test” button that hits the route with a synthesized request
Saving compiles + caches the handler — errors show inline.
Handler signature
Section titled “Handler signature”async function handler(ctx: RouteContext) { // ... return { /* response body */ };}Whatever you return is JSON-encoded as the response body. To control the
status or headers, use ctx.set. To stream / return a non-JSON Response,
return a Web Response directly.
The ctx object
Section titled “The ctx object”interface RouteContext { // Inbound req: Request; // raw Web Request method: string; // "GET", "POST", ... path: string; // inner path (after /api/custom) params: Record<string, string>; // from :name segments query: Record<string, string>; // ?a=1&b=2 → { a: "1", b: "2" } body: any; // parsed JSON for application/json
// Caller identity (Bearer token decoded) auth: { id: string; type: "user" | "admin"; email?: string } | null;
// Server-side helpers (same shape as in hooks/cron) helpers: HookHelpers;
// Outbound shaping set: { status: number; headers: Record<string, string> };}Where HookHelpers is:
interface HookHelpers { slug(s: string): string; abort(message: string): never; find<T>(collection: string, id: string): Promise<T | null>; query<T>(collection: string, opts?: { filter?: string; sort?: string; perPage?: number }): Promise<{ data: T[]; totalItems: number }>; fetch(input: string | URL, init?: RequestInit): Promise<Response>; email(opts: { to: string; subject: string; body: string }): Promise<void>; log(...args: unknown[]): void;}Examples
Section titled “Examples”Health check that fans out
Section titled “Health check that fans out”// GET /api/custom/health/:serviceconst r = await ctx.helpers.fetch(`https://${ctx.params.service}/healthz`, { signal: AbortSignal.timeout(2000),});ctx.set.status = r.ok ? 200 : 503;return { service: ctx.params.service, healthy: r.ok };Public webhook receiver
Section titled “Public webhook receiver”// POST /api/custom/webhooks/stripeconst sig = ctx.req.headers.get("Stripe-Signature");if (!sig) { ctx.set.status = 400; return { error: "missing signature" };}
// ... verify signature ...ctx.helpers.log("Stripe event:", ctx.body.type);
if (ctx.body.type === "invoice.paid") { const order = await ctx.helpers.find("orders", ctx.body.data.object.metadata.order_id); // ...}
return { received: true };Auth-gated server-side compute
Section titled “Auth-gated server-side compute”// POST /api/custom/cart/checkoutif (!ctx.auth) ctx.helpers.abort("Login required");
const cart = await ctx.helpers.query("cart_items", { filter: `user = "${ctx.auth.id}"`,});
const total = cart.data.reduce((s, i) => s + i.price * i.qty, 0);return { total, currency: "USD" };helpers.abort(message) throws and the response becomes
422 { error: <message> } — same as hooks.
Returning a non-JSON Response
Section titled “Returning a non-JSON Response”// GET /api/custom/sitemap.xmlconst posts = await ctx.helpers.query("posts", { perPage: 1000 });const xml = `<?xml version="1.0"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> ${posts.data.map(p => `<url><loc>https://example.com/${p.slug}</loc></url>`).join("")}</urlset>`;
return new Response(xml, { status: 200, headers: { "Content-Type": "application/xml" },});Setting CORS / custom headers
Section titled “Setting CORS / custom headers”// GET /api/custom/public/whateverctx.set.headers["Access-Control-Allow-Origin"] = "*";ctx.set.headers["Cache-Control"] = "public, max-age=60";return { ok: true };Custom routes don’t have rule expressions — you decide who can call them in the handler:
if (!ctx.auth) ctx.helpers.abort("Login required");if (ctx.auth.type !== "admin") ctx.helpers.abort("Admin only");Tokens are validated centrally — the same Bearer token that works for the records API works here. Rate-limit rules apply (see Logging & rate limits).
Limits
Section titled “Limits”- Body: max 1 MB JSON (Bun default).
- Compile errors abort with a
500and the error in the Logs page. helpers.fetchhas no built-in timeout — pass anAbortSignalfor outbound calls.- Routes run in the same process as the rest of Vaultbase — long-running CPU work blocks other requests.
See also
Section titled “See also”- Hooks — record-event hooks (
beforeCreate, etc.) share the helpers API. - Logging & rate limits — both apply to custom routes.