ZuploZuplo
LoginSign Up
  • Documentation
  • API Reference
Introduction
Getting Started
    Develop using the Portal
      1 - Setup Your Gateway2 - Rate Limiting3 - API Key Auth4 - Deploy5 - Dynamic Rate LimitingMCP - Quick start
    Develop Locally
      1 - Setup Your Gateway2 - Rate Limiting3 - API Key Auth
Concepts
Development
    CORSEnvironment VariablesBranch-Based DeploymentsTestingTroubleshootingGitOps vs TerraformCustom Code
    Local Development
    Guides
Policies
Handlers
API Keys
MCP Server
MCP Gateway
AI Gateway
Developer Portal
Monetization
Deploying & Source Control
Observability
Networking & Infrastructure
Account Management
Programming API
Build with AI
Zuplo CLI
Migration Guides
Platform LimitsSecuritySupportTrust & ComplianceChangelog
powered by Zudoku
Development

Custom Code Patterns

Zuplo is fully programmable. You can write custom TypeScript code for inbound policies, outbound policies, and request handlers to extend every part of your API gateway. This guide covers the function signatures, common patterns, and best practices for writing custom code.

Custom Inbound Policy

An inbound policy runs before the request handler. It receives the incoming request and can modify it, pass it along, or short-circuit the pipeline by returning a Response.

Function Signature

Code
export type InboundPolicyHandler<TOptions = any> = ( request: ZuploRequest, context: ZuploContext, options: TOptions, policyName: string, ) => Promise<ZuploRequest | Response>;
  • Return a ZuploRequest to continue the pipeline.
  • Return a Response to short-circuit and respond immediately.

Example: Validate a Custom Header

modules/require-tenant-header.ts
import { ZuploContext, ZuploRequest, HttpProblems } from "@zuplo/runtime"; interface PolicyOptions { headerName: string; } export default async function ( request: ZuploRequest, context: ZuploContext, options: PolicyOptions, policyName: string, ): Promise<ZuploRequest | Response> { const value = request.headers.get(options.headerName); if (!value) { return HttpProblems.badRequest(request, context, { detail: `Missing required header '${options.headerName}'.`, }); } // Store the value for downstream consumption context.custom.tenantId = value; return request; }

Policy Configuration

Register the policy in config/policies.json and reference it on your routes:

config/policies.json
{ "name": "require-tenant-header", "policyType": "custom-code-inbound", "handler": { "export": "default", "module": "$import(./modules/require-tenant-header)", "options": { "headerName": "x-tenant-id" } } }

For details on wiring policies to routes, see Policy Fundamentals.

Custom Outbound Policy

An outbound policy runs after the handler returns a response. It can inspect or transform the response before it reaches the client.

Outbound policies only execute when the response status code is in the 200-299 range (response.ok === true).

Function Signature

Code
export type OutboundPolicyHandler<TOptions = any> = ( response: Response, request: ZuploRequest, context: ZuploContext, options: TOptions, policyName: string, ) => Promise<Response>;

Example: Add Custom Response Headers

modules/add-response-headers.ts
import { ZuploContext, ZuploRequest } from "@zuplo/runtime"; export default async function ( response: Response, request: ZuploRequest, context: ZuploContext, ): Promise<Response> { // Create a new response to get mutable headers const newResponse = new Response(response.body, { status: response.status, headers: response.headers, }); newResponse.headers.set("x-request-id", context.requestId); newResponse.headers.set("x-tenant-id", context.custom.tenantId ?? "unknown"); return newResponse; }

Register outbound policies the same way as inbound, using custom-code-outbound as the policyType. See Custom Code Outbound Policy for the full reference.

Custom Request Handler

A request handler is the function that produces the response for a route. Use a custom handler when you need to orchestrate calls, build a BFF (backend-for-frontend), or return a fully custom response.

Function Signature

Code
export type RequestHandler = ( request: ZuploRequest, context: ZuploContext, ) => Promise<any>;

Returning a plain object or string automatically serializes the response as JSON with a content-type: application/json header.

Example: Aggregate Two Backend Calls

modules/aggregate-handler.ts
import { ZuploContext, ZuploRequest, environment } from "@zuplo/runtime"; export default async function ( request: ZuploRequest, context: ZuploContext, ): Promise<Response> { const baseUrl = environment.BACKEND_URL; // Run requests in parallel const [usersRes, ordersRes] = await Promise.all([ fetch(`${baseUrl}/users/${request.params.userId}`), fetch(`${baseUrl}/users/${request.params.userId}/orders`), ]); if (!usersRes.ok || !ordersRes.ok) { return new Response("Upstream error", { status: 502 }); } const user = await usersRes.json(); const orders = await ordersRes.json(); return new Response(JSON.stringify({ user, orders }), { status: 200, headers: { "content-type": "application/json" }, }); }

For the complete handler reference, see Function Handler.

Accessing Policy Options

Both inbound and outbound policies receive an options parameter populated from the handler.options object in config/policies.json. Define a TypeScript interface for type safety:

modules/rate-limit-by-plan.ts
import { ZuploContext, ZuploRequest, HttpProblems } from "@zuplo/runtime"; interface PolicyOptions { defaultLimit: number; premiumLimit: number; } export default async function ( request: ZuploRequest, context: ZuploContext, options: PolicyOptions, policyName: string, ): Promise<ZuploRequest | Response> { const plan = request.user?.data?.plan ?? "free"; const limit = plan === "premium" ? options.premiumLimit : options.defaultLimit; context.custom.rateLimit = limit; context.log.info(`Applying rate limit of ${limit} for plan '${plan}'`); return request; }
config/policies.json
{ "name": "rate-limit-by-plan", "policyType": "custom-code-inbound", "handler": { "export": "default", "module": "$import(./modules/rate-limit-by-plan)", "options": { "defaultLimit": 100, "premiumLimit": 10000 } } }

You can also reference environment variables in policy options using the $env(VARIABLE_NAME) syntax:

Code
{ "options": { "apiKey": "$env(BACKEND_API_KEY)" } }

Passing Data Between Pipeline Stages

Use context.custom to share data between policies and the request handler within a single request. It is a mutable object that lives for the lifetime of the request.

modules/auth-policy.ts
// Inbound policy: attach metadata export default async function ( request: ZuploRequest, context: ZuploContext, ): Promise<ZuploRequest | Response> { context.custom.userId = request.user?.sub; context.custom.plan = request.user?.data?.plan ?? "free"; context.custom.requestStart = Date.now(); return request; }
modules/my-handler.ts
// Handler: read metadata set by the policy export default async function ( request: ZuploRequest, context: ZuploContext, ): Promise<Response> { const userId = context.custom.userId; const plan = context.custom.plan; context.log.info(`Handling request for user ${userId} on plan ${plan}`); // ...build your response return new Response(JSON.stringify({ userId, plan }), { status: 200, headers: { "content-type": "application/json" }, }); }

For the full ZuploContext reference, see ZuploContext.

Accessing Environment Variables

Import environment from @zuplo/runtime to read environment variables in any custom module:

Code
import { environment } from "@zuplo/runtime"; const apiKey = environment.API_KEY; // string | undefined

Always validate required variables early to surface configuration errors at startup rather than at request time:

Code
import { environment, ConfigurationError } from "@zuplo/runtime"; const apiKey = environment.API_KEY; if (!apiKey) { throw new ConfigurationError("API_KEY environment variable is not set"); }

For more information on setting and managing environment variables, see Configuring Environment Variables and the Environment Variables API.

Common Patterns

Forward User Metadata as Headers

Pass authenticated user information to your backend as headers so backend services don't need to re-validate tokens:

modules/forward-user-headers.ts
import { ZuploContext, ZuploRequest } from "@zuplo/runtime"; export default async function ( request: ZuploRequest, context: ZuploContext, ): Promise<ZuploRequest | Response> { if (!request.user) { return request; } const newRequest = new ZuploRequest(request); newRequest.headers.set("x-user-id", request.user.sub); if (request.user.data?.email) { newRequest.headers.set("x-user-email", request.user.data.email); } if (request.user.data?.plan) { newRequest.headers.set("x-user-plan", request.user.data.plan); } return newRequest; }

Conditional Logic Based on Plan or Metadata

Route or transform requests differently based on the authenticated user's plan or custom metadata:

modules/plan-based-routing.ts
import { ZuploContext, ZuploRequest, environment } from "@zuplo/runtime"; export default async function ( request: ZuploRequest, context: ZuploContext, ): Promise<ZuploRequest | Response> { const plan = request.user?.data?.plan ?? "free"; if (plan === "enterprise") { context.custom.backendUrl = environment.ENTERPRISE_BACKEND_URL; } else { context.custom.backendUrl = environment.DEFAULT_BACKEND_URL; } return request; }

Custom Response Transformation

Reshape a backend response before returning it to the client:

modules/transform-response.ts
import { ZuploContext, ZuploRequest } from "@zuplo/runtime"; export default async function ( response: Response, request: ZuploRequest, context: ZuploContext, ): Promise<Response> { if (response.status !== 200) { return response; } const data = await response.json(); // Wrap the response in an envelope const envelope = { success: true, data, metadata: { requestId: context.requestId, timestamp: new Date().toISOString(), }, }; return new Response(JSON.stringify(envelope), { status: 200, headers: { "content-type": "application/json" }, }); }

When to Use Policy vs Handler vs Hook

Zuplo provides three extension points for custom code. Choose the right one based on your use case:

Extension PointUse When
Inbound PolicyYou need to inspect, validate, or modify the request before it reaches the handler.
Outbound PolicyYou need to inspect or transform the response after the handler runs.
Request HandlerYou need to produce the response -- call backends, aggregate data, or return custom responses.
HookYou need cross-cutting logic that applies globally, such as logging or tracing.

Policies are configured per-route and are reusable across multiple routes. Hooks are registered globally in zuplo.runtime.ts and run on every request. For a detailed explanation of how these fit together, see Request Lifecycle.

Edit this page
Last modified on March 27, 2026
GitOps vs TerraformQuickstart
On this page
  • Custom Inbound Policy
    • Function Signature
    • Example: Validate a Custom Header
    • Policy Configuration
  • Custom Outbound Policy
    • Function Signature
    • Example: Add Custom Response Headers
  • Custom Request Handler
    • Function Signature
    • Example: Aggregate Two Backend Calls
  • Accessing Policy Options
  • Passing Data Between Pipeline Stages
  • Accessing Environment Variables
  • Common Patterns
    • Forward User Metadata as Headers
    • Conditional Logic Based on Plan or Metadata
    • Custom Response Transformation
  • When to Use Policy vs Handler vs Hook
TypeScript
TypeScript
JSON
TypeScript
TypeScript
TypeScript
TypeScript
TypeScript
JSON
JSON
TypeScript
TypeScript
TypeScript
TypeScript
TypeScript
TypeScript
TypeScript