n8n Webhook Tutorial: Complete Guide 2026

The Webhook node is the most powerful and versatile trigger in n8n. It transforms n8n into a real-time API endpoint that any external system can call β€” Stripe, GitHub, Typeform, your own application, or any service that can make HTTP requests.

In this complete tutorial, you'll learn everything about webhooks in n8n: what they are, how to create and configure them, the difference between test and production modes, authentication options, how to process incoming data with expressions, and how to send data to external services with the HTTP Request node.

πŸ“‹ What You'll Learn

How to create a Webhook trigger Β· Test vs production mode Β· Authenticating webhooks Β· Processing webhook data Β· Responding to webhooks Β· Stripe webhook integration Β· GitHub webhook integration Β· Sending HTTP requests from n8n Β· Common troubleshooting tips

What Are Webhooks? A Quick Primer

Webhooks are a mechanism for one application to notify another when something happens, using HTTP. Instead of your application continuously asking "did anything change?" (polling), the external service pushes data to you the moment an event occurs.

Think of webhooks as "reverse APIs":

  • Traditional API call: Your app β†’ asks β†’ External service β†’ responds
  • Webhook: External service β†’ pushes data β†’ Your endpoint (n8n)

Common real-world examples:

  • Stripe: Sends a webhook when a payment succeeds, subscription renews, or refund is issued
  • GitHub: Sends a webhook on push, pull request, issue creation, or deployment
  • Typeform / JotForm: Sends form response data when someone submits a form
  • Shopify: Sends order data when a new purchase is completed
  • Your own app: Can send webhooks to trigger n8n workflows from any user action

Creating Your First Webhook Trigger in n8n

Setting up a Webhook trigger in n8n takes under 2 minutes. Here's the step-by-step process:

1

Create a New Workflow

Open n8n, click New Workflow in the top right. The workflow canvas will open with an empty state.

2

Add the Webhook Node

Click the + button (or press Tab) to open the node menu. Search for "Webhook" and select the Webhook trigger node. It will appear as the first node in your workflow.

3

Configure the Webhook

Click the Webhook node to open its settings panel. Configure:

  • HTTP Method: POST (for receiving data), GET (for simple triggers), or any other method
  • Path: A unique URL path (e.g., my-workflow or stripe-events)
  • Authentication: None for now (we'll cover auth later)
  • Respond: Immediately (returns 200 OK at once) or When Last Node Finishes
4

Copy Your Webhook URL

n8n generates two URLs after configuration. You'll see them in the node panel:

Test URL (only active in editor with "Listen for Test Event")
https://your-n8n.domain.com/webhook-test/my-workflow
Production URL (always active when workflow is enabled)
https://your-n8n.domain.com/webhook/my-workflow
5

Test the Webhook

Click "Listen for Test Event" in the node. Then send a test request to the test URL. n8n will capture the incoming data and display it in the node output panel.

Test Mode vs Production Mode

Understanding the two webhook modes is critical β€” mixing them up is one of the most common n8n mistakes.

Feature Test URL (/webhook-test/) Production URL (/webhook/)
When Active Only when you click "Listen for Test Event" in the editor Always active when workflow is enabled
Execution Saved No β€” shows data in editor but doesn't save Yes β€” full execution log in History
Timeout 120 seconds (waits for request then auto-closes) No timeout β€” always listening
Simultaneous Requests One at a time only Multiple concurrent requests supported
Use For Development and testing only Real production traffic
⚠️ Critical: Never Use Test URL in Production

The test URL only works when you have the workflow open in the editor and have clicked "Listen for Test Event." If you use the test URL as a webhook endpoint in Stripe, GitHub, or any external service, their webhooks will fail 99% of the time (when you're not actively testing). Always use the production URL for real integrations.

Webhook Authentication

Without authentication, anyone who discovers your webhook URL can send requests and trigger your workflow. n8n offers several authentication methods:

Option 1: Header Authentication

The most common approach. The external service sends a secret key in a specific HTTP header. n8n validates the header value before executing the workflow.

# In n8n Webhook node settings: Authentication: Header Auth Credential Name: My Webhook Secret Header Name: X-Webhook-Secret Header Value: my-super-secret-key-here # The external service must include this header: curl -X POST https://n8n.yourdomain.com/webhook/my-workflow \ -H "X-Webhook-Secret: my-super-secret-key-here" \ -H "Content-Type: application/json" \ -d '{"event": "payment.success", "amount": 99.99}'

Option 2: Basic Authentication

Uses standard HTTP Basic Auth with username and password. The credentials are sent in the Authorization header as base64-encoded "username:password".

# In n8n Webhook node settings: Authentication: Basic Auth Username: n8n-webhook Password: your-secure-password # Calling with Basic Auth: curl -X POST https://n8n.yourdomain.com/webhook/my-workflow \ -u "n8n-webhook:your-secure-password" \ -H "Content-Type: application/json" \ -d '{"data": "value"}'

Option 3: Signature Verification (Stripe, GitHub Pattern)

Many services like Stripe and GitHub sign their webhook payloads with HMAC-SHA256. You verify the signature to confirm the request is genuine. This requires the Code node:

// Code node: Verify Stripe webhook signature const crypto = require('crypto'); const payload = $input.first().json.body; const signature = $input.first().json.headers['stripe-signature']; const secret = 'whsec_your_stripe_webhook_secret'; // Parse the Stripe signature header const sigParts = signature.split(',').reduce((acc, part) => { const [key, val] = part.split('='); acc[key] = val; return acc; }, {}); const timestamp = sigParts['t']; const payloadString = JSON.stringify(payload); const signedPayload = `${timestamp}.${payloadString}`; const expectedSig = crypto .createHmac('sha256', secret) .update(signedPayload) .digest('hex'); if (`v1=${expectedSig}` !== sigParts['v1']) { throw new Error('Invalid webhook signature β€” request rejected'); } return [{ json: payload }];

Processing Webhook Data with Expressions

When a webhook request arrives, n8n structures the incoming data with these top-level fields:

// n8n webhook data structure { "body": { ... }, // POST body (parsed JSON or form data) "headers": { ... }, // All request headers (lowercase keys) "params": { ... }, // URL path parameters (/:param) "query": { ... } // Query string parameters (?key=value) } // Example: Stripe payment webhook body { "body": { "type": "payment_intent.succeeded", "data": { "object": { "id": "pi_3OXv...", "amount": 9999, "currency": "usd", "receipt_email": "customer@email.com" } } } }

Access this data in subsequent nodes using n8n expressions:

// Accessing webhook data in expressions {{ $json.body.type }} // "payment_intent.succeeded" {{ $json.body.data.object.id }} // "pi_3OXv..." {{ $json.body.data.object.amount / 100 }} // 99.99 (cents to dollars) {{ $json.body.data.object.receipt_email }} // "customer@email.com" {{ $json.headers["content-type"] }} // "application/json" {{ $json.headers["x-custom-header"] }} // Custom header value {{ $json.query.page }} // ?page=2 query param {{ $json.query.filter }} // ?filter=active query param

Responding to Webhooks

Many services expect a specific response to their webhooks. n8n offers three response modes:

Mode 1: Respond Immediately

n8n returns HTTP 200 as soon as the webhook is received, without waiting for the workflow to finish. Use this for fire-and-forget scenarios where the sending service doesn't need a response.

Mode 2: When Last Node Finishes

n8n waits for the entire workflow to complete, then returns the output of the last node as the response body. Use this when the caller expects data back (e.g., a synchronous API call pattern).

Mode 3: Respond to Webhook Node

The most flexible mode. Place the Respond to Webhook node anywhere in your workflow to send a custom response at any point, then continue the workflow asynchronously.

// Respond to Webhook node configuration Respond With: JSON Response Code: 200 Response Body: { "success": true, "message": "Webhook received", "id": "{{ $json.body.id }}", "processedAt": "{{ $now.toISO() }}" }
πŸ’‘ Stripe Requires 200 Within 30 Seconds

Stripe marks webhooks as failed if they don't receive a 2xx response within 30 seconds. Use Respond Immediately or the Respond to Webhook node early in your workflow, then continue processing asynchronously. This prevents Stripe from retrying and flooding your workflow.

Real Example: Processing Stripe Webhooks

Let's build a complete Stripe payment webhook workflow that:

  1. Receives a Stripe payment_intent.succeeded event
  2. Immediately responds with 200 (to prevent Stripe retries)
  3. Extracts payment details
  4. Saves the record to Google Sheets
  5. Sends a confirmation email to the customer
  6. Notifies the team in Slack
1

Webhook Node (POST, path: stripe-events)

HTTP Method: POST | Authentication: None (we verify signature in code) | Respond: Using 'Respond to Webhook' Node

2

Code Node: Verify Stripe Signature

Validates the Stripe webhook signature using crypto (see code above). Throws an error for invalid signatures, which stops the workflow.

3

IF Node: Filter Event Types

Condition: {{ $json.type }} equals payment_intent.succeeded
True branch continues; False branch ends quietly.

4

Respond to Webhook Node

Immediately returns {"received": true} with HTTP 200. Stripe receives the confirmation. Workflow continues asynchronously.

5

Set Fields Node: Extract Payment Data

paymentId: {{ $json.data.object.id }} amount: {{ $json.data.object.amount / 100 }} currency: {{ $json.data.object.currency.toUpperCase() }} email: {{ $json.data.object.receipt_email }} name: {{ $json.data.object.billing_details.name }} timestamp: {{ $now.toFormat('yyyy-MM-dd HH:mm:ss') }}
6

Parallel: Google Sheets + Gmail + Slack

Three nodes run in parallel from the Set Fields output:

  • Google Sheets: Append row to "Payments" sheet
  • Gmail: Send HTML receipt to {{ $json.email }}
  • Slack: Post to #revenue channel: "πŸ’° New payment: ${{ $json.amount }} from {{ $json.name }}"

Real Example: GitHub Webhook for CI/CD Notifications

This workflow receives GitHub push events and sends team notifications:

// GitHub webhook payload (push event) { "ref": "refs/heads/main", "repository": { "name": "my-project", "full_name": "org/my-project" }, "pusher": { "name": "john.doe" }, "commits": [ { "id": "abc123", "message": "fix: resolve payment processing bug", "url": "https://github.com/org/my-project/commit/abc123" } ], "head_commit": { "added": ["src/utils.ts"], "modified": ["src/payments.ts", "tests/payments.test.ts"], "removed": [] } }

Configure GitHub to send webhooks to your n8n URL:

  1. Go to your GitHub repository β†’ Settings β†’ Webhooks β†’ Add webhook
  2. Set Payload URL to your n8n production webhook URL
  3. Content type: application/json
  4. Secret: generate a random secret and add it to n8n's Header Auth credential
  5. Select events: Push, Pull Request, or specific events

n8n Expressions for GitHub Data

// Access GitHub webhook data {{ $json.body.repository.name }} // "my-project" {{ $json.body.pusher.name }} // "john.doe" {{ $json.body.ref.replace('refs/heads/', '') }} // "main" (branch name) {{ $json.body.commits.length }} // 1 (number of commits) {{ $json.body.head_commit.message }} // "fix: resolve payment..." {{ $json.body.commits[0].url }} // Commit URL // Format modified files list {{ $json.body.head_commit.modified.join(', ') }}

Sending Data Out: HTTP Request Node

The flip side of receiving webhooks is sending them β€” calling external APIs and webhooks from your n8n workflow. The HTTP Request node handles all outgoing HTTP calls.

Basic POST Request

// HTTP Request node config: POST JSON to an API Method: POST URL: https://api.yourservice.com/events Auth: Header Auth β†’ Authorization: Bearer {{ $credentials.apiToken }} Headers: Content-Type: application/json Body: { "event": "user_signup", "userId": "{{ $json.userId }}", "email": "{{ $json.email }}", "timestamp": "{{ $now.toISO() }}" }

GET Request with Query Parameters

// HTTP Request: GET with query params Method: GET URL: https://api.weatherapi.com/v1/current.json Query Params: key={{ $credentials.weatherApiKey }} q={{ $json.city }} aqi=no // Resulting URL: https://api.weatherapi.com/v1/current.json?key=...&q=London&aqi=no

Handling API Pagination

Many APIs paginate their responses. The HTTP Request node has built-in pagination support:

Pagination Mode: Response Contains Next URL // For APIs with cursor-based pagination: Next URL Expression: {{ $response.body.meta.next_cursor ? 'https://api.example.com/users?cursor=' + $response.body.meta.next_cursor : '' }} // For APIs with page number pagination: Pagination Mode: Update a Parameter in Each Request Pagination Key: page Initial Value: 1 Increment By: 1 Complete When: {{ $response.body.data.length === 0 }}

Webhook Use Case: Form Submissions

Processing form submissions is one of the most common webhook use cases. Here's how to handle Typeform, HubSpot forms, and custom HTML forms:

Typeform Webhook

Configure in Typeform: Connect β†’ Webhooks β†’ add your n8n URL.

// Typeform webhook data structure { "event_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV", "event_type": "form_response", "form_response": { "answers": [ { "field": { "ref": "name" }, "text": "John Smith" }, { "field": { "ref": "email" }, "email": "john@example.com" }, { "field": { "ref": "company" }, "text": "Acme Corp" } ] } } // n8n expressions to extract Typeform answers {{ $json.body.form_response.answers.find(a => a.field.ref === 'name')?.text }} {{ $json.body.form_response.answers.find(a => a.field.ref === 'email')?.email }}

Custom HTML Form β†’ n8n Webhook

You can post directly from any HTML form to an n8n webhook using JavaScript:

// Frontend: Post form data to n8n webhook document.querySelector('#myForm').addEventListener('submit', async (e) => { e.preventDefault(); const formData = Object.fromEntries(new FormData(e.target)); const response = await fetch( 'https://n8n.yourdomain.com/webhook/form-submissions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...formData, submittedAt: new Date().toISOString(), source: document.referrer || window.location.href }) } ); const result = await response.json(); console.log('Webhook response:', result); });

Common Webhook Issues & Solutions

Issue 1: Webhook Not Receiving Data

  • Check: Are you using the production URL (not test URL) and is the workflow enabled?
  • Check: Is n8n accessible from the internet? (Self-hosted instances behind a firewall won't receive external webhooks)
  • Check: Is the webhook URL in the external service correct (including https://, correct domain)?
  • Fix: Use a tool like ngrok during development to expose local n8n to the internet

Issue 2: 401 Unauthorized

  • Cause: Authentication is configured on the webhook but the sender isn't providing correct credentials
  • Fix: Temporarily disable authentication to test, then re-enable after confirming basic connectivity

Issue 3: Workflow Triggers But Data is Empty

  • Cause: The sending service uses form encoding instead of JSON
  • Fix: Check the Content-Type header. For application/x-www-form-urlencoded, data is in $json.body as flat key-value pairs

Issue 4: Webhook Times Out

  • Cause: Workflow takes longer than the service's timeout window (e.g., Stripe's 30 seconds)
  • Fix: Use Respond to Webhook node at the beginning to send an immediate 200, then continue processing
πŸ”§ Debugging Tip: Inspect Raw Requests

Use a service like webhook.site or requestbin.com to inspect the exact payload a service sends before configuring n8n. Point the external service at the inspection URL temporarily, examine the data structure, then configure your n8n workflow accordingly.

Webhook Security Best Practices

  1. Always use HTTPS: Never expose n8n over plain HTTP in production. Use a reverse proxy (Nginx/Caddy) with SSL certificates from Let's Encrypt.
  2. Validate signatures when available: Services like Stripe, GitHub, and Shopify sign their webhooks. Always verify the signature before processing the payload.
  3. Use secret paths: Instead of /webhook/orders, use a UUID-like path: /webhook/orders-a7f8b2c9d0e1. This adds obscurity on top of authentication.
  4. Rate limit your webhooks: Add IP-based rate limiting at the Nginx level to prevent flooding.
  5. Respond quickly, process async: Use the Respond to Webhook node to return 200 immediately, then continue processing to prevent timeouts.
  6. Log webhook requests: Store incoming webhook data in a database or Google Sheet for audit trail and replay capabilities.

Frequently Asked Questions

What is a webhook in n8n?
In n8n, a Webhook node creates an HTTP endpoint that external services can call to trigger a workflow. When n8n receives an HTTP request at that URL, it immediately starts executing the workflow, passing the request data (body, headers, query parameters) to subsequent nodes. It's the most real-time and versatile trigger type in n8n.
What's the difference between n8n test URL and production webhook URL?
The test URL (/webhook-test/) only works when you have the workflow open in the editor and have clicked "Listen for Test Event." It's for development use only and times out after 120 seconds. The production URL (/webhook/) is always active whenever the workflow is enabled. Always use the production URL when configuring external services like Stripe or GitHub.
How do I make my n8n webhook publicly accessible?
For production: deploy n8n on a VPS with a public IP address, configure a domain with DNS, and set up Nginx or Caddy as a reverse proxy with SSL. For development: use ngrok (ngrok http 5678) to create a temporary public URL that tunnels to your local n8n instance. Never use ngrok for production β€” it's only for testing.
Can I receive multiple different events on one webhook URL?
Yes. A single webhook endpoint can receive many different event types. Use an IF or Switch node after the webhook to route different event types to different processing branches. For example, a Stripe webhook URL can receive payment.succeeded, refund.created, subscription.canceled events β€” and you route each type to the appropriate workflow path.
How do I send a custom response from an n8n webhook?
Use the "Respond to Webhook" node (n8n-nodes-base.respondToWebhook). First, set the Webhook node's "Respond" option to "Using 'Respond to Webhook' Node." Then place the Respond to Webhook node anywhere in your workflow and configure the response code, headers, and body. The caller receives your custom response, and the workflow continues executing after the response is sent.
What HTTP methods does n8n's Webhook node support?
The n8n Webhook node supports all standard HTTP methods: GET, POST, PUT, PATCH, DELETE, HEAD, and OPTIONS. For most integrations, POST is used (as services typically send data in the request body). GET is useful for simple triggers where parameters are in the query string. You can also configure the webhook to accept any method with the "Any" option.