Skip to content

Technology · 7 November 2026 · 6 minute read

Webhooks explained simply: signature, retries, security

What a webhook is, how it differs from an API query and how to handle signature, retries and duplicates. With curl and Node.

Two ways compared: with an API query your system asks again and again; with a webhook the sender calls your address once as soon as something happens
Graphic: Whovisit · View graphic in full size

A webhook is a call in the other direction. Instead of your system asking a service every few minutes whether something has happened, the service calls an address at your end as soon as it happens. This saves queries and delivers news faster. In return, you have to provide and secure a receiver. This article explains both.

Webhook or API query

API query (polling)Webhook
Who starts it?Your systemThe sender
When?On a schedule, for example hourlyAs soon as an event occurs
EffortRequests even without newsA reachable receiver is needed
Good forReports, reconciliation, reloadingNotification, forwarding
RiskDelay until the next queryMessage is lost if the receiver does not answer

In practice many combine both: the webhook reports the event, and the API fetches the details if needed or closes gaps after an outage.

How a delivery works

  1. An event occurs, such as „payment received“.
  2. The sender sends a POST with JSON to the address you have registered (HTTPS).
  3. Your receiver checks the signature, records the message and answers quickly with a code in the 2xx range.
  4. Your system does the actual work afterwards.

Step 3 is important. In its webhook documentation, Stripe recommends returning a 2xx code immediately, before any complex logic runs that could trigger a timeout. Anyone who books the invoice first and answers afterwards risks unnecessary retries.

Signature: how to verify the origin

Your address is publicly reachable. Anyone could therefore send invented messages to it. This is why reputable senders sign every message with a shared secret. The usual approach is an HMAC with SHA-256 over the content, often together with a timestamp. GitHub describes the method like this: HMAC-SHA256 over the content, shown in hexadecimal, in the header X-Hub-Signature-256. The comparison should be made with a constant-time function so that attackers cannot derive anything from response times.

Important points for your receiver:

  • Use the raw content. Calculate the signature over the received bytes, not over the re-serialised JSON. Even a different line break changes the signature.
  • Check the timestamp. Stripe uses a timestamp in the signature and a default tolerance of five minutes to prevent replay attacks. A tolerance of 0 switches off the check.
  • Keep the secret secret. Not in the code, not in the repository, but in an environment variable or a vault.

The Standard Webhooks initiative bundles open-source guidelines for senders and receivers. How each provider forms its signature in concrete terms is set out in its documentation.

Retries and duplicate delivery

If your receiver does not answer or answers with an error code, the sender tries again. Stripe, for example, according to its own documentation, retries at increasing intervals for up to three days. Other providers have other rules, and some switch the webhook off after too many failures. It follows that:

  • Expect duplicates. The same event can arrive several times, even if your receiver is working correctly. Stripe expressly points this out.
  • Make processing idempotent. That means: processing the same message twice does not produce a second result. Store the event's identifier and skip known ones.
  • Do not rely on order. Events can arrive swapped. If in doubt, check the current state via the API.

Security on both sides

For receivers: HTTPS only, check signature and age, limit message size, do not execute commands from the content. Treat the content like any input from outside.

For senders that call customers' addresses: anyone who offers webhooks calls addresses that others have entered. This is a classic route for server-side request forgery. The attacker enters an internal address. The server calls it from within the protected network. The OWASP description names internal services and cloud providers' metadata addresses as targets, for example. Sensible safeguards:

  • allow only https://,
  • resolve the address when it is entered and before every call, and reject private, local and reserved ranges,
  • do not follow redirects blindly,
  • set short time limits.

Example: sending a test message with curl

With curl and openssl you can test your receiver without the real sender. The script sends a signed message as a sender would send it with a timestamp:

GEHEIMNIS="test-geheimnis"
ZEIT=$(date +%s)
INHALT='{"id":"evt_1","typ":"besuch.begonnen","firma":"beispiel-firma.example"}'
SIGNATUR="sha256=$(printf '%s.%s' "$ZEIT" "$INHALT" | openssl dgst -sha256 -hmac "$GEHEIMNIS" | sed 's/^.* //')"

curl -X POST http://localhost:3000/webhook \
  -H "Content-Type: application/json" \
  -H "X-Zeit: $ZEIT" \
  -H "X-Signatur: $SIGNATUR" \
  -d "$INHALT"

Example: a small receiver in Node.js

const http = require('http')
const crypto = require('crypto')
const GEHEIMNIS = process.env.WEBHOOK_GEHEIMNIS || 'test-geheimnis'
const gesehen = new Set() // in real use: database

http.createServer((req, res) => {
  if (req.method !== 'POST' || req.url !== '/webhook') { res.writeHead(404); return res.end() }
  const teile = []
  req.on('data', t => teile.push(t))
  req.on('end', () => {
    const roh = Buffer.concat(teile).toString('utf8')
    const zeit = req.headers['x-zeit'] || ''
    const erwartet = 'sha256=' + crypto.createHmac('sha256', GEHEIMNIS).update(zeit + '.' + roh).digest('hex')
    const erhalten = String(req.headers['x-signatur'] || '')
    const a = Buffer.from(erwartet), b = Buffer.from(erhalten)
    const echt = a.length === b.length && crypto.timingSafeEqual(a, b)
    const frisch = Math.abs(Date.now() / 1000 - Number(zeit)) < 300
    if (!echt || !frisch) { res.writeHead(401); return res.end() }
    const ereignis = JSON.parse(roh)
    if (!gesehen.has(ereignis.id)) { gesehen.add(ereignis.id); console.log('neu:', ereignis.typ) }
    res.writeHead(200); res.end('ok')
  })
}).listen(3000)

The receiver checks signature and age, skips known identifiers and answers immediately. For production you additionally need HTTPS, a size limit and a queue for further processing.

Where webhooks are common

Payment services, code platforms and form tools report events by webhook. Visitor identification can do it too: when the system reports a company that visits your site, it lands directly in a tool of your choice. How that leads into a sales system is shown in Getting website visits into your CRM. The alternative by query is described in Which companies visit my website?. Which data flows in the process is explained in How does company identification work?.

Frequently asked questions

What is the difference between a webhook and an API? With an API your system asks; with a webhook the sender gets in touch by itself. The two can be combined.

What happens if my receiver goes down? Most senders retry delivery over a certain period. How long and how often is stated in the provider's documentation. For gaps you should plan a reconciliation via the API.

Is it enough to check the sender's address? No. Sender addresses can be spoofed and they change. Only the signature with a secret known to you and the sender alone is reliable.

Read more