Problem: n8n Has No Node for Your API
n8n ships 400+ integrations, but your internal API, niche SaaS, or custom data source isn't one of them. The HTTP Request node works for one-off calls — but it's not reusable, not type-safe, and doesn't show up cleanly in the UI for teammates.
Building a custom node solves all three.
You'll learn:
- How to scaffold a typed n8n node package with the official CLI
- How to wire up credentials, inputs, and API calls with the declarative style
- How to load and test the node in a local n8n instance without publishing to npm
Time: 30 min | Difficulty: Intermediate
Why Custom Nodes Instead of HTTP Request
The HTTP Request node is fine for exploration. Custom nodes are better when:
- Reuse — the same API call appears in 5+ workflows
- Credentials — you want secrets stored in n8n's encrypted credential store, not hardcoded in expressions
- UX — teammates shouldn't need to know the API schema; the node UI documents it for them
- Type safety — TypeScript catches param name typos before runtime
n8n's custom node system is a first-class feature, not a hack. It uses the same INodeType interface the built-in nodes use.
Prerequisites
- Node.js 20+ and npm 9+
- n8n installed locally (
npm install -g n8n) - TypeScript familiarity (interfaces, generics)
- A REST API to wrap — this tutorial uses a fictional
https://api.taskify.iofor clarity; swap in your own
Solution
Step 1: Scaffold the Node Package
n8n provides an official CLI to generate a correctly structured package. Use it — the folder layout and package.json fields matter.
# Install the n8n node creator CLI globally
npm install -g @n8n/n8n-nodes-starter
# Scaffold a new node package
npx @n8n/create-n8n-nodes my-taskify-node
cd my-taskify-node
npm install
Expected output:
✔ Created project at ./my-taskify-node
✔ Installed dependencies
Inspect the generated structure:
my-taskify-node/
├── credentials/
│ └── TaskifyApi.credentials.ts ← API key storage
├── nodes/
│ └── Taskify/
│ ├── Taskify.node.ts ← Main node logic
│ └── taskify.svg ← Node icon
├── package.json
└── tsconfig.json
If it fails:
command not found: npx→ Upgrade Node.js to 20+; npx ships with npm 7+EACCESpermission error → Runnpm install -gwithsudoor fix npm global prefix
Step 2: Define the Credential Type
Credentials are separate from node logic. This keeps secrets out of workflow JSON exports.
Open credentials/TaskifyApi.credentials.ts and replace the contents:
import { ICredentialType, INodeProperties } from 'n8n-workflow';
export class TaskifyApi implements ICredentialType {
name = 'taskifyApi';
displayName = 'Taskify API';
// Links to your API's auth docs — shown in the credential modal
documentationUrl = 'https://docs.taskify.io/auth';
properties: INodeProperties[] = [
{
displayName: 'API Key',
name: 'apiKey',
type: 'string',
// typeOptions.password = masked in UI, never exported in plain text
typeOptions: { password: true },
default: '',
},
{
displayName: 'Base URL',
name: 'baseUrl',
type: 'string',
default: 'https://api.taskify.io',
// Users can override for self-hosted instances
},
];
}
Step 3: Build the Node Class
Open nodes/Taskify/Taskify.node.ts. This is where the node's UI and execution logic live.
import {
IExecuteFunctions,
INodeExecutionData,
INodeType,
INodeTypeDescription,
NodeOperationError,
} from 'n8n-workflow';
export class Taskify implements INodeType {
description: INodeTypeDescription = {
// Internal name — never change this after publishing; breaks saved workflows
name: 'taskify',
displayName: 'Taskify',
icon: 'file:taskify.svg',
group: ['transform'],
version: 1,
description: 'Create, read, and update tasks in Taskify',
defaults: { name: 'Taskify' },
inputs: ['main'],
outputs: ['main'],
// Tells n8n which credential type this node uses
credentials: [
{
name: 'taskifyApi',
required: true,
},
],
properties: [
// Resource selector — top-level grouping
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{ name: 'Task', value: 'task' },
{ name: 'Project', value: 'project' },
],
default: 'task',
},
// Operation selector — shown when Resource = Task
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: { resource: ['task'] },
},
options: [
{ name: 'Create', value: 'create', action: 'Create a task' },
{ name: 'Get', value: 'get', action: 'Get a task by ID' },
{ name: 'Get All', value: 'getAll', action: 'List all tasks' },
],
default: 'create',
},
// Field: Task title — only shown for Create
{
displayName: 'Title',
name: 'title',
type: 'string',
displayOptions: {
show: { resource: ['task'], operation: ['create'] },
},
default: '',
required: true,
description: 'Title of the new task',
},
// Field: Task ID — shown for Get
{
displayName: 'Task ID',
name: 'taskId',
type: 'string',
displayOptions: {
show: { resource: ['task'], operation: ['get'] },
},
default: '',
required: true,
},
],
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const returnData: INodeExecutionData[] = [];
// Pull credentials — n8n decrypts these at runtime
const credentials = await this.getCredentials('taskifyApi');
const baseUrl = credentials.baseUrl as string;
const apiKey = credentials.apiKey as string;
const headers = {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
};
for (let i = 0; i < items.length; i++) {
const resource = this.getNodeParameter('resource', i) as string;
const operation = this.getNodeParameter('operation', i) as string;
try {
let responseData: unknown;
if (resource === 'task') {
if (operation === 'create') {
const title = this.getNodeParameter('title', i) as string;
const response = await this.helpers.httpRequest({
method: 'POST',
url: `${baseUrl}/v1/tasks`,
headers,
body: { title },
json: true,
});
responseData = response;
} else if (operation === 'get') {
const taskId = this.getNodeParameter('taskId', i) as string;
const response = await this.helpers.httpRequest({
method: 'GET',
url: `${baseUrl}/v1/tasks/${taskId}`,
headers,
json: true,
});
responseData = response;
} else if (operation === 'getAll') {
const response = await this.helpers.httpRequest({
method: 'GET',
url: `${baseUrl}/v1/tasks`,
headers,
json: true,
});
// getAll returns an array — wrap each item individually
const tasks = response.data as object[];
for (const task of tasks) {
returnData.push({ json: task });
}
continue; // skip the single-item push below
}
}
returnData.push({ json: responseData as object });
} catch (error) {
// continueOnFail lets the workflow keep running despite errors
if (this.continueOnFail()) {
returnData.push({ json: { error: (error as Error).message }, pairedItem: i });
continue;
}
throw new NodeOperationError(this.getNode(), error as Error, { itemIndex: i });
}
}
return [returnData];
}
}
Step 4: Register the Node and Credential in package.json
n8n discovers your node by reading the n8n key in package.json. Make sure both files are listed:
{
"name": "n8n-nodes-taskify",
"version": "0.1.0",
"n8n": {
"n8nNodesApiVersion": 1,
"credentials": [
"dist/credentials/TaskifyApi.credentials.js"
],
"nodes": [
"dist/nodes/Taskify/Taskify.node.js"
]
},
"main": "index.js",
"scripts": {
"build": "tsc && gulp build:icons",
"dev": "tsc --watch"
}
}
If you skip this step, n8n silently ignores your package. No error, no node — just absence.
Step 5: Build and Link for Local Testing
Don't publish to npm to test. Use npm link to load the package directly from disk.
# Compile TypeScript to dist/
npm run build
# Register this package globally so n8n can find it
npm link
# Tell n8n to load it
cd ~/.n8n
mkdir -p custom
cd custom
npm link n8n-nodes-taskify
Now start n8n:
n8n start
Expected output:
Initializing n8n process
...
n8n ready on 0.0.0.0, port 5678
Open http://localhost:5678, create a new workflow, search for "Taskify" in the node panel — it should appear.
If it fails:
- Node doesn't appear → Check
~/.n8n/custom/node_modules/n8n-nodes-taskify/dist/exists; re-runnpm run buildif dist/ is empty Cannot find moduleerror in n8n logs → Themainfield in package.json points to a missing file; set it to"dist/index.js"or remove it
Verification
Open n8n at http://localhost:5678 and run this smoke test:
- Add a Manual Trigger node
- Add your Taskify node, connect it
- Set Resource → Task, Operation → Create, Title →
"Test task from n8n" - Add a Taskify credential with a valid API key
- Click Execute Workflow
You should see: A JSON output item with the created task's id, title, and createdAt fields returned from the API.
To confirm TypeScript compiled cleanly with no errors:
cd my-taskify-node
npx tsc --noEmit
You should see: No output (silence = success in tsc).
What You Learned
- n8n nodes are plain TypeScript classes implementing
INodeType— no magic framework displayOptions.showdrives conditional field visibility; use it to keep the UI cleanthis.helpers.httpRequestis preferred over rawfetch— it respects n8n's proxy settings and retry logicnpm linkis the right local testing pattern; don't publish to npm on every change- Credential names are a contract — the string
'taskifyApi'in the node must exactly match thenamefield in the credential class
Limitation: The declarative property system covers 95% of use cases, but complex multi-step auth flows (OAuth2 with PKCE, dynamic field loading from an API) require the ICredentialTestRequest interface and loadOptionsMethod — topics for a follow-up article.
Tested on n8n 1.82.0, Node.js 20.11, TypeScript 5.4, macOS 15 and Ubuntu 24.04