Tutorials

Webhook vs API: A Developer's Guide to When, How, and Why (2026)

Gaurav Verma
May 18, 2026
TABLE OF CONTENTS

Last Updated: May 2026

Most "webhook vs API" guides stop at the conceptual difference: APIs are pull, webhooks are push. That framing is correct but it does not help when you are actually building. The real questions are operational: how do you verify a webhook came from who it claims to, what happens when the receiver is down, how do you stop processing the same event twice, and when is plain old polling actually the right answer?

This guide is the developer-focused walkthrough. It covers the differences in one paragraph, the production patterns in code, and the security details (HMAC signing, replay protection) that the introductory articles skip. Examples use Node.js and curl. The patterns are platform-agnostic.

Webhook vs API: The Short Answer

An API is a request-response interface: your code asks for something, the server responds. A webhook is an event-driven callback: the server pushes a payload to a URL you registered, when something happens. Both run over HTTP. The difference is who initiates the call and when.

You use an API when you need data on demand or want to perform an action. You use a webhook when you want to react to an event the moment it happens, without having to poll for it. Most production systems use both: APIs for reads and writes, webhooks for status updates and async notifications.

What an API Is

For this guide, "API" means a REST or RPC HTTP API. Your client opens a connection, sends a request with method, headers, and body, and gets a response back. The connection closes. Nothing happens on the server side until the next request comes in.

A simple API call to fetch a user record:

GET https://api.example.com/v1/users/42
Authorization: Bearer sk_live_abc123
Accept: application/json

The same call in Node.js:

const response = await fetch("https://api.example.com/v1/users/42", {
 headers: {
   Authorization: `Bearer ${API_KEY}`,
   Accept: "application/json",
 },
});
const user = await response.json();

Three properties matter for the comparison: the client controls timing, the response is immediate, and authentication lives in the request. The server has no awareness of you between calls.

What a Webhook Is

A webhook is the inverse. You tell a third-party service "when event X happens, POST a JSON payload to this URL of mine." Your server runs an HTTP endpoint, the third party calls it when the event fires, and your code reacts to the payload.

A typical webhook payload from a payment provider:

POST https://yourapi.com/webhooks/stripe
Content-Type: application/json
Stripe-Signature: t=1715784000,v1=abc123def456...

POST https://yourapi.com/webhooks/payments
Content-Type: application/json
X-Webhook-Signature: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
X-Webhook-Timestamp: 1715784000

{
 "id": "evt_1NaB2C3D4E5F6G7H",
 "type": "payment.succeeded",
 "created": 1715784000,
 "data": {
   "object": {
     "id": "pi_1NaB2C3D4E5F6G7H",
     "amount": 4999,
     "currency": "usd"
   }
 }
}

Your code receives this payload, verifies it really came from the sender, and runs the business logic (mark the order paid, send a receipt, update the dashboard). The sender expects a 2xx response within its timeout - typically 3 to 30 seconds depending on the provider - otherwise it retries.

Different providers use different header formats. The examples in this guide use a generic X-Webhook-Signature (raw hex) header. Real-world signers vary: Stripe combines timestamp and signature into one Stripe-Signature: t=...,v1=... header and signs timestamp.body; GitHub uses X-Hub-Signature-256: sha256=... and signs just the body. Use your sender's exact format - the patterns in this guide transfer, but the parsing details don't.

Comparison of Webhook and API

  • Direction: API is client-to-server. Webhook is server-to-client.
  • Initiator: API caller chooses when to call. Webhook sender chooses when to call.
  • Latency to data: API has polling latency (you fetch on a schedule). Webhook has near-zero latency (push as it happens).
  • Coupling: API requires the client to know the server's endpoints. Webhook requires the server to know the client's endpoint.
  • Authentication: API uses bearer tokens or keys in the request. Webhook uses signature verification on the receiver side.
  • Failure mode: API failure surfaces immediately as an HTTP error. Webhook failure is silent unless the sender implements retries.
  • Cost shape: API cost is per-call you make. Webhook cost is per-event the sender pushes (and per receiver compute cycle to handle it).

When to Use an API

Pick an API when any of the following are true:

  • The data is needed on demand (a user opens a page, you fetch their profile)
  • You are performing a write or mutation (create an order, update a record)
  • You need a synchronous response to make a decision (charge a card, get back success or decline)
  • The data changes slowly enough that polling at a sensible interval is acceptable
  • You do not control the receiving infrastructure (third-party caller cannot rely on your endpoint)

Standard CRUD operations and request-driven UI both belong here. APIs also win when you need request-level error handling, since the failure mode is immediate and synchronous.

When to Use a Webhook

Pick a webhook when any of the following are true:

  • You need to react to an event the moment it occurs (payment succeeds, build finishes, message delivered)
  • The event happens infrequently and unpredictably, making polling wasteful
  • The event source is a third party that supports webhooks (Stripe, GitHub, Twilio, SuprSend)
  • You want to decouple your code from the timing of the upstream system
  • Multiple downstream systems need to react to the same event (one webhook can fan out internally)

Webhooks are the canonical pattern for delivery callbacks (an SMS or email's delivered/bounced status), CI/CD triggers (GitHub push), and async result notifications (long-running jobs that complete out of band).

Building a Webhook Receiver

A webhook receiver is an HTTP endpoint that accepts POSTs, validates them, and acts on the payload. The minimum viable version in Node.js with Express:

import express from "express";

const app = express();

app.post(
 "/webhooks/payments",
 express.raw({ type: "application/json" }),
 async (req, res) => {
   const event = JSON.parse(req.body.toString("utf8"));

   // Push to a queue and acknowledge immediately.
   // Actual business logic runs in a background worker.
   await queue.publish("payment-events", {
     id: event.id,
     type: event.type,
     payload: event,
   });

   res.status(200).send("ok");
 });

app.listen(3000);

Three non-obvious details. First, use express.raw() instead of express.json() on this route, because signature verification (covered in the next section) needs the unmodified raw body bytes. Second, return 2xx fast - the handler's only job is to verify, persist, and acknowledge. The real work happens in a worker reading from the queue. Third, the queue choice is yours (SQS, Pub/Sub, Redis Streams, anything durable) - the principle is that the webhook receipt and the work that follows it are separate concerns with separate failure modes.

Webhook Security: HMAC Signing and Replay Protection

A webhook endpoint is a public URL. Anyone who finds it can POST anything to it. Without verification, you cannot trust the payload came from the legitimate sender. The standard solution is HMAC signing.

The sender computes HMAC-SHA256(secret, raw_body) and sends the result in a header. Your server recomputes the same HMAC with the shared secret and compares. If they match, the payload is authentic and unmodified. If they do not, reject the request.

import crypto from "crypto";

const MAX_AGE_SECONDS = 300; // 5 minutes

function verifySignature(rawBody, signatureHeader, timestampHeader, secret) {
 // Replay protection: reject anything older than MAX_AGE_SECONDS
 const now = Math.floor(Date.now() / 1000);
 const timestamp = parseInt(timestampHeader, 10);
 if (!timestamp || Math.abs(now - timestamp) > MAX_AGE_SECONDS) {
   return false;
 }

 // Sign timestamp + body so the timestamp itself is protected from tampering
 const signedPayload = `${timestamp}.${rawBody.toString("utf8")}`;
 const expected = crypto
   .createHmac("sha256", secret)
   .update(signedPayload)
   .digest("hex");

 const expectedBuf = Buffer.from(expected, "hex");
 const receivedBuf = Buffer.from(signatureHeader, "hex");

 // timingSafeEqual throws if buffers differ in length, so guard first.
 // Equal-length, non-matching buffers are the case timing attacks target.
 if (expectedBuf.length !== receivedBuf.length) return false;
 return crypto.timingSafeEqual(expectedBuf, receivedBuf);
}

app.post(
 "/webhooks/payments",
 express.raw({ type: "application/json" }),
 (req, res) => {
   const signature = req.header("X-Webhook-Signature");
   const timestamp = req.header("X-Webhook-Timestamp");

   if (!verifySignature(req.body, signature, timestamp, process.env.WEBHOOK_SECRET)) {
     return res.status(401).send("invalid signature");
   }
   // ... handle event
   res.status(200).send("ok");
 });

A few details that matter in production. Buffer.from(str, "hex") does not throw on non-hex input - it silently produces a shorter buffer of whatever it could parse. If your sender prefixes the hash (GitHub uses sha256=...), strip the prefix before passing it in. The length check before timingSafeEqual is required, not defensive bloat: the function throws a RangeError on length mismatch, and a different-length signature isn't a timing attack target anyway. Including the timestamp inside the signed payload (rather than only checking it separately) means an attacker can't replay an old request with an updated timestamp - the signature would no longer match.

Two further details that production webhook receivers get right, on top of the HMAC verification shown above:

1. IP allowlisting. When the sender publishes the IPs they send from (Stripe, GitHub, AWS SNS do), allowlist them at the firewall level as a defense-in-depth layer on top of HMAC. Don't use IP allowlisting instead of HMAC - IPs can be spoofed inside a compromised network, and many senders rotate their egress IPs.

2. Secret rotation. Treat the webhook secret like any other credential. Rotate periodically and on suspicion of compromise. During rotation, accept both the old and new secret for a short overlap window so in-flight webhooks signed with the previous secret still verify; most senders (Stripe, GitHub) support this on their side too.

Delivery Guarantees, Retries, and Idempotency

Webhook senders deliver at-least-once, not exactly-once. If your endpoint returns a 500, times out, or even returns a 200 with a slow connection that the sender drops, the sender retries. Most retry on an exponential backoff for hours to days - Stripe retries for up to three days, GitHub backs off and eventually disables a consistently failing webhook, AWS SNS will keep trying for over three weeks.

The consequence: your handler must be idempotent. Processing the same event twice should produce the same result as processing it once.

The standard pattern is to use the event ID (every webhook payload includes one) as an idempotency key. Before doing the work, check whether you have processed that ID before:

// Using ioredis. SET with NX returns "OK" if the key was set,
// null if it already existed (meaning we've seen this event before).
const wasNew = await redis.set(
 `webhook:processed:${event.id}`,
 "1",
 "EX",
 86400, // 24h TTL — must outlive the sender's retry window
 "NX"
);

if (wasNew === null) {
 // Already processed; acknowledge and exit
 return res.status(200).send("already processed");
}

await processEvent(event);
res.status(200).send("ok");

Two things worth getting right. The TTL must outlive the sender's retry window - if a sender retries for three days and your idempotency key expires in one, you'll process the duplicate. Use 24-72 hours depending on your sender. The exact API shape above is ioredis; node-redis v4+ uses an options object instead: redis.set(key, "1", { EX: 86400, NX: true }). For workloads where Redis is not available, a database table with a unique constraint on event_id works the same way: insert, catch the unique-constraint violation, treat as a no-op.

Failure Handling and Dead-Letter Queues

Even with retries, some webhook events fail permanently: malformed payload, downstream service permanently broken, business-rule rejection. The sender will eventually give up after exhausting its retry schedule, and the event is lost unless you captured it.

The pattern: every webhook handler writes the raw payload to a durable queue (SQS, Pub/Sub) before doing the work. Failed processing pushes the message to a dead-letter queue for human review. This way no event is lost between receipt and successful processing, even if your worker code has a bug.

An additional layer: emit metrics on every webhook (received, succeeded, failed, retried). A sudden drop in received-count usually means the sender's outbound queue is backed up or the URL is being blocked by a network change on your side.

Hybrid Patterns: API Plus Webhook

The interesting production architectures use both. Three common patterns:

  • Async API with webhook callback. Your code calls an API that kicks off a long-running job (transcoding, image processing, batch send). The API returns a job ID immediately. When the job finishes, the service POSTs to your webhook with the result. You get the latency benefit of async without polling.
  • API for reads, webhook for changes. Initial fetch via API, then subscribe to webhooks for incremental updates. Common in CRM and inventory integrations.
  • Webhook for trigger, API for detail. The webhook is small (just an event ID and type). Your handler immediately calls the sender's API to fetch the full record, ensuring you always read the latest state and avoid stale data from a delayed webhook.

The "webhook for trigger, API for detail" pattern is particularly safe because it sidesteps a class of race conditions where two webhooks arrive out of order and the second-applied event represents an older state.

Polling vs Webhooks

Polling gets a bad reputation but is sometimes the right call. Use polling instead of a webhook when:

  • The third party does not support webhooks at all (still common for legacy systems)
  • The event rate is so low that a poll every few minutes is fine and webhook setup overhead is not worth it
  • You need exactly-once semantics and your code is designed around the polled cursor
  • Your network setup makes accepting inbound webhook traffic hard (no public ingress, firewall rules)

Polling's hidden cost is request volume. Polling 10,000 records every 60 seconds is 14.4 million requests per day, even when nothing has changed in most of them. A webhook on the same data is one request per actual event. For high-cardinality, low-event-rate systems, the webhook savings are large; for the opposite shape (few records, high churn), the difference is smaller.

Webhooks for Notifications

Notification platforms use webhooks heavily for delivery callbacks. When you send an email, SMS, or push through a multi-channel API, the platform handles the actual provider call (SendGrid, Twilio, FCM) and then fires webhooks back to your application as the message moves through delivered, bounced, opened, clicked, and failed states.

This pattern lets you build customer-facing dashboards, retry failed sends programmatically, and alert on delivery anomalies, without polling the provider's API for every message you sent.

SuprSend emits webhooks for status events (sent, delivered, seen, failed) and inbound messages from channels like WhatsApp and SMS. The receiver pattern is the same as the examples above: a public POST endpoint, fast 2xx response, idempotent processing keyed off the event ID. SuprSend's outbound webhooks support custom headers, which you can use to inject a static auth token that your receiver checks before processing - simpler to set up than HMAC, but with the trade-off that the token is a static shared secret rather than a per-request signature, so treat it like a credential (rotate it, keep it out of logs, restrict it to the receiver endpoint). For sensitive workloads, layer it with IP allowlisting and TLS pinning.See the outbound webhook docs for the full payload schema and event types. For broader reliability patterns, see notification retry and fallback.

Common Pitfalls

  • Doing the work synchronously in the webhook handler. Slack times out at 3 seconds, GitHub at 10, Stripe accepts longer responses but counts slow responses against your endpoint health. Aim for under 1 second; push to a queue and return 200 immediately.
  • Using express.json() instead of express.raw(). JSON parsing reformats the body, breaking signature verification.
  • Skipping signature verification because "the URL is secret." URLs leak in logs, browser history, and accidental commits. Always verify.
  • String comparison for signatures with ===. Vulnerable to timing attacks; use timingSafeEqual.
  • No idempotency check. Retry storms double or triple your business-side state changes.
  • No dead-letter queue. Permanent failures vanish silently and you find out when a customer complains.
  • Polling when a webhook would do. Wasted requests, higher latency, and bigger bills.
  • Webhooking when polling would do. Operational overhead (signing, retries, ingress) for events that fire once an hour.

FAQ

What is the main difference between a webhook and an API?

Direction. An API is called by your code when you need something. A webhook calls your code when an event happens on the sender's side. Both run over HTTP, but the initiator is reversed.

Is a webhook a type of API?

It is an API in the loose sense (an HTTP interface), but it is the inverse pattern: event-driven and push-based instead of request-response and pull-based. Most documentation treats them as separate concepts to keep the distinction clear.

How do I make my webhook endpoint secure?

Verify the HMAC signature on every request using the shared secret and the raw request body. Use a constant-time comparison (crypto.timingSafeEqual in Node.js). Reject requests with timestamps older than a few minutes to prevent replay. Optionally allowlist sender IPs at the firewall level.

What happens if my webhook endpoint is down?

The sender retries on an exponential backoff for several hours, then gives up. To avoid losing events, implement a queue-on-receipt pattern (write the raw payload to durable storage immediately, then process async) and a dead-letter queue for events that fail after all retries.

How do I prevent processing the same webhook twice?

Use the event ID from the payload as an idempotency key. Before processing, check Redis or a database table whether you have already handled that ID; if yes, return 200 without doing the work again. Webhook delivery is at-least-once, not exactly-once.

When should I use polling instead of a webhook?

When the source system does not support webhooks, when your network cannot accept inbound traffic, or when the event rate is so low that webhook setup overhead is not worth it. For high-volume or low-latency-sensitive workloads, webhooks almost always win.

Can a single API call trigger multiple webhooks?

Yes. Many systems fire multiple webhook events from one API action. Sending a notification through a multi-channel platform, for example, can fire sent, delivered, seen, and clicked webhooks over the lifecycle of a single message.

Written by:
Gaurav Verma
Co-Founder, SuprSend
Implement a powerful stack for your notifications
By clicking “Accept All Cookies”, you agree to the storing of cookies on your device to enhance site navigation, analyze site usage, and assist in our marketing efforts. View our Privacy Policy for more information.