Skill: encryptshawn/npm-n8n-nodes (version 1.0.0)

--- name: npm-n8n-nodes description: > Build, structure, and publish npm packages for n8n custom community nodes. Use this skill whenever the user wants to create a custom n8n node, publish a node to npm, add credentials or authentication to an n8n node, handle HTTP requests inside a node, define node UI properties, scaffold a new n8n node package, wire up OAuth2 / API key / header auth, handle request bodies and responses, work with binary files in n8n, create trigger or webhook nodes, handle errors, version nodes, or publish a community node to npm. Also trigger when user mentions "n8n node", "community node", "custom integration", "n8n-nodes-*", "IExecuteFunctions", "ICredentialType", "INodeType", "n8n workflow node", or "n8n trigger". This skill covers the FULL lifecycle: scaffold → code → credentials → test → publish. --- # n8n Custom Node — NPM Package Skill ## Core Mental Model Every n8n node follows one pattern: ``` getInputData() → loop items → do stuff → push to returnData → return [returnData] ``` Two file types do all the work: - **Node file** (`nodes/MyNode/MyNode.node.ts`) — UI fields + execute logic - **Credential file** (`credentials/MyApi.credentials.ts`) — auth definition Everything else is project plumbing. --- ## Project Structure ``` n8n-nodes-yourservice/ ├── package.json ← CRITICAL: must have n8n section + correct keyword ├── tsconfig.json ├── .eslintrc.js ├── gulpfile.js ← copies SVG icons to dist/ ├── index.js ← optional explicit entry point ├── nodes/ │ └── YourService/ │ ├── YourService.node.ts │ ├── YourService.node.json ← optional: codex metadata │ └── yourservice.svg ├── credentials/ │ └── YourServiceApi.credentials.ts └── dist/ ← compiled output (never edit manually) ``` --- ## What to Read and When This skill has focused reference files. Load only what you need: ### Node Types (pick one) | If you need... | Read | |---|---| | Standard request/response node (most common) | `references/examples/nodes/programmatic-node.md` | | Simple REST API, no complex logic | `references/examples/nodes/declarative-node.md` | | Trigger that polls an API on a schedule | `references/examples/nodes/trigger-node.md` | | Webhook that receives HTTP calls | `references/examples/nodes/webhook-node.md` | ### Credentials (pick what matches your auth) | Auth type | Read | |---|---| | API key, Bearer token, custom header, query key | `references/examples/credentials/api-key-patterns.md` | | OAuth2 (user login or machine-to-machine) | `references/examples/credentials/oauth2-patterns.md` | | Basic auth, multi-field, manual inject | `references/examples/credentials/other-patterns.md` | ### Concepts (load when the topic comes up) | Topic | Read | |---|---| | UI field types, displayOptions, collections, fixedCollection | `references/concepts/node-properties.md` | | HTTP requests, bodies, headers, responses, binary | `references/concepts/http-and-binary.md` | | Error types, continueOnFail, NodeApiError vs NodeOperationError | `references/concepts/error-handling.md` | | pairedItem, data flow, why item tracking matters | `references/concepts/data-and-pairing.md` | | Node versioning, updating without breaking workflows | `references/concepts/node-versioning.md` | ### Project Setup & Publishing | Topic | Read | |---|---| | package.json, tsconfig, gulpfile, eslintrc, index.js | `references/templates/project-files.md` | | Local testing, npm link, n8n start | `references/templates/local-testing.md` | | npm publish, GitHub Actions, provenance | `references/templates/publishing.md` | | Common gotchas and silent failures | `references/gotchas/common-gotchas.md` | --- ## Quick-Start Pattern (copy this first) ```typescript // nodes/YourService/YourService.node.ts import { IExecuteFunctions, INodeExecutionData, INodeType, INodeTypeDescription, NodeOperationError, } from 'n8n-workflow'; export class YourService implements INodeType { description: INodeTypeDescription = { displayName: 'Your Service', name: 'yourService', icon: 'file:yourservice.svg', group: ['transform'], version: 1, description: 'Interact with Your Service API', defaults: { name: 'Your Service' }, inputs: ['main'], outputs: ['main'], credentials: [{ name: 'yourServiceApi', required: true }], properties: [ { displayName: 'Endpoint', name: 'endpoint', type: 'string', default: '/users', required: true, }, ], }; async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> { const items = this.getInputData(); const returnData: INodeExecutionData[] = []; const credentials = await this.getCredentials('yourServiceApi'); for (let i = 0; i < items.length; i++) { try { const endpoint = this.getNodeParameter('endpoint', i) as string; const response = await this.helpers.httpRequest({ method: 'GET', url: `https://api.yourservice.com${endpoint}`, headers: { Authorization: `Bearer ${credentials.apiToken}`, }, }); returnData.push({ json: response, pairedItem: { item: i } }); } catch (error) { if (this.continueOnFail()) { returnData.push({ json: { error: error.message }, pairedItem: { item: i } }); continue; } throw new NodeOperationError(this.getNode(), error, { itemIndex: i }); } } return [returnData]; } } ``` --- ## Essential APIs Cheat Sheet ```typescript // Input const items = this.getInputData(); // Parameters this.getNodeParameter('name', i) as string this.getNodeParameter('count', i, 0) as number this.getNodeParameter('options', i, {}) as IDataObject // Credentials const creds = await this.getCredentials('myCredentialName'); // HTTP await this.helpers.httpRequest({ method, url, headers, qs, body }) // Error handling this.continueOnFail() throw new NodeOperationError(this.getNode(), message, { itemIndex: i }) throw new NodeApiError(this.getNode(), error) // for API-level HTTP errors // Output returnData.push({ json: data, pairedItem: { item: i } }) return [returnData]; ``` --- ## Pre-Publish Checklist - [ ] `keywords` in package.json includes `"n8n-community-node-package"` - [ ] `n8n.nodes` and `n8n.credentials` arrays point to `dist/` `.js` paths - [ ] Node `name` is camelCase; `displayName` is human-readable - [ ] Credential `name` exactly matches string passed to `getCredentials('...')` - [ ] Every `returnData.push()` includes `pairedItem: { item: i }` - [ ] `continueOnFail()` is handled in all try/catch blocks - [ ] SVG icon exists in `nodes/YourService/` and referenced as `'file:yourservice.svg'` - [ ] `npm run build` succeeds (no TypeScript errors) - [ ] `npm run lint` passes (required for community submission) - [ ] Tested locally via `npm link` - [ ] Version bumped in `package.json` before publish

path: references/gotchas/common-gotchas.md
content: # Common Gotchas and Silent Failures

These are the issues that don't throw obvious errors — they just make your node broken, invisible, or wrong in subtle ways.

## Table of Contents
1. [Registration and discovery failures](#1-registration-and-discovery-failures)
2. [Credential name mismatches (silent!)](#2-credential-name-mismatches)
3. [pairedItem missing — expressions break silently](#3-paireditem-missing)
4. [json must be an object, never an array or primitive](#4-json-must-be-an-object)
5. [dist/ not committed or not built in CI](#5-dist-not-committed-or-built)
6. [Icons not copied to dist/](#6-icons-not-copied)
7. [Linting failures block n8n Cloud installation](#7-linting-failures)
8. [noDataExpression missing — expressions appear on wrong fields](#8-nodataexpression)
9. [OAuth2 token caching bug](#9-oauth2-token-caching)
10. [continueOnFail not handled — error swallowed silently](#10-continuenonfail-not-handled)
11. [TypeScript strict mode catches these — enable it](#11-typescript-strict-mode)
12. [Version mismatch — wrong node version on old workflows](#12-version-mismatch)

---

## 1. Registration and Discovery Failures

**Symptom:** Node doesn't appear in n8n's node picker.

**Causes and fixes:**

```json
// ❌ Wrong — missing the required keyword
"keywords": ["n8n", "automation"]

// ✅ Correct
"keywords": ["n8n-community-node-package"]
```

```json
// ❌ Wrong — pointing at source TypeScript files
"n8n": {
  "nodes": ["nodes/MyNode/MyNode.node.ts"]
}

// ✅ Correct — must point at compiled JavaScript in dist/
"n8n": {
  "nodes": ["dist/nodes/MyNode/MyNode.node.js"]
}
```

```json
// ❌ Wrong — path doesn't match actual folder/file name (case-sensitive!)
"nodes": ["dist/nodes/mynode/MyNode.node.js"]

// ✅ Correct — path must exactly match the filesystem
"nodes": ["dist/nodes/MyNode/MyNode.node.js"]
```

---

## 2. Credential Name Mismatches (Silent!)

This is the most common silent failure. The credential loads but never applies.

```typescript
// In credentials file:
export class MyServiceApi implements ICredentialType {
  name = 'myServiceApi';    // ← THIS string must match exactly
}

// In node file:
// ❌ Wrong — different casing
const creds = await this.getCredentials('MyServiceApi');
const creds = await this.getCredentials('myserviceapi');
const creds = await this.getCredentials('my-service-api');

// ✅ Correct — exact match, case-sensitive
const creds = await this.getCredentials('myServiceApi');
```

Also check the `credentials` array in the node description:
```typescript
credentials: [
  {
    name: 'myServiceApi',   // ← must match credential's `name` property
    required: true,
  },
],
```

And the `n8n.credentials` entry in `package.json`:
```json
"credentials": ["dist/credentials/MyServiceApi.credentials.js"]
//                                 ^ filename must match the TypeScript class file
```

---

## 3. pairedItem Missing — Expressions Break Silently

**Symptom:** `{{ $('Previous Node').item.json.field }}` returns wrong data or undefined. Users won't know why.

```typescript
// ❌ Wrong — pairedItem missing
returnData.push({ json: response });

// ✅ Correct — always include pairedItem
returnData.push({ json: response, pairedItem: { item: i } });
```

If you push multiple items for one input item:
```typescript
for (const subItem of responseArray) {
  returnData.push({
    json: subItem,
    pairedItem: { item: i },   // ← all sub-items link back to the same input item
  });
}
```

---

## 4. json Must Be an Object, Never an Array or Primitive

**Symptom:** n8n throws a confusing internal error or the node output looks wrong.

```typescript
// ❌ Wrong — json is an array
returnData.push({ json: [1, 2, 3], pairedItem: { item: i } });

// ❌ Wrong — json is a string or number
returnData.push({ json: 'hello' as any, pairedItem: { item: i } });

// ✅ Correct — wrap arrays
returnData.push({ json: { items: [1, 2, 3] }, pairedItem: { item: i } });

// ✅ Correct — or push each element separately
for (const element of responseArray) {
  returnData.push({ json: element as IDataObject, pairedItem: { item: i } });
}

// ✅ Correct — wrap primitives
returnData.push({ json: { value: 'hello' }, pairedItem: { item: i } });
```

---

## 5. dist/ Not Committed or Built in CI

**Symptom:** Works locally, broken when installed from npm.

npm only ships files listed in `"files"`. If `"files": ["dist"]` and `dist/` is gitignored and not built in CI, your published package will be empty.

**Fix for GitHub Actions:**
```yaml
# In publish.yml — always build before publishing
- name: Build
  run: npm run build      # ← must be here
- name: Publish
  run: npm publish --access public
```

The `prepublishOnly` script in package.json also helps: `"prepublishOnly": "npm run build && npm run lint"` — it runs automatically before `npm publish`.

---

## 6. Icons Not Copied to dist/

**Symptom:** Node appears with a broken/missing icon.

TypeScript compiler (`tsc`) copies `.ts` files only. SVG/PNG icons need to be copied separately by gulpfile:

```bash
# Check: does dist/ have your SVG?
ls dist/nodes/MyNode/
# Should show: MyNode.node.js  MyNode.node.d.ts  mynode.svg

# If SVG is missing, run:
npx gulp build:icons
# OR:
npm run build   # (if build script includes gulp)
```

And verify the icon path in your node matches exactly:
```typescript
icon: 'file:mynode.svg',   // file must exist at dist/nodes/MyNode/mynode.svg
```

---

## 7. Linting Failures Block n8n Cloud Installation

n8n Cloud validates `eslint-plugin-n8n-nodes-base` rules before installing community nodes. Linting errors = installation failure.

```bash
# Always check before publishing
npm run lint

# Auto-fix what's fixable
npm run lintfix

# Common errors:
# - "node-class-description-missing-subtitle" — add subtitle field
# - "node-param-description-missing-final-period" — end descriptions with "."
# - "node-param-display-name-miscased" — use Title Case for displayName
# - "cred-class-field-name-unsuffixed" — credential name must end with "Api"
```

---

## 8. noDataExpression Missing on Selectors

**Symptom:** User accidentally types an expression into the Resource or Operation dropdown, breaking the node UI.

```typescript
// ❌ Wrong — user can put expressions into resource/operation fields
{
  displayName: 'Resource',
  name: 'resource',
  type: 'options',
  options: [...],
  default: 'user',
}

// ✅ Correct — prevent expression mode on structural selectors
{
  displayName: 'Resource',
  name: 'resource',
  type: 'options',
  noDataExpression: true,   // ← always add this to resource/operation fields
  options: [...],
  default: 'user',
}
```

---

## 9. OAuth2 Token Caching Bug

**Symptom:** OAuth2 node works on first run, fails after token expiry.

```typescript
// ❌ Wrong — storing token outside getCredentials causes stale token issues
let cachedToken: string;

async execute() {
  if (!cachedToken) {
    const creds = await this.getCredentials('myOAuth2Api');
    cachedToken = (creds.oauthTokenData as any).access_token;
  }
  // cachedToken may be expired!
}

// ✅ Correct — always call getCredentials() fresh inside execute()
// n8n handles refresh automatically and returns the current valid token
async execute() {
  const creds = await this.getCredentials('myOAuth2Api');
  const [REDACTED_SECRET] as { access_token: string }).access_token;
  // token is always fresh — n8n refreshed it before returning
}
```

---

## 10. continueOnFail Not Handled

**Symptom:** When user enables "Continue On Error", the node throws anyway and halts the workflow.

```typescript
// ❌ Wrong — error always stops workflow
} catch (error) {
  throw new NodeOperationError(this.getNode(), error.message);
}

// ✅ Correct — respect user's setting
} catch (error) {
  if (this.continueOnFail()) {
    returnData.push({ json: { error: error.message }, pairedItem: { item: i } });
    continue;
  }
  throw new NodeOperationError(this.getNode(), error, { itemIndex: i });
}
```

---

## 11. TypeScript Strict Mode Catches These — Enable It

```json
// tsconfig.json — always use strict: true
{
  "compilerOptions": {
    "strict": true    // ← catches null issues, implicit any, and more
  }
}
```

Common strict-mode patterns:
```typescript
// Instead of: const name = response.name
const name = response.name as string;        // explicit cast
const name = (response as IDataObject).name as string;

// Instead of: credentials.apiToken
const [REDACTED_SECRET] as string;  // TypeScript won't infer the type
```

---

## 12. Version Mismatch on Old Workflows

**Symptom:** Existing workflows use old node behavior; updating the package breaks them.

When you bump the npm package version but don't increment the node's `version` field, all instances in existing workflows silently use the new behavior — even if you changed output structure.

```typescript
// After a breaking change, ALWAYS increment version in the node description:
description: INodeTypeDescription = {
  version: [1, 2],          // ← add the new version
  defaultVersion: 2,         // ← new nodes get v2
}
// And add @version displayOptions to fields that changed
```

See `references/concepts/node-versioning.md` for the full pattern.


path: references/templates/project-files.md
content: # Project File Templates

Copy these into your project root. Replace `YOURSERVICE` / `YourService` / `yourService`.

## Table of Contents
1. [package.json](#1-packagejson)
2. [tsconfig.json](#2-tsconfigjson)
3. [gulpfile.js](#3-gulpfilejs)
4. [.eslintrc.js](#4-eslintrcjs)
5. [index.js (optional)](#5-indexjs)
6. [.prettierrc (optional)](#6-prettierrc)
7. [.gitignore](#7-gitignore)

---

## 1. package.json

> Critical fields explained:
> - `keywords` must include `"n8n-community-node-package"` — n8n won't find it otherwise
> - `n8n.nodes` and `n8n.credentials` must point to compiled `.js` files in `dist/`
> - `files: ["dist"]` means only the compiled output is published to npm (not source)
> - `peerDependencies` ensures the user's n8n provides `n8n-workflow`, not a duplicate

```json
{
  "name": "n8n-nodes-yourservice",
  "version": "0.1.0",
  "description": "n8n community node for YourService API",
  "keywords": [
    "n8n-community-node-package"
  ],
  "license": "MIT",
  "homepage": "https://github.com/YOURGITHUB/n8n-nodes-yourservice",
  "author": {
    "name": "Your Name",
    "email": "[REDACTED_SECRET]"
  },
  "repository": {
    "type": "git",
    "url": "https://github.com/YOURGITHUB/n8n-nodes-yourservice.git"
  },
  "main": "index.js",
  "scripts": {
    "build": "tsc && gulp build:icons",
    "dev": "tsc --watch",
    "format": "prettier nodes credentials --write",
    "lint": "eslint nodes credentials --ext .ts",
    "lintfix": "eslint nodes credentials --ext .ts --fix",
    "prepublishOnly": "npm run build && npm run lint"
  },
  "n8n": {
    "n8nNodesApiVersion": 1,
    "credentials": [
      "dist/credentials/YourServiceApi.credentials.js"
    ],
    "nodes": [
      "dist/nodes/YourService/YourService.node.js"
    ]
  },
  "files": [
    "dist"
  ],
  "devDependencies": {
    "@types/node": "^18.16.16",
    "eslint-plugin-n8n-nodes-base": "^1.16.1",
    "gulp": "^4.0.2",
    "n8n-core": "*",
    "n8n-workflow": "*",
    "prettier": "^3.3.2",
    "typescript": "^5.3.3"
  },
  "peerDependencies": {
    "n8n-workflow": "*"
  }
}
```

### Adding more nodes or credentials

```json
"n8n": {
  "n8nNodesApiVersion": 1,
  "credentials": [
    "dist/credentials/YourServiceApi.credentials.js",
    "dist/credentials/YourServiceOAuth2Api.credentials.js"
  ],
  "nodes": [
    "dist/nodes/YourService/YourService.node.js",
    "dist/nodes/YourServiceTrigger/YourServiceTrigger.node.js"
  ]
}
```

### Adding npm dependencies (e.g. form-data, jsonwebtoken)

```json
"dependencies": {
  "form-data": "^4.0.0",
  "jsonwebtoken": "^9.0.2"
}
```

---

## 2. tsconfig.json

```json
{
  "compilerOptions": {
    "strict": true,
    "module": "commonjs",
    "target": "ES2019",
    "lib": ["ES2019"],
    "outDir": "./dist",
    "rootDir": ".",
    "typeRoots": ["./node_modules/@types"],
    "esModuleInterop": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "skipLibCheck": true,
    "resolveJsonModule": true
  },
  "include": ["nodes/**/*.ts", "credentials/**/*.ts"],
  "exclude": ["node_modules", "dist"]
}
```

---

## 3. gulpfile.js

Copies SVG and PNG icons from `nodes/` into `dist/nodes/` after TypeScript compiles. Required because `tsc` only copies `.ts` files.

```javascript
const { src, dest } = require('gulp');

function buildIcons() {
  return src('nodes/**/*.{png,svg}').pipe(dest('dist/nodes'));
}

exports['build:icons'] = buildIcons;
```

---

## 4. .eslintrc.js

Required by n8n community standards. Passes linting is checked before the node can be installed on n8n Cloud.

```javascript
module.exports = {
  root: true,
  env: { node: true },
  parser: '@typescript-eslint/parser',
  parserOptions: {
    project: ['./tsconfig.json'],
    sourceType: 'module',
    extraFileExtensions: ['.json'],
  },
  plugins: ['n8n-nodes-base'],
  extends: ['plugin:n8n-nodes-base/nodes'],
  rules: {
    // Relax specific rules if needed:
    // 'n8n-nodes-base/node-class-description-credentials-name-unsuffixed': 'off',
    // 'n8n-nodes-base/node-dirname-against-convention': 'off',
  },
};
```

Common lint rules you may need to suppress with an inline comment:
```typescript
// eslint-disable-next-line n8n-nodes-base/node-class-description-credentials-name-unsuffixed
name: 'myApiCredential',
```

---

## 5. index.js

Usually not needed — `package.json`'s `n8n` section handles registration. Add this only if n8n complains about a missing entry point:

```javascript
// index.js — explicit entry point (rarely needed)
module.exports = {};
```

---

## 6. .prettierrc

```json
{
  "semi": true,
  "trailingComma": "all",
  "singleQuote": true,
  "printWidth": 100,
  "tabWidth": 2
}
```

---

## 7. .gitignore

```
node_modules/
dist/
*.js.map
.env
```

> Note: Some projects commit `dist/` so users can install from GitHub without building. Either approach is fine — just be consistent.


path: references/templates/local-testing.md
content: # Local Testing

## Table of Contents
1. [npm link workflow](#1-npm-link-workflow)
2. [n8n custom folder locations](#2-n8n-custom-folder-locations)
3. [Using the official starter with hot reload](#3-hot-reload-dev-mode)
4. [Testing in Docker](#4-testing-in-docker)
5. [Debugging tips](#5-debugging-tips)

---

## 1. npm link Workflow

The standard way to test locally without publishing:

```bash
# 1. Build the node
npm run build

# 2. Register it globally on your machine
npm link

# 3. Navigate to n8n's custom node folder
#    (create the folder if it doesn't exist)
cd ~/.n8n/custom

# 4. Link your package into n8n's custom folder
npm link n8n-nodes-yourservice

# 5. Start n8n
n8n start
# → Open http://localhost:5678
# → Your node should appear in the node picker
```

After making changes:
```bash
# In your package directory:
npm run build   # recompile
# n8n should pick up changes after restart (or save + refresh in the editor)
```

To unlink:
```bash
cd ~/.n8n/custom
npm unlink n8n-nodes-yourservice
```

---

## 2. n8n Custom Folder Locations

| OS | Path |
|---|---|
| macOS / Linux | `~/.n8n/custom/` |
| Windows | `C:\Users\<YourUser>\.n8n\custom\` |
| Docker | Mount a volume — see section 4 |
| n8n Cloud | Install via Settings → Community Nodes (npm only) |

Create the folder if it doesn't exist:
```bash
mkdir -p ~/.n8n/custom
```

---

## 3. Hot-Reload Dev Mode

The official n8n starter template includes n8n as a dev dependency with hot reload:

```bash
# Clone the starter
git clone https://github.com/n8n-io/n8n-nodes-starter.git n8n-nodes-yourservice
cd n8n-nodes-yourservice
npm install

# Develop with hot reload (recompiles and reloads on file save)
npm run dev
# → Starts n8n at http://localhost:5678 with your node already loaded
```

This is the fastest development loop for new nodes.

---

## 4. Testing in Docker

Mount your built node directory as a volume:

```bash
# Build first
npm run build

# Run n8n with the node mounted
docker run -it --rm \
  -p 5678:5678 \
  -v ~/.n8n:/home/node/.n8n \
  -v $(pwd)/dist:/home/node/.n8n/custom/node_modules/n8n-nodes-yourservice/dist \
  n8nio/n8n
```

Or use docker-compose:
```yaml
version: '3.8'
services:
  n8n:
    image: n8nio/n8n
    ports:
      - '5678:5678'
    volumes:
      - ~/.n8n:/home/node/.n8n
      - ./:/home/node/.n8n/custom/node_modules/n8n-nodes-yourservice
    environment:
      - N8N_CUSTOM_EXTENSIONS=/home/node/.n8n/custom
```

---

## 5. Debugging Tips

### Node doesn't appear in picker
- Check `npm run build` completed without TypeScript errors
- Verify `n8n.nodes` in `package.json` points to the correct `dist/` path
- Confirm the `dist/` folder exists and has your compiled files
- Restart n8n completely (hot reload doesn't always catch structural changes)
- Check n8n logs for "Error loading node" messages

### Credentials don't load
- Check `n8n.credentials` path in `package.json`
- Ensure credential `name` property exactly matches `getCredentials('...')` string (case-sensitive)
- The credential class name in `package.json` path must match the actual file path

### "Cannot find module" error
- Run `npm install` to install dependencies
- For `npm link` setups, check the symlink is intact: `ls ~/.n8n/custom/node_modules/`

### Changes not reflected after build
- Hard-refresh n8n in browser (Ctrl+Shift+R)
- Restart the n8n process completely
- Check you saved and the TypeScript compiled (watch for tsc errors)

### Console logging for debugging
```typescript
// Temporary — remove before publishing
console.log('DEBUG credentials:', JSON.stringify(credentials, null, 2));
console.log('DEBUG response:', JSON.stringify(response, null, 2));
console.log('DEBUG item:', JSON.stringify(items[i], null, 2));
```

### Test a specific credential test
In n8n UI → Credentials → open your credential → click "Test" button.  
The result shows any error from your `test` block or credential test method.


path: references/templates/publishing.md
content: # Publishing to npm

## Table of Contents
1. [Manual publish](#1-manual-publish)
2. [GitHub Actions workflow (recommended)](#2-github-actions-workflow)
3. [npm provenance (required from May 2026)](#3-npm-provenance)
4. [Versioning strategy](#4-versioning-strategy)
5. [Submitting to n8n community](#5-submitting-to-n8n-community)
6. [Updating an existing package](#6-updating-an-existing-package)

---

## 1. Manual Publish

```bash
# One-time: log in to npm
npm login

# Bump version in package.json (pick one)
npm version patch   # 0.1.0 → 0.1.1  (bug fix)
npm version minor   # 0.1.0 → 0.2.0  (new feature, backwards compatible)
npm version major   # 0.1.0 → 1.0.0  (breaking change)

# Publish (prepublishOnly runs build + lint automatically)
npm publish --access public
```

The `--access public` flag is required for scoped packages (`@yourorg/n8n-nodes-...`). For unscoped packages it's optional but harmless.

---

## 2. GitHub Actions Workflow

Save as `.github/workflows/publish.yml` — triggers automatically when you push a version tag.

```yaml
name: Publish to npm

on:
  push:
    tags:
      - 'v*'     # triggers on v0.1.0, v1.0.0, etc.

jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write    # required for provenance attestation

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          registry-url: 'https://registry.npmjs.org'

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build

      - name: Lint
        run: npm run lint

      - name: Publish with provenance
        run: npm publish --access public --provenance
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
```

**Publish flow:**
```bash
# Update version
npm version patch

# Push tag (triggers the workflow)
git push && git push --tags
```

---

## 3. npm Provenance

**Required for n8n community nodes from May 1, 2026.**

Provenance is a signed attestation from npm that the package was built by a specific GitHub Actions run — it proves the code in the package matches the code in the repo.

### Option A: Trusted Publishers (no token needed)

1. Go to [npmjs.com](https://www.npmjs.com) → your package → Settings → Publish access → Trusted Publishers
2. Click "Add a publisher"
3. Fill in:
   - **Repository owner**: your GitHub org or username
   - **Repository name**: your repo name
   - **Workflow filename**: `publish.yml`
4. Save — GitHub Actions can now publish without an `NPM_TOKEN` stored in secrets

Remove `NODE_AUTH_TOKEN` from the workflow when using Trusted Publishers.

### Option B: NPM Token (traditional)

1. Go to npmjs.com → Access Tokens → Generate New Token → Granular Access Token
2. Give it publish access to your package
3. Copy the token
4. In GitHub: repo Settings → Secrets → Actions → New repository secret
5. Name: `NPM_TOKEN`, value: the token

Keep `NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}` in the workflow.

---

## 4. Versioning Strategy

| Change type | Version bump | Notes |
|---|---|---|
| Bug fix, no interface change | `patch` (0.1.0 → 0.1.1) | |
| New operation, new optional field | `minor` (0.1.0 → 0.2.0) | |
| Renamed field, changed output structure | `major` (0.1.0 → 1.0.0) | Use node versioning too |
| First stable release | `1.0.0` | |

---

## 5. Submitting to n8n Community

After publishing to npm:

1. **Tag your package correctly** — `keywords: ["n8n-community-node-package"]` must be in `package.json`
2. **Submit to n8n Creator Hub**: https://n8n.io/creators — fill out the submission form
3. **Users install via**: n8n Settings → Community Nodes → Install → enter your npm package name

Your node will be installable immediately after publishing. The Creator Hub submission makes it discoverable in n8n's official directory.

---

## 6. Updating an Existing Package

```bash
# 1. Make your code changes
# 2. Build and test locally
npm run build

# 3. Bump version
npm version patch   # or minor / major

# 4. Publish
npm publish --access public
# OR push a tag to trigger GitHub Actions:
git push && git push --tags
```

Users who installed via n8n's community nodes UI will see an "Update available" badge in Settings → Community Nodes and can update with one click.

> ⚠️ If you made breaking changes (changed output structure, renamed fields), bump the major version AND add a node version increment — existing workflows using the old node will continue working on the old version.


path: references/examples/nodes/declarative-node.md
content: # Example: Declarative (Low-Code) Node

Define what the HTTP request looks like — n8n executes it automatically. No `execute()` needed.

**Use when:** single resource, simple CRUD, straightforward REST API, you want to ship fast.  
**Switch to programmatic when:** you need loops, conditional logic between calls, pagination, or data transformation.

## Table of Contents
1. [Full declarative node](#1-full-declarative-node)
2. [requestDefaults and credential injection](#2-requestdefaults-and-credential-injection)
3. [routing — request, output, postReceive](#3-routing)
4. [Expressions in routing](#4-expressions-in-routing)

---

## 1. Full Declarative Node

```typescript
import { INodeType, INodeTypeDescription } from 'n8n-workflow';

export class SimpleService implements INodeType {
  description: INodeTypeDescription = {
    displayName: 'Simple Service',
    name: 'simpleService',
    icon: 'file:simpleservice.svg',
    group: ['transform'],
    version: 1,
    subtitle: '={{$parameter["operation"]}}',
    description: 'Interact with Simple Service API',
    defaults: { name: 'Simple Service' },
    inputs: ['main'],
    outputs: ['main'],
    credentials: [{ name: 'simpleServiceApi', required: true }],

    // Base URL + headers applied to every operation.
    // Credentials with an `authenticate` block are auto-injected here.
    requestDefaults: {
      baseURL: 'https://api.simpleservice.com',
      headers: {
        'Content-Type': 'application/json',
        Accept: 'application/json',
      },
    },

    properties: [
      {
        displayName: 'Operation',
        name: 'operation',
        type: 'options',
        noDataExpression: true,
        options: [

          // ── GET single item ────────────────────────────────────────
          {
            name: 'Get Item',
            value: 'getItem',
            action: 'Get an item',
            routing: {
              request: { method: 'GET', url: '=/items/{{$parameter.itemId}}' },
            },
          },

          // ── GET list ───────────────────────────────────────────────
          {
            name: 'Get Many Items',
            value: 'getManyItems',
            action: 'Get many items',
            routing: {
              request: { method: 'GET', url: '/items' },
              output: {
                // Unwrap { data: [...] } → return just the array
                postReceive: [
                  { type: 'rootProperty', properties: { property: 'data' } },
                ],
              },
            },
          },

          // ── POST create ────────────────────────────────────────────
          {
            name: 'Create Item',
            value: 'createItem',
            action: 'Create an item',
            routing: {
              request: { method: 'POST', url: '/items' },
              // Body is assembled from individual field routing blocks below
            },
          },

          // ── DELETE ────────────────────────────────────────────────
          {
            name: 'Delete Item',
            value: 'deleteItem',
            action: 'Delete an item',
            routing: {
              request: { method: 'DELETE', url: '=/items/{{$parameter.itemId}}' },
              output: {
                // Replace API response with a clean success object
                postReceive: [
                  { type: 'set', properties: { value: '={{ { "success": true } }}' } },
                ],
              },
            },
          },
        ],
        default: 'getItem',
      },

      // ── Item ID (used in URL for get/delete) ───────────────────────
      {
        displayName: 'Item ID',
        name: 'itemId',
        type: 'string',
        required: true,
        default: '',
        displayOptions: { show: { operation: ['getItem', 'deleteItem'] } },
      },

      // ── Create fields — each adds to the request body ──────────────
      {
        displayName: 'Name',
        name: 'name',
        type: 'string',
        required: true,
        default: '',
        displayOptions: { show: { operation: ['createItem'] } },
        routing: { request: { body: { name: '={{$value}}' } } },
      },
      {
        displayName: 'Description',
        name: 'description',
        type: 'string',
        default: '',
        displayOptions: { show: { operation: ['createItem'] } },
        routing: { request: { body: { description: '={{$value}}' } } },
      },

      // ── Query params for list ──────────────────────────────────────
      {
        displayName: 'Limit',
        name: 'limit',
        type: 'number',
        default: 50,
        typeOptions: { minValue: 1 },
        displayOptions: { show: { operation: ['getManyItems'] } },
        routing: { request: { qs: { limit: '={{$value}}' } } },
      },
      {
        displayName: 'Status Filter',
        name: 'status',
        type: 'options',
        options: [
          { name: 'All',      value: '' },
          { name: 'Active',   value: 'active' },
          { name: 'Archived', value: 'archived' },
        ],
        default: '',
        displayOptions: { show: { operation: ['getManyItems'] } },
        routing: {
          request: {
            // Only send if not empty — omit param when 'All' selected
            qs: { status: '={{$value !== "" ? $value : undefined}}' },
          },
        },
      },
    ],
  };
}
```

---

## 2. requestDefaults and Credential Injection

`requestDefaults` sets headers/baseURL for all operations. If your credential has an `authenticate` block (e.g., `Authorization: Bearer {{$credentials.apiToken}}`), n8n auto-injects it into every request made via `requestDefaults`.

**This only works for declarative nodes.** In programmatic nodes you must inject credentials manually in `execute()`.

```typescript
requestDefaults: {
  baseURL: '={{$credentials.baseUrl}}',   // baseUrl can come from credentials too
  headers: {
    Accept: 'application/json',
    // Authorization is injected automatically from credential's authenticate block
  },
},
```

---

## 3. routing

Each operation (or individual field) can have a `routing` block:

```typescript
routing: {
  request: {
    method: 'POST',
    url: '/items',
    body: { key: '={{$value}}' },     // $value = this field's current value
    qs:   { page: '={{$value}}' },
    headers: { 'X-Extra': 'value' },
  },
  output: {
    postReceive: [/* transforms */],
  },
}
```

### postReceive transforms

| Type | What it does |
|---|---|
| `rootProperty` | Unwraps `{ data: [...] }` — extracts `data` array as output |
| `set` | Replaces entire output with a fixed value (good for DELETE) |
| `filter` | Filters output array by a condition |
| `limit` | Limits output to N items |

---

## 4. Expressions in routing

Use `={{...}}` to reference values dynamically:

```typescript
// Reference another parameter
url: '=/users/{{$parameter.userId}}/posts/{{$parameter.postId}}'

// Reference this field's value
body: { name: '={{$value}}' }

// Conditional — omit param if empty
qs: { filter: '={{$value !== "" ? $value : undefined}}' }

// Reference credentials
baseURL: '={{$credentials.instanceUrl}}'
```


path: references/examples/nodes/webhook-node.md
content: # Example: Webhook Node

A webhook node listens for incoming HTTP requests and fires a workflow when one arrives. Unlike trigger nodes (which poll), webhook nodes are passive — the external service pushes data to n8n.

**Key difference from trigger nodes:** implements `webhook()` instead of `poll()`. n8n registers a unique URL and keeps it alive while the workflow is active.

## Table of Contents
1. [Full webhook node](#1-full-webhook-node)
2. [HMAC signature verification](#2-hmac-signature-verification)
3. [Responding to the webhook caller](#3-responding-to-the-webhook-caller)
4. [Multiple webhook events / paths](#4-multiple-webhook-events--paths)
5. [Lifecycle hooks — activate/deactivate](#5-lifecycle-hooks)

---

## 1. Full Webhook Node

```typescript
import {
  IHookFunctions,
  IWebhookFunctions,
  INodeType,
  INodeTypeDescription,
  IWebhookResponseData,
  NodeOperationError,
} from 'n8n-workflow';

export class MyServiceWebhook implements INodeType {
  description: INodeTypeDescription = {
    displayName: 'My Service Webhook',
    name: 'myServiceWebhook',
    icon: 'file:myservice.svg',
    group: ['trigger'],
    version: 1,
    description: 'Starts workflow when My Service sends an event',
    defaults: { name: 'My Service Webhook' },
    inputs: [],
    outputs: ['main'],
    credentials: [{ name: 'myServiceApi', required: true }],

    webhooks: [
      {
        name: 'default',
        httpMethod: 'POST',    // method the external service will call
        responseMode: 'onReceived',  // respond immediately on receipt
        path: 'webhook',       // becomes part of the URL: .../webhook/UUID/webhook
      },
    ],

    properties: [
      {
        displayName: 'Events',
        name: 'events',
        type: 'multiOptions',
        options: [
          { name: 'Item Created', value: 'item.created' },
          { name: 'Item Updated', value: 'item.updated' },
          { name: 'Item Deleted', value: 'item.deleted' },
        ],
        default: ['item.created'],
        required: true,
      },
      {
        displayName: 'Verify Signature',
        name: 'verifySignature',
        type: 'boolean',
        default: true,
        description: 'Whether to verify the HMAC signature from My Service',
      },
    ],
  };

  // ── Lifecycle hooks (called when workflow is activated/deactivated) ─
  webhookMethods = {
    default: {
      // Called when workflow activates — register webhook with external service
      async checkExists(this: IHookFunctions): Promise<boolean> {
        const webhookData = this.getWorkflowStaticData('node');
        const webhookUrl  = this.getNodeWebhookUrl('default') as string;
        const credentials = await this.getCredentials('myServiceApi');

        if (!webhookData.webhookId) return false;

        // Check if our webhook still exists in the external service
        try {
          await this.helpers.httpRequest({
            method: 'GET',
            url: `https://api.myservice.com/webhooks/${webhookData.webhookId}`,
            headers: { Authorization: `Bearer ${credentials.apiToken}` },
          });
          return true;
        } catch {
          return false;
        }
      },

      async create(this: IHookFunctions): Promise<boolean> {
        const webhookUrl  = this.getNodeWebhookUrl('default') as string;
        const credentials = await this.getCredentials('myServiceApi');
        const events      = this.getNodeParameter('events') as string[];

        const response = await this.helpers.httpRequest({
          method: 'POST',
          url: 'https://api.myservice.com/webhooks',
          headers: {
            Authorization: `Bearer ${credentials.apiToken}`,
            'Content-Type': 'application/json',
          },
          body: { url: webhookUrl, events },
        }) as { id: string; [REDACTED_SECRET] };

        // Save webhook ID and secret for verification and deletion later
        const webhookData = this.getWorkflowStaticData('node');
        webhookData.webhookId  = response.id;
        webhookData.webhookSecret = response.secret;

        return true;
      },

      async delete(this: IHookFunctions): Promise<boolean> {
        const webhookData = this.getWorkflowStaticData('node');
        const credentials = await this.getCredentials('myServiceApi');

        if (!webhookData.webhookId) return true;

        try {
          await this.helpers.httpRequest({
            method: 'DELETE',
            url: `https://api.myservice.com/webhooks/${webhookData.webhookId}`,
            headers: { Authorization: `Bearer ${credentials.apiToken}` },
          });
        } catch { /* ignore — webhook may already be gone */ }

        delete webhookData.webhookId;
        delete webhookData.webhookSecret;
        return true;
      },
    },
  };

  // ── Called on every incoming HTTP request ──────────────────────────
  async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
    const req           = this.getRequestObject();
    const body          = this.getBodyData() as IDataObject;
    const verifySignature = this.getNodeParameter('verifySignature') as boolean;

    // ── Signature verification ─────────────────────────────────────
    if (verifySignature) {
      const webhookData  = this.getWorkflowStaticData('node');
      const [REDACTED_SECRET] as string;
      const signature    = req.headers['x-my-service-signature'] as string;

      if (!signature) {
        return { webhookResponse: { code: 401, data: 'Missing signature' } };
      }

      const crypto = require('crypto');
      const rawBody = (req as any).rawBody as Buffer;
      const expected = crypto
        .createHmac('sha256', secret)
        .update(rawBody)
        .digest('hex');

      if (`sha256=${expected}` !== signature) {
        return { webhookResponse: { code: 401, data: 'Invalid signature' } };
      }
    }

    // ── Return data to workflow ────────────────────────────────────
    return {
      workflowData: [[{ json: body }]],
      // Respond 200 OK to the caller immediately
      webhookResponse: { code: 200, data: JSON.stringify({ received: true }) },
    };
  }
}
```

---

## 2. HMAC Signature Verification

Most webhook providers sign payloads so you can verify authenticity.

```typescript
import crypto from 'crypto';

function verifyHmac(
  [REDACTED_SECRET],
  rawBody: Buffer,
  signatureHeader: string,
  algorithm: 'sha256' | 'sha1' = 'sha256',
): boolean {
  const expected = crypto
    .createHmac(algorithm, secret)
    .update(rawBody)
    .digest('hex');

  const received = signatureHeader.replace(`${algorithm}=`, '');

  // Use timingSafeEqual to prevent timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(expected, 'hex'),
    Buffer.from(received, 'hex'),
  );
}

// Usage in webhook():
const rawBody = (this.getRequestObject() as any).rawBody as Buffer;
if (!verifyHmac(secret, rawBody, signature)) {
  return { webhookResponse: { code: 401, data: 'Signature mismatch' } };
}
```

> ⚠️ Always use `rawBody` (unparsed bytes) for HMAC — using the parsed body object may produce different bytes.

---

## 3. Responding to the Webhook Caller

Control what the external service receives as the HTTP response:

```typescript
// Immediate 200 (most common)
return {
  workflowData: [[{ json: body }]],
  webhookResponse: {
    code: 200,
    data: JSON.stringify({ status: 'received' }),
  },
};

// Custom headers in response
return {
  workflowData: [[{ json: body }]],
  webhookResponse: {
    code: 200,
    headers: { 'X-Processed-By': 'n8n' },
    data: 'OK',
  },
};

// Reject with an error (no workflow execution)
return {
  webhookResponse: { code: 400, data: 'Bad request' },
};
```

---

## 4. Multiple Webhook Events / Paths

```typescript
webhooks: [
  {
    name: 'default',
    httpMethod: 'POST',
    responseMode: 'onReceived',
    path: 'events',       // → /webhook/UUID/events
  },
  {
    name: 'ping',
    httpMethod: 'GET',
    responseMode: 'onReceived',
    path: 'ping',         // → /webhook/UUID/ping  (for health check)
  },
],

// In webhook(), check which was called:
async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
  const webhookName = this.getWebhookName();  // 'default' | 'ping'

  if (webhookName === 'ping') {
    return { webhookResponse: { code: 200, data: 'pong' } };
  }

  // handle 'default' ...
}
```

---

## 5. Lifecycle Hooks

`webhookMethods` hooks fire when the workflow is activated/deactivated — use them to register/unregister with the external service:

| Method | When called | Use for |
|---|---|---|
| `checkExists` | On activation | Check if a previous registration still exists |
| `create` | On activation (if checkExists returns false) | Register webhook URL with the API |
| `delete` | On deactivation | Unregister from the API |

Always persist the webhook ID in `getWorkflowStaticData('node')` so `delete` can find it later.

> ⚠️ If your external API doesn't support programmatic webhook registration, skip `webhookMethods` entirely and show the webhook URL as a read-only field the user copies manually.


path: references/examples/nodes/trigger-node.md
content: # Example: Trigger Node (Polling)

A trigger node starts a workflow automatically on a schedule. It polls an API, checks for new data, and fires the workflow when something new appears.

**Key difference from regular nodes:** implements `poll()` instead of `execute()`. Uses `group: ['trigger']`. Has no input connection — it's always the first node.

## Table of Contents
1. [Full polling trigger](#1-full-polling-trigger)
2. [Deduplication — avoiding duplicate fires](#2-deduplication)
3. [Storing state between polls](#3-storing-state-between-polls)
4. [Manual execution for testing](#4-manual-execution-for-testing)

---

## 1. Full Polling Trigger

```typescript
import {
  IPollFunctions,
  INodeExecutionData,
  INodeType,
  INodeTypeDescription,
  NodeOperationError,
} from 'n8n-workflow';

export class MyServiceTrigger implements INodeType {
  description: INodeTypeDescription = {
    displayName: 'My Service Trigger',
    name: 'myServiceTrigger',
    icon: 'file:myservice.svg',
    group: ['trigger'],              // ← must be 'trigger'
    version: 1,
    description: 'Starts workflow when new items appear in My Service',
    defaults: { name: 'My Service Trigger' },
    inputs: [],                      // ← triggers have no inputs
    outputs: ['main'],
    credentials: [{ name: 'myServiceApi', required: true }],

    // How often to poll — user can override in workflow settings
    polling: true,

    properties: [
      {
        displayName: 'Event',
        name: 'event',
        type: 'options',
        options: [
          { name: 'New Item Created', value: 'itemCreated' },
          { name: 'Item Updated',     value: 'itemUpdated' },
        ],
        default: 'itemCreated',
        required: true,
      },
      {
        displayName: 'Resource',
        name: 'resource',
        type: 'options',
        options: [
          { name: 'Task',    value: 'task' },
          { name: 'Comment', value: 'comment' },
        ],
        default: 'task',
      },
    ],
  };

  // poll() is called on each polling interval (set by user in n8n schedule)
  async poll(this: IPollFunctions): Promise<INodeExecutionData[][] | null> {
    const credentials = await this.getCredentials('myServiceApi');
    const event    = this.getNodeParameter('event') as string;
    const resource = this.getNodeParameter('resource') as string;

    // Get the last time we ran (stored by n8n between polls)
    const webhookData = this.getWorkflowStaticData('node');
    const lastChecked = webhookData.lastChecked as string | undefined;

    // First run — set baseline, don't fire
    if (!lastChecked) {
      webhookData.lastChecked = new Date().toISOString();
      return null;  // returning null = no new data, don't trigger workflow
    }

    try {
      const response = await this.helpers.httpRequest({
        method: 'GET',
        url: `https://api.myservice.com/${resource}s`,
        headers: { Authorization: `Bearer ${credentials.apiToken}` },
        qs: {
          event,
          created_after: lastChecked,
          limit: 100,
        },
      }) as { items: IDataObject[] };

      // Update timestamp for next poll
      webhookData.lastChecked = new Date().toISOString();

      if (!response.items || response.items.length === 0) {
        return null;  // nothing new
      }

      // Return each new item as a separate workflow execution
      return [
        response.items.map((item) => ({
          json: item,
        })),
      ];

    } catch (error) {
      throw new NodeOperationError(this.getNode(), error.message);
    }
  }
}
```

---

## 2. Deduplication

If the API doesn't support `created_after`, track seen IDs manually:

```typescript
async poll(this: IPollFunctions): Promise<INodeExecutionData[][] | null> {
  const webhookData = this.getWorkflowStaticData('node');

  // Initialize set of seen IDs
  if (!webhookData.seenIds) {
    webhookData.seenIds = [];
  }
  const seenIds = webhookData.seenIds as string[];

  const response = await this.helpers.httpRequest({
    method: 'GET',
    url: 'https://api.myservice.com/items',
    headers: authHeader,
    qs: { limit: 100, sort: 'created_desc' },
  }) as { items: Array<{ id: string; [key: string]: unknown }> };

  // Filter to items we haven't seen
  const newItems = response.items.filter((item) => !seenIds.includes(item.id));

  if (newItems.length === 0) return null;

  // Remember these IDs (keep list bounded to last 1000)
  webhookData.seenIds = [...seenIds, ...newItems.map((i) => i.id)].slice(-1000);

  return [newItems.map((item) => ({ json: item }))];
}
```

---

## 3. Storing State Between Polls

`this.getWorkflowStaticData('node')` returns a persistent object saved between poll runs:

```typescript
const state = this.getWorkflowStaticData('node');

// Read
const cursor = state.cursor as string | undefined;
const lastId = state.lastId as number | undefined;

// Write (automatically persisted after poll() returns)
state.cursor  = 'abc123';
state.lastId  = 99;
state.lastRun = new Date().toISOString();

// Reset (useful for testing)
delete state.cursor;
```

> ⚠️ `getWorkflowStaticData('global')` is shared across all nodes in the workflow. `getWorkflowStaticData('node')` is scoped to this node instance — use 'node' unless you specifically need cross-node sharing.

---

## 4. Manual Execution for Testing

When a user clicks "Test Workflow" on a trigger node, n8n calls `poll()` with `this.getMode() === 'manual'`. You can return mock/recent data in this case:

```typescript
async poll(this: IPollFunctions): Promise<INodeExecutionData[][] | null> {
  const isManual = this.getMode() === 'manual';

  // In manual mode, return last 5 items regardless of lastChecked
  const limit = isManual ? 5 : 100;
  const qs: IDataObject = { limit };

  if (!isManual) {
    const state = this.getWorkflowStaticData('node');
    if (state.lastChecked) {
      qs.created_after = state.lastChecked;
    }
  }

  const response = await this.helpers.httpRequest({
    method: 'GET',
    url: 'https://api.myservice.com/items',
    headers: authHeader,
    qs,
  }) as { items: IDataObject[] };

  if (!response.items?.length) return null;

  if (!isManual) {
    this.getWorkflowStaticData('node').lastChecked = new Date().toISOString();
  }

  return [response.items.map((item) => ({ json: item }))];
}
```


path: references/examples/nodes/programmatic-node.md
content: # Example: Programmatic Node (Full Pattern)

The programmatic style uses an `execute()` method with full TypeScript control.

**Use when:** multiple resources, multiple operations, conditional logic, pagination, multiple API calls per item, or anything that needs real code.

## Table of Contents
1. [Minimal working node](#1-minimal-working-node)
2. [Resources + Operations pattern](#2-resources--operations-pattern)
3. [Dynamic dropdowns via loadOptions](#3-dynamic-dropdowns-via-loadoptions)
4. [Pagination — cursor and page-based](#4-pagination)
5. [Full execute() with all operations](#5-full-execute)

---

## 1. Minimal Working Node

```typescript
import {
  IExecuteFunctions,
  INodeExecutionData,
  INodeType,
  INodeTypeDescription,
  NodeOperationError,
} from 'n8n-workflow';

export class MyNode implements INodeType {
  description: INodeTypeDescription = {
    displayName: 'My Node',
    name: 'myNode',
    icon: 'file:mynode.svg',
    group: ['transform'],
    version: 1,
    description: 'Does something useful',
    defaults: { name: 'My Node' },
    inputs: ['main'],
    outputs: ['main'],
    credentials: [{ name: 'myNodeApi', required: true }],
    properties: [
      {
        displayName: 'User ID',
        name: 'userId',
        type: 'string',
        default: '',
        required: true,
      },
    ],
  };

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

    for (let i = 0; i < items.length; i++) {
      try {
        const userId = this.getNodeParameter('userId', i) as string;

        const response = await this.helpers.httpRequest({
          method: 'GET',
          url: `https://api.example.com/users/${userId}`,
          headers: { Authorization: `Bearer ${credentials.apiToken}` },
        });

        returnData.push({ json: response, pairedItem: { item: i } });

      } catch (error) {
        if (this.continueOnFail()) {
          returnData.push({ json: { error: error.message }, pairedItem: { item: i } });
          continue;
        }
        throw new NodeOperationError(this.getNode(), error, { itemIndex: i });
      }
    }

    return [returnData];
  }
}
```

---

## 2. Resources + Operations Pattern

The standard pattern for nodes that cover multiple entities (users, posts) with multiple actions each.

```typescript
properties: [
  // ── Step 1: Resource selector ─────────────────────────────────────
  {
    displayName: 'Resource',
    name: 'resource',
    type: 'options',
    noDataExpression: true,   // ← prevents expression mode on this field
    options: [
      { name: 'User', value: 'user' },
      { name: 'Post', value: 'post' },
    ],
    default: 'user',
  },

  // ── Step 2: Operations per resource ───────────────────────────────
  {
    displayName: 'Operation',
    name: 'operation',
    type: 'options',
    noDataExpression: true,
    displayOptions: { show: { resource: ['user'] } },
    options: [
      { name: 'Create', value: 'create', description: 'Create a user', action: 'Create a user' },
      { name: 'Delete', value: 'delete', description: 'Delete a user', action: 'Delete a user' },
      { name: 'Get',    value: 'get',    description: 'Get a user',    action: 'Get a user' },
      { name: 'Get Many', value: 'getAll', description: 'Get many users', action: 'Get many users' },
      { name: 'Update', value: 'update', description: 'Update a user', action: 'Update a user' },
    ],
    default: 'get',
  },

  // ── Step 3: Fields per operation ──────────────────────────────────
  {
    displayName: 'User ID',
    name: 'userId',
    type: 'string',
    required: true,
    default: '',
    displayOptions: {
      show: {
        resource: ['user'],
        operation: ['get', 'update', 'delete'],
      },
    },
  },
  {
    displayName: 'Name',
    name: 'name',
    type: 'string',
    default: '',
    required: true,
    displayOptions: { show: { resource: ['user'], operation: ['create'] } },
  },
  {
    displayName: 'Email',
    name: 'email',
    type: 'string',
    placeholder: '[REDACTED_SECRET]',
    default: '',
    required: true,
    displayOptions: { show: { resource: ['user'], operation: ['create'] } },
  },

  // ── Return All / Limit pattern ─────────────────────────────────────
  {
    displayName: 'Return All',
    name: 'returnAll',
    type: 'boolean',
    default: false,
    description: 'Whether to return all results or only up to a given limit',
    displayOptions: { show: { resource: ['user'], operation: ['getAll'] } },
  },
  {
    displayName: 'Limit',
    name: 'limit',
    type: 'number',
    default: 50,
    typeOptions: { minValue: 1 },
    displayOptions: {
      show: { resource: ['user'], operation: ['getAll'], returnAll: [false] },
    },
  },

  // ── Additional Options (optional extras) ──────────────────────────
  {
    displayName: 'Additional Options',
    name: 'options',
    type: 'collection',
    placeholder: 'Add Option',
    default: {},
    displayOptions: { show: { resource: ['user'], operation: ['create', 'update'] } },
    options: [
      {
        displayName: 'Role',
        name: 'role',
        type: 'options',
        options: [
          { name: 'Admin',  value: 'admin' },
          { name: 'Member', value: 'member' },
        ],
        default: 'member',
      },
      { displayName: 'Active', name: 'active', type: 'boolean', default: true },
    ],
  },

  // ── subtitle trick: shows current operation in node header ─────────
  // Add to description object (not properties):
  // subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}'
],
```

---

## 3. Dynamic Dropdowns via loadOptions

Populates a dropdown from a live API call (e.g. list of projects, workspaces, tags).

```typescript
// In the node description properties:
{
  displayName: 'Project',
  name: 'projectId',
  type: 'options',
  typeOptions: {
    loadOptionsMethod: 'getProjects',  // ← matches method name below
  },
  default: '',
  required: true,
  description: 'Choose from projects in your account. Loaded live from API.',
}

// In the node class, add a methods block alongside description:
methods = {
  loadOptions: {
    async getProjects(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
      const credentials = await this.getCredentials('myNodeApi');

      const response = await this.helpers.httpRequest({
        method: 'GET',
        url: 'https://api.example.com/projects',
        headers: { Authorization: `Bearer ${credentials.apiToken}` },
      }) as Array<{ id: string; name: string }>;

      return response.map((project) => ({
        name: project.name,   // shown in dropdown
        value: project.id,    // stored value
      }));
    },

    // Multiple loadOptions methods are fine
    async getCategories(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
      // ...same pattern
      return [];
    },
  },
};
```

Import needed: `ILoadOptionsFunctions, INodePropertyOptions` from `'n8n-workflow'`

---

## 4. Pagination

### Page-based (page=1, page=2, ...)

```typescript
const returnAll = this.getNodeParameter('returnAll', i) as boolean;
const limit     = returnAll ? Infinity : this.getNodeParameter('limit', i, 50) as number;

let page = 1;
const allItems: unknown[] = [];

while (true) {
  const result = await this.helpers.httpRequest({
    method: 'GET',
    url: 'https://api.example.com/items',
    headers: authHeader,
    qs: { page, per_page: 100 },
  }) as { data: unknown[]; has_more: boolean };

  allItems.push(...result.data);

  if (!result.has_more || allItems.length >= limit) break;
  page++;
}

const sliced = returnAll ? allItems : allItems.slice(0, limit);
for (const item of sliced) {
  returnData.push({ json: item as IDataObject, pairedItem: { item: i } });
}
```

### Cursor-based (next_cursor)

```typescript
let cursor: string | undefined;
const allItems: unknown[] = [];

do {
  const result = await this.helpers.httpRequest({
    method: 'GET',
    url: 'https://api.example.com/items',
    headers: authHeader,
    qs: { cursor, limit: 100 },
  }) as { data: unknown[]; next_cursor?: string };

  allItems.push(...result.data);
  cursor = result.next_cursor;
} while (cursor);
```

---

## 5. Full execute()

```typescript
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
  const items     = this.getInputData();
  const returnData: INodeExecutionData[] = [];
  const resource  = this.getNodeParameter('resource', 0) as string;
  const operation = this.getNodeParameter('operation', 0) as string;
  const credentials = await this.getCredentials('myNodeApi');

  const baseURL    = 'https://api.example.com';
  const authHeader = { Authorization: `Bearer ${credentials.apiToken}` };
  const jsonHeader = { ...authHeader, 'Content-Type': 'application/json' };

  for (let i = 0; i < items.length; i++) {
    try {
      let responseData: unknown;

      if (resource === 'user') {

        if (operation === 'get') {
          const userId = this.getNodeParameter('userId', i) as string;
          responseData = await this.helpers.httpRequest({
            method: 'GET',
            url: `${baseURL}/users/${userId}`,
            headers: authHeader,
          });

        } else if (operation === 'create') {
          const name    = this.getNodeParameter('name', i) as string;
          const email   = this.getNodeParameter('email', i) as string;
          const options = this.getNodeParameter('options', i, {}) as IDataObject;

          responseData = await this.helpers.httpRequest({
            method: 'POST',
            url: `${baseURL}/users`,
            headers: jsonHeader,
            body: { name, email, ...options },
          });

        } else if (operation === 'update') {
          const userId  = this.getNodeParameter('userId', i) as string;
          const options = this.getNodeParameter('options', i, {}) as IDataObject;

          responseData = await this.helpers.httpRequest({
            method: 'PATCH',
            url: `${baseURL}/users/${userId}`,
            headers: jsonHeader,
            body: options,
          });

        } else if (operation === 'delete') {
          const userId = this.getNodeParameter('userId', i) as string;
          await this.helpers.httpRequest({
            method: 'DELETE',
            url: `${baseURL}/users/${userId}`,
            headers: authHeader,
          });
          responseData = { success: true, id: userId };

        } else if (operation === 'getAll') {
          // See pagination patterns above
          responseData = { message: 'use pagination pattern from section 4' };
        }

      } else if (resource === 'post') {
        // same pattern for each resource
      }

      returnData.push({
        json: responseData as IDataObject,
        pairedItem: { item: i },
      });

    } catch (error) {
      if (this.continueOnFail()) {
        returnData.push({
          json: { error: error.message, statusCode: error.statusCode ?? 'unknown' },
          pairedItem: { item: i },
        });
        continue;
      }
      // NodeApiError for HTTP errors (has statusCode), NodeOperationError for logic errors
      if (error.statusCode) {
        throw new NodeApiError(this.getNode(), error, { itemIndex: i });
      }
      throw new NodeOperationError(this.getNode(), error.message, { itemIndex: i });
    }
  }

  return [returnData];
}
```


Security scan verdicts — ClawScan: clean | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)