Public API v1
Public API v1 lives in packages/cli/src/public-api/v1/, mounted at /api/v1
with API-key auth and public error formatting via PublicApiControllerRegistry
(packages/cli/src/public-api/public-api-controller.registry.ts).
Two rule tiers: invariants (never break) and team defaults (follow unless
an existing public contract forces otherwise). When this skill and the code
disagree on a detail, the code wins — so open the files below. That is a reason to
check the code, not license to drop a team default.
Non-negotiable rules
- New endpoints are
@PublicApiController classes under v1/controllers/, one
*.public.controller.ts per feature. A controller is a class — never
export = (the legacy tuple style; require-public-api-controller flags it).
- Public API and internal REST are separate HTTP surfaces. A public controller
never calls an internal controller/endpoint; both reuse the same service.
- Controllers and handlers delegate to a service — never import a repository or
Container.get(…Repository) (no-repository-in-public-api-handler).
- Input/output go through DTOs from
@n8n/api-types; every JSON route declares
@ApiResponse(Dto).
- Register each controller via a side-effect import in
v1/controllers/index.ts
(public-api-controllers.test.ts fails otherwise).
- Don't add business logic to legacy
express-openapi-validator (EOV) handlers.
- Migrating a legacy endpoint must not change its public contract.
These are n8n-local-rules ESLint rules (see packages/cli/eslint.config.mjs)
and can't be silenced inline (no-public-api-guardrail-disable). The off
allowlist there covers pre-existing legacy files only — it's shrink-only, don't
add to it.
Team defaults
- List endpoints: cursor-based pagination (internal API uses both cursor- and
page-based — don't copy an internal endpoint's model).
- Pagination args are always
offset and limit — on service methods, handler
calls, and repository methods you add. Never skip/take (TypeORM names).
Translate to skip/take only inside a repository, at the TypeORM find
call. The public query string is still cursor + limit; offset is the
decoded cursor field passed into the service, never a client-facing param.
- Updates: full-object
PUT, not PATCH. A successful GET body should be
acceptable as a PUT body for the same resource (round-trip), aside from
server-managed/immutable fields.
- Strict input DTOs; output DTOs are an allowlist of public fields.
- Never return real secrets/tokens in responses or error details — mask with the
resource's sentinel/placeholder (or omit). Echoing that sentinel on
PUT
means keep; any other value replaces. Detail:
Updates and write-only secrets.
- "Test connection/config" endpoints validate the request body (test-before-save).
Architecture
Public and internal are sibling routes over one shared, HTTP-agnostic service;
neither calls the other.
GET /rest/tags → TagsController ┐ JWT auth, internal shape
├─→ TagService
GET /api/v1/tags → TagsPublicController ┘ API-key auth, public DTO
Reuse the service behavior. Reuse a DTO only when public and internal contracts
are intentionally identical; otherwise make a public-specific DTO that doesn't
depend on a UI-oriented internal shape.
Before editing
Open these — they are the source of truth, not this skill:
v1/controllers/ — copy structure from tags.public.controller.ts (list +
cursor) or workflows.public.controller.ts (@Param + @ProjectScope), and
index.ts for the barrel.
- Decorators in
packages/@n8n/decorators/src/controller/:
public-api-controller.ts, api-key-scope.ts, api-response.ts,
api-error-response.ts, api-summary.ts, api-description.ts, api-tags.ts,
route.ts, scoped.ts, args.ts, licensed.ts.
- The OpenAPI generator (reads the decorators above, no hand-written YAML
needed for a controller route):
v1/openapi-gen/generate.ts,
v1/openapi-gen/decorator-routes.ts.
- Pagination helpers:
v1/shared/services/pagination.service.ts
(decodeCursor, encodeNextCursor).
- DTOs:
packages/@n8n/api-types/src/dto/.
- Gating tests:
v1/__tests__/public-api-controllers.test.ts,
v1/__tests__/scope-parity.test.ts,
v1/openapi-gen/__tests__/generated-spec-drift.test.ts.
- The internal controller for this resource and its neighboring functional tests.
Declaring a controller
A controller is a class marked @PublicApiController('/base') that injects the
shared service via its constructor and delegates to it. Copy the shape from an
existing controller in v1/controllers/ with the same operation type and auth
model; reuse only what applies. Decorators, all from @n8n/decorators:
| Decorator |
Use |
@PublicApiController('/base') |
Class marker; mounts routes at /api/v1/base. |
@Get/@Post/@Put/@Patch/@Delete('/path') |
Route method. |
@ApiKeyScope('res:action') |
API-key grant check. |
@ProjectScope/@GlobalScope('res:action') |
User RBAC check. |
@ApiResponse(status) / @ApiResponse(status, Dto) |
Success status + (optional) output DTO; registry .parse()s + strips the return value. Exactly one per route — a second @ApiResponse throws. 204 can't carry a DTO — throws. |
@ApiErrorResponse(status) |
Declares an additional documented non-2xx status (e.g. 404, 409). Stack multiple for more than one. 400/401/403 are added automatically (body/query present, always, and @ApiKeyScope present, respectively) — don't declare those yourself. |
@ApiSummary(text) / @ApiDescription(text) / @ApiTags([...]) |
OpenAPI summary/description/tags. @ApiTags sorts alphabetically regardless of the order you pass. All optional but expected on every real route. |
@Query / @Body / @Param('name') |
Bind + validate via a Z.class DTO / path param. |
@Licensed('feat') |
Gates the route on a single BooleanLicenseFeature; PublicApiControllerRegistry runs its own license middleware (after auth/@ApiKeyScope/@ProjectScope |
Authorization (easy to get wrong)
@ApiKeyScope (what the API key is granted) and @ProjectScope/@GlobalScope
(what the user may do) are independent. Use both when the model needs both.
@ProjectScope reads req.params as-is and does not remap id — name the path
param what the resolver expects (workflowId, credentialId, projectId,
dataTableId, …). A generic id often fails.
@ApiKeyScope takes a string, { anyOf: [...] }, or { allOf: [...] } — never
a bare array. The scope must exist in the permissions registry
(API_KEY_RESOURCES in @n8n/permissions); scope-parity.test.ts fails on an
orphan scope.
DTOs
- Build the public response shape explicitly; don't return an ORM entity and lean
on
@ApiResponse stripping to hide fields.
- Treat the output DTO as an allowlist. Re-check nested relations, ownership
fields, tokens, and encrypted values.
- An output DTO restricts which fields you return, not which values they may hold.
The registry parses the handler's return value against it, so a value the schema
rejects becomes a
500. Keep the schema loose enough for anything an existing
row may contain.
- Build the response from the relations the route loaded, not from the entity type.
TypeORM relations are opt-in, so two routes over the same entity can return
different shapes.
- Make input DTOs strict so unknown/partial fields aren't silently accepted:
Z.class(shape, { strict: true }).
- Secrets: never return a real secret; use the resource's sentinel/placeholder
(or omit). See Updates and write-only secrets.
List endpoints (cursor pagination)
Copy the cursor flow from tags.public.controller.ts. The input DTO takes
limit: publicApiPaginationSchema.limit plus cursor: z.string().optional() —
pick limit off the schema, never spread the whole publicApiPaginationSchema
(it also exports offset, which must never be a Public API query param). Use
decodeCursor / encodeNextCursor from the shared pagination service; the
cursor is opaque; return { data, nextCursor } (never a bare array) with
nextCursor: null on the last page; an invalid cursor is a 400. Preserve an
existing endpoint's cursor semantics as-is — but an offset param is a
defect to remove, not a contract to preserve. Detail:
List endpoints and cursor pagination.
Wiring checklist
v1/controllers/<feature>.public.controller.ts + side-effect import in
v1/controllers/index.ts.
- Public DTO in
@n8n/api-types + export from the barrel (src/dto/).
@ApiKeyScope value exists in the permissions registry.
- Don't hand-write the OpenAPI path or
x-required-scope for a controller
route — the generator (v1/openapi-gen/generate.ts) builds it from your
decorators (@ApiSummary/@ApiDescription/@ApiTags/@ApiKeyScope/
@ApiResponse/@ApiErrorResponse). Run the full pnpm build and commit
the regenerated handlers/<feature>/spec/paths/*.generated.yml fragment(s)
and openapi.decorator-routes.generated.yml —
generated-spec-drift.test.ts fails CI if they're stale. pnpm run build:data alone is not enough after touching a controller: it runs
the generator against the already-compiled dist/, so a new/changed
controller silently doesn't show up unless tsc ran first.
- Add the route to
packages/nodes-base/nodes/N8n/n8n-api-coverage.json.
- Tests.
Testing
Always cover: happy path, input-validation failure, missing API-key scope, RBAC
denial. Prefer covering the business path in
packages/cli/test/integration/public-api/ (real HTTP + DB); mocked-service unit
tests don't replace that. Add the cases that apply (cursor pages,
not-found/conflict, no sensitive fields, credential keep/replace, migration
contract) — see Testing matrix. Match the nearest
existing tests.
More detail (reference.md)
- List endpoints and cursor pagination
- Updates and write-only secrets
- Test-before-save endpoints
- Errors
- Testing matrix
- Migrating legacy EOV endpoints
- Verifying a migration
- CI and merging
1---2name: n8n-public-api3description: Adds, migrates, or updates n8n Public API v1 endpoints with @PublicApiController — public DTOs, API-key and RBAC scopes, cursor pagination, OpenAPI + coverage wiring, and tests. Use when working under packages/cli/src/public-api/v1/ or when exposing an existing service through /api/v1.4---5
6# Public API v1
7
8Public API v1 lives in `packages/cli/src/public-api/v1/`, mounted at `/api/v1`
9with API-key auth and public error formatting via `PublicApiControllerRegistry`
10(`packages/cli/src/public-api/public-api-controller.registry.ts`).
11
12Two rule tiers: **invariants** (never break) and **team defaults** (follow unless
13an existing public contract forces otherwise). When this skill and the code
14disagree on a detail, the code wins — so open the files below. That is a reason to
15check the code, not license to drop a team default.
16
17## Non-negotiable rules
18
19- New endpoints are `@PublicApiController` classes under `v1/controllers/`, one
20 `*.public.controller.ts` per feature. A controller is a class — never
21 `export =` (the legacy tuple style; `require-public-api-controller` flags it).
22- Public API and internal REST are separate HTTP surfaces. A public controller
23 never calls an internal controller/endpoint; both reuse the same service.
24- Controllers and handlers delegate to a service — never import a repository or
25 `Container.get(…Repository)` (`no-repository-in-public-api-handler`).
26- Input/output go through DTOs from `@n8n/api-types`; every JSON route declares
27 `@ApiResponse(Dto)`.
28- Register each controller via a side-effect import in `v1/controllers/index.ts`
29 (`public-api-controllers.test.ts` fails otherwise).
30- Don't add business logic to legacy `express-openapi-validator` (EOV) handlers.
31- Migrating a legacy endpoint must not change its public contract.
32
33These are `n8n-local-rules` ESLint rules (see `packages/cli/eslint.config.mjs`)
34and can't be silenced inline (`no-public-api-guardrail-disable`). The `off`
35allowlist there covers pre-existing legacy files only — it's shrink-only, don't
36add to it.
37
38## Team defaults
39
40- List endpoints: cursor-based pagination (internal API uses both cursor- and
41 page-based — don't copy an internal endpoint's model).
42- Pagination args are always `offset` and `limit` — on service methods, handler
43 calls, and repository methods you add. Never `skip`/`take` (TypeORM names).
44 Translate to `skip`/`take` only inside a repository, at the TypeORM `find`
45 call. The public query string is still `cursor` + `limit`; `offset` is the
46 decoded cursor field passed into the service, never a client-facing param.
47- Updates: full-object `PUT`, not `PATCH`. A successful `GET` body should be
48 acceptable as a `PUT` body for the same resource (round-trip), aside from
49 server-managed/immutable fields.
50- Strict input DTOs; output DTOs are an allowlist of public fields.
51- Never return real secrets/tokens in responses or error details — mask with the
52 resource's sentinel/placeholder (or omit). Echoing that sentinel on `PUT`
53 means keep; any other value replaces. Detail:
54 [Updates and write-only secrets](reference.md#updates-and-write-only-secrets).
55- "Test connection/config" endpoints validate the request body (test-before-save).
56
57## Architecture
58
59Public and internal are sibling routes over one shared, HTTP-agnostic service;
60neither calls the other.
61
62```
63GET /rest/tags → TagsController ┐ JWT auth, internal shape
64 ├─→ TagService
65GET /api/v1/tags → TagsPublicController ┘ API-key auth, public DTO
66```
67
68Reuse the service behavior. Reuse a DTO only when public and internal contracts
69are intentionally identical; otherwise make a public-specific DTO that doesn't
70depend on a UI-oriented internal shape.
71
72## Before editing
73
74Open these — they are the source of truth, not this skill:
75
76- `v1/controllers/` — copy structure from `tags.public.controller.ts` (list +
77 cursor) or `workflows.public.controller.ts` (`@Param` + `@ProjectScope`), and
78 `index.ts` for the barrel.
79- Decorators in `packages/@n8n/decorators/src/controller/`:
80 `public-api-controller.ts`, `api-key-scope.ts`, `api-response.ts`,
81 `api-error-response.ts`, `api-summary.ts`, `api-description.ts`, `api-tags.ts`,
82 `route.ts`, `scoped.ts`, `args.ts`, `licensed.ts`.
83- The OpenAPI generator (reads the decorators above, no hand-written YAML
84 needed for a controller route): `v1/openapi-gen/generate.ts`,
85 `v1/openapi-gen/decorator-routes.ts`.
86- Pagination helpers: `v1/shared/services/pagination.service.ts`
87 (`decodeCursor`, `encodeNextCursor`).
88- DTOs: `packages/@n8n/api-types/src/dto/`.
89- Gating tests: `v1/__tests__/public-api-controllers.test.ts`,
90 `v1/__tests__/scope-parity.test.ts`,
91 `v1/openapi-gen/__tests__/generated-spec-drift.test.ts`.
92- The internal controller for this resource and its neighboring functional tests.
93
94## Declaring a controller
95
96A controller is a class marked `@PublicApiController('/base')` that injects the
97shared service via its constructor and delegates to it. Copy the shape from an
98existing controller in `v1/controllers/` with the same operation type and auth
99model; reuse only what applies. Decorators, all from `@n8n/decorators`:
100
101| Decorator | Use |
102|---|---|
103| `@PublicApiController('/base')` | Class marker; mounts routes at `/api/v1/base`. |
104| `@Get/@Post/@Put/@Patch/@Delete('/path')` | Route method. |
105| `@ApiKeyScope('res:action')` | API-key grant check. |
106| `@ProjectScope/@GlobalScope('res:action')` | User RBAC check. |
107| `@ApiResponse(status)` / `@ApiResponse(status, Dto)` | Success status + (optional) output DTO; registry `.parse()`s + strips the return value. Exactly one per route — a second `@ApiResponse` throws. `204` can't carry a DTO — throws. |
108| `@ApiErrorResponse(status)` | Declares an additional documented non-2xx status (e.g. `404`, `409`). Stack multiple for more than one. `400`/`401`/`403` are added automatically (body/query present, always, and `@ApiKeyScope` present, respectively) — don't declare those yourself. |
109| `@ApiSummary(text)` / `@ApiDescription(text)` / `@ApiTags([...])` | OpenAPI summary/description/tags. `@ApiTags` sorts alphabetically regardless of the order you pass. All optional but expected on every real route. |
110| `@Query` / `@Body` / `@Param('name')` | Bind + validate via a `Z.class` DTO / path param. |
111| `@Licensed('feat')` | Gates the route on a single `BooleanLicenseFeature`; `PublicApiControllerRegistry` runs its own license middleware (after auth/`@ApiKeyScope`/`@ProjectScope`|`@GlobalScope`, before the handler) and 403s unlicensed requests. Only takes one feature — if the gate is an any-of/all-of combination (e.g. `LicenseState.isProvisioningLicensed()`, which is `feat:saml` OR `feat:oidc`), `@Licensed` can't express that; check manually in the handler instead, same as the internal `provisioning.controller.ee.ts`/`role-mapping-rule.controller.ee.ts` do today (throwing `ForbiddenError` on failure). |
112
113## Authorization (easy to get wrong)
114
115- `@ApiKeyScope` (what the API key is granted) and `@ProjectScope`/`@GlobalScope`
116 (what the user may do) are independent. Use both when the model needs both.
117- `@ProjectScope` reads `req.params` as-is and does not remap `id` — name the path
118 param what the resolver expects (`workflowId`, `credentialId`, `projectId`,
119 `dataTableId`, …). A generic `id` often fails.
120- `@ApiKeyScope` takes a string, `{ anyOf: [...] }`, or `{ allOf: [...] }` — never
121 a bare array. The scope must exist in the permissions registry
122 (`API_KEY_RESOURCES` in `@n8n/permissions`); `scope-parity.test.ts` fails on an
123 orphan scope.
124
125## DTOs
126
127- Build the public response shape explicitly; don't return an ORM entity and lean
128 on `@ApiResponse` stripping to hide fields.
129- Treat the output DTO as an allowlist. Re-check nested relations, ownership
130 fields, tokens, and encrypted values.
131- An output DTO restricts which fields you return, not which values they may hold.
132 The registry parses the handler's return value against it, so a value the schema
133 rejects becomes a `500`. Keep the schema loose enough for anything an existing
134 row may contain.
135- Build the response from the relations the route loaded, not from the entity type.
136 TypeORM relations are opt-in, so two routes over the same entity can return
137 different shapes.
138- Make input DTOs strict so unknown/partial fields aren't silently accepted:
139 `Z.class(shape, { strict: true })`.
140- Secrets: never return a real secret; use the resource's sentinel/placeholder
141 (or omit). See [Updates and write-only secrets](reference.md#updates-and-write-only-secrets).
142
143## List endpoints (cursor pagination)
144
145Copy the cursor flow from `tags.public.controller.ts`. The input DTO takes
146`limit: publicApiPaginationSchema.limit` plus `cursor: z.string().optional()` —
147pick `limit` off the schema, never spread the whole `publicApiPaginationSchema`
148(it also exports `offset`, which must never be a Public API query param). Use
149`decodeCursor` / `encodeNextCursor` from the shared pagination service; the
150cursor is opaque; return `{ data, nextCursor }` (never a bare array) with
151`nextCursor: null` on the last page; an invalid cursor is a `400`. Preserve an
152existing endpoint's cursor semantics as-is — but an `offset` param is a
153defect to remove, not a contract to preserve. Detail:
154[List endpoints and cursor pagination](reference.md#list-endpoints-and-cursor-pagination).
155
156## Wiring checklist
157
1581. `v1/controllers/<feature>.public.controller.ts` + side-effect import in
159 `v1/controllers/index.ts`.
1602. Public DTO in `@n8n/api-types` + export from the barrel (`src/dto/`).
1613. `@ApiKeyScope` value exists in the permissions registry.
1624. Don't hand-write the OpenAPI path or `x-required-scope` for a controller
163 route — the generator (`v1/openapi-gen/generate.ts`) builds it from your
164 decorators (`@ApiSummary`/`@ApiDescription`/`@ApiTags`/`@ApiKeyScope`/
165 `@ApiResponse`/`@ApiErrorResponse`). Run the full `pnpm build` and commit
166 the regenerated `handlers/<feature>/spec/paths/*.generated.yml` fragment(s)
167 and `openapi.decorator-routes.generated.yml` —
168 `generated-spec-drift.test.ts` fails CI if they're stale. `pnpm run
169 build:data` alone is **not** enough after touching a controller: it runs
170 the generator against the already-compiled `dist/`, so a new/changed
171 controller silently doesn't show up unless `tsc` ran first.
1725. Add the route to `packages/nodes-base/nodes/N8n/n8n-api-coverage.json`.
1736. Tests.
174
175## Testing
176
177Always cover: happy path, input-validation failure, missing API-key scope, RBAC
178denial. Prefer covering the business path in
179`packages/cli/test/integration/public-api/` (real HTTP + DB); mocked-service unit
180tests don't replace that. Add the cases that apply (cursor pages,
181not-found/conflict, no sensitive fields, credential keep/replace, migration
182contract) — see [Testing matrix](reference.md#testing-matrix). Match the nearest
183existing tests.
184
185## More detail (reference.md)
186
187- [List endpoints and cursor pagination](reference.md#list-endpoints-and-cursor-pagination)
188- [Updates and write-only secrets](reference.md#updates-and-write-only-secrets)
189- [Test-before-save endpoints](reference.md#test-before-save-endpoints)
190- [Errors](reference.md#errors)
191- [Testing matrix](reference.md#testing-matrix)
192- [Migrating legacy EOV endpoints](reference.md#migrating-legacy-eov-endpoints)
193- [Verifying a migration](reference.md#verifying-a-migration)
194- [CI and merging](reference.md#ci-and-merging)