DEV Community

Cover image for Building Custom Webhooks vs Using n8n Triggers
Raizan
Raizan

Posted on • Originally published at chasebot.online

Building Custom Webhooks vs Using n8n Triggers

What You'll Need

  • n8n Cloud or self-hosted n8n
  • Hetzner VPS or Contabo VPS for hosting custom webhook servers
  • DigitalOcean as an alternative hosting option
  • Node.js (v18+) if building custom webhooks
  • A code editor (VS Code recommended)
  • Basic knowledge of REST APIs and JSON

Table of Contents

Understanding Webhooks and Triggers

I've spent years building automation systems, and the webhook vs. trigger question comes up constantly. Let me cut through the noise: they're solving the same problem differently.

A webhook is essentially an HTTP POST request sent from one service to another when something happens. It's a push notification. Your app pushes data to n8n when an event occurs. A trigger, in n8n's context, is a built-in listener that polls or subscribes to specific events within a service and automatically starts your workflow.

The core difference? Control and flexibility versus ease of setup.

When you build a custom webhook, you control the exact payload, authentication, retry logic, and event timing. When you use n8n's built-in triggers, you get instant integration with thousands of apps but less granular control. I've found myself using both in the same project—triggers for simple integrations and custom webhooks for complex, high-volume systems.

When to Build Custom Webhooks

I reach for custom webhooks in these scenarios:

Legacy system integration. If you're connecting to a 15-year-old internal database that n8n doesn't have a native connector for, custom webhooks are your answer. The legacy system pushes data to your webhook endpoint, n8n processes it, and downstream tools consume the results.

High-volume event handling. When you're processing thousands of events daily, webhook efficiency matters. Webhooks are push-based (event-driven), so you're not wasting compute cycles polling endpoints that haven't changed.

Custom authentication. Some internal APIs use proprietary auth schemes. Building a custom webhook lets you handle mutual TLS certificates, custom headers, or API tokens that n8n's standard connectors don't support.

Payload transformation at the source. Sometimes it's cleaner to filter or reshape data at the webhook source rather than inside n8n. This reduces workflow complexity and saves processing time.

Building a Custom Webhook Server

Let me show you a production-ready webhook server. I've simplified this, but it's what I run on a Hetzner VPS handling thousands of daily events.

First, your Node.js server:

const express = require('express');
const crypto = require('crypto');
const axios = require('axios');
require('dotenv').config();

const app = express();
const PORT = process.env.PORT || 3000;
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || 'your-secret-key-change-this';
const N8N_WEBHOOK_URL = process.env.N8N_WEBHOOK_URL;

app.use(express.json());

function verifySignature(req, secret) {
  const signature = req.headers['x-webhook-signature'];
  const timestamp = req.headers['x-webhook-timestamp'];
  const body = JSON.stringify(req.body);

  const message = `${timestamp}.${body}`;
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(message)
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );
}

app.post('/webhook/events', async (req, res) => {
  try {
    if (!verifySignature(req, WEBHOOK_SECRET)) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    const payload = req.body;
    const eventType = payload.event_type;
    const timestamp = new Date().toISOString();

    console.log(`[${timestamp}] Received ${eventType} event`);

    const enrichedPayload = {
      original_event: payload,
      received_at: timestamp,
      event_type: eventType,
      source_ip: req.ip,
      processed: false
    };

    try {
      const response = await axios.post(N8N_WEBHOOK_URL, enrichedPayload, {
        headers: {
          'Content-Type': 'application/json'
        },
        timeout: 30000
      });

      enrichedPayload.processed = true;
      enrichedPayload.n8n_response_code = response.status;

      console.log(`[${timestamp}] Successfully forwarded to n8n`);
      return res.status(200).json({ 
        success: true, 
        message: 'Event received and forwarded',
        event_id: enrichedPayload.event_id
      });
    } catch (error) {
      console.error(`[${timestamp}] n8n forwarding failed:`, error.message);

      if (error.response?.status >= 500) {
        return res.status(202).json({ 
          success: false,
          message: 'Event queued for retry',
          error: error.message
        });
      }

      return res.status(400).json({ 
        success: false,
        message: 'Failed to process event',
        error: error.message
      });
    }
  } catch (error) {
    console.error('Webhook handler error:', error);
    return res.status(500).json({ error: 'Internal server error' });
  }
});

app.get('/health', (req, res) => {
  res.status(200).json({ status: 'ok', timestamp: new Date().toISOString() });
});

app.listen(PORT, () => {
  console.log(`Webhook server running on port ${PORT}`);
  console.log(`Health check: http://localhost:${PORT}/health`);
});
Enter fullscreen mode Exit fullscreen mode

Your .env file:

PORT=3000
WEBHOOK_SECRET=your-super-secret-key-at-least-32-characters-long
N8N_WEBHOOK_URL=https://your-n8n-instance.com/webhook/your-webhook-path
NODE_ENV=production
Enter fullscreen mode Exit fullscreen mode

Your package.json:

{
  "name": "webhook-server",
  "version": "1.0.0",
  "description": "Custom webhook server for n8n",
  "main": "server.js",
  "scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js"
  },
  "dependencies": {
    "express": "^4.18.2",
    "axios": "^1.6.0",
    "dotenv": "^16.3.1"
  },
  "devDependencies": {
    "nodemon": "^3.0.1"
  }
}
Enter fullscreen mode Exit fullscreen mode

Install dependencies:

npm install
Enter fullscreen mode Exit fullscreen mode

Deploy this to DigitalOcean or Contabo. Use PM2 to keep it running:

npm install -g pm2
pm2 start server.js --name webhook-server
pm2 save
pm2 startup
Enter fullscreen mode Exit fullscreen mode

Now, in your source system (the one sending events), you'd call your webhook like this:

curl -X POST https://your-webhook-server.com/webhook/events \
  -H "Content-Type: application/json" \
  -H "x-webhook-signature: $(echo -n '1702000000.{"event_type":"user.created"}' | openssl dgst -sha256 -hmac 'your-secret-key' | cut -d' ' -f2)" \
  -H "x-webhook-timestamp: 1702000000" \
  -d '{"event_type":"user.created","user_id":"12345","email":"user@example.com"}'
Enter fullscreen mode Exit fullscreen mode

💡 Fast-Track Your Project: Don't want to configure this yourself? I build custom n8n pipelines and bots. Message me with code SYS3-DEVTO.

Using n8n Triggers: The Simpler Path

Now let me show you how the same flow works with n8n Cloud.

With n8n's native triggers, you skip the custom server entirely. Instead, you add a trigger node directly in your workflow. Here's what that looks like:

Create a new workflow in n8n:

  1. Click Add trigger
  2. Search for Webhook
  3. Select Webhook

In the Webhook trigger node configuration:

HTTP Method: POST
Path: /my-webhook-path
Authentication: None (or Basic Auth if needed)
Response Mode: On Received
Execution Data: First incoming data
Enter fullscreen mode Exit fullscreen mode

Your n8n node setup:

{
  "node": "Webhook",
  "type": "n8n-nodes-base.webhook",
  "typeVersion": 1,
  "position": [250, 300],
  "parameters": {
    "httpMethod": "POST",
    "path": "my-webhook-path",
    "responseMode": "onReceived",
    "options": {
      "authenticationMethod": "none"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The webhook URL n8n generates looks like:

https://your-n8n-instance.com/webhook/my-webhook-path
Enter fullscreen mode Exit fullscreen mode

You send data to that URL:

curl -X POST https://your-n8n-instance.com/webhook/my-webhook-path \
  -H "Content-Type: application/json" \
  -d '{"user_id":"12345","event":"signup"}'
Enter fullscreen mode Exit fullscreen mode

Next, add a Set node to process the incoming data:

{
  "node": "Set",
  "type": "n8n-nodes-base.set",
  "typeVersion": 3,
  "position": [450, 300],
  "parameters": {
    "mode": "manual",
    "assignments": {
      "assignments": [
        {
          "id": "1",
          "name": "event_type",
          "value": "={{ $json.event }}",
          "type": "string"
        },
        {
          "id": "2",
          "name": "user_id",
          "value": "={{ $json.user_id }}",
          "type": "string"
        },
        {
          "id": "3",
          "name": "processed_at",
          "value": "={{ now().toISOString() }}",
          "type": "string"
        }
      ]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Then add your downstream action—maybe sending to a database or another service. If you're integrating with OAuth2 for services like Google Workspace, I'd recommend reading my guide on OAuth2 for Beginners: Connecting APIs Without the Pain.

The beauty of n8n triggers is speed. No server setup, no authentication to build, automatic retries, and logging built in. The tradeoff is less control over how events are filtered or transformed before reaching your workflow.

Performance and Cost Comparison

Here's what I've learned running both approaches:

Custom Webhooks:

  • Latency: 50-150ms end-to-end (after your server

Originally published on Automation Insider.

Top comments (0)