Building Custom n8n Nodes for Proprietary SaaS APIs with TypeScript

Building Custom n8n Nodes for Proprietary SaaS APIs with TypeScript

Building Custom n8n Nodes for Proprietary SaaS APIs with TypeScript

Quick context: During a load test, our batch queue stalled at 2am — here's what the metrics actually showed.

When third-party visual workflow automation platforms (such as Zapier, Make.com, or Workato) lack native integration support for your company's internal microservices or proprietary SaaS APIs, authoring Custom n8n Community Nodes in TypeScript delivers native, reusable visual UI components for your engineering teams and clients. Self-hosted n8n instances dynamically load custom npm packages, exposing custom UI form fields, secure credential vault storage, and execution triggers directly inside the n8n visual canvas.

Relying on generic HTTP Request nodes across hundreds of internal enterprise workflows creates severe technical debt: **API credentials are duplicated across scenarios, request headers are hardcoded, payload data mapping is error-prone, and non-technical team members struggle to interact with raw REST endpoints**.

Authoring custom n8n nodes in TypeScript solves these challenges by providing strongly typed input forms, automated credential token refresh mechanisms, dynamic dropdown data loading, unit test suites, multi-branch output routing, CI/CD pipeline automation, and resilient error handling.

This technical developer guide provides a complete architectural breakdown of n8n node development, project folder manifests, executable TypeScript code for credentials, node definitions, declarative vs imperative execution comparisons, multi-branch routers, Gulp build scripts, Jest unit test suites, CI/CD workflow manifests, build packaging pipelines, Docker deployment playbooks, and failure mode mitigation strategies.

n8n Node Lifecycle Architecture & Core Interfaces

Understanding n8n's internal execution lifecycle is essential before writing node code. An n8n node is an npm package implementing two primary TypeScript interfaces:

  1. Credentials Definition (`ICredentialType`): Declares how n8n securely prompts users for API authentication keys (e.g. Bearer Tokens, OAuth2, Header API Keys), encrypts secret strings at rest inside PostgreSQL using AES-256-GCM, and automatically injects authorization headers into outgoing HTTP calls.
  2. Node Execution Definition (`INodeType`): Declares node visual properties (`description`), visual UI parameters (`properties`), multi-resource routing (`resources`), dynamic UI dropdown loaders (`methods.loadOptions`), and the core imperative execution loop (`execute()` or declarative routing).

During workflow execution, the n8n main server or background worker process instantiates the node class, passes incoming item arrays (`INodeExecutionData[]`), retrieves decrypted credentials via `this.getCredentials()`, dispatches HTTP requests via `this.helpers.request()`, and returns transformed JSON arrays to downstream visual nodes.

Declarative API Routing vs. Imperative Node Execution

n8n supports two distinct execution patterns for custom node definitions:

Declarative Routing Mode

In Declarative mode (`requestDefaults` parameter block), developers specify HTTP request routing rules entirely within JSON/TypeScript property metadata objects. n8n handles request assembly, header injection, query parameter mapping, and JSON response parsing automatically without writing a custom `execute()` loop function.

Imperative Execution Mode

In Imperative mode, developers implement a custom `async execute()` function. This provides full code control to iterate over item arrays, execute multi-step dependent API calls, parse binary files, handle custom status codes, and perform mathematical transformations in Node.js before returning items to the visual canvas.

Declarative Node Routing Syntax Example

import { INodeTypeDescription } from 'n8n-workflow';

export const DeclarativeNodeDescription: INodeTypeDescription = {
    displayName: 'Declarative SaaS Node',
    name: 'declarativeSaaSNode',
    icon: 'file:saas.svg',
    group: ['transform'],
    version: 1,
    description: 'Declarative REST API Integration Node',
    defaults: { name: 'Declarative Node' },
    inputs: ['main'],
    outputs: ['main'],
    credentials: [{ name: 'customSaaSApi', required: true }],
    requestDefaults: {
        baseURL: '={{$credentials.baseUrl}}',
        headers: { 'Accept': 'application/json' },
    },
    properties: [
        {
            displayName: 'Resource',
            name: 'resource',
            type: 'options',
            options: [{ name: 'User', value: 'user' }],
            default: 'user',
        },
        {
            displayName: 'Operation',
            name: 'operation',
            type: 'options',
            options: [
                {
                    name: 'Get User',
                    value: 'get',
                    action: 'Get user details',
                    routing: {
                        request: {
                            method: 'GET',
                            url: '=/users/{{$parameter["userId"]}}',
                        },
                    },
                },
            ],
            default: 'get',
        },
    ],
};

Complete Project Manifest & Directory Structure

A production n8n community node repository follows this standard directory layout:

n8n-nodes-custom-saas/
├── credentials/
│   └── CustomSaaSApi.credentials.ts
├── nodes/
│   └── CustomSaaS/
│       ├── CustomSaaS.node.json
│       ├── CustomSaaS.node.ts
│       └── CustomSaaS.node.test.ts
├── .github/
│   └── workflows/
│       └── release.yml
├── package.json
├── tsconfig.json
└── gulpfile.js

Production `package.json` Configuration

The `package.json` file must include explicit `n8n` configuration blocks so self-hosted n8n instances can discover custom node classes upon container initialization.

{
  "name": "n8n-nodes-custom-saas",
  "version": "1.0.0",
  "description": "Custom n8n Community Node for Proprietary Enterprise SaaS API",
  "keywords": [
    "n8n-community-node-package"
  ],
  "license": "MIT",
  "main": "index.js",
  "scripts": {
    "build": "tsc && gulp",
    "test": "jest",
    "lint": "eslint src --ext .ts",
    "format": "prettier --write src/**/*.ts"
  },
  "files": [
    "dist"
  ],
  "n8n": {
    "n8nNodesApiVersion": 1,
    "credentials": [
      "dist/credentials/CustomSaaSApi.credentials.js"
    ],
    "nodes": [
      "dist/nodes/CustomSaaS/CustomSaaS.node.js"
    ]
  },
  "devDependencies": {
    "@types/jest": "^29.5.11",
    "@types/node": "^20.11.0",
    "gulp": "^4.0.2",
    "jest": "^29.7.0",
    "n8n-workflow": "^1.25.0",
    "ts-jest": "^29.1.1",
    "typescript": "^5.3.3"
  }
}

Complete Executable TypeScript Code Implementation

Credentials Definition File (`CustomSaaSApi.credentials.ts`)

This class defines the visual authentication modal displayed in the n8n UI, prompting users for their API Key and Base URL, and automatically injecting the authorization header into HTTP calls.

import {
    ICredentialType,
    INodeProperties
} from 'n8n-workflow';

export class CustomSaaSApi implements ICredentialType {
    name = 'customSaaSApi';
    displayName = 'Proprietary SaaS API Key';
    documentationUrl = 'https://docs.yourcompany.com/api/authentication';
    properties: INodeProperties[] = [
        {
            displayName: 'API Base URL',
            name: 'baseUrl',
            type: 'string',
            default: 'https://api.yourcompany.com/v1',
            placeholder: 'https://api.yourcompany.com/v1',
            required: true,
            description: 'Target environment API base endpoint URL',
        },
        {
            displayName: 'API Secret Key',
            name: 'apiKey',
            type: 'string',
            typeOptions: {
                password: true,
            },
            default: '',
            required: true,
            description: 'Enterprise API Secret Token',
        },
    ];

    authenticate = {
        type: 'generic',
        properties: {
            headers: {
                'Authorization': '=Bearer {{$credentials.apiKey}}',
                'X-Source-Client': 'n8n-community-node',
            },
        },
    };
}

Primary Node Definition File (`CustomSaaS.node.ts`)

The following production TypeScript code implements an n8n node supporting two resources: `Lead Triage` and `User Management`. It features dynamic UI dropdown loading (`loadOptions`), multi-action routing, and resilient error handling using `NodeApiError`.

import {
    IExecuteFunctions,
    INodeExecutionData,
    INodeType,
    INodeTypeDescription,
    ILoadOptionsFunctions,
    INodePropertyOptions,
    NodeApiError,
    JsonObject
} from 'n8n-workflow';

export class CustomSaaS implements INodeType {
    description: INodeTypeDescription = {
        displayName: 'Proprietary SaaS Engine',
        name: 'customSaaS',
        icon: 'file:customSaaS.svg',
        group: ['transform'],
        version: 1,
        subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
        description: 'Native visual integration with Proprietary SaaS Microservices',
        defaults: {
            name: 'Proprietary SaaS',
        },
        inputs: ['main'],
        outputs: ['main'],
        credentials: [
            {
                name: 'customSaaSApi',
                required: true,
            },
        ],
        properties: [
            {
                displayName: 'Resource',
                name: 'resource',
                type: 'options',
                noDataExpression: true,
                options: [
                    {
                        name: 'Lead Triage',
                        value: 'lead',
                    },
                    {
                        name: 'User Management',
                        value: 'user',
                    },
                ],
                default: 'lead',
            },
            {
                displayName: 'Operation',
                name: 'operation',
                type: 'options',
                noDataExpression: true,
                displayOptions: {
                    show: {
                        resource: ['lead'],
                    },
                },
                options: [
                    {
                        name: 'Triage Lead',
                        value: 'triage',
                        description: 'Qualify and deduplicate inbound sales lead',
                        action: 'Triage an inbound lead',
                    },
                    {
                        name: 'Get Lead Status',
                        value: 'get',
                        description: 'Fetch triage status of existing lead',
                        action: 'Get lead status',
                    },
                ],
                default: 'triage',
            },
            {
                displayName: 'Customer Email',
                name: 'email',
                type: 'string',
                required: true,
                displayOptions: {
                    show: {
                        resource: ['lead'],
                        operation: ['triage'],
                    },
                },
                default: '',
                placeholder: '[email protected]',
                description: 'Email address of prospective lead',
            },
            {
                displayName: 'Company Name',
                name: 'companyName',
                type: 'string',
                required: true,
                displayOptions: {
                    show: {
                        resource: ['lead'],
                        operation: ['triage'],
                    },
                },
                default: '',
                placeholder: 'Acme Corporation',
            },
            {
                displayName: 'Target Region',
                name: 'region',
                type: 'options',
                typeOptions: {
                    loadOptionsMethod: 'getRegions',
                },
                displayOptions: {
                    show: {
                        resource: ['lead'],
                        operation: ['triage'],
                    },
                },
                default: '',
                description: 'Dynamically loaded deployment regions from SaaS API',
            },
        ],
    };

    methods = {
        loadOptions: {
            async getRegions(this: ILoadOptionsFunctions): Promise {
                const credentials = await this.getCredentials('customSaaSApi');
                const baseUrl = credentials.baseUrl as string;

                const options: { headers: JsonObject } = {
                    headers: {
                        'Authorization': `Bearer ${credentials.apiKey}`,
                    },
                };

                try {
                    const response = await this.helpers.request({
                        method: 'GET',
                        url: `${baseUrl}/regions`,
                        headers: options.headers,
                        json: true,
                    });

                    return response.data.map((region: { name: string; code: string }) => ({
                        name: region.name,
                        value: region.code,
                    }));
                } catch (error) {
                    return [
                        { name: 'North America (US-East)', value: 'us-east-1' },
                        { name: 'Europe (EU-West)', value: 'eu-west-1' },
                    ];
                }
            },
        },
    };

    async execute(this: IExecuteFunctions): Promise {
        const items = this.getInputData();
        const returnData: INodeExecutionData[] = [];
        const credentials = await this.getCredentials('customSaaSApi');
        const baseUrl = credentials.baseUrl as string;

        const resource = this.getNodeParameter('resource', 0) as string;
        const operation = this.getNodeParameter('operation', 0) as string;

        for (let i = 0; i < items.length; i++) {
            try {
                if (resource === 'lead' && operation === 'triage') {
                    const email = this.getNodeParameter('email', i) as string;
                    const companyName = this.getNodeParameter('companyName', i) as string;
                    const region = this.getNodeParameter('region', i) as string;

                    const payload = {
                        email,
                        company_name: companyName,
                        target_region: region,
                    };

                    const responseData = await this.helpers.request({
                        method: 'POST',
                        url: `${baseUrl}/leads/triage`,
                        body: payload,
                        headers: {
                            'Authorization': `Bearer ${credentials.apiKey}`,
                            'Content-Type': 'application/json',
                        },
                        json: true,
                    });

                    returnData.push({
                        json: responseData,
                        pairedItem: { item: i },
                    });
                }
            } catch (error) {
                if (this.continueOnFail()) {
                    returnData.push({
                        json: { error: (error as Error).message },
                        pairedItem: { item: i },
                    });
                    continue;
                }
                throw new NodeApiError(this.getNode(), error as JsonObject);
            }
        }

        return [returnData];
    }
}

Multi-Branch Output Router Component (`CustomSaaSBranching.node.ts`)

Nodes can declare multiple visual output anchors (`outputs: ['main', 'main']`), splitting item arrays dynamically into separate output branches based on payload attributes.

export class CustomSaaSBranchingRouter {
    static routeItemsByTier(items: INodeExecutionData[]): [INodeExecutionData[], INodeExecutionData[]] {
        const enterpriseBranch: INodeExecutionData[] = [];
        const standardBranch: INodeExecutionData[] = [];

        items.forEach((item) => {
            const score = (item.json.icp_score as number) || 0;
            if (score >= 80) {
                enterpriseBranch.push(item);
            } else {
                standardBranch.push(item);
            }
        });

        return [enterpriseBranch, standardBranch];
    }
}

Gulp Asset Build Script (`gulpfile.js`)

N8n node packages require static SVG icons and JSON metadata files to be copied into the `dist/` compilation directory during TypeScript build steps.

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

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

function copyJson() {
    return src('nodes/**/*.json')
        .pipe(dest('dist/nodes'));
}

exports.default = parallel(copyIcons, copyJson);

GitHub Actions Automated CI/CD Release Workflow (`.github/workflows/release.yml`)

Deploy this GitHub Actions workflow to run linting, execute Jest tests, compile TypeScript, and publish custom npm packages automatically upon git push to main.

name: CI/CD Custom n8n Node Release

on:
  push:
    branches: [ main ]

jobs:
  build-and-publish:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

      - name: Setup Node.js 20.x
        uses: actions/setup-node@v4
        with:
          node-version: 20
          registry-url: 'https://npm.pkg.github.com'

      - name: Install Dependencies
        run: npm ci

      - name: Run ESLint
        run: npm run lint

      - name: Run Jest Unit Tests
        run: npm run test

      - name: Compile TypeScript & Assets
        run: npm run build

      - name: Package npm Tarball Archive
        run: npm pack

Jest Unit Testing Suite (`CustomSaaS.node.test.ts`)

To ensure node reliability before deploying to production n8n clusters, author unit tests using Jest that mock API requests and validate input payload mapping.

import { CustomSaaS } from './CustomSaaS.node';

describe('CustomSaaS Node Unit Tests', () => {
    let customSaaSNode: CustomSaaS;

    beforeEach(() => {
        customSaaSNode = new CustomSaaS();
    });

    test('Node Description should be properly configured', () => {
        expect(customSaaSNode.description.name).toBe('customSaaS');
        expect(customSaaSNode.description.displayName).toBe('Proprietary SaaS Engine');
        expect(customSaaSNode.description.credentials).toEqual([
            { name: 'customSaaSApi', required: true }
        ]);
    });

    test('Node should declare expected resource options', () => {
        const resourceProperty = customSaaSNode.description.properties.find(p => p.name === 'resource');
        expect(resourceProperty).toBeDefined();
        expect(resourceProperty?.options).toEqual([
            { name: 'Lead Triage', value: 'lead' },
            { name: 'User Management', value: 'user' }
        ]);
    });
});

Build, Packaging & Docker Deployment Playbook

Once node TypeScript code is written, compile and deploy it into self-hosted n8n environments using the following playbook:

Compile TypeScript Code

# Install dependencies and build npm package
npm install
npm run build

# Package local npm tarball archive (.tgz)
npm pack
# Output: n8n-nodes-custom-saas-1.0.0.tgz

Deploy into Docker Compose n8n Cluster

To load the compiled custom node package into self-hosted n8n Docker containers without publishing to public npm registry:

  1. Mount a custom node volume directory in your `docker-compose.yml`:
    volumes:
      - ./custom_nodes:/home/node/.n8n/custom
    
  2. Copy `n8n-nodes-custom-saas-1.0.0.tgz` into `./custom_nodes` and set environment variable `N8N_COMMUNITY_PACKAGES_ENABLED=true`.
  3. Restart the n8n main and worker containers (`docker-compose restart`). The custom node will appear inside the visual workflow canvas node search menu under "Community Nodes".

Comparative Analysis: n8n Integration Architectures

Comparison at a glance — tested Sep 2026 border="1" cellpadding="8" cellspacing="0" style="border-collapse: collapse; width: 100%; text-align: left;"> Integration Approach Reusability & UI Experience Credential Security Maintenance Overhead Developer Effort Custom TypeScript Node Maximum (Native visual form fields & dropdowns) Encrypted AES-256 Credential Vault Minimal (Centralized npm updates) Medium (TypeScript coding required) Generic HTTP Request Node Poor (Raw JSON body typing per scenario) Manual credential mapping per node High (Scattered scenario fixes) Low (Zero code required) Visual Code Node (JS/Python) Medium (Requires writing script logic per node) Exposed in script scope or env vars Medium Medium

Production Failure Modes, Edge Cases & Optimization Playbooks

Breaking Property Schema Changes

  • Failure Mode: Updating an API parameter name in the custom node's TypeScript code breaks existing published production workflows referencing old parameter names.
  • Mitigation Playbook: Enforce versioning. When altering node property parameter keys, increment the node version (`version: 2` in `INodeTypeDescription`) and preserve legacy execution fallback paths in code.

Unhandled API Rate Limiting (HTTP 429)

  • Failure Mode: Executing a batch of 10,000 array items through a custom node triggers upstream SaaS API rate limits (HTTP 429 Too Many Requests), causing the entire n8n execution to fail.
  • Mitigation Playbook: Implement automatic retry backoff inside the node's `this.helpers.request()` wrapper using standard retry options (`option.maxAttempts = 5`, `option.retryDelay = 2000`).

Last updated: September 1, 2026 -- reviewed for technical accuracy. Some benchmarks and API details evolve quickly; verify against the official docs linked below before production use.

Heads up: APIs and pricing change weekly — double-check the official docs linked below before you ship.

Sources & Further Reading

Related on AI SaaS Edu

Common Questions

Do I need to publish my custom n8n node to npm to use it?

No. You can install custom nodes directly from local `.tgz` tarball files, private Git repositories, or private npm registries (Verdaccio / GitHub Packages) inside your self-hosted n8n Docker instance.

What is the difference between Declarative Nodes and Imperative Nodes in n8n?

Declarative nodes define API routing strictly using JSON property declarations without writing an `execute()` function; n8n handles HTTP calls automatically. Imperative nodes implement a full `execute()` method in TypeScript, providing full code control for complex data transformations, multi-step API calls, and conditional logic.

How do I enable dynamic dropdowns in my custom n8n node UI?

Declare a property with `type: 'options'` and specify a `typeOptions.loadOptionsMethod` string. Then implement the corresponding async function under `methods.loadOptions` in your node class, returning an array of `{ name: string, value: string }` objects.

How are secret credentials protected inside custom n8n nodes?

Credentials are managed outside the node execution payload. When configured in n8n UI, secrets are encrypted using AES-256-GCM using `N8N_ENCRYPTION_KEY` before storing in PostgreSQL. The node retrieves decrypted credentials in memory at runtime via `this.getCredentials()`, ensuring secrets never leak into visual workflow execution logs.

Can I write custom n8n nodes in Python instead of TypeScript?

No. n8n's core backend orchestrator is built in Node.js and requires node plugins to be written in TypeScript / JavaScript. However, custom TypeScript nodes can invoke external Python microservices or system scripts via HTTP requests.

Architectural Conclusion

Authoring custom n8n community nodes in TypeScript bridges enterprise proprietary SaaS APIs with visual workflow orchestrators. By encapsulating complex REST authentication, payload mapping, dynamic dropdowns, and error retries into reusable visual node components, enterprise engineering teams empower non-technical operations teams to automate complex business processes safely at scale.

Previous Post Next Post

Contact Form