Custom HTTP Routes

SkillDev tools

Helps your agent add custom HTTP routes to a Webiny project by writing Api.Route registrations and handler files.

Available today. Use it from your connected AI after setup.

Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.

Then ask your AI: use the Custom HTTP Routes skill

About this skill

Adding custom HTTP routes to the API with <Api.Route> and HttpRouteHandler. Use this skill when the developer wants to expose a custom HTTP endpoint (GET, POST, PUT, etc.) on the API alongside the GraphQL handler, implement a route handler with full DI support, or register a custom HTTP route in web

What this skill tells your AI

The instructions your AI receives, as published by webiny/webiny-js in skills/user-skills/api/http-route/SKILL.md and read by ahel’s review.

TL;DR

Write a handler that implements HttpRouteHandler.Interface, then point <Api.Route> at it in webiny.config.tsx. The method and path props configure both the API Gateway route and the router, so the handler file never restates them. Handlers get full DI.

YOU MUST include the full file path with the .ts extension in the src prop. Use src={"/extensions/MyRoute.ts"}, not src={"/extensions/MyRoute"}. Omitting it fails the build.

YOU MUST use export default for the createImplementation() call. Named exports fail here.

The route pattern

// extensions/MyRoute.ts
import { HttpRouteHandler, Logger } from "webiny/api";

class MyRouteImpl implements HttpRouteHandler.Interface {
  constructor(private logger: Logger.Interface) {}

  async handle(request: HttpRouteHandler.Request, response: HttpRouteHandler.Response) {
    this.logger.info({ path: request.path }, "Handling request");

    return response.status(200).json({ status: "ok" });
  }
}

export default HttpRouteHandler.createImplementation({
  implementation: MyRouteImpl,
  dependencies: [Logger]
});

Register it:

<Api.Route method={"POST"} path={"/my-route"} src={"/extensions/MyRoute.ts"} />

Props reference

PropTypeRequiredDescription
pathstringYesRoute path — must start with /
methodstringYesHTTP method (see below)
srcstringYesPath to the handler file (must include .ts)
routeNamestringNoRoute name (kebab-case). Derived from path + method if omitted. Doubles as the Pulumi resource name and the id a decorator matches on

Methods: DELETE, GET, HEAD, PATCH, POST, PUT, OPTIONS, ANY. Use ANY to match every method on a path.

Path parameters

Write them either way. {orderId} is API Gateway syntax, :orderId is the router's; the extension converts to whichever each consumer needs, so both work and mean the same thing.

<Api.Route method={"GET"} path={"/orders/{orderId}"} src={"/extensions/GetOrderRoute.ts"} />
<Api.Route method={"GET"} path={"/orders/:orderId"} src={"/extensions/GetOrderRoute.ts"} />

Read them off request.pathParameters:

class GetOrderRouteImpl implements HttpRouteHandler.Interface {
  constructor(private getOrder: GetOrderUseCase.Interface) {}

  async handle(request: HttpRouteHandler.Request, response: HttpRouteHandler.Response) {
    const order = await this.getOrder.execute(request.pathParameters.orderId);

    return response.status(200).json(order);
  }
}

export default HttpRouteHandler.createImplementation({
  implementation: GetOrderRouteImpl,
  dependencies: [GetOrderUseCase]
});

Wildcards are the exception: /files/* matches for the router, {proxy+} for API Gateway, and they capture differently. Write a wildcard route for the target you mean.

Request

HttpRouteHandler.Request is transport-agnostic — no API Gateway or Node types leak into your code.

interface Request {
  method: string;
  path: string;
  headers: Record<string, string>;
  query: Record<string, string>;
  pathParameters: Record<string, string>;
  body: any;
  /** Which route matched — `{ name, method, path }`. */
  route: MatchedRouteDefinition;
}

route is what makes a decorator able to act on one route (see below), and lets a handler read its own identity. method/path on it are the route's PATTERN (/orders/:orderId), where the top-level method/path are the request's actual values (/orders/abc123).

Response

HttpRouteHandler.Response is a mutable builder, the res of an Express-style handler. Every method returns this, so calls chain:

MethodPurpose
status(code)Set the status code (defaults to 200)
json(body)JSON body + content type
text(body)Plain-text body
send(body)Body as-is
header(name, value)Set one header
getHeader(name)Read a header already set
cookie(name, value, options?)Set a cookie
clearCookie(name, options?)Expire a cookie
redirect(url, statusCode?)Redirect
sse(source)Server-sent events stream
return response.status(201).cookie("sid", id, { httpOnly: true }).json({ id });

Returning the builder is optional — mutate it and return nothing for the same result. Returning a plain object works too; anything set on the builder is merged underneath it, and the returned object wins on conflicts.

cookie's maxAge is in seconds (the Max-Age attribute), unlike Express, which uses milliseconds.

How it works

<Api.Route> does two things:

  1. Build time — writes a registration into apps/api/graphql/src/extensions.ts that registers an HttpRouteDefinition built from your method and path, pointing at your handler.
  2. Deploy time — calls addRoute({ name, path, method }) on the API Pulumi module to create the API Gateway route, with the path converted to {param} syntax.

At request time the router matches the definition (cheap — it holds only method, path and the handler class) and builds your handler only then, with its dependencies injected. A route your request didn't match is never constructed.

Decorating a route

Two hooks, both ordinary DI decorators.

Change what a route DOES

Decorate HttpRouteHandler. It applies to every route, and request.route.name picks the one you mean:

import { HttpRouteHandler } from "webiny/api";

export default HttpRouteHandler.createDecorator({
  decorator: class implements HttpRouteHandler.Interface {
    constructor(private decoratee: HttpRouteHandler.Interface) {}

    async handle(request: HttpRouteHandler.Request, response: HttpRouteHandler.Response) {
      if (request.route.name !== "my-route-get") {
        return this.decoratee.handle(request, response);
      }

      if (request.headers["x-api-key"] !== "expected") {
        return response.status(401).json({ message: "Not authorized." });
      }

      return this.decoratee.handle(request, response);
    }
  },
  dependencies: []
});

Drop the name check and it wraps every route, which is what you want for timing or logging.

request.route is { name, method, path } of the matched route. name is routeName, or the value derived from path and method (/my-route + GET → my-route-get). A route can read it to find out its own identity too.

Change what a route IS

Decorate HttpRouteDefinition when you need to alter the route itself — point it at a different handler, or move its path:

import { HttpRouteDefinition } from "webiny/api";

export default HttpRouteDefinition.createDecorator({
  decorator: class implements HttpRouteDefinition.Interface {
    readonly name: string;
    readonly method: string;
    readonly path: string;
    readonly handler: HttpRouteDefinition.Interface["handler"];

    constructor(decoratee: HttpRouteDefinition.Interface) {
      this.name = decoratee.name;
      this.method = decoratee.method;
      this.path = decoratee.name === "my-route-get" ? "/moved" : decoratee.path;
      this.handler = decoratee.handler;
    }
  },
  dependencies: []
});

A decorator exposes the same properties as what it wraps, so it copies through the ones it doesn't change. Getters work too if you prefer them — these are plain properties, not methods.

Key rules

  • Do not declare method/path in the handler file. They come from the props. A handler that sets its own would be ignored, and the two could disagree.
  • Export the handler, not a definition. <Api.Route> builds the definition for you.
  • Constructor parameter order must match the dependencies array exactly.
  • Declare dependencies; don't inject the container and resolve inside handle().
  • One handler per file — each src file default-exports one implementation.
  • Use .js extensions in relative imports (ESM).
  • Do not read process.env at runtime; use BuildParams.
  • No console.* in API code — inject Logger.

Quick reference

Import:      import { HttpRouteHandler } from "webiny/api";
Interface:   HttpRouteHandler.Interface
Request:     HttpRouteHandler.Request
Response:    HttpRouteHandler.Response
Export:      export default HttpRouteHandler.createImplementation({ implementation, dependencies })
Register:    <Api.Route method={"POST"} path={"/my-route"} src={"/extensions/MyRoute.ts"} />
Deploy:      yarn webiny deploy api --env=dev

Related skills

  • webiny-api-architect — DI patterns, services, use cases, feature organization
  • webiny-custom-graphql-api — custom GraphQL endpoints (alternative to HTTP)
  • webiny-dependency-injection — injectable services catalog (Logger, BuildParams, etc.)
  • webiny-infrastructure-extensions — Pulumi-level infrastructure customization

Signals

GitHub stars
8k
Forks
679
Last commit
Sep 2026
Advanced
Item type
skill
Key
webiny-http-route
Source
github.com/webiny/webiny-js
Custom HTTP Routes (webiny-http-route): Skill · ahel