BytePane

Webhook Guide: How Webhooks Work & How to Build One

API Integration16 min read

Key Takeaways

  • Webhooks are HTTP callbacks — the sender POSTs a JSON payload to your URL when an event occurs, eliminating polling overhead entirely.
  • Always verify HMAC-SHA256 signatures using the raw request body and constant-time comparison — never deserialize first, verify later.
  • Return 200 immediately after signature verification, then process asynchronously via a queue to avoid timeout retries.
  • Implement idempotency using the event ID as a deduplication key — every major platform retries on failure, so you will receive duplicates.
  • The CNCF CloudEvents 1.0 spec standardizes webhook payloads — supported by Azure Event Grid, Google Eventarc, and Knative.

It is 2 AM and your e-commerce server is processing 40 payments per minute during a flash sale. Each payment needs to update inventory, trigger a confirmation email, and notify the warehouse system. The naive implementation polls your payment processor every 5 seconds — a loop that fires 17,280 requests per day regardless of whether a single payment occurs. At 40 payments/minute this is an order of magnitude too slow anyway, and most requests return empty.

This is exactly the problem webhooks solve. When the payment succeeds, Stripe POSTs a charge.succeeded event to your endpoint within milliseconds. Your server handles the event, fans it out to inventory/email/warehouse, and finishes. No polling. No idle requests. Near-instant processing.

According to Hookdeck's 2025 State of Webhooks report, over 83% of SaaS platforms now deliver real-time event notifications exclusively via webhooks rather than polling APIs — a figure that has grown from 61% in 2022 as the pattern has become the de facto standard for event-driven integrations.

How Webhooks Work: The Full Request Lifecycle

A webhook is an HTTP POST request. The "sender" (Stripe, GitHub, Shopify) makes the request; your server is the "receiver." Here is the complete sequence:

  1. Event occurs — a customer pays, a commit is pushed, an order ships
  2. Sender computes HMAC — signs the raw JSON body with your shared secret using SHA-256
  3. POST request fires — to the URL you registered in the sender's dashboard, with the signature in a header
  4. Your server receives — verifies the signature before reading the payload
  5. You return 200 — within 5–30 seconds or the sender marks the delivery as failed
  6. Async processing — you enqueue the event and process it outside the HTTP request cycle
# Example Stripe webhook payload (charge.succeeded)
POST /webhooks/stripe HTTP/1.1
Host: yourapp.com
Content-Type: application/json
Stripe-Signature: t=1714084800,v1=a65d4f2...
Content-Length: 842

{
  "id": "evt_3Pz7j2LkdIwHu7ix0Yvz8kA1",
  "object": "event",
  "type": "charge.succeeded",
  "created": 1714084800,
  "livemode": true,
  "data": {
    "object": {
      "id": "ch_3Pz7j2LkdIwHu7ix0a1b2c3d",
      "amount": 4999,
      "currency": "usd",
      "customer": "cus_Abc123",
      "receipt_email": "[email protected]",
      "status": "succeeded"
    }
  }
}

Notice the Stripe-Signature header. That is the HMAC signature — the most critical part of any webhook implementation. Never skip verifying it.

Signature Verification: The Non-Negotiable Step

Without signature verification, any attacker can POST fake events to your endpoint. The OWASP API Security Top 10 lists broken object-level authorization as the #1 API risk — unverified webhooks are a direct attack surface for injecting fraudulent orders, triggering unauthorized privilege escalations, or exfiltrating data.

Critical detail: you must verify the signature against the raw request body bytes, before any JSON parsing. If you parse the JSON first and re-serialize it, whitespace differences will cause signature mismatches.

import express from 'express'
import crypto from 'crypto'

const app = express()
const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET!

// CRITICAL: Use express.raw() to get the raw body buffer
// express.json() would parse it first, breaking HMAC verification
app.post(
  '/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.headers['stripe-signature'] as string
    const rawBody = req.body as Buffer

    if (!verifyStripeSignature(rawBody, signature, WEBHOOK_SECRET)) {
      console.warn('Webhook signature verification failed')
      return res.status(400).send('Invalid signature')
    }

    // Signature is valid — safe to parse the payload
    const event = JSON.parse(rawBody.toString())

    // Return 200 IMMEDIATELY before processing
    res.status(200).send('OK')

    // Process asynchronously
    eventQueue.push(event)
  }
)

function verifyStripeSignature(
  payload: Buffer,
  signatureHeader: string,
  secret: string
): boolean {
  // Parse: "t=1714084800,v1=a65d4f2...,v0=legacy..."
  const parts = Object.fromEntries(
    signatureHeader.split(',').map(p => p.split('='))
  )
  const timestamp = parts['t']
  const expectedSig = parts['v1']

  if (!timestamp || !expectedSig) return false

  // Reject events older than 5 minutes (replay attack prevention)
  const tolerance = 300  // seconds
  const eventAge = Math.floor(Date.now() / 1000) - parseInt(timestamp)
  if (eventAge > tolerance) {
    console.warn(`Webhook timestamp too old: ${eventAge}s`)
    return false
  }

  // Compute HMAC-SHA256 of "timestamp.payload"
  const signedPayload = `${timestamp}.${payload.toString()}`
  const computedSig = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex')

  // Use timingSafeEqual to prevent timing attacks
  const expected = Buffer.from(expectedSig)
  const computed = Buffer.from(computedSig)
  if (expected.length !== computed.length) return false
  return crypto.timingSafeEqual(expected, computed)
}

The timingSafeEqual function prevents timing attacks where an attacker could deduce the correct signature by measuring how long your string comparison takes. A naive === comparison short-circuits on the first mismatched byte — a measurable timing difference.

Building a Generic Webhook Receiver

The pattern above works for Stripe, but every platform uses slightly different header names and payload structures. Here is a generic receiver that normalizes inputs across multiple sources.

// Generic webhook receiver supporting multiple providers
interface WebhookProvider {
  name: string
  signatureHeader: string
  verify: (payload: Buffer, header: string, secret: string) => boolean
  normalizeEvent: (payload: unknown) => NormalizedEvent
}

interface NormalizedEvent {
  id: string
  type: string
  source: string
  timestamp: Date
  data: unknown
}

// Stripe provider
const stripeProvider: WebhookProvider = {
  name: 'stripe',
  signatureHeader: 'stripe-signature',
  verify: (payload, header, secret) => {
    // ... HMAC verification from above
    return verifyStripeSignature(payload, header, secret)
  },
  normalizeEvent: (payload: any) => ({
    id: payload.id,
    type: payload.type,
    source: 'stripe',
    timestamp: new Date(payload.created * 1000),
    data: payload.data.object,
  }),
}

// GitHub provider (different header, different algo format)
const githubProvider: WebhookProvider = {
  name: 'github',
  signatureHeader: 'x-hub-signature-256',
  verify: (payload, header, secret) => {
    const expected = header.replace('sha256=', '')
    const computed = crypto
      .createHmac('sha256', secret)
      .update(payload)
      .digest('hex')
    return crypto.timingSafeEqual(
      Buffer.from(expected),
      Buffer.from(computed)
    )
  },
  normalizeEvent: (payload: any) => ({
    id: crypto.randomUUID(),
    type: 'github.' + (payload.action ?? 'push'),
    source: 'github',
    timestamp: new Date(),
    data: payload,
  }),
}

const providers: Record<string, WebhookProvider> = {
  stripe: stripeProvider,
  github: githubProvider,
}

// Route: /webhooks/:provider
app.post(
  '/webhooks/:provider',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const providerName = req.params.provider
    const provider = providers[providerName]

    if (!provider) {
      return res.status(404).send('Unknown provider')
    }

    const secret = process.env[`${providerName.toUpperCase()}_WEBHOOK_SECRET`]!
    const signatureHeader = req.headers[provider.signatureHeader] as string

    if (!provider.verify(req.body, signatureHeader, secret)) {
      return res.status(400).send('Invalid signature')
    }

    const payload = JSON.parse(req.body.toString())
    const event = provider.normalizeEvent(payload)

    res.status(200).send('OK')  // Acknowledge immediately
    await eventQueue.enqueue(event)  // Process asynchronously
  }
)

Idempotency: Handling Duplicate Deliveries

Every webhook platform retries on failure. Stripe retries up to 3 times with exponential backoff over 3 days. GitHub retries for 72 hours. Shopify retries 19 times over 48 hours. This means your handler will receive the same event multiple times if your endpoint was temporarily unavailable.

The fix is idempotency: processing the same event twice produces the same result. Use the event ID as a deduplication key.

// Redis-based idempotency with 24-hour TTL
import Redis from 'ioredis'

const redis = new Redis()

async function processEventIdempotently(event: NormalizedEvent) {
  const key = `webhook:processed:${event.id}`

  // Atomic set-if-not-exists with 24h TTL
  const isNew = await redis.set(key, '1', 'EX', 86400, 'NX')

  if (!isNew) {
    console.log(`Skipping duplicate event: ${event.id}`)
    return  // Already processed
  }

  try {
    await handleEvent(event)
  } catch (err) {
    // Delete the key so the event can be retried
    await redis.del(key)
    throw err
  }
}

async function handleEvent(event: NormalizedEvent) {
  switch (event.type) {
    case 'charge.succeeded':
      await Promise.all([
        updateInventory(event.data),
        sendConfirmationEmail(event.data),
        notifyWarehouse(event.data),
      ])
      break
    case 'customer.subscription.deleted':
      await deactivateSubscription(event.data)
      break
    default:
      console.log(`Unhandled event type: ${event.type}`)
  }
}

Webhook Delivery Comparison: Major Platforms

PlatformSignature HeaderAlgorithmTimeoutMax Retries
StripeStripe-SignatureHMAC-SHA25630s3 (over 3 days)
GitHubX-Hub-Signature-256HMAC-SHA25610s3 (over 72h)
ShopifyX-Shopify-Hmac-Sha256HMAC-SHA256 (base64)5s19 (over 48h)
TwilioX-Twilio-SignatureHMAC-SHA1 (base64)15sNone (fire-and-forget)
SlackX-Slack-SignatureHMAC-SHA256 (v0=prefix)3sNone
Svix/CloudEventssvix-signatureEd25519 (asymmetric)30s5 (over 3 days)

Note Slack's 3-second timeout — the most aggressive in the industry. If you're building a Slack app, you must queue work asynchronously. Svix uses Ed25519 (asymmetric cryptography) rather than HMAC, which allows receivers to verify signatures without a shared secret.

Sending Webhooks: Building the Delivery System

If you are building a platform that sends webhooks to customers, you need to handle delivery reliability. A missed webhook is data loss from your customer's perspective.

import crypto from 'crypto'

interface WebhookSubscription {
  id: string
  endpointUrl: string
  secret: string
  events: string[]  // ['payment.succeeded', 'payment.failed']
}

async function deliverWebhook(
  subscription: WebhookSubscription,
  eventType: string,
  payload: unknown,
  attempt = 1
): Promise<void> {
  const body = JSON.stringify({
    id: crypto.randomUUID(),
    type: eventType,
    created: Math.floor(Date.now() / 1000),
    data: payload,
  })

  const timestamp = Math.floor(Date.now() / 1000).toString()
  const signedPayload = `${timestamp}.${body}`
  const signature = crypto
    .createHmac('sha256', subscription.secret)
    .update(signedPayload)
    .digest('hex')

  try {
    const response = await fetch(subscription.endpointUrl, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Webhook-Signature': `t=${timestamp},v1=${signature}`,
        'X-Webhook-Attempt': attempt.toString(),
      },
      body,
      signal: AbortSignal.timeout(30_000),
    })

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`)
    }

    console.log(`Webhook delivered: ${subscription.id} attempt ${attempt}`)
  } catch (err) {
    const maxAttempts = 5
    if (attempt >= maxAttempts) {
      console.error(`Webhook failed after ${maxAttempts} attempts: ${subscription.id}`)
      await markSubscriptionFailed(subscription.id)
      return
    }

    // Exponential backoff: 1m, 5m, 30m, 2h, 5h
    const delays = [60, 300, 1800, 7200, 18000]
    const delaySeconds = delays[attempt - 1]

    console.log(`Retrying in ${delaySeconds}s (attempt ${attempt + 1})`)
    await scheduleRetry(subscription, eventType, payload, attempt + 1, delaySeconds)
  }
}

Testing Webhooks Locally

Your webhook endpoint needs to be reachable from the internet for the sender to reach it. During development, use one of these approaches:

# Option 1: Stripe CLI (best for Stripe integrations)
# Automatically decrypts and forwards real Stripe events
stripe listen --forward-to localhost:3000/webhooks/stripe
# Output: Ready! Webhook signing secret: whsec_test_...

# Option 2: ngrok (universal tunnel)
ngrok http 3000
# Output: https://abc123.ngrok-free.app → localhost:3000

# Option 3: Cloudflare Tunnel (free, no account needed for local dev)
cloudflared tunnel --url http://localhost:3000

# Option 4: webhook.site (inspect only, no code)
# Visit https://webhook.site → get a unique URL
# All POSTs are logged in the browser — great for debugging payloads

# Test a webhook manually with curl
curl -X POST http://localhost:3000/webhooks/test \
  -H "Content-Type: application/json" \
  -H "X-Webhook-Signature: t=$(date +%s),v1=$(echo -n 'test' | openssl dgst -sha256 -hmac 'secret' | awk '{print $2}')" \
  -d '{"type":"test.event","data":{"id":"123"}}'

Use our JSON Formatter to inspect and validate webhook payloads during development. Paste the raw JSON body to spot malformed structures before they hit your parser.

The CloudEvents Standard

The CNCF CloudEvents 1.0 specification standardizes the envelope structure for webhook payloads. Instead of every platform inventing its own payload schema, CloudEvents defines a common set of required attributes. Major adopters include Azure Event Grid, Google Cloud Eventarc, Oracle Functions, and Knative.

// CloudEvents 1.0 structured content mode (JSON)
{
  "specversion": "1.0",
  "type": "com.example.payment.succeeded",
  "source": "https://api.example.com/payments",
  "id": "evt_3Pz7j2LkdIwHu7ix",
  "time": "2026-04-16T12:00:00Z",
  "datacontenttype": "application/json",
  "data": {
    "amount": 4999,
    "currency": "usd",
    "customerId": "cus_Abc123"
  }
}

// Parsing a CloudEvents payload
import { CloudEvent } from 'cloudevents'

app.post('/webhooks/cloudevents', express.json(), (req, res) => {
  const event = new CloudEvent(req.body)

  // Guaranteed fields across all CloudEvents-compliant senders
  console.log(event.id)         // Unique event ID
  console.log(event.type)       // "com.example.payment.succeeded"
  console.log(event.source)     // Source URI
  console.log(event.time)       // ISO 8601 timestamp
  console.log(event.data)       // Your domain payload

  res.status(200).send()
})

Webhook vs Polling vs WebSockets

Webhooks are not always the right tool. Here is when each approach makes sense:

ApproachLatencyBest ForDrawback
PollingN × intervalSimple reads, batch processingWasted requests, delay proportional to interval
Webhook< 1 secondServer-to-server integrations, B2B eventsReceiver must be publicly accessible; no built-in backpressure
WebSocket< 100msBrowser UIs, chat, live dashboardsStateful connections, harder to scale horizontally
SSE< 200msBrowser → server push (read-only)One-way, browser-only, connection limit of 6 per domain (HTTP/1.1)

For a deep dive on WebSockets, see our WebSockets Guide. For REST API design patterns that complement webhooks, see REST API Best Practices.

Frequently Asked Questions

What is the difference between a webhook and an API?

An API is pull-based: your code initiates the request and waits for a response. A webhook is push-based: the external service calls your endpoint when something happens. Most integrations use both — an API to read current state and webhooks to receive real-time event notifications.

How do I secure a webhook endpoint?

Verify the HMAC-SHA256 signature on the raw request body using the shared secret provided by the sender. Use timingSafeEqual for the comparison. Reject events with timestamps older than 5 minutes to prevent replay attacks. Never trust the payload before signature verification.

What HTTP status code should a webhook endpoint return?

Return 200 OK immediately after receiving and verifying the event — before any processing. If you need time to process, return 200 immediately and handle it asynchronously. Return 5xx only if you want the sender to retry. Return 400 for payloads you will never be able to process (e.g., signature verification failures).

How does webhook retry logic work?

Platforms like Stripe and GitHub retry failed deliveries using exponential backoff. Stripe retries 3 times over 3 days; Shopify retries 19 times over 48 hours. Make your handler idempotent using the event ID as a deduplication key to safely handle retries without double-processing.

What is the CloudEvents specification?

CloudEvents is a CNCF standard that defines a common JSON envelope structure for event data (id, source, type, time). It eliminates per-provider parsers by standardizing metadata. Azure Event Grid, Google Eventarc, and Knative all support it natively.

How do I test webhooks locally during development?

Use the Stripe CLI (stripe listen --forward-to localhost:3000) for Stripe events, ngrok for any HTTP tunnel, or webhook.site to inspect payloads without running code. Cloudflare Tunnel is a free alternative to ngrok with no rate limits.

Should I process webhooks synchronously or with a queue?

Always queue webhook processing in production. Return 200 immediately after signature verification, enqueue the event (BullMQ, SQS, Celery), and process asynchronously. This prevents timeout retries, handles traffic spikes, and provides built-in retry logic for downstream failures.

Debug Webhook Payloads with BytePane

Inspect and format incoming webhook JSON payloads with the JSON Formatter. Decode JWT tokens from webhook auth headers with the JWT Decoder. Validate your HMAC signatures with the Hash Generator.

Open JSON Formatter

Related Articles