14 KiB
Public API v1 — reference
Detail for tasks that need it. Essentials and the rule tiers are in SKILL.md. Open the cited files; they are the source of truth.
List endpoints and cursor pagination
The internal API mixes cursor- and page-based pagination. New Public API list endpoints use cursor-based pagination — do not copy an internal controller's model.
Copy the working flow from v1/controllers/tags.public.controller.ts (and
workflows.public.controller.ts for a @Param list) rather than pasting a
snippet here — a copy would drift. The moving parts:
- Input DTO takes
limit: publicApiPaginationSchema.limitplus an opaquecursor: z.string().optional()— cherry-picklimitbut never spread the wholepublicApiPaginationSchema. That schema also exportsoffset, used by internal-API-style page params elsewhere; a Public API list DTO must never expose it as a query param. SeeListTagsQueryDtofor the shape to copy. decodeCursor/encodeNextCursorlive inv1/shared/services/pagination.service.ts. Decode the incoming cursor to{ offset, limit }, guard the decoded shape, and passoffset/limitto the service — never{ skip, take }.offsetis an internal implementation detail of the cursor here, never a client-facing query param. TypeORM'sskip/takestay inside the repository, at thefindcall.- Treat the cursor as opaque; never hand-encode a token.
- Return an envelope
{ data, nextCursor }— never a bare array. encodeNextCursor(...)returnsnullwhen there is no further page; surface that asnextCursor: null.- An invalid/undecodable cursor is a
400via the existing bad-request error. - For an existing list endpoint, keep its current cursor semantics unchanged.
A leaked
offsetquery param (DTO spreadingpublicApiPaginationSchemainstead of pickinglimit) is a defect to remove, not a contract to preserve — decorator-routed DTOs validate via a plainz.object(), which silently strips unknown query keys rather than rejecting them, so removingoffsetfrom the DTO makes it inert rather than erroring for existing callers.
The output DTO wraps the list as { data, nextCursor } and is declared with
@ApiResponse(...) so the registry strips undeclared fields.
Updates and write-only secrets
- Default to
PUT. The update DTO describes the full mutable object; the validation layer rejects a partial payload (typically400). Don't implement merge semantics behind aPUT. - Don't add a new
PATCHunless the task explicitly requires partial-update semantics or an established resource-specific exception applies. - Migrating an update endpoint keeps its current public HTTP semantics.
Write-only secrets (credentials, tokens, keys) support GET→PUT round-trip via a
resource-specific sentinel/placeholder (e.g. credentials use
CREDENTIAL_BLANKING_VALUE / related helpers — do not invent a new format):
GETnever returns the real secret; it returns that sentinel (or omits the field). Never echo a real secret in responses or error details (including test-connection and upstream error messages).- On
PUT, sending the exact sentinel fromGETmeans keep the stored secret. Sending any other value means replace it. Do not treat "looks masked" or "field omitted" as keep unless the resource helper/tests say so. - Reuse the resource's redact/unredact (or equivalent) helper in the service — don't reimplement sentinel detection or credential merge in the controller, and never persist the sentinel as a real secret.
- Sentinel support does not make the whole
PUTa partial update; other required client-manageable fields stay required. Server-managed/immutable fields fromGET(id, timestamps, …) follow the resource DTO (ignored or not required on write).
Test-before-save endpoints
A connection/config-test endpoint validates the config in the request body, not stored state — unless the endpoint explicitly verifies an already-saved resource. Secret handling is the same as any other endpoint — see above.
Errors
- Don't leak persistence errors, stack traces, or internal messages. Reuse existing domain errors when the registry already maps them to the right public errors; otherwise map at the controller boundary.
- Follow the error semantics of the nearest existing public controller; invalid input and invalid cursors use the existing bad-request pattern.
- When migrating, preserve the documented status codes and public error behavior.
Testing matrix
Always (in SKILL.md): happy path, input-validation failure, missing API-key scope, RBAC denial. Add whichever apply, matching the nearest existing tests:
- At least one integration test under
packages/cli/test/integration/public-api/that exercises the real service/DB path for the main success case (and paging/RBAC where they matter). Controller unit tests with a mocked service are fine for wiring/validation edges — not as the only coverage of behavior. - Cursor paging: first page, final page with
nextCursor: null, invalid cursor,limithandling. - Not-found and conflict semantics.
- Response carries no sensitive/internal fields.
- Credential resources: response has no real secret (sentinel or omitted);
PUTwith the exact sentinel keeps the secret; any other value replaces it; the sentinel is never persisted as a real secret. - Migration: path, method, status codes, scope, and response contract are unchanged. Tests alone cannot show this — see Verifying a migration.
Request body media types
@Body defaults to application/json; the body is already parsed by the
app-wide bodyParser before the registry sees it. Declaring
@Body({ mediaType: 'multipart/form-data', uploadLimits }) instead takes a
multipart/form-data body:
uploadLimits: () => MultipartUploadLimitsis a thunk, read once the route handles its first request (not at startup) — read live config inside it (e.g.Container.get(GlobalConfig)), don't inline a literal.- The route's
@BodyDTO validates text fields merged with uploaded files, notreq.bodyalone. A text field and a file share the same flat object; a file field usespublicApiUploadedFileSchema(@n8n/api-types) — a multer-file-shaped schema that documents itself as{ type: 'string', format: 'binary' }. Several files under the same field name become an array. - An unknown field (one the DTO doesn't declare, on a
{ strict: true }DTO) fails withUnexpected form field "<name>"— not the default "unrecognized keys" wording. - Multer's own parsing errors map to the same statuses express-openapi-validator
used:
413for a size/count limit,400for any other multer error,500— unmasked, since this is aResponseError— for anything else (a malformed body; a missing boundary gives400withmultipart file(s) required). - The body is parsed after every auth/scope/license/quota gate and
before controller/route middlewares — a caller those gates would reject
never has their (possibly huge) body read off the socket, and a middleware
that reads
req.bodysees it already parsed. /discovershows no request schema for a multipart route (same as a legacy multipart route today) — a client can't assume a JSON schema it never gets.- The generator documents the body under the
multipart/form-datacontent key (notapplication/json) and adds413alongside415to the route's documented responses automatically.
Everything above lives in packages/cli/src/public-api/media-types/, one
handler per media type (REQUEST_BODY_HANDLERS in
media-types/request-body/index.ts). Adding a further media type (e.g.
application/octet-stream) means: add it to RequestBodyMediaOptions in
@n8n/decorators's controller/types.ts, write a handler implementing
RequestBodyHandler in media-types/request-body/, and register it in
REQUEST_BODY_HANDLERS — the registry, resolver, generator and /discover
need no change, since they all read the handler, not the media type.
Binary response bodies
@ApiResponse(status, { mediaType, description?, headers? }) declares a
success body that the controller method returns as { body, headers? }, for
example an application/gzip stream. The framework treats options whose
mediaType is in BINARY_RESPONSE_MEDIA_TYPES as a binary response.
mediaType accepts only those types.
bodyis aBufferor aReadable. The method returns the result and never writes tores.headersholds the response headers. Every header declared inheadersmust be in the result. A missing header fails the request with a500. Matching is case-insensitive.- The registry checks the headers, then sets the declared status, the headers,
and
Content-Type: <mediaType>. A header in the result cannot replace theContent-Type. - A stream's first chunk is read before any header is set. So a stream that
fails before its first chunk gives the normal JSON error response, with no
binary
Content-Type. A method that throws before it returns does the same. - After the first chunk is sent, an error ends the response. A client that disconnects is not an error, and the framework destroys the stream.
- A method that returns no binary body fails with a
500. - The generator documents the body as
{ type: string, format: binary }under themediaTypecontent key, with the description and headers. The description defaults toOperation successful..
The runtime part is sendBinaryResponse in
packages/cli/src/public-api/media-types/binary-response.ts.
Migrating legacy EOV endpoints
Legacy express-openapi-validator endpoints live under
v1/handlers/, wired through openapi.yml with x-eov-operation-* and request
types in packages/cli/src/public-api/types.ts. Treat these as migration targets,
not templates.
- Prefer migrating to
@PublicApiControllerover extending the handler. - Preserve the public contract: path, method, scopes, status codes, response shape, and pagination.
- Move HTTP concerns into the controller and business orchestration into the
shared service; keep the controller a thin HTTP boundary. If the legacy
handler was itself already just a thin wrapper around an internal
@RestController(calling its methods directly, e.g.Container.get(SomeController).createThing(req, res, payload)), the new public controller can call that same internal controller directly — no need to duplicate its validation/business logic. - A route must be served by either the EOV handler or a controller, not both —
the build's
mergeDecoratorDocument(v1/openapi-gen/generate.ts) throws on a path+method declared by both sides. Remove the legacy wiring (its path's$refentry inopenapi.yml, thex-eov-operation-*handler, and itshandler.ts) only after the new controller is registered,pnpm buildregenerates the spec cleanly, and tests are updated. - Fully delete the migrated legacy files: the handler's
.ts, itsspec/paths/*.ymlandspec/schemas/*.yml, and any now-dead request type inpackages/cli/src/public-api/types.ts. Then checkv1/shared/spec/schemas/_index.ymlandv1/shared/spec/parameters/_index.ymlfor entries that$refone of the deleted schema/parameter files — those are separate from the path's own$refinopenapi.ymland are easy to miss; left dangling, the next bundle fails on a broken$ref. - If the legacy handler gated on a license (
isLicensed('feat:x')middleware),@Licensed('feat:x')now replicates that for a controller route (see the decorator table in SKILL.md) — but only for a single feature. If the legacy check was an any-of/all-of over several flags (e.g.LicenseState.isProvisioningLicensed()),@Licensedcan't express that; replicate it manually in the controller instead, don't drop it - this is exactly what the internalprovisioning.controller.ee.tsandrole-mapping-rule.controller.ee.tsalready do, since neither uses@Licensedfor that reason. - As a legacy file drops repository access / the
export =tuple, remove its entry from theoffallowlists forno-repository-in-public-api-handlerandrequire-public-api-controllerinpackages/cli/eslint.config.mjs(shrink-only — never extend them). - A
multipart/form-datalegacy endpoint migrates onto@Body({ mediaType: 'multipart/form-data', uploadLimits })— see Request body media types. For any other non-standard endpoint, study the nearest existing handler first. - Keep each field in its original position when you extract a request shape shared
by two routes, and destructure out the ones a route doesn't take. The generator
emits properties in shape order, so a moved field rewrites the
*.generated.ymlof a route the PR wasn't changing.
Verifying a migration
Tests are written against the new code, so they can't show that the old behavior survived. These checks can:
- Diff live responses against master. Run the route on an instance per branch and compare status, body, and error message for the same requests. Reading the old YAML beside the new Zod schema doesn't find the differences.
- Check whether a field is absent or
null.activeVersionomitted is not the same response asactiveVersion: null; use a conditional spread to omit it. - Check query-param coercion at the edges (
"","0","false", absent). A Zod DTO and the old validator don't coerce identically. - Request a route the PR didn't migrate. A shared DTO change can stop the spec
bundle compiling at startup, which turns every legacy route into a
500without failing a test.
CI and merging
Two failures that a migration hits outside the code itself:
- Merge master in before merging. A generator change on master leaves every
branch's committed
*.generated.ymlstale, andgenerated-spec-drift.test.tsthen fails only on the merge commit, so the PR itself stays green andMERGEABLE.gh pr checkshidesmerge_groupruns; look for the run ongh-readonly-queue/master/pr-<number>-<sha>. - Ask a maintainer for a
/size-limit-overrideearly. Generated YAML counts toward the 1,000-line PR size limit, so a single-route migration can exceed it on generator output alone.