background-shape
Building Reusable Connectors for n8n and Make in 2024
September 16, 2024 · 8 min read · by Muhammad Amal programming

TL;DR — Internal connectors are the highest-leverage thing a platform team can build for citizen developers. A well-built n8n custom node hides auth, retry, and pagination behind a single drag-in. Build a small library, ship it on your self-hosted instance, and the rest of the program gets easier.

When I tell senior engineers their citizen developers should not be hand-rolling OAuth in a “HTTP Request” node, the response is usually “obviously, but where’s the time to build twenty connectors?” The answer is, you don’t build twenty. You build four, and you build them right.

This post walks through what those connectors actually look like, in n8n 1.58 and in Make as of September 2024. The code is real. The patterns are what I’ve shipped on internal connector libraries at three companies.

If you’ve read the webhook reliability post, the connector code below assumes those primitives exist on your backend. Connectors mostly just wrap them.

What a connector is doing

The conceptual job of a custom connector is the same on n8n and Make. It does three things on behalf of the citizen developer.

  1. Manages auth. OAuth flow, token refresh, scope handling. The citizen dev clicks “Sign In” and never sees a token.
  2. Wraps the API surface. Each operation is a named action (“Create Invoice,” “List Customers”) with typed inputs and outputs. No URL construction. No header munging.
  3. Handles the boring bits. Pagination, rate-limit-aware retries, error normalisation, schema discovery.

Done well, the citizen dev never knows whether your API is REST, GraphQL, or gRPC under the hood. They drag “Acme — Create Invoice,” fill in fields, connect outputs. That’s it.

The n8n custom node, deeper

I sketched a minimal n8n custom node in the first post of this series. Here’s a fuller version that’s closer to production-ready, including a credentials class with OAuth2, dynamic option loading, and pagination.

The credentials class

// credentials/AcmeOAuth2Api.credentials.ts
import { ICredentialType, INodeProperties } from 'n8n-workflow';

export class AcmeOAuth2Api implements ICredentialType {
  name = 'acmeOAuth2Api';
  extends = ['oAuth2Api'];
  displayName = 'Acme OAuth2 API';
  documentationUrl = 'https://docs.acme.example.com/oauth';

  properties: INodeProperties[] = [
    { displayName: 'Grant Type', name: 'grantType', type: 'hidden', default: 'authorizationCode' },
    { displayName: 'Authorization URL', name: 'authUrl', type: 'hidden', default: 'https://auth.acme.example.com/authorize' },
    { displayName: 'Access Token URL', name: 'accessTokenUrl', type: 'hidden', default: 'https://auth.acme.example.com/oauth/token' },
    { displayName: 'Scope', name: 'scope', type: 'hidden', default: 'invoices:read invoices:write customers:read' },
    { displayName: 'Auth URI Query Parameters', name: 'authQueryParameters', type: 'hidden', default: 'audience=https://api.acme.example.com' },
    { displayName: 'Authentication', name: 'authentication', type: 'hidden', default: 'header' },
  ];
}

Notice this class extends the built-in oAuth2Api. n8n 1.58 handles the entire OAuth dance — redirect, code exchange, refresh — once you describe the endpoints. Don’t reimplement OAuth in your node.

The node itself, with operations

// nodes/Acme/Acme.node.ts
import {
  INodeType,
  INodeTypeDescription,
  IExecuteFunctions,
  ILoadOptionsFunctions,
  INodePropertyOptions,
  NodeOperationError,
} from 'n8n-workflow';

export class Acme implements INodeType {
  description: INodeTypeDescription = {
    displayName: 'Acme',
    name: 'acme',
    icon: 'file:acme.svg',
    group: ['transform'],
    version: 1,
    description: 'Talk to the Acme platform',
    defaults: { name: 'Acme' },
    inputs: ['main'],
    outputs: ['main'],
    credentials: [{ name: 'acmeOAuth2Api', required: true }],
    properties: [
      {
        displayName: 'Resource',
        name: 'resource',
        type: 'options',
        noDataExpression: true,
        options: [
          { name: 'Invoice', value: 'invoice' },
          { name: 'Customer', value: 'customer' },
        ],
        default: 'invoice',
      },
      {
        displayName: 'Operation',
        name: 'operation',
        type: 'options',
        noDataExpression: true,
        displayOptions: { show: { resource: ['invoice'] } },
        options: [
          { name: 'Create', value: 'create', action: 'Create an invoice' },
          { name: 'Get', value: 'get', action: 'Get an invoice' },
          { name: 'List', value: 'list', action: 'List invoices' },
        ],
        default: 'create',
      },
      {
        displayName: 'Customer',
        name: 'customerId',
        type: 'options',
        typeOptions: { loadOptionsMethod: 'getCustomers' },
        displayOptions: { show: { resource: ['invoice'], operation: ['create'] } },
        default: '',
        required: true,
      },
      {
        displayName: 'Amount (cents)',
        name: 'amountCents',
        type: 'number',
        displayOptions: { show: { resource: ['invoice'], operation: ['create'] } },
        default: 0,
        required: true,
      },
    ],
  };

  methods = {
    loadOptions: {
      async getCustomers(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
        const res = await this.helpers.httpRequestWithAuthentication.call(this, 'acmeOAuth2Api', {
          method: 'GET',
          url: 'https://api.acme.example.com/v1/customers',
          qs: { limit: 200 },
          json: true,
        });
        return res.data.map((c: { id: string; name: string }) => ({
          name: c.name,
          value: c.id,
        }));
      },
    },
  };

  async execute(this: IExecuteFunctions) {
    const items = this.getInputData();
    const out = [];

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

      if (resource === 'invoice' && operation === 'create') {
        const customerId = this.getNodeParameter('customerId', i) as string;
        const amount = this.getNodeParameter('amountCents', i) as number;

        try {
          const res = await this.helpers.httpRequestWithAuthentication.call(this, 'acmeOAuth2Api', {
            method: 'POST',
            url: 'https://api.acme.example.com/v1/invoices',
            body: {
              customer_id: customerId,
              amount_cents: amount,
              idempotency_key: `n8n-${this.getExecutionId()}-${i}`,
            },
            json: true,
          });
          out.push({ json: res });
        } catch (err) {
          if (this.continueOnFail()) {
            out.push({ json: { error: (err as Error).message } });
            continue;
          }
          throw new NodeOperationError(this.getNode(), err as Error);
        }
      }
    }

    return [out];
  }
}

Four things to call out.

Dynamic options via loadOptionsMethod. The “Customer” field is a dropdown populated by hitting your API at edit time. The citizen dev picks “Acme Corp” not cus_3kj4kj.... Massive UX win.

Idempotency key from execution ID. The connector adds idempotency_key: n8n-${executionId}-${itemIndex} so retries are safe. The receiver dedupes on it. The citizen dev doesn’t have to think about this.

continueOnFail honoured. n8n lets workflow authors choose whether a node failure stops the workflow or continues. Your node must check this.continueOnFail() and either push an error item or throw.

httpRequestWithAuthentication. This helper does the OAuth handshake, refresh, and header injection. Use it. Don’t construct headers yourself.

Packaging and distribution

You don’t publish your internal connector to the public n8n marketplace. You ship it on your self-hosted instance.

The pattern that works:

  1. Build the node as an npm package, scoped (@acme/n8n-nodes-acme).
  2. Publish to an internal npm registry (Verdaccio, GitHub Packages, AWS CodeArtifact).
  3. Install on n8n via N8N_CUSTOM_EXTENSIONS mount or via the community node install path.

For the Docker-based self-hosted setup, the cleanest path is a small custom image:

FROM n8nio/n8n:1.58.2
USER root
RUN npm install -g --omit=dev @acme/n8n-nodes-acme@1.0.0
USER node

Pin the version. Roll the image when you update the connector. The connector lifecycle is a release artefact like anything else.

Make custom apps, briefly

Make’s equivalent of an n8n custom node is a “custom app.” It’s defined in JSON via the Make app builder, with optional inline JavaScript-like expressions for transforms. The development surface is more constrained than n8n’s TypeScript, but the operational shape is similar — OAuth, modules (operations), webhook receivers.

A trimmed Make module definition for the same “Create Invoice”:

{
  "name": "createInvoice",
  "label": "Create an invoice",
  "connection": "acme",
  "url": "https://api.acme.example.com/v1/invoices",
  "method": "POST",
  "headers": {
    "Authorization": "Bearer {{connection.access_token}}",
    "Content-Type": "application/json"
  },
  "body": {
    "customer_id": "{{parameters.customerId}}",
    "amount_cents": "{{parameters.amountCents}}",
    "idempotency_key": "make-{{executionId}}"
  },
  "response": {
    "output": "{{body}}",
    "error": {
      "message": "[{{statusCode}}] {{body.error.message}}"
    }
  },
  "parameters": [
    { "name": "customerId", "type": "select", "label": "Customer", "required": true,
      "rpc": { "url": "rpc://listCustomers" } },
    { "name": "amountCents", "type": "uinteger", "label": "Amount (cents)", "required": true }
  ]
}

The rpc://listCustomers reference points at a separately defined RPC module that does the dynamic-option fetch. Same idea as n8n’s loadOptionsMethod. The custom-app development experience is documented in the Make Developer Hub.

Make custom apps can be private to your org. You don’t need to publish them to the public Make marketplace. Make’s invite-only sharing handles the distribution.

What to build first

If you’re staring at a list of twenty internal APIs and trying to decide where to start, here’s the prioritisation I use.

  1. Auth + a “list things” operation. Once a citizen dev can authenticate and pull a list of customers or accounts, half the workflows that get built are “for each thing, do something.” You unblock a lot with a thin slice.
  2. The destructive operations on the highest-traffic API. Whichever API gets called the most from HTTP Request nodes today — wrap those operations first. The connector replaces existing in-flight patterns.
  3. The webhook receivers. A connector that listens for your webhooks and exposes them as triggers is huge for inbound automation.
  4. Bulk operations last. Citizen workflows tend not to need bulk, and bulk has more pagination/retry complexity than per-item. Defer.

A library of four to six well-built connector operations beats twenty half-finished ones. The platform you’d be ashamed to ship is the right size to actually ship.

Common pitfalls

The recurring traps.

  • Hand-rolling OAuth. Use the framework’s OAuth helpers. n8n’s oAuth2Api extension and Make’s connection types both work. Don’t reimplement state, PKCE, refresh.
  • Static option lists. When a node has a “Status” dropdown with 12 hardcoded values, they go stale within months. Use dynamic loading or expose the list via an enum endpoint.
  • Returning the raw vendor response. Your API returns { data: [...], meta: {...} }. The connector should expose [...] to the workflow, not the wrapper. Normalise.
  • Skipping pagination. “We’ll just return the first 100.” Then the citizen dev’s workflow silently truncates the customer list. Implement pagination via the requestOptionsArray pattern or a getAll parameter that loops.
  • Hard-coded base URLs. Production, staging, sandbox. The connector should have an environment selector or read from credentials.
  • No semver discipline. When the connector ships v2 with a breaking change, every workflow using it breaks at upgrade. Either keep v1 nodes in the codebase or do a hard version bump and migration plan.
  • No telemetry. A connector is a piece of platform code. It should emit metrics — operations called, latency, error rate by operation. Run a tiny outbound metrics handler from inside the node if you have to.

What’s next

Reusable connectors are where the platform team’s leverage on a low-code program really shows up. Build four good ones, ship them on your self-hosted n8n, and you’ve moved citizen developers from “constructing HTTP requests” to “composing operations.” That’s an entirely different conversation about safety, audit, and review.

Next in the series, identity federation. Citizen devs need to sign into n8n, into Make, and the workflows they build need delegated access. Keycloak 25 and Auth0 are the two stacks I’ll cover. See you Wednesday.