TL;DR: A webhook endpoint is an HTTPS URL in your application that receives event notifications from another service. When something happens, such as a payment succeeding, the provider sends an HTTP POST with a JSON payload to that URL. Your endpoint verifies the signature, stores the event, returns a fast 2xx response, and processes it in the background.
Webhook Endpoint Definition
A webhook endpoint is a public HTTPS URL, owned by your application and registered with an external service, that the service calls with an HTTP POST request whenever a subscribed event occurs. It is responsible for verifying, storing, and processing each event.
Example endpoints:
https://api.example.com/webhooks/github
https://api.example.com/webhooks/stripe
https://app.example.com/api/webhooks/order-created
Almost every modern integration depends on webhook endpoints. Payment providers report successful charges through them, GitHub reports pushes and pull requests through them, and CRMs, logistics platforms, and automation tools use them to tell your system that something changed without you having to keep asking.
GitHub describes webhooks as a way for notifications to reach an external web server when events occur. The important word is "external": your application must expose an endpoint that another system can call.
Webhook Endpoint vs API Endpoint vs Callback URL
| Area | API Endpoint | Webhook Endpoint | Callback URL |
|---|---|---|---|
| Who starts the request? | Your frontend or another client | The external provider | The provider, once, at the end of a flow |
| Timing | Whenever a client asks | Every time a subscribed event happens | After one specific action, such as OAuth sign-in |
| Main risk | Auth, validation, permissions | Signature verification, retries, duplicate delivery | Open redirects, state and CSRF checks |
| Example | GET /orders/123 | POST /webhooks/payment-succeeded | GET /auth/callback?code=... |
A callback URL is often confused with a webhook endpoint. The difference is lifetime: a callback is used once to finish a flow the user started, while a webhook endpoint stays registered and receives a stream of events for as long as the integration exists.
Webhooks vs Polling: Why Push Beats Pull
Before webhooks, integrations relied on polling: asking the other system for updates on a timer.
// Polling: ask the payment API every 5 seconds whether the order is paid.
setInterval(async () => {
const response = await checkPaymentStatus('order_123');
if (response.status === 'paid') {
shipOrder('order_123');
}
}, 5000);
If a customer takes 3 minutes to find their card, that loop makes 36 requests and 35 of them return "not paid yet". Multiply by every open checkout and you are paying for traffic that carries no information, and you still learn about the payment up to 5 seconds late.
A webhook reverses the direction. You give the provider a URL, and it tells you the moment the event happens:
// Webhook: do nothing until the provider calls us.
app.post('/api/webhooks/payment', express.json(), (req, res) => {
if (req.body.type === 'payment.success') {
queueShipment(req.body.orderId);
}
res.status(202).send('Accepted');
});
| Approach | Requests while waiting 3 minutes | Delay after the event | Load on your server |
|---|---|---|---|
| Polling | 36 | up to 5 seconds | constant, even when idle |
| Webhook | 1 | near zero | only when something happens |
What a Webhook Payload Looks Like
Most providers send a JSON body with an event id, an event type, a timestamp, and the changed object:
{
"event_id": "evt_987654321",
"event_type": "user.created",
"timestamp": "2026-04-20T10:30:00Z",
"data": {
"user_id": "usr_123",
"email": "newuser@example.com",
"plan": "premium"
}
}
Two fields matter most in production. The event id is your idempotency key, so duplicate deliveries can be ignored. The event type decides which handler runs. Subscribe only to the types you actually handle, because every extra type is more traffic and more code paths to secure.
How a Webhook Endpoint Works
- You create an endpoint in your application.
- You register that URL in the provider dashboard and choose the events to receive.
- The provider sends an HTTP POST when an event happens.
- Your endpoint verifies the signature or shared secret.
- Your endpoint stores the event and returns a fast 2xx response.
- A background worker processes the event, and is safe to run twice.
Node.js Example: Verify a GitHub Webhook
import crypto from 'node:crypto';
import express from 'express';
const app = express();
function isValidSignature(rawBody: Buffer, signatureHeader: string, secret: string): boolean {
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const received = Buffer.from(signatureHeader);
const computed = Buffer.from(expected);
// timingSafeEqual throws on different lengths, so compare lengths first.
return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}
app.post('/webhooks/github', express.raw({ type: 'application/json' }), async (req, res) => {
const signature = req.header('x-hub-signature-256') ?? '';
if (!isValidSignature(req.body, signature, process.env.WEBHOOK_SECRET!)) {
return res.status(401).send('Invalid signature');
}
// Store first, process later.
await saveWebhookEvent({
provider: 'github',
deliveryId: req.header('x-github-delivery'),
payload: JSON.parse(req.body.toString('utf8')),
});
return res.status(202).send('Accepted');
});
Three details in this example are easy to get wrong:
express.rawkeeps the body as bytes. The signature is computed over the exact bytes the provider sent, so parsing JSON first and re-serialising it breaks verification.- The comparison is constant-time, so an attacker cannot learn the expected signature from response timings.
- The handler returns
202 Acceptedafter storing the event, not after doing the work.
Stripe Example: Signed Payment Events
Payment webhooks carry the highest risk, because a forged payment_intent.succeeded event could ship an order nobody paid for. Stripe signs every delivery, and its SDK verifies the signature for you:
const express = require('express');
const Stripe = require('stripe');
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
const app = express();
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers['stripe-signature'],
endpointSecret,
);
} catch (err) {
return res.status(400).send(`Webhook signature verification failed: ${err.message}`);
}
if (event.type === 'checkout.session.completed') {
await saveWebhookEvent({ provider: 'stripe', deliveryId: event.id, payload: event });
}
return res.status(200).send();
});
constructEvent also checks the signed timestamp, which protects against an attacker replaying an old, genuine delivery. Keep the webhook secret in your secrets manager, not in code, and use a different secret per environment.
Production Webhook Checklist
| Requirement | Why It Matters |
|---|---|
| HTTPS only | protects payloads in transit |
| Signature validation | confirms the request came from the provider |
| Idempotency key | prevents duplicate processing |
| Fast response | avoids provider timeout and retry storms |
| Event log | gives auditability and replay support |
| Background jobs | keeps endpoint reliable under slow downstream work |
| Alerting | catches failed deliveries before customers notice |
Acknowledge fast, process later
Providers start a timer when they send a delivery. If you do not answer with a 2xx within a few seconds, they assume failure and retry, sometimes for days. Save the event, respond, and hand the work to a queue such as BullMQ, AWS SQS, or a PostgreSQL job table.
On serverless platforms such as AWS Lambda or Vercel functions, work started after the response is sent can be killed when the function freezes. There, push the event onto an external queue before responding instead of continuing in the same invocation.
Make processing idempotent
Retries and network glitches mean the same event can arrive twice. Record each processed event id with a unique constraint and skip ids you have already seen:
CREATE TABLE webhook_events (
provider text NOT NULL,
delivery_id text NOT NULL,
payload jsonb NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz,
PRIMARY KEY (provider, delivery_id)
);
An INSERT ... ON CONFLICT DO NOTHING on that table both stores the raw payload for debugging and drops duplicates in one statement.
Testing a Webhook Endpoint Locally
Providers cannot reach localhost, so local testing needs a tunnel or a provider CLI:
| Tool | Use it for |
|---|---|
stripe listen --forward-to ... | Forwards real, signed Stripe test events to your local server |
| GitHub "Redeliver" in the webhook settings | Replays a past delivery against your endpoint |
| ngrok or Cloudflare Tunnel | Gives any provider a temporary public HTTPS URL for your machine |
A saved payload and curl | Unit tests for handler logic, with signature checks stubbed |
Test the failure paths as well as the happy path: an invalid signature should return 401 or 400, a duplicate delivery should do nothing, and a slow downstream service should not delay the response.
Common Mistakes
- processing the full workflow before returning a response,
- trusting the request without verifying the signature,
- parsing the body as JSON before verifying a signature computed over raw bytes,
- not handling duplicate deliveries,
- not storing raw payloads for debugging,
- accepting every event instead of subscribing only to needed events,
- using one endpoint for every provider without clear routing.
When SoftwareCrafting Can Help
We build webhook-heavy backend systems for payments, billing, CRMs, logistics workflows, GitHub automation, and SaaS integrations. If you need reliable API integrations, see our backend API services and payment gateway integration, or send a brief.
Sources
- GitHub Docs: About webhooks
- GitHub Docs: Validating webhook deliveries
- Stripe Docs: Receive Stripe events in your webhook endpoint
Frequently Asked Questions
Is a webhook endpoint just a URL?
A webhook endpoint is a URL, but a production endpoint is more than an address: it must verify signatures, log each event, deduplicate by event ID, return a 2xx response quickly, and hand processing to a background worker.
What is the difference between an API and a webhook?
With an API, your application pulls data by sending a request when it wants something. With a webhook, the other system pushes data to your endpoint the moment an event happens, so you do not have to keep asking.
Should webhook endpoints return 200 or 202?
Use a 2xx response when the event is accepted. Many systems use 202 Accepted when the event is queued for later processing.
What data format do webhooks use?
Almost always JSON in the body of an HTTP POST request, with headers that carry the signature, the event type, and a delivery id.
Can a webhook endpoint be private?
Usually it must be reachable by the provider. Some systems support private delivery through tunnels, reverse proxies, or private connectivity.
Do webhooks need authentication?
They need verification. Most providers use signatures, shared secrets, or tokens rather than normal browser-style login.
What happens if my endpoint fails?
Most providers retry failed deliveries with backoff. Your endpoint should be idempotent so repeated events do not create duplicate side effects, and you should alert on repeated failures before the provider disables the endpoint.
