Skip to content

Creating a New Gateway API Route

Follow this process to add a new API endpoint to the gateway.

Step 1: Create the route file

Create a new file in apps/gateway/src/api/v1/<domain>/ following the naming convention:

  • get-<resource>s.ts — List (GET, plural)
  • get-<resource>.ts or [id]/get-<resource>.ts — Detail (GET, singular)
  • create-<resource>.ts — Create (POST)
  • update-<resource>.ts — Update (PUT/PATCH)
  • delete-<resource>.ts — Delete (DELETE)

Nested resource routes use [id] directories: orders/[id]/transactions/get-order-transactions.ts

Step 2: Follow the route file template

Every route file must follow this exact structure:

typescript
import { authJwt } from '@develit-services/auth/middlewares'
import { access } from '@develit-io/backend-sdk/middlewares'
import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi'

// 1. Define Zod schemas for request params/query/body and response
const paramsSchema = z.object({
  orderId: z.uuid(),
})

const orderSchema = z.object({
  id: z.uuid(),
  orderNumber: z.number(),
  status: z.string(),
  type: z.string(),
  organizationId: z.uuid(),
  createdAt: z.string().nullable(),
  updatedAt: z.string().nullable(),
})

const responseBodySchema = z.object({
  message: z.string(),
  order: orderSchema,
})

// 2. Export inferred types — these propagate to the SDK and frontend
export interface GetOrderParams extends z.infer<typeof paramsSchema> {}
export interface GetOrderResponseBody extends z.infer<typeof responseBodySchema> {}

// 3. Define the OpenAPI route with metadata
export const getOrderRoute = createRoute({
  method: 'get',
  path: '/',
  tags: ['Orders'],
  summary: 'Get Order',
  middleware: [authJwt(), access([{ scope: 'tickets.read' }])],
  security: [{ JwtAuth: [] }],
  request: {
    params: paramsSchema,
  },
  responses: {
    200: {
      content: { 'application/json': { schema: responseBodySchema } },
      description: 'Successfully obtained the order.',
    },
    404: {
      content: { 'application/json': { schema: z.object({ message: z.string() }) } },
      description: 'Order not found.',
    },
    500: {
      content: { 'application/json': { schema: z.object({ message: z.string() }) } },
      description: 'Internal Server Error.',
    },
  },
})

// 4. Export the Hono route handler — delegate to service binding
// IMPORTANT: The response schema above MUST match what context.json() returns.
// Service data is passed through directly — design the schema to match the service output shape.
export const route = new OpenAPIHono<{ Bindings: GatewayEnv }>().openapi(
  getOrderRoute,
  async (context) => {
    const { orderId } = context.req.valid('param')

    const { data, error, message } = await context.env.ORDER_SERVICE.getOrder({
      id: orderId,
    })

    if (error || !data) {
      return context.json({ message: message || 'Order not found.' }, 404)
    }

    // data is passed directly — orderSchema must declare all fields the service returns
    return context.json({ message: 'Order obtained.', order: data }, 200)
  },
)

List endpoint template (with pagination)

typescript
const querySchema = z.object({
  page: z.coerce.number().min(1).optional(),
  perPage: z.coerce.number().min(1).max(100).optional(),
  filterStatus: z
    .preprocess((val) => {
      if (typeof val === 'string')
        return val.includes(',') ? val.split(',') : [val]
      return val
    }, z.array(z.enum(ORDER_STATUSES)))
    .optional(),
})

const responseBodySchema = z.object({
  message: z.string(),
  metadata: z.object({
    totalCount: z.number(),
  }),
  orders: z.array(orderSchema),
})

export interface GetOrdersQuery extends z.infer<typeof querySchema> {}
export interface GetOrdersResponseBody extends z.infer<typeof responseBodySchema> {}

Step 3: Register the route in the gateway index

Open apps/gateway/src/index.ts and:

  1. Add an import at the top:

    typescript
    import { route as getOrderRoute } from './api/v1/orders/[id]/get-order'
  2. Chain it onto the routes variable:

    typescript
    .route('/v1/orders/:orderId', getOrderRoute)

The parameter syntax in the path uses :paramName (Hono convention), while directory structure uses [id].

Middleware & access control

Authentication — authJwt()

Every endpoint MUST use authJwt() except:

  • Token endpoints (/v1/tokens) — unauthenticated entry point
  • Health check (/observability) — public
  • User verification endpoints — public flows

Authorization — access()

Every authenticated endpoint MUST have an access() rule, except:

  • /codes/* endpoints (reference data like currencies, banks, corporate bank accounts) — only need authJwt()
  • If you are unsure which scope to use, ask the developer

The access() array uses OR logic — the user needs at least ONE of the listed scopes:

typescript
// Single scope
middleware: [authJwt(), access([{ scope: 'tickets.read' }])]

// Multiple scopes (OR — user needs any one)
middleware: [authJwt(), access([{ scope: 'tickets.read' }, { scope: 'clients.read' }])]

Common scope patterns:

  • tickets.* — orders, forwards, FX operations (create, read, edit, dependencies.create, automations.read, confirmation.edit, aml.edit)
  • clients.* — client management (read, create, update)
  • pricelists.* — pricelist CRUD (create, read, edit, delete)
  • roles.* — role CRUD (create, read, edit, delete)
  • scopes.read — scope listing
  • users.create — user creation

Additional middlewares for mutations

For create/update/delete endpoints, add signature() and idempotency():

typescript
import { access, signature, idempotency } from '@develit-io/backend-sdk/middlewares'

// Mutation endpoint
middleware: [
  authJwt(),
  signature(),
  idempotency(),
  access([{ scope: 'tickets.create' }]),
]

// Read endpoint
middleware: [authJwt(), access([{ scope: 'tickets.read' }])]

Available service bindings

Access services through context.env:

typescript
context.env.ORDER_SERVICE         // FX orders, forwards, drafts, payouts
context.env.ORGANIZATION_SERVICE  // Orgs, clients, disponents, bank accounts
context.env.AUTH_SERVICE           // Authentication, JWT, MFA
context.env.BANK_SERVICE           // Bank sync, payments, batches
context.env.LEDGER_SERVICE         // Ledger transactions, internal transfers
context.env.NOTIFICATION_SERVICE   // Email (EcoMail), SMS (Twilio)
context.env.RBAC_SERVICE           // Roles, scopes, permissions
context.env.ACTIVITY_SERVICE       // Activity/audit logging
context.env.CURRENCYFEED_SERVICE   // FX rate feeds
context.env.SECRETS_STORE          // Encrypted secret storage

All service calls return { data, error, message, status } (the IRPCResponse pattern).

Step 4: Add RBAC scopes (if the route uses access())

When a route requires RBAC authorization via access([{ scope: '...' }]), the scope must be registered in three places:

  1. Gateway route — Add access([{ scope: 'domain.action' }]) to the middleware array alongside authJwt():

    typescript
    import { access } from '@develit-io/backend-sdk/middlewares'
    // ...
    middleware: [authJwt(), access([{ scope: 'logs.read' }])],
  2. RBAC seed migration — Add the scope to the relevant role(s) in services/rbac/src/database/migrations/0002_seed-role-scopes.sql

  3. Shared types — Add { label: '...', value: 'domain.action' } to RBAC_SCOPES in packages/shared/@types/scopes.ts

Missing any of these will cause either a runtime 403 (scope not assigned to role) or the scope not appearing in the UI for role management.

Key rules

  • Response schema = response body contract — the response Zod schema MUST declare every field that context.json() returns. Service data is passed through directly, so design the schema to match the service output shape. Do not return fields that aren't in the schema, and do not declare fields you don't return.
  • Always use authJwt() — never jwt() or other variants
  • Every endpoint must have access() with appropriate scopes (except /codes/*)
  • Always export inferred types — the SDK auto-generates the typed client
  • Use Zod v4's z.uuid() for ID validation
  • Set path: '/' in createRoute — the actual path is set during .route() registration
  • Handle both error and null data cases from service responses
  • Gateway defines its own request/response schemas — it does NOT depend on service types
  • Use as const objects, never TypeScript enum