| |

REST API 23 ๐ŸŒ Webhooks and Event-Driven Notifications

โฑ๏ธ Estimated read: 17 min | |

A webhook is an HTTP callback: instead of a client repeatedly asking whether something has changed, the server sends a POST request to a URL the client registered in advance, at the moment the event occurs . This inversion of the request-response model is what makes webhooks the standard mechanism for real-time server-to-server notifications. A payment processor notifies a shop when a charge succeeds. A repository notifies a CI system when code is pushed. A document service notifies a portal when a status changes.

For the REST API series, webhooks introduce a different reliability model than synchronous endpoints. The producer does not know whether the consumer is available, how long it will take to process, or whether the same event will be delivered twice. The consumer must verify authenticity, tolerate duplicates, acknowledge quickly, and reconcile when deliveries are missed. This chapter covers the webhook contract, the handler sequence that makes receivers correct, the security model based on HMAC signatures, and the operational patternsโ€”retries, idempotency, queue-first ingestionโ€”that separate a demo from a production integration.

Key point: A webhook handler must verify the signature before parsing the payload, parse the payload before acting on it, and act idempotently so that a duplicate delivery does not produce a duplicate side effect .


Why webhooks exist

The polling inefficiency problem. A client that needs to know when an order ships can ask the API every thirty seconds. Most of those requests return “no change.” Polling wastes bandwidth, consumes rate-limit budget, and introduces latency equal to the polling interval . Webhooks eliminate the wasted requests by pushing the notification at the moment the event occurs. The trade-off is that the consumer must maintain a publicly reachable endpoint and handle the failure modes that push introduces.

The real-time requirement problem. Some workflows cannot tolerate polling latency. A fraud detection system that learns about a suspicious transaction five minutes after it happens has lost five minutes. A provisioning system that waits for a payment confirmation before allocating resources needs that confirmation immediately. Webhooks exist because these workflows require the producer to tell the consumer, not the other way around .

The operational complexity problem. Webhooks are harder to operate than polling. The consumer needs a server, a public URL, signature verification, idempotency storage, and a reconciliation strategy for missed events. Asana’s documentation is explicit about this trade-off: “Use polling instead of webhooks if possible. Avoid using webhooks unless reacting to events in real time is a strict requirement” . The complexity is justified only when the latency reduction matters to the business.

The security problem. A webhook endpoint is a public URL that accepts POST requests. Without verification, anyone who discovers the URL can send fake events. The standard solution is HMAC signing: the producer signs the request body with a shared secret, and the consumer recomputes the signature to verify authenticity and integrity . Without this, webhook endpoints are an injection vector.


a. The Webhook Contract and Payload

A webhook delivery is an HTTP POST with a JSON body. The body contains the event type, a unique event identifier, a timestamp, and the event data. The exact shape varies by provider, but the structure is consistent: identify the event, then describe what happened.

{
  "id": "evt_01HXJ4K2M9",
  "type": "order.created",
  "created_at": "2026-10-21T14:32:00Z",
  "data": {
    "order_id": "ord_8871",
    "total": 149.99,
    "currency": "USD",
    "customer_id": "cus_4412"
  }
}

The id field is the idempotency key. Every retry of the same event carries the same id, which allows the consumer to recognize duplicates . The type field determines which handler logic runs. The created_at timestamp enables replay protection: a request whose timestamp is too old can be rejected as stale .

The CloudEvents specification standardizes this envelope across providers. A CloudEvent carries id, source, type, specversion, and data, which means a consumer can process events from multiple producers with a single handler structure . For providers that do not use CloudEvents, the handler must be written against their specific schema.


b. The Handler Sequence: Verify, Parse, Handle Idempotently

The order of operations inside a webhook handler is not a matter of preference. It is a correctness requirement. The sequence is: verify the signature using the raw request body, parse the payload only after verification succeeds, then process the event idempotently .

The reason verification must come first is that the signature covers the raw bytes of the request body. If the framework parses and re-serializes the JSON before verification, the bytes changeโ€”whitespace, key ordering, encodingโ€”and the signature no longer matches . This is why Express handlers use express.raw() instead of express.json() on the webhook route, and why FastAPI handlers call await request.body() before json.loads() .

// Express: raw body for signature verification
const crypto = require('crypto');

function verifySignature(rawBody, signature, secret) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');
  // Constant-time comparison prevents timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}

app.post('/webhooks',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.headers['x-webhook-signature'];
    if (!verifySignature(req.body, signature, process.env.WEBHOOK_SECRET)) {
      return res.status(401).send('Invalid signature');
    }
    // Parse only after verification
    const event = JSON.parse(req.body.toString());
    // Handle idempotently
    handleEvent(event);
    res.status(200).json({ received: true });
  }
);

The idempotency check comes after parsing. The handler extracts the event id, checks whether it has already been processed, and returns success immediately if it has. If not, it marks the event as processing, executes the side effects, and marks it as completed .

# FastAPI: idempotency with a persistent store
@app.post("/webhooks")
async def webhook(request: Request):
    raw_body = await request.body()
    signature = request.headers.get("x-webhook-signature")

    if not verify_signature(raw_body, signature, SECRET):
        raise HTTPException(status_code=401)

    event = json.loads(raw_body)
    event_id = event["id"]

    # Check idempotency store before processing
    status = await redis.get(f"webhook:{event_id}")
    if status == "processed":
        return {"received": True}  # Duplicate, no side effect
    if status == "processing":
        return JSONResponse(status_code=409)  # Let provider retry

    await redis.set(f"webhook:{event_id}", "processing", ex=60)
    await process_event(event)
    await redis.set(f"webhook:{event_id}", "processed", ex=86400)
    return {"received": True}

The response codes matter. A 2xx response tells the provider the event was accepted and no retry is needed. A 4xx response (other than 429) tells the provider not to retry, usually because the request is malformed or the signature is invalid. A 5xx response tells the provider to retry with backoff . Returning 200 for a duplicate is correct: the event has been handled, just not by this delivery.


c. Security and Operational Reliability

The security model for webhooks rests on three layers: HTTPS for transport, HMAC signatures for authenticity and integrity, and timestamp validation for replay protection .

HMAC signing works as follows. The producer and consumer share a secret. The producer computes HMAC-SHA256(secret, raw_body) and sends the result in a header. The consumer recomputes the HMAC and compares it in constant time. If the values match, the request came from someone who knows the secret and the body was not modified in transit . A simple equality comparison (==) is insufficient because it leaks timing information; hmac.compare_digest in Python and crypto.timingSafeEqual in Node.js perform the comparison without early exit .

Replay protection adds a timestamp check. A valid signature on an old request can be replayed by an attacker who captured it. If the request carries a timestamp and the consumer rejects requests older than a small window (two to five minutes), the replay window closes . Some providers include the timestamp inside the signed payload so it cannot be altered independently.

# Timestamp validation for replay protection
import time

def verify_with_timestamp(raw_body, signature, timestamp, secret, max_age=300):
    # Reject stale requests
    if abs(time.time() - int(timestamp)) > max_age:
        return False
    # Sign the timestamp and body together
    signed_payload = f"v0:{timestamp}:{raw_body.decode()}"
    expected = hmac.new(
        secret.encode(), signed_payload.encode(), hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)

The operational model is where webhooks diverge most from synchronous APIs. The consumer must assume deliveries will fail, arrive late, arrive out of order, and arrive more than once. The producer retries on 5xx responses with exponential backoff, but retries are boundedโ€”typically over hours or days. Beyond that window, the consumer must reconcile by querying the API for missed events .

The queue-first pattern addresses bursts and slow processing. Instead of processing the event synchronously within the request handler, the handler verifies the signature, writes the raw event to a durable queue, and returns 2xx immediately. A separate worker consumes from the queue and performs the actual processing . This decouples the provider’s retry timeout from the consumer’s processing time and protects against retry storms during outages. The cost is additional infrastructure: a queue, a worker, and monitoring for queue depth.


Complete Example Session

# ============================================
# PART 1: WEBHOOK ENDPOINT STRUCTURE
# ============================================
# A webhook handler has three stages:
# 1. Verify signature (raw body)
# 2. Check idempotency (event ID)
# 3. Process and acknowledge

from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
import hmac, hashlib, json, os, time

app = FastAPI()
SECRET = os.environ["WEBHOOK_SECRET"].encode()


# ============================================
# PART 2: SIGNATURE VERIFICATION FUNCTION
# ============================================
def verify_signature(raw_body: bytes, signature: str, timestamp: str) -> bool:
    # Reject stale requests (replay protection)
    if abs(time.time() - int(timestamp)) > 300:
        return False
    # Sign timestamp + body together
    signed = f"v0:{timestamp}:{raw_body.decode()}"
    expected = hmac.new(SECRET, signed.encode(), hashlib.sha256).hexdigest()
    # Constant-time comparison
    return hmac.compare_digest(expected, signature)


# ============================================
# PART 3: IDEMPOTENCY STORE (IN-MEMORY FOR DEMO)
# ============================================
# In production, use Redis or a database table
# with a TTL at least as long as the retry window
processed_events: dict[str, float] = {}
DEDUPE_TTL = 24 * 60 * 60  # 24 hours

def is_duplicate(event_id: str) -> bool:
    now = time.time()
    # Clean expired entries
    for eid, ts in list(processed_events.items()):
        if now - ts > DEDUPE_TTL:
            del processed_events[eid]
    return event_id in processed_events

def mark_processed(event_id: str):
    processed_events[event_id] = time.time()


# ============================================
# PART 4: WEBHOOK HANDLER
# ============================================
@app.post("/webhooks/orders")
async def handle_order_webhook(request: Request):
    # Stage 1: read raw body, verify signature
    raw_body = await request.body()
    signature = request.headers.get("x-signature", "")
    timestamp = request.headers.get("x-timestamp", "")

    if not signature or not timestamp:
        raise HTTPException(status_code=400, detail="Missing headers")

    if not verify_signature(raw_body, signature, timestamp):
        raise HTTPException(status_code=401, detail="Invalid signature")

    # Stage 2: parse and check idempotency
    event = json.loads(raw_body)
    event_id = event.get("id")

    if not event_id:
        raise HTTPException(status_code=400, detail="Missing event ID")

    if is_duplicate(event_id):
        # Already handled; return success to stop retries
        return JSONResponse(status_code=200, content={"received": True})

    # Stage 3: process the event
    if event["type"] == "order.created":
        # Business logic here
        print(f"Order created: {event['data']['order_id']}")
    elif event["type"] == "order.cancelled":
        print(f"Order cancelled: {event['data']['order_id']}")

    # Mark processed after successful handling
    mark_processed(event_id)
    return JSONResponse(status_code=200, content={"received": True})


# ============================================
# PART 5: QUEUE-FIRST VARIANT
# ============================================
# For high-throughput or slow-processing scenarios
# The handler returns 2xx immediately after enqueueing
import asyncio

queue: asyncio.Queue = asyncio.Queue()

@app.post("/webhooks/queue")
async def handle_with_queue(request: Request):
    raw_body = await request.body()
    signature = request.headers.get("x-signature", "")
    timestamp = request.headers.get("x-timestamp", "")

    if not verify_signature(raw_body, signature, timestamp):
        raise HTTPException(status_code=401)

    # Enqueue raw bytes; worker parses and processes
    await queue.put(raw_body)
    return JSONResponse(status_code=200, content={"queued": True})


# ============================================
# PART 6: WORKER CONSUMER
# ============================================
async def worker():
    while True:
        raw_body = await queue.get()
        try:
            event = json.loads(raw_body)
            if not is_duplicate(event["id"]):
                # Process event
                print(f"Processing {event['type']}")
                mark_processed(event["id"])
        except Exception as e:
            print(f"Worker error: {e}")
        finally:
            queue.task_done()


# ============================================
# PART 7: EXPRESS EQUIVALENT
# ============================================
# Express requires raw body middleware on the webhook route
# const express = require('express');
# const app = express();
# app.post('/webhooks',
#   express.raw({ type: 'application/json' }),
#   (req, res) => {
#     const signature = req.headers['x-signature'];
#     if (!verify(req.body, signature)) return res.status(401).send();
#     const event = JSON.parse(req.body.toString());
#     // ... idempotency and processing
#     res.json({ received: true });
#   }
# );


# ============================================
# PART 8: TESTING WITH CURL
# ============================================
# Generate a valid signature for local testing
# import hmac, hashlib, time
# body = '{"id":"evt_1","type":"order.created","data":{}}'
# ts = str(int(time.time()))
# sig = hmac.new(SECRET, f"v0:{ts}:{body}".encode(), hashlib.sha256).hexdigest()
# curl -X POST http://localhost:8000/webhooks/orders \
#   -H "Content-Type: application/json" \
#   -H "X-Signature: $sig" \
#   -H "X-Timestamp: $ts" \
#   -d "$body"


# ============================================
# PART 9: RESPONSE CODE TABLE
# ============================================
# 200: Accepted, no retry
# 400: Bad request (missing headers, malformed body)
# 401: Invalid signature (do not retry)
# 409: Already processing (let provider retry later)
# 429: Rate limited (retry after delay)
# 5xx: Server error (retry with backoff)


# ============================================
# PART 10: RECONCILIATION JOB
# ============================================
# When retries are exhausted, poll the API for missed events
# This runs periodically (e.g., nightly) to fill gaps
async def reconcile():
    # Fetch events since last checkpoint from the provider API
    # Compare against processed_events
    # Process any that were missed
    pass

The ten parts trace the webhook lifecycle: signature verification against the raw body, idempotency checking before processing, the queue-first variant for high throughput, the Express equivalent, local testing with curl, and the reconciliation job that closes the gap when retries fail.


Quick Reference

Handler Sequence

StageActionFailure Response
1Verify signature on raw body401
2Parse JSON400
3Check event ID for duplicates200 (if duplicate)
4Process event5xx (triggers retry)
5Mark processed, acknowledge200

Response Codes

CodeMeaningProvider Behavior
2xxSuccessNo retry
4xxClient errorUsually no retry
429Rate limitedRetry after delay
5xxServer errorRetry with backoff

Security Layers

LayerMechanismPurpose
TransportHTTPSEncrypt in transit
AuthenticityHMAC-SHA256 signatureVerify sender
IntegritySignature covers raw bodyDetect modification
ReplayTimestamp window (2โ€“5 min)Reject stale requests

Best Practices

โœ… Do This:

# Verify signature on raw body before parsing
raw_body = await request.body()
if not verify_signature(raw_body, signature, secret): raise HTTPException(401)  # โœ…
# Check event ID for duplicates before processing
if is_duplicate(event["id"]): return {"received": True}                         # โœ…
// Use express.raw() on webhook routes
app.post('/webhooks', express.raw({ type: 'application/json' }), handler)       // โœ…
# Use constant-time comparison for signatures
hmac.compare_digest(expected, provided)                                          # โœ…
# Return 200 for duplicates to stop retries
if is_duplicate(event_id): return JSONResponse(status_code=200)                 # โœ…
# Enqueue immediately and process asynchronously
await queue.put(raw_body); return {"queued": True}                              # โœ…

โŒ Don’t Do This:

// Parse JSON before verification (signature fails)
app.use(express.json()); // breaks signature verification on webhook route     // โŒ
# Compare signatures with == (timing attack)
if expected == signature:                                                       # โŒ
# Process the same event twice
if not is_duplicate(event_id): await process(event)                            # โŒ
# Return 500 for a duplicate (causes unnecessary retries)
raise HTTPException(500, "Already processed")                                   # โŒ
# Process synchronously in the handler (slow response, retry storms)
await heavy_processing(event); return {"ok": True}                             # โŒ
# Ignore timestamp validation (replay vulnerability)
if verify_signature(raw_body, signature):  # no timestamp check               # โŒ

Common Pitfalls

PitfallWhy It HappensFix
Signature verification always failsFramework parses JSON before verificationUse raw body middleware
Duplicate side effectsNo idempotency checkStore and check event ID
Retry storm after outageSynchronous processing times outQueue-first ingestion
Replay attack succeedsNo timestamp validationReject requests older than 5 min
Missed events never recoveredNo reconciliation jobPoll API for gaps after retry exhaustion
Rate-limited during burstNo backpressure handlingQueue depth monitoring, worker scaling

Real-World Examples

1. Verify HMAC Signature

expected = hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature): reject()

2. Idempotency Check

if await redis.get(f"event:{event_id}"): return {"received": True}

3. Queue-First Handler

await queue.put(raw_body)
return {"queued": True}

4. Timestamp Validation

if abs(time.time() - int(timestamp)) > 300: reject()

5. Raw Body Middleware

app.post('/webhooks', express.raw({ type: 'application/json' }), handler)

6. Constant-Time Compare

hmac.compare_digest(expected, provided)

7. Mark Processed After Success

await redis.set(f"event:{event_id}", "processed", ex=86400)

8. Return 200 for Duplicate

if is_duplicate(event_id): return JSONResponse(status_code=200)

9. Reconciliation Job

# Nightly: fetch events since checkpoint, process missing
async def reconcile(): ...

10. Test with curl

curl -X POST localhost:8000/webhooks -H "X-Signature: $sig" -d "$body"

Visual

The Webhook Handler Sequence

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  INCOMING WEBHOOK REQUEST                                    โ”‚
โ”‚                                                              โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”                                        โ”‚
โ”‚  โ”‚ Read raw body   โ”‚  Do NOT parse JSON yet                 โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜                                        โ”‚
โ”‚           โ–ผ                                                  โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”                                        โ”‚
โ”‚  โ”‚ Verify signatureโ”‚โ”€โ”€โ”€โ”€โ–บ INVALID: return 401              โ”‚
โ”‚  โ”‚ on raw bytes    โ”‚      (no retry from provider)          โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜                                        โ”‚
โ”‚           โ”‚ VALID                                            โ”‚
โ”‚           โ–ผ                                                  โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”                                        โ”‚
โ”‚  โ”‚ Parse JSON      โ”‚  Now it is safe to parse               โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜                                        โ”‚
โ”‚           โ–ผ                                                  โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”                                        โ”‚
โ”‚  โ”‚ Check event ID  โ”‚โ”€โ”€โ”€โ”€โ–บ DUPLICATE: return 200            โ”‚
โ”‚  โ”‚ in idempotency  โ”‚      (stop retries, no side effect)    โ”‚
โ”‚  โ”‚ store           โ”‚                                        โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜                                        โ”‚
โ”‚           โ”‚ NEW                                              โ”‚
โ”‚           โ–ผ                                                  โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”                                        โ”‚
โ”‚  โ”‚ Process event   โ”‚โ”€โ”€โ”€โ”€โ–บ FAILURE: return 5xx              โ”‚
โ”‚  โ”‚                 โ”‚      (provider retries with backoff)   โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜                                        โ”‚
โ”‚           โ”‚ SUCCESS                                          โ”‚
โ”‚           โ–ผ                                                  โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”                                        โ”‚
โ”‚  โ”‚ Mark processed  โ”‚                                        โ”‚
โ”‚  โ”‚ Return 200      โ”‚                                        โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜                                        โ”‚
โ”‚                                                              โ”‚
โ”‚  Order matters: verify โ†’ parse โ†’ idempotency โ†’ process.     โ”‚
โ”‚  Parsing before verification breaks the signature.          โ”‚
โ”‚                                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

HMAC Signing Flow

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  PRODUCER                       CONSUMER                    โ”‚
โ”‚                                                              โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”          โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”           โ”‚
โ”‚  โ”‚ Event occurs    โ”‚          โ”‚ Receives POST   โ”‚           โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜          โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜           โ”‚
โ”‚           โ–ผ                            โ–ผ                     โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”          โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”           โ”‚
โ”‚  โ”‚ Serialize body  โ”‚          โ”‚ Read raw body   โ”‚           โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜          โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜           โ”‚
โ”‚           โ–ผ                            โ–ผ                     โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”          โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”           โ”‚
โ”‚  โ”‚ HMAC-SHA256     โ”‚          โ”‚ HMAC-SHA256     โ”‚           โ”‚
โ”‚  โ”‚ (secret, body)  โ”‚          โ”‚ (secret, body)  โ”‚           โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜          โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜           โ”‚
โ”‚           โ–ผ                            โ–ผ                     โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”          โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”           โ”‚
โ”‚  โ”‚ Send signature  โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บโ”‚ Compare in      โ”‚           โ”‚
โ”‚  โ”‚ in header       โ”‚          โ”‚ constant time   โ”‚           โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜          โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜           โ”‚
โ”‚                                        โ”‚                     โ”‚
โ”‚                              โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”          โ”‚
โ”‚                              โ–ผ                   โ–ผ          โ”‚
โ”‚                       โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”        โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”      โ”‚
โ”‚                       โ”‚ MATCH    โ”‚        โ”‚ MISMATCH โ”‚      โ”‚
โ”‚                       โ”‚ Process  โ”‚        โ”‚ Reject   โ”‚      โ”‚
โ”‚                       โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜        โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜      โ”‚
โ”‚                                                              โ”‚
โ”‚  The signature covers the exact bytes sent.                  โ”‚
โ”‚  Any re-serialization breaks the comparison.                 โ”‚
โ”‚                                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Queue-First Architecture

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  WITHOUT QUEUE                   WITH QUEUE                  โ”‚
โ”‚                                                              โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”                    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”                 โ”‚
โ”‚  โ”‚ Providerโ”‚                    โ”‚ Providerโ”‚                 โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”˜                    โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”˜                 โ”‚
โ”‚       โ”‚ POST                         โ”‚ POST                 โ”‚
โ”‚       โ–ผ                              โ–ผ                       โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”                    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”                 โ”‚
โ”‚  โ”‚ Handler โ”‚                    โ”‚ Handler โ”‚                 โ”‚
โ”‚  โ”‚         โ”‚                    โ”‚         โ”‚                 โ”‚
โ”‚  โ”‚ Process โ”‚                    โ”‚ Verify  โ”‚                 โ”‚
โ”‚  โ”‚ (slow)  โ”‚                    โ”‚ Enqueue โ”‚                 โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”˜                    โ”‚ Return  โ”‚                 โ”‚
โ”‚       โ”‚                         โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”˜                 โ”‚
โ”‚       โ–ผ                              โ”‚                       โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”                         โ–ผ                       โ”‚
โ”‚  โ”‚ 200/5xx โ”‚                    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”                 โ”‚
โ”‚  โ”‚ based onโ”‚                    โ”‚ Queue   โ”‚                 โ”‚
โ”‚  โ”‚ process โ”‚                    โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”˜                 โ”‚
โ”‚  โ”‚ time    โ”‚                         โ”‚                       โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜                         โ–ผ                       โ”‚
โ”‚                                 โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”                 โ”‚
โ”‚  Slow processing                โ”‚ Worker  โ”‚                 โ”‚
โ”‚  causes timeouts                โ”‚ Process โ”‚                 โ”‚
โ”‚  and retries.                   โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜                 โ”‚
โ”‚                                                              โ”‚
โ”‚  Queue-first decouples acknowledgment from processing.       โ”‚
โ”‚  Handler returns 2xx in milliseconds, not seconds.           โ”‚
โ”‚                                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Polling vs Webhooks

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  POLLING                      WEBHOOKS                       โ”‚
โ”‚                                                              โ”‚
โ”‚  Client asks repeatedly       Server pushes on event         โ”‚
โ”‚                                                              โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”                  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”                   โ”‚
โ”‚  โ”‚ Client  โ”‚โ”€โ”€GETโ”€โ”€โ–บ          โ”‚ Server  โ”‚โ”€โ”€POSTโ”€โ”€โ–บ          โ”‚
โ”‚  โ”‚         โ”‚โ—„โ”€200โ”€โ”€          โ”‚         โ”‚                   โ”‚
โ”‚  โ”‚ (every  โ”‚                  โ”‚ (on     โ”‚                   โ”‚
โ”‚  โ”‚  30s)   โ”‚                  โ”‚  event) โ”‚                   โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜                  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜                   โ”‚
โ”‚                                                              โ”‚
โ”‚  Latency: up to interval      Latency: near-zero             โ”‚
โ”‚  Waste: many empty responses  Waste: none                    โ”‚
โ”‚  Simple to implement          Requires public endpoint       โ”‚
โ”‚  No public URL needed         Must verify signatures         โ”‚
โ”‚                               Must handle duplicates         โ”‚
โ”‚                                                              โ”‚
โ”‚  Use polling when:            Use webhooks when:             โ”‚
โ”‚  - Real-time not needed       - Real-time required           โ”‚
โ”‚  - Simpler infrastructure     - Event-driven workflow        โ”‚
โ”‚  - No public endpoint         - Willing to handle complexity โ”‚
โ”‚                                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Summary

ItemValue
WebhookHTTP POST sent by producer to consumer URL on event
Handler sequenceVerify signature โ†’ parse โ†’ idempotency โ†’ process
Signature algorithmHMAC-SHA256 over raw body
Replay protectionTimestamp window (2โ€“5 minutes)
Idempotency keyEvent id from payload
Duplicate response200 (stop retries, no side effect)
Server error response5xx (provider retries with backoff)
Queue-firstHandler enqueues and returns immediately
ReconciliationPoll API for missed events after retries exhausted

Key takeaways:

  • Verify before parsing. The signature covers the raw request body. If the framework parses and re-serializes the JSON before verification, the bytes change and the signature fails. Use raw body middleware on webhook routes .
  • Idempotency is mandatory, not optional. Webhook providers retry on failure, which means the same event can arrive multiple times. Store the event ID after processing and return 200 for duplicates. Without this, a retry produces a duplicate side effect .
  • Use constant-time comparison. A simple == comparison on signatures leaks timing information. Use hmac.compare_digest in Python or crypto.timingSafeEqual in Node.js .
  • Queue-first protects against bursts. Processing synchronously in the handler means the provider’s timeout becomes your processing deadline. Enqueue the event and return 2xx immediately, then process from a worker .
  • Webhooks require a reconciliation strategy. Retries are bounded. When they are exhausted, the consumer must poll the API for events it missed. A webhook integration without reconciliation is incomplete .
  • Polling is often the right choice. Webhooks add operational complexity: a public endpoint, signature verification, idempotency storage, and reconciliation. Use them only when real-time notification is a genuine requirement .

Remember: A webhook is not a request your API handles; it is a request your API receives. The distinction matters because the security model, the reliability model, and the failure modes are all different. Your API does not control when the webhook arrives, whether it arrives twice, or whether it arrives at all. The handler’s job is to verify that the request is authentic, recognize whether it has been seen before, acknowledge it quickly, and process it safely. The producer’s job is to retry with backoff and to provide a way for the consumer to reconcile. Both sides must hold up their end for the integration to work. The handler sequenceโ€”verify, parse, idempotency, processโ€”is the consumer’s contribution to that contract.

Was this helpful?


Stop using slow, ad-bloated tool sites! ๐Ÿคฎ

๐Ÿ”Ž Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
โœ… Finance (Mortgage, Interest, Inflation)
โœ… Tech (Base64, JSON, Dev Suite, IP)
โœ… Health (BMI, BMR, TDEE)
โœ… Productivity (Timer, Workspace, QR)

โšก๏ธ Fast & Private
๐Ÿ”’ No data leaves your device
๐Ÿ’Ž 100% Free

๐Ÿ”— Use it now: https://tools.kandz.me
๐Ÿ”– Bookmark itโ€”youโ€™ll need it later!