Guides · Webhooks · November 10, 2026 · 11 min read

What is a webhook? A plain-English guide with a real example

What a webhook is in plain English, how it differs from polling an API, what a real webhook request looks like, and how to receive, verify and test one in Node.js.

A webhook is an HTTP request that one system sends to a URL you give it when something happens. Instead of your code asking a service "anything new?" every few minutes, the service calls you the moment there is news: push instead of pull, which is why webhooks are sometimes called a reverse API.

This guide explains the idea with an analogy, compares webhooks with polling, shows a real webhook request, and walks through receiving, securing and testing one in Node.js. The examples are flight status events, because that is what we send, but nothing here is specific to flights.

How a webhook works

You are picking a friend up at JFK. You can refresh the airline's arrivals page every five minutes until it says "Landed", or your friend can text you when the wheels touch down. The first is polling. The second is a webhook.

The text message has everything a webhook has:

  • An address agreed in advance. Your friend needs your number; a webhook needs a URL on your server, registered with the sender. That is the webhook URL, or endpoint.
  • A trigger. Landing. In software, an event: a payment succeeded, someone pushed code, a gate changed.
  • The details. "Landed, terminal 4, bags on carousel 6." In software, a JSON body.
  • Proof it is really your friend. You recognize the number. A webhook carries a signature only the real sender can produce.

A webhook is an ordinary HTTP POST, the kind a browser sends when you submit a form. What makes it a webhook is the direction: the provider is the client and you are the server. You set it up once, then wait, and answer each delivery with a 2xx status to say "got it".

Webhook vs API: pull and push

"Webhook vs API" is a slightly misleading comparison, because a webhook is part of an API. The real distinction is between two ways of getting data out of one: you ask whenever you want and get the current state, or the provider tells you when the state changes.

Polling an APIWebhook
Who sends the requestYouThe provider
When you learn about a changeOn your next poll, up to one interval lateAs soon as the provider notices it
Requests when nothing changedEvery interval, each one wastedNone
What you have to runA scheduler or loopA public HTTPS endpoint
If your side is down for a whileNothing lost; you ask againYou depend on the sender's retries
Order of what you seeThe order you asked inCan arrive out of order
Best forOn-demand questions and one-off lookupsOngoing changes to things you are watching

GitHub's documentation puts it plainly: webhooks let you receive data as it happens, while polling many resources can exhaust your rate limit, though polling is fine for occasional questions about a few things. Most real systems use both: a lookup when a user opens a page, a webhook for everything that changes in between. The cost side of polling, with arithmetic for a few hundred watched flights, is in why polling a flight API costs more than you think.

What a webhook request looks like

Here is a real FlightByte delivery: Turkish Airlines flight 1848 has just pushed back from its gate in Athens. Headers first.

POST /hooks/flights HTTP/1.1
Host: yourapp.com
Content-Type: application/json
x-flightbyte-event-id: evt_9b41c2d0-37e5-4f0a-8c6e-2a7d5e1f0c93
x-flightbyte-event-type: flight.updated
x-flightbyte-subscription-id: sub_7fd9fa92faf3475db99b
x-flightbyte-delivery-attempt: 1
x-flightbyte-timestamp: 1789205471
x-flightbyte-signature: t=1789205471,v1=5f1c9a...e3b2

The headers tell the receiver which event this is, what kind, which subscription it belongs to, whether it is a retry, and when it was signed. Then the body, with the flight snapshot trimmed to a few fields:

{
  "id": "evt_9b41c2d0-37e5-4f0a-8c6e-2a7d5e1f0c93",
  "type": "flight.updated",
  "createdAt": "2026-09-12T09:31:11.000Z",
  "subscriptionId": "sub_7fd9fa92faf3475db99b",
  "flightKey": "TK1848:2026-09-12@ATH-IST",
  "flight": {
    "status": { "code": "A", "label": "Active", "phase": "departed", "delayed": false },
    "departure": {
      "airport": { "iata": "ATH" },
      "gate": "A22",
      "times": {
        "scheduledGate": { "utc": "2026-09-12T09:25:00.000Z", "local": "2026-09-12T12:25:00.000" },
        "actualGate": { "utc": "2026-09-12T09:27:00.000Z", "local": "2026-09-12T12:27:00.000" }
      }
    },
    "fetchedAt": "2026-09-12T09:31:09.000Z"
  },
  "changes": [
    { "type": "status.changed", "path": "status.code", "from": "S", "to": "A", "message": "Status changed from Scheduled to Active" },
    { "type": "flight.departed", "path": "status.phase", "from": "scheduled", "to": "departed", "message": "Flight departed ATH" },
    {
      "type": "departure.actual.changed",
      "path": "departure.times.actualGate",
      "from": null,
      "to": "2026-09-12T09:27:00.000Z",
      "deltaMinutes": null,
      "message": "Departure actual gate time changed from none to 2026-09-12T09:27:00Z"
    }
  ],
  "previousFetchedAt": "2026-09-12T09:28:05.000Z",
  "metadata": { "bookingId": "BK-1042" }
}

Three choices here are worth copying. The event has a stable id, so a retry with the same id and body is recognizably the same event. The body carries the full state in flight, not just the news, so the receiver never has to call back to learn what "departed" means for the arrival; changes lists what moved since the last delivery. And metadata returns whatever you stored when subscribing, here a booking id, so one endpoint can serve thousands of bookings. Every field and event type is in the webhooks reference.

Webhooks you have probably met already

Many people meet webhooks somewhere else first, and they come in two directions. Mixing them up is the most common source of confusion.

Webhooks a service sends to you, the kind described so far:

  • GitHub sends a delivery when someone pushes, opens a pull request or publishes a release. It signs the body with HMAC-SHA256 in the X-Hub-Signature-256 header, expects a 2xx within 10 seconds, and does not automatically redeliver failures; you redeliver by hand or with a script.
  • Stripe sends events such as payment_intent.succeeded, signed in a Stripe-Signature header shaped t=...,v1=..., and in live mode retries for up to three days with exponential backoff. FlightByte's signature header has the same shape.

Webhooks you send to a service. Slack and Discord use the word for a URL that you post to:

  • A Slack incoming webhook is a unique hooks.slack.com URL tied to one channel. You POST JSON with at least a text field and the message appears (Slack docs).
  • A Discord webhook works the same way, a low-effort way to post to a channel with no bot user or authentication. Anyone with the URL can post, so treat it like a password.

The two directions often meet: a provider's webhook arrives at your server, and your server forwards a summary to a Slack or Discord URL. "TK1848 departed ATH" in a team channel is twenty lines of code, or a few nodes in n8n.

How to receive a webhook in Node.js

A receiver has four jobs: verify the signature against the raw bytes, reply 2xx quickly, skip events it has already handled, and do the real work after replying. Here are all four in Express, using the verification function from the FlightByte docs unchanged.

server.mjs
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";

const SECRET = process.env.FLIGHTBYTE_WEBHOOK_SECRET;
const app = express();

// x-flightbyte-signature: t=<unix seconds>,v1=<hex>
// v1 = HMAC-SHA256(secret, `${t}.${raw body}`)
function verify(header = "", rawBody, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(",").map((part) => part.split("=", 2)));
  if (!parts.t || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSeconds) return false;
  const expected = createHmac("sha256", SECRET).update(`${parts.t}.`).update(rawBody).digest("hex");
  return parts.v1.length === expected.length && timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
}

const seen = new Set(); // a database table keyed by event id in production

// express.raw keeps the exact bytes that were signed; express.json() would not.
app.post("/hooks/flights", express.raw({ type: "application/json" }), (req, res) => {
  if (!Buffer.isBuffer(req.body) || !verify(req.get("x-flightbyte-signature"), req.body)) {
    return res.status(401).end();
  }

  const event = JSON.parse(req.body.toString("utf8"));
  res.status(204).end(); // reply first: the sender is waiting

  if (seen.has(event.id)) return; // a retry carries the same id
  seen.add(event.id);
  setImmediate(() => handle(event)); // the slow work happens after the reply
});

function handle(event) {
  for (const change of event.changes) {
    console.log(`${event.flightKey}: ${change.message}`);
  }
}

app.listen(3000, () => console.log("listening on http://localhost:3000"));

Run npm install express, set FLIGHTBYTE_WEBHOOK_SECRET, and start it with node server.mjs. Two lines are where most first receivers go wrong.

express.raw, not express.json. The signature covers the exact bytes sent. If a framework parses the JSON and you turn the object back into a string, spacing or key order can differ and every check fails, which looks exactly like a wrong secret.

Replying before working. The sender gives up after a timeout: ten seconds for FlightByte, and for GitHub. A receiver that writes to a database and sends an email before replying is fast in testing and times out on a busy day, and a timeout counts as a failure even if the work finished.

For a complete build that subscribes to a real flight and messages a driver when it lands, with no dependencies at all, see flight landed notification in 20 minutes with Node.js.

Are webhooks secure?

They are as secure as the receiver makes them. An endpoint is a public URL that accepts a POST from anyone, so each request has to prove it came from the real sender.

Signatures. You and the sender share a secret. The sender computes an HMAC of the body with it and puts it in a header; you compute the same and compare. Without the secret nobody can produce a match, and changing one byte of the body breaks it. Compare with a constant-time function (timingSafeEqual in Node.js), not ===.

Timestamps. A valid signature stays valid forever, so a captured delivery could be sent again later: a replay. FlightByte and Stripe put a timestamp inside the signed string, so it cannot be altered, and receivers reject anything older than a few minutes. FlightByte's verifier allows five, the same as the default in Stripe's libraries.

HTTPS. A signature proves who sent a request but hides nothing. Use HTTPS so the body, booking id included, is encrypted in transit.

Secrets kept secret. The signing secret belongs in an environment variable or secrets manager, never the repository or a log line. FlightByte returns a generated secret once, at creation, and can rotate it. For a second lock, a subscription's headers field adds your own token to every delivery.

The three ways verifiers go wrong, with code for seven frameworks, are in verifying webhook signatures the right way.

What happens when a delivery fails

Every serious provider retries, and how it retries shapes your receiver.

FlightByte counts a delivery as successful when your endpoint answers any 2xx within ten seconds. Network errors, timeouts, 5xx, 408 and 429 are retried up to eight times with exponential backoff starting at four seconds. Any other 4xx is taken as a deliberate rejection and not retried. After twenty consecutive failed deliveries the subscription is suspended and polling stops until you fix the endpoint and resume it (retries and suspension).

That has three consequences for your code.

Duplicates are normal. If you processed an event but your reply was lost, the sender cannot tell and sends it again. Store each processed event id and treat a repeat as done. Stripe gives the same advice, and GitHub keeps the same X-GitHub-Delivery value on a redelivery so you can spot it.

Order is not guaranteed. A retry of an older event can arrive after a newer one; Stripe states outright that it does not guarantee order. With FlightByte, createdAt and flight.fetchedAt say which is newer, and when in doubt the snapshot in the latest event is the truth.

Status codes are a message. Answer 2xx when you have the event, 5xx when you want it again later, and 4xx only when retrying can never help.

Missed events are not lost

If your endpoint was down longer than the retries last, the payloads FlightByte sent are still readable from GET /v1/subscriptions/{id}/events, and each attempt is in the deliveries log, kept for thirty days.

When not to use webhooks

Webhooks answer "tell me when this changes". They are the wrong tool in a few common cases.

You need one answer now. A user opens a booking page and wants the current status. That is a lookup; waiting for a webhook would be slower and more complicated.

You cannot run a public endpoint. A phone app or a browser tab cannot receive a webhook. The usual pattern is a webhook into your backend, which sends a push notification; fanning flight events out to push notifications covers it.

You need a live two-way connection. A dashboard streaming positions to a browser is a job for a WebSocket or server-sent events.

The volume is tiny. Checking one thing once a day does not justify an endpoint, a secret and a retry policy. A scheduled request is simpler.

How to test a webhook

Expose your local server with a tunnel such as cloudflared or ngrok, which gives you a public HTTPS URL that forwards to localhost:3000. FlightByte refuses webhook URLs that resolve to private or loopback addresses, so http://localhost itself cannot be registered.

Then send traffic without waiting for a real event. POST /v1/subscriptions/{id}/test sends a subscription.test event through the real pipeline, signed with the real secret (test events), and the deliveries log shows what your endpoint answered. Stripe's CLI and GitHub's redeliver option do the same job on their platforms.

Finally, test the failures: a tampered body, a stale timestamp and a wrong secret must all get a 401. The example receivers repository has a sender with a flag for each, and the signature guide explains what each proves.

Vendor behavior checked against GitHub, Stripe, Slack and Discord documentation on November 10, 2026.

To try it with a flight of your own, the quickstart goes from API key to your first verified webhook in a few requests, and there is a free plan to do it on.

Frequently asked questions

Is a webhook an API?

A webhook is part of an API rather than an alternative to one. A regular endpoint waits for you to ask; a webhook is the provider calling an endpoint you host when something happens. Most services offer both, and most integrations use both.

What is a webhook URL?

It is the address on your server where the provider sends its requests, such as https://yourapp.com/hooks/flights. You register it once, in a dashboard or when you create a subscription through the API. It must be reachable from the public internet and should use HTTPS.

Are webhooks secure?

They are when the receiver verifies them. A signed webhook carries an HMAC of the body made with a secret only you and the sender know, plus a timestamp that stops old deliveries from being replayed. Check both on every request, use HTTPS, and keep the secret out of your code.

What happens if my server is down when a webhook is sent?

The sender retries. FlightByte retries failed deliveries up to eight times with exponential backoff, and the events it sent stay readable from the API so you can catch up. Other providers differ: Stripe retries for up to three days in live mode, and GitHub does not retry automatically.

What is the difference between a webhook and a WebSocket?

A webhook is a one-off HTTP request from one server to another, sent when an event happens. A WebSocket is a long-lived two-way connection, usually between a browser or app and a server, that carries many messages. Webhooks suit server-to-server notifications; WebSockets suit live interfaces.

More in Webhooks

Get started

Watch your first flight tonight.

Nine dollars, a hundred flights, every event, signed. The receiver above is the whole integration.

A free key in a minute, no card. Paid plans are paid by card in the dashboard.