
What Is a Webhook and How Does IT Work
You're probably dealing with this right now. Something happens in another system, a payment succeeds, a GitHub push lands, a form gets submitted, and your app needs to react right away.
The beginner solution is usually polling. Hit an API every few seconds, ask “anything new?”, then keep doing that forever. It works, but it's noisy, late, and wasteful when nothing changed. That's the gap webhooks fill.
The Problem Webhooks Solve
A useful way to frame the problem is this: your system often depends on work that starts somewhere else.
A customer submits a form on your static site. A payment provider marks an invoice as paid. A teammate pushes code to GitHub and your deployment pipeline should react. In each case, your app is not creating the event. It is waiting for another system to say, "something happened, act on it now."
That waiting creates an architectural choice.
You can keep asking the other service for updates on a schedule, or you can let that service notify you when the event occurs. Polling can work, but it pushes a lot of responsibility onto the receiver. Your app has to keep checking, keep track of what it has already seen, and decide what changed since the last request.
That sounds simple at first. It rarely stays simple.
Once the integration matters to the business, the main problem is not just wasted requests. It is reliability. If your polling job runs late, you react late. If it fails between checks, you need a safe way to catch up. If the provider returns partial data or rate limits you, your "did anything change?" loop gets more complicated fast.
Webhooks shift that model. The system that knows an event happened sends an HTTP request to your app at the moment it happens. That turns the integration from repeated checking into event delivery.
Webhooks are less about saving API calls and more about assigning responsibility to the system that has the freshest truth.
That shift matters most on the unhappy path. A production webhook flow has to answer questions polling tends to hide: What if the sender times out? What if it retries the same event three times? What if someone posts a fake payload to your endpoint? What if your app is down for a minute right when the event arrives?
Those are the problems worth solving early.
A webhook receiver is not just a URL that accepts POST requests. It is a small event ingestion system. It needs to verify who sent the request, return the right status code quickly, and handle duplicate deliveries without creating duplicate side effects like double emails, double CRM entries, or repeated deployment jobs. That is why teams use webhooks when they need systems to react quickly, but it is also why production-grade webhook handling needs more care than a toy demo suggests.
Webhooks vs Polling The Core Concept
A good mental model is this:
Polling is like walking to your mailbox every few minutes to see if a letter arrived.
A webhook is like getting a text message the moment someone has something for you.

What a webhook actually is
A webhook is best understood as a push-based HTTP callback. Instead of asking a server for updates repeatedly, the receiving system registers a URL and gets notified automatically when a specific event happens. That design makes webhooks event-driven and near real time because data moves only when something changes rather than on a fixed polling schedule, as explained in Theneo's webhook overview.
In plain English, that means:
- You expose a URL your app can receive requests on.
- You tell another service to send event notifications to that URL.
- When the event happens, that service sends you an HTTP request.
- Your code handles it.
That's the whole core idea.
Push versus pull
Here's the shortest useful comparison:
| Approach | Who starts the request | When data moves | Typical downside |
|---|---|---|---|
| Polling | Your app | On a schedule | You ask even when nothing changed |
| Webhook | The source system | When an event happens | You must run a public receiver |
Polling is still fine sometimes. If a provider doesn't support webhooks, or if you need to fetch a large dataset on a schedule, polling can be the right tool.
But if your question is “what is a webhook and how does it work?”, the answer starts with this distinction. A webhook flips the control flow. Your app stops asking and starts listening.
A simple example
Suppose Stripe, GitHub, or your form backend supports a payment_succeeded, push, or submission.created event.
Without webhooks:
- your app checks every so often for new payments, commits, or submissions
With webhooks:
- the provider sends a POST request to your endpoint the moment that event occurs
Practical rule: Use polling when you need to fetch data on demand. Use webhooks when your app needs to react to events as they happen.
Anatomy of a Webhook A Look Under the Hood
A webhook works like a signed delivery arriving at your office. The package has a destination, labels on the outside, and a message inside. Your job is not just to open it. Your job is to confirm it came from the expected sender, record that it arrived, and avoid processing the same package twice if the courier shows up again.
That reliability angle matters because webhook delivery is not a single happy-path POST. In production, requests get retried, handlers time out, payloads arrive out of order, and bad actors can send lookalike requests to public endpoints.

The endpoint URL
The endpoint is the public HTTPS address that receives the event. Examples:
https://example.com/api/webhooks/githubhttps://app.example.com/webhooks/payments
This URL is the delivery address you register with the provider.
A good webhook endpoint stays stable. If you change the path, break TLS, add a redirect the provider will not follow, or deploy an app that sometimes hangs, deliveries start failing. That is why teams often isolate webhook routes from the rest of the app and keep them as boring as possible.
The HTTP method and headers
Webhook requests are usually HTTP POST requests. The headers tell your receiver how to interpret the body and how to decide whether the request should be trusted.
Common header categories include:
Content-Typefor the payload format, oftenapplication/json- Event headers such as the event name or type
- Signature headers used to verify the sender
- Delivery ID headers used to identify one delivery attempt or event message
A conceptual request looks like this:
POST /api/webhooks/githubContent-Type: application/jsonX-Event-Name: pushX-Signature: ...X-Delivery-Id: ...
The exact names vary by provider, so the safe habit is simple. Read the provider docs. Map each header to a clear purpose in your code. Do not guess.
The payload
The payload is the request body. It contains the event data your app will act on.
Usually it is JSON:
{
"event": "order.paid",
"id": "evt_123",
"createdAt": "2026-06-11T12:00:00Z",
"data": {
"orderId": "ord_456",
"customerEmail": "dev@example.com"
}
}At first glance, that looks straightforward. Parse JSON, branch on event, run some code.
Real systems make it messier.
Some providers send a full object. Others send only an ID, and you fetch the latest state from their API. Some events arrive more than once because the sender retried after a timeout. Some arrive late, so order.shipped can reach you before order.paid if network delays or retries reshuffle delivery. Your handler has to cope with all of that without corrupting state.
The response code is part of delivery
Your response is not a courtesy. It is part of the delivery protocol.
Providers usually treat a quick 2xx response as acknowledgment. Anything else tells the sender the event may not have been handled successfully.
| Your response | What the sender usually assumes | Likely outcome |
|---|---|---|
| 2xx | You accepted the event | Delivery marked successful |
| 4xx | You rejected the request | Retry behavior depends on provider |
| 5xx | Your server failed while handling it | Retry is common |
| Timeout | No acknowledgment arrived | Retry is common |
That retry behavior is where many webhook bugs begin. If your code creates an order, sends an email, or updates a subscription before it can safely detect duplicates, the same event can trigger the same side effect more than once.
What a production-ready receiver actually does
A solid webhook handler usually follows this flow:
- Receive the raw HTTP request.
- Verify the signature using the raw body bytes.
- Extract an event ID or delivery ID.
- Check whether that event was already processed.
- Store or queue the event for asynchronous work.
- Return a
2xxresponse quickly. - Process the business logic in a retry-safe way.
That order matters. Signature verification should happen before you trust the payload. Idempotency checks should happen before you trigger side effects. The acknowledgment should usually happen after the event is durably recorded, not after a long chain of downstream work.
A useful mental model is “ingest first, process second.” Your webhook endpoint is the front door, not the whole house. Its first job is to accept valid deliveries safely and consistently, even when your background workers or third-party dependencies are having a bad day.
If you remember one thing from this section, remember this: a webhook is not complete when a POST hits your server. It is complete when your system can verify it, acknowledge it correctly, and survive retries without doing the same work twice.
Securing Your Webhooks From Payloads to Production
A webhook endpoint is a public door into your system. If someone can send requests to that door, your code needs a reliable way to tell a real provider delivery from a forged one.
That is the security problem webhooks introduce.

Why signature verification matters
Start with the two questions your receiver must answer before it trusts any payload:
- Did the expected provider send this request?
- Did the request body arrive unchanged?
Providers usually answer both with an HMAC signature. They compute a digest from the raw request body and a shared secret, then send that digest in a header. Your server computes the same digest from the exact bytes it received and compares the two values with a timing-safe check.
If the values do not match, reject the request.
A practical analogy helps here. Signature verification works like checking the tamper seal on a package before you stock it in inventory. The package may look valid from the outside, but you still verify that it came from the expected sender and was not altered on the way.
Why raw body handling breaks so many webhook integrations
This part trips up a lot of developers because the JSON looks identical in logs while the signature still fails.
A common failure path looks like this:
- your framework parses the JSON body automatically
- your code turns that parsed object back into a string
- the provider signed the original bytes, not your reconstructed version
- the signature check fails
Whitespace, key order, and encoding details can change the byte sequence without changing the visible data. That is why webhook verification usually has to run against the raw body buffer before normal JSON parsing.
If the provider signs bytes, verify bytes.
Timestamp checks and replay protection
A valid signature is not enough on its own. If an old request is captured somewhere along the route, it can be replayed later.
Many providers reduce that risk by signing a timestamp along with the body. Your receiver checks whether the timestamp falls inside a small acceptance window. If it is too old, reject the delivery even if the signature matches.
That protects against replay attacks. The payload may be real, but it is no longer safe to act on.
Security and reliability meet at the same endpoint
Webhook security is not only about blocking fake requests. It is also about handling real requests safely when networks, queues, and downstream services fail.
A provider might send a legitimate event, wait for your acknowledgment, hit a timeout, and retry. Your endpoint now receives the same signed payload again. If your handler creates an order, sends an email, or posts a Slack alert every time it sees that event, one valid delivery can produce duplicate side effects.
That is why a production-ready receiver needs three protections working together:
- Signature verification so you only trust authenticated payloads
- Replay protection so stale deliveries are rejected
- Idempotent processing so retries do not repeat the same business action
This matters in small automations too, not only payment systems. If you are wiring form submissions into chat ops, a duplicated delivery can spam a channel or create duplicate support work. A practical pattern is to verify the webhook, store the event ID, then fan out the notification once. The same care applies when sending form submissions to Slack with a webhook flow or automating Slack Zendesk workflows.
A short production checklist
Use this checklist before you expose any webhook receiver to the internet:
- Use HTTPS so request data is encrypted in transit
- Verify the provider signature with the raw body
- Check timestamps if the provider includes them
- Compare signatures with a timing-safe function
- Store event IDs or delivery IDs to detect retries
- Queue or persist the event before long-running work
- Keep signing secrets in environment variables or a secret manager
The happy path is easy to demo. Critical work starts when the request is delayed, replayed, retried, or forged. That is the difference between a webhook example that works on localhost and one you can trust in production.
Practical Use Cases From GitHub to Static Forms
Webhooks make the most sense when you tie them to workflows you already know.
A GitHub push can trigger a deployment. A payment success event can trigger fulfillment. A form submission can create a lead record and send a Slack message. The pattern is the same even though the payloads differ.
GitHub, payments, and team notifications
A few common examples:
- GitHub to CI/CD: when someone pushes to a repository, GitHub sends a webhook that starts a build in Jenkins, GitHub Actions, or your deployment service
- Payments to order processing: when a payment provider confirms a charge, your app marks an order as paid and starts fulfillment
- Slack or support workflows: an incoming event gets transformed into a message, ticket, or audit log entry
If your team works across support and chat tools, this same model shows up in operational automations too. A practical example is automating Slack Zendesk workflows, where event notifications keep support and internal communication in sync.
Forms are events too
Developers sometimes think of webhooks as something only Stripe or GitHub uses. But forms are one of the simplest webhook sources.
A form submission is just an event:
- someone fills out fields
- the browser sends a POST request
- your backend or form service processes it
- optional downstream actions run after that
For static and JAMstack sites, this is especially useful because you often don't want to maintain a custom backend just to receive contact forms.
Here's a plain HTML example that posts to a realistic endpoint:
<form action="https://api.staticforms.dev/submit" method="POST">
<input type="hidden" name="apiKey" value="YOUR_STATIC_FORMS_API_KEY" />
<input type="hidden" name="redirectTo" value="https://example.com/thanks" />
<label>
Name
<input type="text" name="name" required />
</label>
<label>
Email
<input type="email" name="email" required />
</label>
<label>
Message
<textarea name="message" required></textarea>
</label>
<button type="submit">Send</button>
</form>And a React version:
export default function ContactForm() {
return (
<form action="https://api.staticforms.dev/submit" method="POST">
<input type="hidden" name="apiKey" value="YOUR_STATIC_FORMS_API_KEY" />
<input type="hidden" name="redirectTo" value="https://example.com/thanks" />
<label>
Name
<input type="text" name="name" required />
</label>
<label>
Email
<input type="email" name="email" required />
</label>
<label>
Message
<textarea name="message" required />
</label>
<button type="submit">Send</button>
</form>
);
}If you need spam protection on a public form, use a supported challenge such as reCAPTCHA v2/v3, Cloudflare Turnstile, Altcha, or a honeypot. If you accept uploads, keep the platform limit in mind. For Static Forms, uploads are supported up to 5MB per submission. If you send mail from a custom domain, make sure SPF, DKIM, and DMARC are configured correctly.
Where Static Forms fits

For static sites, Static Forms is one option that handles form submissions at https://api.staticforms.dev/submit and can route each submission to a generic webhook as JSON POST, along with destinations like Slack, Discord, Google Sheets, Notion, Airtable, Telegram, and Mailchimp. If your goal is to turn frontend form events into downstream automations without building your own receiver first, their guide on sending form submissions to Slack is a useful concrete example.
How to Build and Consume Webhooks Code Examples
A webhook integration feels simple right up until the first failure. Your payment provider sends order.paid, your endpoint times out after writing to the database, and a retry arrives 30 seconds later. If your handler is not verifying signatures or guarding against duplicates, you can ship the same order twice, send two emails, or corrupt state in ways that are hard to unwind.
That is why it helps to build both sides once. One sender. One receiver. One signed request. Then one failed delivery and one duplicate. Webhooks start to make sense when you see the full delivery path, not just the POST request that worked in a demo.
Send a webhook with Node.js
Start with the producer side. A webhook sender packages an event, signs the exact bytes it will send, and treats any non-2xx response as a failed delivery that may need a retry.
This example signs the raw JSON payload with HMAC-SHA256 and sends it with a timestamp header.
import crypto from "node:crypto";
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
const WEBHOOK_URL = "https://example.com/api/webhooks/orders";
async function sendWebhook() {
const payload = {
id: "evt_order_paid_123",
event: "order.paid",
createdAt: new Date().toISOString(),
data: {
orderId: "ord_456",
customerEmail: "dev@example.com"
}
};
const rawBody = JSON.stringify(payload);
const timestamp = Math.floor(Date.now() / 1000).toString();
const signedContent = `${timestamp}.${rawBody}`;
const signature = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(signedContent)
.digest("hex");
const response = await fetch(WEBHOOK_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Webhook-Timestamp": timestamp,
"X-Webhook-Signature": signature
},
body: rawBody
});
const text = await response.text();
console.log(response.status, text);
}
sendWebhook().catch(console.error);A few details matter here:
- Sign the raw string you send: the receiver must verify the same bytes, in the same order
- Include a timestamp: this gives the receiver a replay window check
- Use a stable event ID: retries should carry the same ID so the consumer can deduplicate
- Treat non-
2xxresponses as failed deliveries: retries are normal webhook behavior
A mailing analogy helps here. The JSON body is the letter. The signature is the tamper-evident seal. The timestamp is the postmark. The event ID is the tracking number. If any of those are missing, the receiver has less context for deciding whether the message is authentic, fresh, and already handled.
Receive and verify with Express
The receiver side is where production bugs usually show up. The job is not just “parse JSON and run business logic.” The job is to accept an event safely.
That means four steps, in order:
- Read the raw request body
- Verify the timestamp and signature
- Check whether this event was already processed
- Acknowledge quickly, then do slower work
This example captures the raw body, validates the timestamp, verifies the signature, checks for duplicates, then acknowledges receipt quickly.
import express from "express";
import crypto from "node:crypto";
const app = express();
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
// Demo only. Replace with a database or persistent store.
const processedEventIds = new Set();
app.use(
express.json({
verify: (req, res, buf) => {
req.rawBody = buf.toString("utf8");
}
})
);
function timingSafeEqual(a, b) {
const aBuf = Buffer.from(a, "utf8");
const bBuf = Buffer.from(b, "utf8");
if (aBuf.length !== bBuf.length) return false;
return crypto.timingSafeEqual(aBuf, bBuf);
}
app.post("/api/webhooks/orders", async (req, res) => {
const signature = req.header("X-Webhook-Signature");
const timestamp = req.header("X-Webhook-Timestamp");
if (!signature || !timestamp || !req.rawBody) {
return res.status(400).send("Missing signature data");
}
const timestampMs = Number(timestamp) * 1000;
const age = Math.abs(Date.now() - timestampMs);
// Example replay window. Tune for your provider and tolerance.
if (Number.isNaN(timestampMs) || age > 5 * 60 * 1000) {
return res.status(401).send("Stale webhook");
}
const signedContent = `${timestamp}.${req.rawBody}`;
const expectedSignature = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(signedContent)
.digest("hex");
if (!timingSafeEqual(signature, expectedSignature)) {
return res.status(401).send("Invalid signature");
}
const event = req.body;
const eventId = event.id;
if (!eventId) {
return res.status(400).send("Missing event ID");
}
if (processedEventIds.has(eventId)) {
return res.status(200).send("Already processed");
}
processedEventIds.add(eventId);
res.status(200).send("OK");
try {
switch (event.event) {
case "order.paid":
console.log("Process paid order:", event.data.orderId);
break;
default:
console.log("Unhandled event:", event.event);
}
} catch (err) {
console.error("Async processing failed:", err);
}
});
app.listen(3000, () => {
console.log("Listening on http://localhost:3000");
});Two parts are easy to miss.
First, signature verification must use req.rawBody, not JSON.stringify(req.body). Parsing and reserializing can change whitespace, key order, or encoding details. That breaks verification even when the sender is valid.
Second, the in-memory Set() is only a teaching aid. In a real app, duplicate protection belongs in durable storage, often with a unique constraint on the provider's event ID or delivery ID. If your process restarts, an in-memory set forgets everything and duplicate deliveries can slip through.
The response timing matters too. Return 200 OK after you have validated the request and stored enough state to process it safely. Do not wait for email sends, third-party API calls, or long database workflows before acknowledging receipt. Slow handlers invite retries, and retries create duplicates unless your code expects them.
A Next.js route handler example
The same rules apply in Next.js. Get the raw body first. Verify before trusting the payload. Then parse.
import crypto from "node:crypto";
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
function verifySignature(rawBody, timestamp, signature) {
const expected = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const a = Buffer.from(signature, "utf8");
const b = Buffer.from(expected, "utf8");
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
export async function POST(req) {
const rawBody = await req.text();
const timestamp = req.headers.get("x-webhook-timestamp");
const signature = req.headers.get("x-webhook-signature");
if (!timestamp || !signature) {
return new Response("Missing headers", { status: 400 });
}
if (!verifySignature(rawBody, timestamp, signature)) {
return new Response("Invalid signature", { status: 401 });
}
const event = JSON.parse(rawBody);
// Do dedupe and async work here
console.log("Received event:", event.event);
return new Response("OK", { status: 200 });
}If you are mentoring a junior dev on this handler, the key lesson is simple. Verification happens before trust. A request that looks like JSON is still just an untrusted HTTP request until the signature matches and the timestamp is within your allowed window.
A Vue form that submits to a real endpoint
Not every webhook flow starts with your own backend. Sometimes the event begins as a plain HTML form submission, and a hosted service forwards that submission to your app or another tool.
On the sender side, forms are still just POST requests. Here's a Vue component using a hosted form endpoint:
<template>
<form action="https://api.staticforms.dev/submit" method="POST">
<input type="hidden" name="apiKey" value="YOUR_STATIC_FORMS_API_KEY" />
<input type="hidden" name="redirectTo" value="https://example.com/thanks" />
<label>
Name
<input type="text" name="name" required />
</label>
<label>
Email
<input type="email" name="email" required />
</label>
<label>
Message
<textarea name="message" required></textarea>
</label>
<button type="submit">Send</button>
</form>
</template>If you want those submissions forwarded as webhook events, Static Forms explains the setup in its generic webhook integration docs for forwarding form submissions.
The useful mental model is that the form service becomes the webhook producer, and your backend becomes the consumer. The same production rules still apply on your side. Verify where the platform supports verification. Store delivery identifiers. Make your handler idempotent. Expect retries.
Troubleshooting and Best Practices for Reliability
Most webhook bugs aren't about the happy path. They're about retries, duplicates, timeouts, and handlers that did real work before returning an acknowledgment.
Reliability checklist
- Acknowledge fast: return a
2xxas soon as you've validated and safely accepted the event - Process asynchronously: queue work or hand it off after acknowledgment when possible
- Make handlers idempotent: processing the same event twice should not create duplicate side effects
- Store an event or delivery ID: dedupe before doing writes or sending emails
- Log headers and payload metadata: don't log secrets, but do log enough to debug failures
- Test with public URLs: use a tunnel during local development so providers can reach your machine
- Subscribe narrowly: only receive the events your app uses
The duplicate-delivery mindset
Retries aren't an edge case. They're part of normal webhook behavior.
If your server timed out after successfully writing to the database but before sending 200 OK, the provider may resend the same event. If your code isn't idempotent, you'll run the same workflow twice.
Build webhook consumers as if every event might be delivered more than once, because sometimes it will.
A simple debugging flow
When a webhook “isn't working,” check these in order:
- Did the provider send the request?
- Did your endpoint receive it?
- Did signature verification fail?
- Did your code return a non-2xx or time out?
- Did async processing fail after acknowledgment?
A webhook inspector helps a lot here. If you want a disposable endpoint to inspect payloads and delivery behavior while testing form or automation flows, try a webhook tester for local debugging.
If you're building a static or JAMstack site and want forms to feed email, Slack, Sheets, or your own webhook endpoint without maintaining backend form handling yourself, Static Forms is one practical option to evaluate.
Related Articles
Effective Form Error Messages: UX & Accessibility Guide
Write effective form error messages with UX best practices, WCAG, code examples, & server-side validation. Enhance user experience.
A Developer's Guide to All HTML Form Input Types
Master all HTML form input types with this practical guide. Explore examples, accessibility best practices, and how to connect your forms in minutes.
10 Form UX Best Practices for Developers in 2026
Boost conversions with our 10 form UX best practices for 2026. Learn clear labeling, real-time validation, mobile design, and more with practical code examples.