n8ncommunity nodetypescriptnpmautomationintegration

How to Build an n8n Community Node from Scratch: Scaffold to Creator Portal

Douglas Haruo 12 min 9/14/2026

An n8n community node, an integration published by third parties, is an npm package with three identifying marks. The name starts with n8n-nodes-, the keyword is n8n-community-node-package, and an n8n attribute in package.json points at the compiled node and credential classes. Along with those come two TypeScript classes n8n loads at runtime. The official docs recommend this path today (checked 2026-08-25): scaffold with the n8n-node CLI and test locally with n8n-node dev. If verification is the goal, the next step is publishing to npm through GitHub Actions with a provenance statement, the signed attestation of who built the package. That requirement has applied to verified nodes since May 1st, 2026.

This guide walks that entire path using a real, public node as the map. It is n8n-nodes-tamperlens, which exposes the Tamperlens API as three operations and has been on npm since 2026-08-11. The verification rules that reshape the architecture (zero dependencies, hand-built multipart, the scanner, the afternoon OIDC cost) have their own post. This one is about the how: what each file does, what each block of code has to contain, and in what order things happen on the way to submission.


What is inside a community node?

Tests and CI aside, the whole package is seven source files. The real node’s tree:

n8n-nodes-tamperlens/
├── package.json                  ← the contract with n8n
├── credentials/
│   └── TamperlensApi.credentials.ts
└── nodes/Tamperlens/
    ├── Tamperlens.node.ts        ← the node class
    ├── GenericFunctions.ts       ← pure helpers, testable without n8n
    ├── Tamperlens.node.json      ← the "codex": categories and doc links
    ├── tamperlens.svg            ← icon (light theme)
    └── tamperlens.dark.svg       ← icon (dark theme)

Four pieces, four roles:

1. package.json is the contract. It is what turns an npm package into a community node, through the n8n attribute:

{
  "name": "n8n-nodes-tamperlens",
  "keywords": ["n8n-community-node-package", "..."],
  "files": ["dist"],
  "n8n": {
    "n8nNodesApiVersion": 1,
    "credentials": ["dist/credentials/TamperlensApi.credentials.js"],
    "nodes": ["dist/nodes/Tamperlens/Tamperlens.node.js"]
  },
  "peerDependencies": { "n8n-workflow": "*" }
}

Notice the paths point at dist/, the compiled JavaScript, not the TypeScript. That creates the project’s first practical gotcha: tsc only emits .js, and the SVG icons and the .node.json need to land in dist/ too. The real node’s build script solves it by copying the three static files after tsc. If yours forgets, the package installs and the node shows up without an icon, or not at all.

2. The node class (*.node.ts) implements INodeType: a description object declaring the entire interface (name, icon, operations, fields) and an execute() method that processes the items. The next two sections open up each half.

3. The credential class (*.credentials.ts) implements ICredentialType. It declares the fields the user fills in, the authentication rule n8n applies on its own, and a test request behind the “Test” button.

4. The codex (*.node.json) is catalog metadata: categories and documentation links. It is what fills the side panel when a user finds the node.

Declarative or programmatic: which style to use?

n8n has two node styles. The style-choice docs (checked 2026-08-25) recommend declarative for most cases: JSON describing the request routing, less code, fewer bugs. But they list where programmatic is required: trigger nodes, non-REST APIs, full versioning, and “any node that needs to transform incoming data”. That last criterion is what decides it for many file-handling APIs.

The Tamperlens node is programmatic for that reason. It does not pass JSON along: it assembles the request body from the item’s binary data, including a multipart/form-data built by hand for the compare operation. The story of why lives in the verification post. If your API takes JSON and returns JSON, start declarative. If it takes files, you will end up in execute().

Where to start: the n8n-node CLI scaffold

The current way to start from zero, per the CLI docs (checked 2026-08-25):

npm create @n8n/node@latest
# or, installed globally:
npm install --global @n8n/node-cli
n8n-node new

The scaffold, the starter-structure generator, asks for the project name, the node type (HTTP API, the declarative one, or programmatic) and a template. Then it generates the full structure. From there, two commands carry the development loop:

  • n8n-node dev compiles the project and starts a local n8n at localhost:5678 with your node already loaded. You add the node to a real workflow and test against the real API. It is the modern replacement for the npm link into ~/.n8n/custom ritual the older tutorials teach.
  • n8n-node lint (with --fix for what is automatic) runs the quality rules verification will demand later.

Full transparency on the real case: n8n-nodes-tamperlens was born before the CLI became the recommended path. The package was assembled by hand on the n8n-nodes-starter structure. Today the building docs ask new submissions to start from the CLI scaffolding. There is no reason to disobey: the generated structure comes out in the shape review expects, publish workflow included.

How does the node declare its interface?

The description half of the class is declarative even in a programmatic node. Four of its details are worth pointing out, because none is obvious in a first node:

export class Tamperlens implements INodeType {
  description: INodeTypeDescription = {
    displayName: 'Tamperlens',
    name: 'tamperlens',
    icon: { light: 'file:tamperlens.svg', dark: 'file:tamperlens.dark.svg' },
    group: ['transform'],
    version: 1,
    subtitle: '={{$parameter["operation"]}}',
    usableAsTool: true,
    inputs: [NodeConnectionTypes.Main],
    outputs: [NodeConnectionTypes.Main],
    credentials: [{ name: 'tamperlensApi', required: true }],
    properties: [ /* Operation + per-operation fields */ ],
  };
  • The icon is a light/dark pair. icon accepts { light, dark }, and verification demands both variants. In the real node, the dark one only changes the stroke color of the same SVG.
  • subtitle is an expression. ={{$parameter["operation"]}} makes the node show on the canvas which operation that instance runs, for free, no code.
  • usableAsTool: true is one line with large consequences: it marks the node as usable as a tool by n8n’s AI Agent. For a packaged API, it is the difference between “exists in a workflow” and “an agent can decide to call it”.
  • Fields appear per operation via displayOptions. Each property can declare displayOptions: { show: { operation: ['inspect'] } }. The field only renders when the selected operation matches. That is how three operations share one form without becoming three nodes.

And one vocabulary decision review looks at: every Operation option carries an action (“Inspect a document for fraud signals”) besides name and description. The action is the text that shows in the node search, written as a verb phrase.

How does execute() process items without killing the workflow?

The execute() skeleton is a per-item loop with a specific error contract. That contract is the part tutorials summarize and review demands in full:

async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
  const items = this.getInputData();
  const returnData: INodeExecutionData[] = [];

  for (let i = 0; i < items.length; i++) {
    try {
      // ... build item i's request and call the API ...
      returnData.push({ json, pairedItem: { item: i } });
    } catch (error) {
      if (this.continueOnFail()) {
        returnData.push({
          json: { error: error instanceof Error ? error.message : String(error) },
          pairedItem: { item: i },
        });
        continue;
      }
      throw new NodeOperationError(this.getNode(), error as Error, { itemIndex: i });
    }
  }
  return [returnData];
}

Four rules baked in there:

  1. try/catch per item, not per execution. A batch of twenty documents where the tenth is corrupted must not lose the other nineteen.
  2. continueOnFail() decides the error’s fate. Turned on (by the user, in the node’s settings tab), the error becomes an output item { json: { error } } and the workflow continues; off, the execution stops with an error.
  3. Errors always wrapped in NodeOperationError, with itemIndex. Never a raw throw error: the verification scanner, the automated package validator, rejects the raw throw syntactically, even when it looks defensible. itemIndex is what lets the UI point at which item failed.
  4. pairedItem on both paths. Success and error both carry pairedItem: { item: i }. It is what lets n8n trace which input item produced each output. It is also what makes expressions like $('PreviousNode').item work downstream.

How does the credential inject the key without the node seeing it?

The credential is a separate class, and the design matters: the node’s code never touches the API key. The class declares the fields and a generic authentication rule:

export class TamperlensApi implements ICredentialType {
  name = 'tamperlensApi';
  properties = [
    { displayName: 'API Key', name: 'apiKey', type: 'string',
      typeOptions: { password: true }, required: true, default: '' },
    { displayName: 'Base URL', name: 'baseUrl', type: 'string',
      default: 'https://tamperlens.com/api/v1' },
  ];
  authenticate: IAuthenticateGeneric = {
    type: 'generic',
    properties: { headers: { Authorization: '=Bearer {{$credentials.apiKey}}' } },
  };
  test: ICredentialTestRequest = {
    request: { baseURL: '={{$credentials.baseUrl}}', url: '/receipt/verify',
      method: 'POST', body: { report: {}, receipt: {} } },
  };
}

Three decisions in that block:

  • typeOptions: { password: true } renders the field masked. Review expects that on any secret.
  • authenticate is declarative: the =Bearer {{$credentials.apiKey}} expression tells n8n how to build the header. In the node, the call is this.helpers.httpRequestWithAuthentication.call(this, 'tamperlensApi', options). The runtime injects the header at request time, and the key never passes through code you wrote. It is also what makes the credential work unchanged for any future operation.
  • The test block feeds the credential’s “Test” button. Which endpoint to point it at is a product decision with billing consequences. The full argument (point it at something authenticated and unmetered) is in the verification post.

How does the file get in? The item’s binary data, never a path

The verification guidelines (checked 2026-08-25) are direct: the code “must not interact with environment variables or attempt to read/write files”. Everything the node needs arrives through parameters. For a node that processes documents, the practical consequence is the pair:

const binary = this.helpers.assertBinaryData(i, binaryPropertyName);
const buffer = await this.helpers.getBinaryDataBuffer(i, binaryPropertyName);

assertBinaryData validates that the binary field exists on the item (with a readable error if it does not) and returns the metadata: fileName, mimeType. getBinaryDataBuffer returns the bytes. The item’s mimeType becomes the request’s Content-Type, falling back to application/octet-stream. The field the user configures on the node is not a file path. It is the name of the item’s binary field (data by default), which a previous node has already loaded: IMAP, webhook, HTTP Request.

The side effect is the selling point. A node that only reads the item’s binary data runs identically on self-managed n8n and on n8n Cloud. There, “a path on disk” is not even a concept the user can reach.

How do you publish to npm the way verification requires?

Since May 1st, 2026, the building docs (checked 2026-08-25) require verified nodes to be published through GitHub Actions with a provenance statement. Publishing from a local machine does not qualify. The CLI’s new scaffold ships with the publish workflow ready. For an existing package, the docs say to adopt the publish.yml from n8n-nodes-starter.

Provenance, on npm, is the signed attestation that the package was built from that public repository, by that workflow. It is the ecosystem’s answer to automation packages becoming attack vectors.

The node’s real workflow, in the token-less variant (npm Trusted Publishing via OIDC, no token stored in the repository):

on:
  push:
    tags: ["v*"]
permissions:
  contents: read
  id-token: write   # OIDC for npm Trusted Publishing + provenance
jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4   # no registry-url — deliberate
        with:
          node-version: 22
      - run: npm install -g npm@11    # pinned: not 10, not 12.0.x
      - run: npm ci
      - run: npm test
      - run: npm publish --access public

The release flow becomes: version in package.json, tag v0.1.2, push the tag. CI builds, tests and publishes. Two seatbelts sit outside the YAML. The first is prepublishOnly: npm test in package.json, so no publish ever leaves without a green suite, not even by accident. The second is the test script building before testing, so the suite runs against the dist/ that goes to npm.

The commented lines in the YAML above (the absent registry-url, npm pinned at 11) are scars from a debugging afternoon that has its own section in the verification post. Copy the final state, and read the story if anything says ENEEDAUTH.

How does n8n’s verification work — and how long does it take?

With the package on npm, submission happens on the Creator Portal. What the guidelines and the submission page demand (both checked 2026-08-25):

  • MIT license and a public repository, with the repository URL on npm matching GitHub.
  • Interface and documentation in English.
  • One service per node, and not a service n8n already integrates.
  • Zero external dependencies and no filesystem or environment variables: the two rules that shaped the earlier sections.
  • Passing the scanner: npx @n8n/scan-community-package n8n-nodes-YOURPACKAGE. Run it before the first publish: every finding after publishing is a new version on npm.
  • Publishing through GitHub Actions with provenance, from the previous section.
  • And one caveat worth reading before investing the effort: n8n reserves the right to reject nodes that compete with the platform’s paid features.

What verification buys, in the docs’ words: users “can discover and install verified community nodes from the nodes panel in n8n”. Without it, the user has to find the package on npm and install it by name in the settings.

The real case’s timeline, to calibrate expectations: the node reached npm on 2026-08-11, in two versions the same day, the second fixing the scanner’s findings. The Creator Portal submission went in on 2026-08-14, and the confirmation indicated a review window of up to four weeks. The public docs commit to no duration at all (checked 2026-08-25). This post was written with the review still open: no result to report, and none to promise. The house rule in the meantime: repository frozen until the review ends.

The checklist, from zero to submitted

  1. Scaffold: npm create @n8n/node@latest; pick declarative if the API is pure JSON, programmatic if there are files or transformations.
  2. Contract: n8n-nodes-* name, n8n-community-node-package keyword, n8n attribute pointing at dist/, build copying SVGs and codex into dist/.
  3. Interface: light/dark icon, subtitle by expression, action as a verb on every operation, per-operation fields via displayOptions, usableAsTool if it makes sense as an agent tool.
  4. execute(): per-item loop, per-item try/catch, continueOnFail() respected, NodeOperationError with itemIndex, pairedItem on both paths.
  5. Credential: secret with password: true, declarative authenticate, test pointing at an authenticated, cheap endpoint. The node never touches the key.
  6. File = the item’s binary data: assertBinaryData + getBinaryDataBuffer; no fs, no process.env.
  7. Local testing: n8n-node dev, a real workflow, the real API; n8n-node lint and the scanner before the first publish.
  8. Publish and submit: tag → GitHub Actions → npm with provenance; then creators.n8n.io/nodes. The repo freezes until the answer.

Packaging an API into an automation ecosystem is a repeating pattern. The same decision showed up here in the MCP version. And the n8n that runs the house’s own customer service is the same one this node exists for. The cost of doing it the verifiable way is almost all paid on day one. From there on, a zero-dependency node is the kind of software that wakes nobody up.

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 →