DEV Community

Pirate Prentice
Pirate Prentice

Posted on

n8n Webhook Node: Response Modes, Custom Headers, and Auth (Free Workflow JSON)

n8n Webhook Node: Response Modes, Custom Headers, and Auth (Free Workflow JSON)

The n8n Webhook node is where most automation workflows begin — but most tutorials only cover the basics: receive a request, do something, done. This guide goes deeper: response modes, custom response headers, header-based auth, managing multiple HTTP methods, and real-world patterns that handle edge cases cleanly.

What we cover:

  • The 4 response modes and when to use each
  • Custom response headers and status codes
  • Webhook auth: header secret, HMAC signature, and IP allowlist
  • Handling GET vs POST on the same webhook path
  • Idempotency: safely processing the same event twice
  • Free workflow JSON at the end

1. Response Mode Overview

Every n8n Webhook node has a Response Mode setting. Choosing the wrong one is the most common cause of integration failures.

Mode What it does Use when
Immediately Responds 200 OK as soon as the request arrives Best for Stripe, GitHub, Slack — they just want acknowledgement
When Last Node Finishes Waits for the full workflow to complete, returns the last node output Use for synchronous APIs that expect a real response body
Using Respond to Webhook Node You control exactly what gets returned Best for GraphQL-style APIs, custom error formats, partial results
No Response Returns nothing Rare; use only when the caller manages the connection itself

Trap: Using "When Last Node Finishes" with a Stripe webhook causes Stripe to retry. Stripe expects a 200 within 5 seconds. Use Immediately for any webhook that just needs acknowledgement.


2. The Respond to Webhook Node

When you need full control over the response body, status code, and headers, set Webhook Response Mode to Using Respond to Webhook Node, then add a Respond to Webhook node anywhere in the flow.

Example — return 201 Created with the new record ID by setting the Respond to Webhook node:

  • Status Code: 201
  • Body Type: JSON
  • Body: {"success": true, "id": "{{$('Save to DB').item.json.id}}"}

You can place multiple Respond to Webhook nodes on different branches (success, error). Only the first one that executes fires — the others are skipped.


3. Custom Response Headers

Use case: your webhook returns JSON and needs CORS headers so a browser can call it directly.

In the Respond to Webhook node, enable Custom Headers and add:

  • Access-Control-Allow-Origin: *
  • Content-Type: application/json
  • X-Request-Id: {{$execution.id}}

Or set headers on the Webhook node itself under Response Headers for headers that should always be present.


4. Securing Your Webhook

4a. Header Secret (Simplest)

Most SaaS platforms send a secret in a header (e.g., X-Webhook-Secret). Validate it with an IF node right after the Webhook node:

  • Condition: {{$headers['x-webhook-secret']}} equals {{$vars.WEBHOOK_SECRET}}
  • True branch: continue processing
  • False branch: Respond to Webhook with status 401

Store the secret in n8n Variables ($vars.WEBHOOK_SECRET), not hardcoded in the expression.

4b. Stripe-Style HMAC Signature

Stripe signs the payload with HMAC-SHA256. Use the Code node to verify — and enable Raw Body in the Webhook node Options first (the signature is over the raw bytes, not the parsed JSON):

const crypto = require('crypto');
const sig = $input.first().headers['stripe-signature'];
const payload = $input.first().rawBody;
const secret = $vars.STRIPE_WEBHOOK_SECRET;

const [tPart, v1Part] = sig.split(',');
const timestamp = tPart.replace('t=', '');
const received = v1Part.replace('v1=', '');

const expected = crypto
  .createHmac('sha256', secret)
  .update(timestamp + '.' + payload)
  .digest('hex');

if (expected !== received) throw new Error('Invalid Stripe signature');
return $input.all();
Enter fullscreen mode Exit fullscreen mode

4c. IP Allowlist

const allowed = ['192.30.252.', '185.199.108.']; // GitHub webhook IP prefixes
const ip = ($input.first().headers['x-forwarded-for'] || '').split(',')[0].trim()
  || $input.first().headers['x-real-ip'] || '';
if (!allowed.some(prefix => ip.startsWith(prefix))) {
  throw new Error('Blocked IP: ' + ip);
}
return $input.all();
Enter fullscreen mode Exit fullscreen mode

5. Handling GET and POST on the Same Path

n8n creates separate Webhook nodes per HTTP method. To handle both GET and POST at the same logical path, use two Webhook nodes (same path, different method settings) merged downstream:

GET  /endpoint  ->  [Webhook GET]   \
                                     [Merge: Multiplex] -> [Process]
POST /endpoint  ->  [Webhook POST]  /
Enter fullscreen mode Exit fullscreen mode

Set the Merge node to Multiplex mode so each incoming item is processed independently.


6. Idempotency: Safely Re-Processing Retried Events

Webhooks get retried. Prevent duplicate inserts with the Redis node (SETNX pattern):

  1. Redis GET processed:{{$json.id}}
  2. IF key exists → Respond 200 "already processed" (stop)
  3. IF key missing → Process the event → Redis SET processed:{{$json.id}} value 1 TTL 604800 (7 days)

This guarantees each event ID is processed exactly once regardless of how many times the platform retries.


7. Free Workflow JSON

Production-ready webhook with header-secret auth, custom response, and error branch:

{
  "name": "Secure Webhook Template",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "secure-hook",
        "responseMode": "responseNode",
        "options": { "rawBody": true }
      },
      "name": "Webhook",
      "type": "n8n-nodes-base.webhook",
      "position": [240, 300]
    },
    {
      "parameters": {
        "conditions": {
          "string": [{"value1": "={{$headers['x-webhook-secret']}}", "value2": "={{$vars.WEBHOOK_SECRET}}", "operation": "equal"}]
        }
      },
      "name": "Auth Check",
      "type": "n8n-nodes-base.if",
      "position": [440, 300]
    },
    {
      "parameters": { "statusCode": 401, "body": "{\"error\":\"Unauthorized\"}" },
      "name": "Respond 401",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [640, 460]
    },
    {
      "parameters": { "statusCode": 200, "body": "{\"ok\":true}" },
      "name": "Respond 200",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [840, 300]
    }
  ],
  "connections": {
    "Webhook": { "main": [[{"node": "Auth Check", "type": "main", "index": 0}]] },
    "Auth Check": {
      "main": [
        [{"node": "Respond 200", "type": "main", "index": 0}],
        [{"node": "Respond 401", "type": "main", "index": 0}]
      ]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Import into n8n, set WEBHOOK_SECRET in Variables, activate — done in under 2 minutes.


8. Common Gotchas

Gotcha Fix
rawBody is undefined Enable Raw Body in Webhook node Options
Stripe keeps retrying Switch to Immediately response mode
HMAC signature always fails Enable Raw Body; verify you are reading the correct header name
Webhook not triggering in production Activate the workflow — test URL bypasses activation; production URL requires it
CORS errors from browser Add Access-Control-Allow-Origin header in Respond to Webhook
Test vs live behaves differently Test URL runs synchronously; production URL runs async in the background

9. Related Guides

Want 10 production-ready n8n workflows (including the Webhook Auth + Idempotency template)? Grab the n8n Workflow Starter Pack ($29) — includes webhooks, lead capture, Stripe fulfillment, Slack notifications, and more.

Drop a comment with your toughest webhook edge case — I will share the specific pattern for it.

Top comments (1)

Collapse
 
pirateprentice profile image
Pirate Prentice

Which response mode do you use most often? Have you hit the Stripe retry problem from picking the wrong one? Drop your webhook setup in the comments.