Blog
BLOG — Guide

Webhooks 101 — your first step to connecting external services

A webhook is the shortest piece of code that implements "tell me when something happens." Form submissions, completed payments, new signups — you can turn any external event into the starting point of a workflow. No polling, no cron, no batch jobs.

This is a hands-on guide for anyone touching the n8n Webhook node for the first time. It covers the full path: getting a URL, inspecting the data that comes in, and adding signature verification.

What webhooks actually change

Polling means "check every minute." A webhook means "you get pinged the moment it happens." Latency drops from 60 seconds to 200ms, and the server sits fully idle when there's nothing to do. Traffic saved, power saved, peace of mind saved.

Average latency
~200ms
Requests vs polling
1/300
Build time
15 min

How it works — one URL and you're done

Drop a Webhook node on the n8n canvas and you get two URLs — one for test, one for production. The workflow starts the moment an external service fires an HTTP request at that URL.

Basic webhook trigger flowLIVE
WEBExternal serviceHOOKWebhook nodeSETSet / IFOKRespond

Receive your first webhook in 5 minutes

  1. Add a Webhook node to a blank workflow
  2. Set HTTP Method to POST and Path to orders
  3. Click Listen for Test Event in the top right
  4. Send test data from your terminal with the curl command below
  5. Confirm the JSON that lands in the node and hit Save
curl -X POST \
  https://your-n8n.example.com/webhook-test/orders \
  -H 'Content-Type: application/json' \
  -d '{"order_id": 1024, "amount": 89000, "status": "paid"}'
The test URL works even when the workflow is inactive. The production URL only responds after you flip the workflow to Active. 90% of "why isn't this working?" moments right after deploy come from forgetting this one thing.

Working with incoming data

In the next node, reference values with expressions like {{ $json.order_id }}. If you also need headers, open the Webhook node's Response section and enable the option to split the payload into $json.headers, $json.body, and $json.query.

  • $json — request body (default)
  • $json.headers — all headers
  • $json.query — query string
  • $binary — raw binary for file uploads

Security is not optional

A public URL means anyone can hit it. Put at least three layers in front of it. Most breaches are stopped by any one of these three.

  • Unguessable path — use orders-8f3a2b1c instead of orders, with a token baked in
  • Signature verification — check the X-Signature header from Stripe, GitHub, etc. against an HMAC
  • Basic Auth or header token — use the node's Authentication option
// HMAC signature check in a Code node
// Setup:
//   1) Enable "Raw Body" (or Binary Data) in the Webhook node's options so the raw request bytes
//      land in $binary.data.
//   2) On self-hosted n8n, set NODE_FUNCTION_ALLOW_BUILTIN=crypto in the container env so
//      require('crypto') works inside Code nodes.
const crypto = require('crypto');
const secret = $env.WEBHOOK_SECRET;
const provided = ($json.headers['x-signature'] || '').replace(/^sha256=/, '');
// JSON.stringify($json.body) is a re-serialization and won't match the raw bytes the sender signed.
// Always verify against the original request bytes:
const raw = Buffer.from($binary.data.data, 'base64');
const expected = crypto.createHmac('sha256', secret).update(raw).digest('hex');
// timingSafeEqual throws on length mismatch, so guard the length first:
const a = Buffer.from(provided, 'hex');
const b = Buffer.from(expected, 'hex');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
  throw new Error('bad signature');
}
return $input.all();

How to return a response

The default is an immediate 200 OK. The problem starts when the other service expects a response body. Switch the Webhook node's Response Mode to Using 'Respond to Webhook' Node, then add a Respond to Webhook node at the end to return the exact status code and JSON you want.

Once you understand webhooks, the word "integration" stops being scary. Most connections start with a single URL.SynAct.ai engineering

Traps you'll step in

  • Pointed production at the test URL and left the workflow inactive — silent failure
  • Response takes over 30 seconds, and the caller unleashes retry storms
  • Retries send the same event multiple times, and there's no idempotency

The third one is especially dangerous in domains like payments and tax invoices. Get in the habit of using an identifier like order_id as a key and checking whether you've already processed it.

Frequently asked

  • Q. What's the difference between the test URL and the production URL? A. The test URL works even when the workflow is inactive — hit 'Listen for Test Event' in the canvas and it accepts exactly one incoming request to populate the node. The production URL only responds when the workflow is Active and runs on every incoming request. 90% of 'why isn't this working' moments right after deploy come from forgetting this one thing.
  • Q. If my webhook response is slow, will the sender retry? A. Most services (Stripe, GitHub, Slack) wait 15–30 seconds for a 200 OK and retry if it doesn't arrive. Heavy processing should return 200 immediately and continue in the background. Only use the Respond to Webhook node when the caller expects a response body.
  • Q. When is HMAC signature verification actually required? A. Any time an external service sends a signature header (Stripe, GitHub, Shopify) — always verify. Even for your own services, any webhook that triggers hard-to-reverse actions (payments, deletes, bulk sends) should require a signature. Verification snippet is in the code block above.
  • Q. How do I handle duplicate events? A. Retries from the sender happen. Use the event identifier (order_id, etc.) as an idempotency key and check whether you've already processed it. Redis or a Postgres table with a UNIQUE constraint is the usual pattern.
  • Q. Do I need Basic Auth AND signature verification? A. Basic Auth answers 'who is sending this'; signature verification answers 'has the content been tampered with'. Different guarantees — keep both, especially for payment and admin triggers.
  • Q. Are webhook URLs different on n8n Cloud vs self-hosted? A. The path format is identical (/webhook/<path> or /webhook-test/<path>). Only the domain changes — Cloud is https://<workspace>.app.n8n.cloud/webhook/..., self-hosted is your own domain.

What's next

Once you've got the trigger, all that's left is conditional branching and actions. Split flow with IF/Switch, wire it to Slack or your ERP, and that's what people call "automation" in the real world. Build it once and you rarely touch it again.

The Webhook node and signature-check code in this guide were verified against a n8n 2.29.9 self-hosted instance (NODE_FUNCTION_ALLOW_BUILTIN=crypto set), round-tripped with real curl requests and Stripe-style HMAC signatures. Last verified: 2026-01-21.
Want to add webhook automation to your team's stack? Book a free consultation. Payments, forms, ERP, CRM — we'll sketch the architecture with you in a 30-minute call.

Reading is fine.
Doing is better.

We'll apply what you read to your operation. Start with a free audit.

One hands-on automation piece a month

n8n patterns, AI-agent recipes, and case studies — one email a month. No sales pitches, no filler.

Unsubscribe anytime · No spam