Zuplo
Tutorial

How to Write Your First Custom API Gateway Policy in TypeScript

Martyn DaviesMartyn Davies
April 17, 2026
9 min read

Custom gateway logic usually means Lua, VTL, or C# smuggled inside XML. Zuplo lets you write it as a TypeScript function using standard Request and Response objects. Here's how to build your first inbound and outbound policy.

Every API gateway eventually makes you write custom code. Maybe a partner’s sending a weird header, maybe you need to enrich requests with the caller’s org, maybe a backend field shouldn’t be leaking to consumers. The code itself is small, twenty or thirty lines you’ve written a hundred times. The annoying part is everything else: the language, the file layout, the deploy cycle, and the shape the code has to take to run.

With Kong, you write Lua. With AWS API Gateway, you wrestle with Velocity Template Language or wire up Lambda authorizers. Over in Azure APIM world, you write C# snippets inside XML policy documents. None of these are languages most backend developers reach for voluntarily (but I’ll admit it might get me dusting off my copy of Sams’ Teach Yourself C# in 24 Hours book. Remember them? Anyone…).

Zuplo takes a different approach: custom policies are TypeScript functions that use the standard web Request and Response objects. If you’ve written a service worker or an edge function before, you’re already pretty familiar with this programming model. This tutorial walks through a custom inbound policy, a custom outbound policy, wiring them up, and deploying, in about 15 minutes.

Use this approach if you're:
  • You want custom logic in your API gateway without learning Lua, VTL, or inline C#
  • You're evaluating Zuplo and want to see what writing a real policy looks like
  • You're migrating from Kong, AWS API Gateway, or Azure APIM and comparing developer experience

What Are Zuplo Policies?

Every request to a Zuplo gateway flows through a pipeline:

  1. Inbound policies run before the request handler. They can inspect, validate, or modify the incoming request, or short-circuit the pipeline by returning a Response.
  2. The request handler produces the response. Typically a URL forward to your backend, but it can also be a custom handler.
  3. Outbound policies run after the handler. They can inspect or transform the response before it’s sent back to the client.

You can attach multiple inbound and outbound policies to each route, and they execute in order. Mix built-in policies (like rate limiting or API key auth) with your own custom logic.

Policy Fundamentals

The full picture of how policies, handlers, and the request pipeline fit together in Zuplo.

Prerequisites

You’ll need a Zuplo project. If you don’t have one:

Either way, you’ll end up with a project with a config/ directory for route and policy configuration and a modules/ directory for custom TypeScript code.

Writing a Custom Inbound Policy

A practical scenario: your API requires a custom X-Request-Source header on every POST request. If it’s missing or invalid, reject the request with a 400 Bad Request before it reaches your backend.

The Policy Function

Create modules/validate-source-header.ts:

TypeScripttypescript
import { ZuploContext, ZuploRequest } from "@zuplo/runtime";

const ALLOWED_SOURCES = ["web", "mobile", "internal"];

export default async function (
  request: ZuploRequest,
  context: ZuploContext,
  options: never,
  policyName: string,
) {
  // Only POSTs need this header, so let everything else through.
  if (request.method !== "POST") {
    return request;
  }

  const source = request.headers.get("x-request-source");

  if (!source) {
    return new Response(
      JSON.stringify({
        title: "Bad Request",
        detail: "Missing required header: X-Request-Source",
      }),
      {
        status: 400,
        headers: { "content-type": "application/problem+json" },
      },
    );
  }

  if (!ALLOWED_SOURCES.includes(source)) {
    context.log.warn(`Invalid request source: ${source}`);
    return new Response(
      JSON.stringify({
        title: "Bad Request",
        detail: `Invalid X-Request-Source value. Allowed: ${ALLOWED_SOURCES.join(", ")}`,
      }),
      {
        status: 400,
        headers: { "content-type": "application/problem+json" },
      },
    );
  }

  context.log.info(`Request source: ${source}`);
  return request;
}

The key concepts:

  • Function signature: An inbound policy receives a ZuploRequest (which extends the standard web Request with properties like user, params, and query), a ZuploContext for logging and metadata, options from the policy configuration (typed never here since this policy takes none), and the policyName.
  • Return request to continue: Return the request object and Zuplo passes it to the next policy in the chain, or to the handler if this is the last.
  • Return a Response to short-circuit: Return a Response directly and Zuplo sends it back to the client. The handler and outbound policies never execute.
  • Standard web APIs: Nothing Zuplo-specific about Response or JSON.stringify. If you’ve used fetch or built a Cloudflare Worker, you already know these APIs.

Register the Policy

Add the policy to config/policies.json. The $import(...) syntax is Zuplo’s module reference: it resolves a file path (no .ts extension) or an npm package name at build time and wires the exported function into the policy. No options block is needed here.

JSONjson
{
  "policies": [
    {
      "name": "validate-source-header",
      "policyType": "custom-code-inbound",
      "handler": {
        "export": "default",
        "module": "$import(./modules/validate-source-header)"
      }
    }
  ]
}

Then attach it to a route in config/routes.oas.json. This is a standard OpenAPI document with Zuplo extensions under x-zuplo-route, where you declare the handler (here, the built-in urlForwardHandler that proxies to your backend) and the policy chain:

JSONjson
{
  "paths": {
    "/items": {
      "post": {
        "x-zuplo-route": {
          "handler": {
            "export": "urlForwardHandler",
            "module": "$import(@zuplo/runtime)",
            "options": {
              "baseUrl": "https://your-backend.example.com"
            }
          },
          "policies": {
            "inbound": ["validate-source-header"],
            "outbound": []
          }
        }
      }
    }
  }
}

A POST to /items without the header now returns:

JSONjson
{
  "title": "Bad Request",
  "detail": "Missing required header: X-Request-Source"
}

Writing a Custom Outbound Policy

Now the response side. Say your backend returns user objects with an internal internalId field that should never reach API consumers. You also want to add an X-Request-Id header so consumers can reference a specific request when reporting issues.

The Policy Function

Create modules/clean-response.ts:

TypeScripttypescript
import { ZuploContext, ZuploRequest } from "@zuplo/runtime";

export default async function (
  response: Response,
  request: ZuploRequest,
  context: ZuploContext,
  options: never,
  policyName: string,
) {
  // Skip anything that isn't JSON.
  const contentType = response.headers.get("content-type") ?? "";
  if (!contentType.includes("application/json")) {
    return response;
  }

  const data = await response.json();
  delete data.internalId;

  // Keep CORS and other upstream headers intact.
  const headers = new Headers(response.headers);
  headers.set("x-request-id", context.requestId);

  return new Response(JSON.stringify(data), {
    status: response.status,
    headers,
  });
}

Key differences from inbound policies:

  • First parameter is Response: Outbound policies receive the handler’s response, plus the original request, context, options, and policy name.
  • Must return a Response: Return the original response or construct a new one.
  • Only runs on successful responses: By default, custom outbound policies execute when response.ok === true (status codes 200 to 299). A 500 from your backend skips the outbound policy.
  • Copy headers, then adjust: Clone response.headers into a new Headers object so you keep CORS and anything Zuplo or the backend set, then layer your own headers on top.

Common mistake:

Forgetting to copy response.headers into the new Response is the most common outbound policy bug. The response still works, but CORS headers and custom headers from earlier policies silently disappear, so your frontend starts failing preflight checks for no obvious reason. A close second is forwarding the original content-length after changing the body, which can cause the edge to truncate or reject the response.

Register the Outbound Policy

Add the outbound policy to config/policies.json:

JSONjson
{
  "policies": [
    {
      "name": "validate-source-header",
      "policyType": "custom-code-inbound",
      "handler": {
        "export": "default",
        "module": "$import(./modules/validate-source-header)"
      }
    },
    {
      "name": "clean-response",
      "policyType": "custom-code-outbound",
      "handler": {
        "export": "default",
        "module": "$import(./modules/clean-response)"
      }
    }
  ]
}

And attach it to the route’s outbound array:

JSONjson
"policies": {
  "inbound": ["validate-source-header"],
  "outbound": ["clean-response"]
}

Your API now strips internalId from every JSON response and adds a request ID, without changing a line of backend code.

Making Policies Configurable with Options

Hard-coding field names works, but different routes often need to strip different fields. Zuplo policies support an options object you can configure per policy instance.

A more flexible version of the outbound policy:

TypeScripttypescript
import { ZuploContext, ZuploRequest } from "@zuplo/runtime";

type CleanResponseOptions = {
  fieldsToRemove: string[];
};

export default async function (
  response: Response,
  request: ZuploRequest,
  context: ZuploContext,
  options: CleanResponseOptions,
  policyName: string,
) {
  const contentType = response.headers.get("content-type") ?? "";
  if (!contentType.includes("application/json")) {
    return response;
  }

  const data = await response.json();

  for (const field of options.fieldsToRemove) {
    delete data[field];
  }

  const headers = new Headers(response.headers);

  return new Response(JSON.stringify(data), {
    status: response.status,
    headers,
  });
}

Configure which fields to strip per route in policies.json:

JSONjson
{
  "name": "clean-response",
  "policyType": "custom-code-outbound",
  "handler": {
    "export": "default",
    "module": "$import(./modules/clean-response)",
    "options": {
      "fieldsToRemove": ["internalId", "secret", "passwordHash"]
    }
  }
}

Whatever JSON you declare under options in policies.json is exactly what gets passed into your handler’s options argument at runtime, so the shape is entirely up to you. Declare a matching TypeScript type on that argument (like CleanResponseOptions above) and TypeScript will autocomplete and typecheck every access, so a typo in options.fieldsToRemove fails at build time instead of silently doing nothing in production.

Custom Code Patterns

More examples for structuring custom inbound and outbound policies, including options validation and shared helpers.

How This Compares to Other Gateways

You saw the whole Zuplo version in the sections above: one TypeScript file, a bit of JSON wiring, done. Compare that to the same header-validation job on a Kong plugin, which needs two Lua files, a priority/version table, and a restart or admin API call to deploy:

lua
-- handler.lua
local ValidateHeader = {
  PRIORITY = 1000,
  VERSION = "1.0.0",
}

function ValidateHeader:access(conf)
  local source = kong.request.get_header("x-request-source")
  if not source then
    return kong.response.exit(400, {
      message = "Missing required header"
    })
  end
end

return ValidateHeader

On AWS API Gateway, the same task means a Lambda authorizer that returns an IAM policy document, plus IAM roles, cold starts, and a Gateway Response template to stop AWS collapsing your 400 into a generic 401. On Azure APIM, it’s inline C# smuggled inside an XML <choose><when> block, debugged without real IDE support.

The Zuplo advantage isn’t TypeScript by itself. It’s that policies use the same Request and Response objects you’d use in any modern JavaScript runtime, with no gateway-specific SDK to learn.

Deploy and Test

With your policies wired up, deploying is straightforward:

  • Local development: Your gateway is already running via npm run dev. Module changes hot-reload.
  • Portal: Click Save. The working copy updates immediately and you can test in the built-in API tester.
  • Production: Push to your connected Git repository. Zuplo builds and deploys to 300+ edge locations automatically.

Test with curl:

Terminalbash
# Missing header, should return 400
curl -X POST https://your-gateway.zuplo.dev/items \
  -H "Content-Type: application/json" \
  -d '{"name": "test"}'

# Valid header, should forward to backend
curl -X POST https://your-gateway.zuplo.dev/items \
  -H "Content-Type: application/json" \
  -H "X-Request-Source: web" \
  -d '{"name": "test"}'

If you’d rather stay in the browser, the Zuplo Portal has a Test Route button on any route inside your project. It opens a built-in tester where you can set the method, headers, and body and fire a request without touching a terminal.

Other Common Custom Policy Patterns

A few more custom policies you’ll see in production:

  • Request enrichment: Look up the authenticated user’s organization in a database and add it as a header before forwarding.
  • Response caching logic: Check a custom cache header from your backend and set Cache-Control dynamically.
  • Audit logging: Clone the request, extract key fields, and send them to your logging service via context.waitUntil to avoid latency.
  • Request body transformation: Parse an incoming JSON body, reshape it to your backend’s expected format, and return a new ZuploRequest.

Where to Go From Here

Most gateways push custom logic into a dialect and a deployment loop that doesn’t match how you write the rest of your code. Zuplo keeps it in TypeScript, against the same Request and Response objects you’d reach for anywhere else, so a custom policy stops being a special artefact and starts being a function you can read, test, and review like everything else in your codebase.

If you’re migrating from another gateway, our migration guides for Kong, AWS API Gateway, and Azure APIM include side-by-side policy and concept mappings.

Custom Code Policy Reference

Full type signatures and examples for both custom-code-inbound and custom-code-outbound policies, including how options, context.waitUntil, and context.log work.