---
title: "How to Write Your First Custom API Gateway Policy in TypeScript"
description: "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."
canonicalUrl: "https://zuplo.com/blog/2026/04/17/write-your-first-custom-api-gateway-policy-in-typescript"
pageType: "blog"
date: "2026-04-17"
authors: "martyn"
tags: "Tutorial, TypeScript"
image: "https://zuplo.com/og?text=Write%20Your%20First%20Custom%20API%20Gateway%20Policy%20in%20TypeScript"
---
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.

<CalloutAudience
  variant="useIf"
  items={[
    `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](/docs/policies/rate-limit-inbound) or
[API key auth](/docs/policies/api-key-inbound)) with your own custom logic.

<CalloutDoc
  title="Policy Fundamentals"
  description="The full picture of how policies, handlers, and the request pipeline fit together in Zuplo."
  href="https://zuplo.com/docs/articles/policies"
  icon="book"
/>

## Prerequisites

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

- **Local development**: Run `npx create-zuplo-api@latest --empty` then
  `npm run dev` in the new directory. See the
  [local development guide](/docs/articles/local-development).
- **Portal development**: Sign in at
  [portal.zuplo.com](https://portal.zuplo.com) and create a new empty project.
  See
  [Step 1: Setup a Basic Gateway](/docs/articles/step-1-setup-basic-gateway).

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`:

```typescript
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`](/docs/programmable-api/zuplo-request) (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.

```json
{
  "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:

```json
{
  "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:

```json
{
  "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`:

```typescript
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.

<CalloutTip variant="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.
</CalloutTip>

### Register the Outbound Policy

Add the outbound policy to `config/policies.json`:

```json
{
  "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:

```json
"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:

```typescript
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`:

```json
{
  "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.

<CalloutDoc
  title="Custom Code Patterns"
  description="More examples for structuring custom inbound and outbound policies, including options validation and shared helpers."
  href="https://zuplo.com/docs/articles/custom-code-patterns"
  icon="code"
/>

## 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:

```bash
# 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`](/docs/programmable-api/zuplo-context) 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](/docs/articles/migrate-from-kong),
[AWS API Gateway](/docs/articles/migrate-from-aws-api-gateway), and
[Azure APIM](/docs/articles/migrate-from-azure-apim) include side-by-side policy
and concept mappings.

<CalloutDoc
  title="Custom Code Policy Reference"
  description="Full type signatures and examples for both custom-code-inbound and custom-code-outbound policies, including how options, context.waitUntil, and context.log work."
  href="https://zuplo.com/docs/policies/custom-code-inbound"
  icon="code"
/>