A Verified n8n Community Node: the rules the docs don't emphasize
Packaging an API as an n8n community node, a community plugin published on npm, is a one-day project. The surface is small: a node class, a credential (where the user pastes the API key), an SVG. What is not a one-day project is doing it the way n8n’s verification requires. The requirements change the package’s architecture, and three of them barely show up in tutorials.
This is the field note of n8n-nodes-tamperlens, the node that exposes the Tamperlens API as three operations (Inspect, Metadata, Compare). It hit npm on 2026-08-11, in two versions on the same day (I’ll explain why), and was submitted to the Creator Portal three days later. What follows are the rules that apply to any API you want to package. And also the afternoon that npm’s trusted publishing cost, with the mistakes in the order they happened.
The three rules that change the design
A verified node is one that has passed n8n’s official review. Since 2026-05-01, it must satisfy three conditions that are not cosmetic:
- Publishing via GitHub Actions with provenance, the package’s proof of origin. Publishing from a local machine is rejected. This is not a process preference: it is what guarantees the package on npm was built from the public repository, by an auditable workflow.
- The node may touch no files and no environment variables. No
fs.readFileon a path the user typed. The practical consequence: the input is the item’s binary data, never a file path. - Passing
npx @n8n/scan-community-package. It is a static scanner that rejects specific patterns, and it is worth running before publishing, not after.
There is a fourth rule the ecosystem assumes without writing it in bold: zero runtime dependencies. The node’s package.json has devDependencies and a peerDependencies: { "n8n-workflow": "*" }, and no dependencies. That reverberates through the entire codebase, as becomes clear with the first operation that needs an upload.
Zero deps in practice: multipart by hand
Two of the operations (inspect, metadata) send the raw buffer as the body. The Content-Type comes from the binary’s mimeType, falling back to application/octet-stream. Simple.
The third (compare) sends two files, and two files call for multipart/form-data, the format that packs several files into one request. The reflex is to install the form-data package. With zero deps, you can’t. So the multipart is assembled by hand, in about thirty lines: a buildMultipart() that concatenates boundary, part headers, and buffers.
Hand-rolling multipart has two known traps, and both became tests:
- A predictable boundary. The boundary is generated with 24 base-36 characters, and the generator accepts an injectable RNG, a swappable random-number generator. It is not for security: it is so the test can pin it and assert the body byte for byte.
- Filename as a header-injection vector. The file name comes from the workflow item, that is, from outside. An
escapeQuotes()handles quotes and backslashes, and the corresponding test asserts that a malicious filename cannot break out of the part header.
The architecture detail that made those tests cheap: the helpers (buildMultipart, joinUrl, escapeQuotes) live in a module with no n8n types at all. They test with plain node --test, no harness, no n8n runtime mock. The node class stays thin. The testable logic lives outside it.
Binary instead of paths: the rule that protects the user
The “touch no files” rule looks like bureaucracy. Consider what a malicious node would do with a file-path field: read ~/.ssh/id_rsa and ship it to an endpoint. Hence the input is assertBinaryData + getBinaryDataBuffer: the node only sees what the workflow has already loaded as the item’s binary.
For anyone packaging an API, this defines the contract: your node receives buffers and your endpoint receives buffers. Any story of “point at the file on disk” dies at the design stage. The side effect is good: the same node works identically on n8n Cloud, where disk doesn’t even exist the way the user imagines.
The credential test that costs nothing
Every n8n credential can declare a test endpoint: the little “Test” button the user presses after pasting the API key. The naive choice is to point it at the main operation. Except if the API is metered, every credential test burns quota.
The solution was to point the test at an endpoint that is authenticated but not metered. In this case, the receipt-verification endpoint: it returns 401 with a wrong key and 200 with a valid one, without consuming anything from the monthly allowance. If your API has no such endpoint, it is worth creating a GET /auth/check just for this. The credential test is the user’s first interaction with your API, and “testing charged me” is a terrible first interaction.
The scanner rejects things that look right
@n8n/scan-community-package ran over 0.1.1 and flagged three things. Hence 0.1.2 on the same day:
- A credential without an
icon. The credential needs its own icon reference, with light/dark variants. - A node icon without a dark variant. The SVG needs the
tamperlens.svg/tamperlens.dark.svgpair. In this case, the variant only changes the stroke color. - A raw
throw errorinside thecatch. The code had a conditional rethrow (if (error instanceof NodeOperationError) throw error;) before wrapping the rest inNodeOperationError. The scanner counts the rethrow as a “raw throw” and rejects it. The fix was to always wrap, withitemIndexin the context.
The third item is the instructive one: the rejected pattern is defensible in application code. In a verified node, the rule is syntactic and non-negotiable. So run the scanner before the first publish, because every run of it after publishing is a new version on npm.
The error handling that remained is n8n’s idiomatic pattern and worth copying: try/catch per item. With continueOnFail() on, the error becomes { json: { error } } and the workflow continues. With it off, it becomes a NodeOperationError with the item’s index. On both paths the output carries pairedItem, so n8n knows which input item each output came from.
The OIDC afternoon: four mistakes until token-less worked
The part that pays the most per hour of suffering. Publishing with provenance via trusted publishing (no token in the repository) is the right path. The road to it, on 2026-08-11, took five commits in forty minutes:
- npm 10 does not do the OIDC exchange, which authenticates the CI without a token. The Node 22 from
setup-nodeships npm 10, and that version does not implement the token-less exchange. The symptom is not a message about OIDC: it is the registry returning 404 on the unauthenticated PUT. registry-urlinsetup-nodesabotages OIDC. Withregistry-urlconfigured,setup-nodewrites an.npmrcwith the placeholder${NODE_AUTH_TOKEN}. npm presents that placeholder as if it were a real token, preempting the OIDC exchange (issueactions/setup-node#1551). The fix is counter-intuitive: remove theregistry-url.- An explicit
--provenancebreaks trusted publishing. With trusted publishing, provenance is automatic. Passing the flag by hand routes the publish through the token-signing path, which does not exist, and yieldsENEEDAUTH. - npm 12.0.x regressed the exchange. Even with everything right, the npm 12.0.x of the time returned
ENEEDAUTHwith a valid configuration.
The final state, which works and is pinned in the workflow: setup-node without registry-url, npm install -g npm@11 (neither 10 nor 12), npm publish --access public with no flag and no NODE_AUTH_TOKEN. On the job, just permissions: id-token: write. And two seatbelts outside the YAML: prepublishOnly: npm test in the package.json, and a test script that builds before testing. That way a publish never leaves without a green suite over the real dist/.
If you set up the same pipeline: copy the final state, but keep the symptom list. 404 on the PUT, ENEEDAUTH, and “works locally, fails in CI” are three disguises of the same problem, and none of them mentions OIDC in the error text.
Distribution channel? Measuring before claiming
The thesis that justified the node was “marketplace as a distribution channel”. The honest reading, recorded in internal docs before this post: the ecosystem had 5,834 community nodes indexed in January 2026 and about 25 verified ones. An unverified node is a needle in a haystack with no good search. Verification is what changes the shelf.
That is why the operational conclusion was different: the node is hygiene, not a channel. It needs to exist for the product to be taken seriously by people who live inside n8n. Its maintenance cost is near zero (zero deps helps again: no dependabot waking the repo every week). But the real distribution bet is verification, submitted to the Creator Portal on 2026-08-14, with a review window of up to four weeks. Along with it came a self-imposed rule worth adopting: do not touch the repository while the review is open, because every new version resets the queue.
The checklist, for your API
If you are going to package an API as a community node with verification ambitions:
- Zero
dependencies. Multipart by hand if needed; pure helpers in a module without n8n types, tested withnode --test. - Binary, never file paths.
assertBinaryData+getBinaryDataBuffer; the item’s mimeType becomes theContent-Type. - Credential test on an authenticated, unmetered endpoint. If one doesn’t exist, create it.
- Run
npx @n8n/scan-community-packagebefore the first publish. Icons with dark variants (node and credential), no raw throws, errors withitemIndexandpairedItemon both paths. - Token-less trusted publishing: no
registry-url, npm pinned to a version that does the OIDC exchange, no manual--provenance,id-token: write,prepublishOnlyrunning the suite. - Treat the node as hygiene. Verification is the distribution bet, and the node itself is the entry condition. Freeze the repo while the review is open.
None of this is hard the second time. The goal of this post is to be someone’s second time.
Need a custom technical project?
Architecture, TypeScript, APIs and automation, from prototype to production. The person answering your email is the one writing the code, and the deadline I promise is the one I can meet.
Send me a message →