Back to Blog
webhookstutorialfundamentals

Webhooks 101: A Developer's Complete Guide

From the basics of HTTP callbacks to HMAC verification, retry logic, and fan-out routing — everything you need to know to build reliable webhook integrations.

A
Aleksa Vukovic
Developer Relations
August 25, 2025
12 min read

Webhooks are the backbone of modern SaaS integrations. Stripe uses them to notify you of payment events. GitHub uses them to trigger CI/CD pipelines. Shopify uses them to sync inventory. If you're building anything that integrates with third-party services, you're using webhooks.

This guide covers everything from the absolute basics to production patterns.


What Is a Webhook?

A webhook is an HTTP POST request that a service sends to your server when something happens.

Provider ──── POST /your-endpoint ──→ Your Server

Unlike a traditional API where you poll for data ("give me the latest orders"), webhooks are event-driven ("I'll tell you when a new order happens"). This is the "push vs pull" distinction.

When to use webhooks:

  • You need near-real-time notifications
  • Events are irregular / unpredictable
  • You can't afford the overhead of continuous polling
  • Multiple systems need to react to the same event

When polling is fine:

  • Events are predictable and regular
  • You need to aggregate historical data
  • The source doesn't support webhooks

Anatomy of a Webhook Request

A webhook from Stripe looks like this:

http
POST /webhooks/stripe HTTP/1.1
Host: api.yourapp.com
Content-Type: application/json
Stripe-Signature: t=1614556800,v1=abc123...,v0=def456...
User-Agent: Stripe/1.0 (+https://stripe.com/docs/webhooks)

{
  "id": "evt_1234567890",
  "object": "event",
  "type": "payment_intent.succeeded",
  "data": {
    "object": {
      "id": "pi_1234567890",
      "amount": 2000,
      "currency": "usd",
      "status": "succeeded"
    }
  },
  "created": 1614556800
}

Key components:

  • Signature headerStripe-Signature (each provider has a different header name)
  • Event typepayment_intent.succeeded
  • Event IDevt_1234567890 (use for idempotency)
  • Timestampcreated: 1614556800 (Unix timestamp)

Step 1: Your Webhook Handler

The most basic webhook handler in Go:

go
func stripeWebhookHandler(w http.ResponseWriter, r *http.Request) {
    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "bad request", 400)
        return
    }

    // Step 1: Verify signature (covered below)
    if !verifyStripeSignature(body, r.Header.Get("Stripe-Signature")) {
        http.Error(w, "invalid signature", 400)
        return
    }

    // Step 2: Parse event
    var event StripeEvent
    if err := json.Unmarshal(body, &event); err != nil {
        http.Error(w, "parse error", 400)
        return
    }

    // Step 3: Acknowledge IMMEDIATELY
    w.WriteHeader(200)

    // Step 4: Process asynchronously
    go processEvent(event)
}

Critical rule: Return 200 before processing. If processing fails, you handle it — not the provider.


Step 2: Signature Verification

Every serious webhook provider signs their requests with HMAC-SHA256. This proves the request came from them, not a bad actor.

How HMAC Signing Works

signature = HMAC-SHA256(secret, payload)

The provider has a secret key (you set it up when configuring the webhook). They compute HMAC over the request body and include the result in a header. You compute the same HMAC with the same secret and compare.

Stripe's Signature Format

Stripe uses a compound format with a timestamp to prevent replay attacks:

Stripe-Signature: t=1614556800,v1=abc123...,v0=def456...

To verify:

go
func verifyStripeSignature(payload []byte, sigHeader string) bool {
    // Parse t= and v1= from header
    parts := parseStripeHeader(sigHeader)
    timestamp := parts["t"]
    signature := parts["v1"]

    // Reconstruct the signed payload
    signedPayload := timestamp + "." + string(payload)

    // Compute HMAC
    mac := hmac.New(sha256.New, []byte(stripeWebhookSecret))
    mac.Write([]byte(signedPayload))
    expected := hex.EncodeToString(mac.Sum(nil))

    // Constant-time compare (prevents timing attacks)
    return hmac.Equal([]byte(expected), []byte(signature))
}

Signature Header Names by Provider

ProviderHeader
StripeStripe-Signature (format: t=...,v1=...)
GitHubX-Hub-Signature-256 (format: sha256=...)
ShopifyX-Shopify-Hmac-SHA256 (base64-encoded)
TwilioX-Twilio-Signature (proprietary format)
PagerDutyX-Webhook-Token (simple bearer)
SlackX-Slack-Signature (format: v0=...)

Each provider has slightly different formats. This is one reason webhook gateways like GetHook exist — they abstract the format differences behind a single verification engine.


Step 3: Idempotency

Webhooks are at-least-once delivery. You will receive duplicates. Your handler must be idempotent: processing the same event twice should produce the same result as processing it once.

The standard pattern:

go
func processPaymentSucceeded(event StripeEvent) {
    // Use event.ID as idempotency key
    processed, _ := redis.Get("processed:" + event.ID)
    if processed != "" {
        log.Info("skipping duplicate", "event_id", event.ID)
        return
    }

    // Mark as processing (with TTL to handle crashes)
    redis.SetEx("processed:"+event.ID, 24*time.Hour, "1")

    // Do the actual work
    db.Exec("UPDATE orders SET status = 'paid' WHERE stripe_pi_id = ?", event.Data.PaymentIntentID)
}

Why do duplicates happen?

  • Your server returned 500 (even though the DB write succeeded)
  • Your server timed out (even though you processed the event)
  • The provider's delivery infrastructure had a retry on their side
  • A network timeout made it unclear if delivery succeeded

Step 4: Fan-Out Routing

One incoming webhook often needs to trigger multiple actions:

payment.succeeded ├─ Update database (billing service) ├─ Send confirmation email (email service) ├─ Notify fulfillment (fulfillment service) └─ Update analytics (analytics service)

The naive approach is to call each service sequentially in your handler. The problem: if one service is down, all subsequent ones are blocked.

Better pattern — fan-out to independent queues:

payment.succeeded ─→ queue ─┬─→ billing worker ├─→ email worker ├─→ fulfillment worker └─→ analytics worker

Each worker has its own retry logic. An email service outage doesn't block fulfillment.


Step 5: Retry Logic

When delivery fails (5xx, timeout, network error), you need automatic retry.

The best-practice schedule is exponential backoff:

Attempt 1: Immediately Attempt 2: +30 seconds Attempt 3: +2 minutes Attempt 4: +10 minutes Attempt 5: +1 hour → Dead letter queue

Why not retry immediately? If the destination is overloaded, hammering it with retries makes it worse. Exponential backoff gives it time to recover.

Why a dead-letter queue? Some events genuinely can't be delivered (destination URL changed, service decommissioned). Dead-letter queues let you inspect these manually.


Step 6: Event Replay

Replay lets you re-deliver past events to a destination. Use cases:

  • Your consumer service was down for an hour, missed events
  • You added a new destination and want to backfill historical events
  • A bug in your handler caused data to be processed incorrectly
  • A new customer needs onboarding with historical data

Good replay implementations:

  1. Mark the original event as replayed
  2. Create a new event with a reference to the original
  3. Queue the new event for delivery
  4. Track replay delivery separately from original delivery

Common Mistakes and How to Avoid Them

MistakeConsequenceFix
Processing before responding 200Duplicate deliveries on timeoutEnqueue first, respond immediately
No signature verificationFake events from bad actorsAlways verify HMAC
String comparison for HMACTiming attack vulnerabilityUse hmac.Equal()
No idempotencyDuplicate charges, duplicate emailsCheck event ID before processing
Synchronous fan-outOne slow service blocks all othersFan out to independent queues
No retry logicPermanent event loss on transient failureExponential backoff + DLQ
Logging raw payloadsSecret leakage in logsRedact sensitive fields

Webhook Security Checklist

  • Verify HMAC signature on every incoming request
  • Use constant-time comparison for signature verification
  • Validate the timestamp (reject events older than 5 minutes)
  • Return 200 before processing
  • Implement idempotency with event IDs
  • Don't log raw webhook bodies (they may contain secrets)
  • Rotate webhook secrets periodically
  • Use HTTPS endpoints only (never HTTP in production)

When You've Outgrown DIY

Building a basic webhook receiver is straightforward. Building production-grade infrastructure with all of the above — signature verification across 10 different provider formats, fan-out routing, retry with dead-letter queues, replay, multi-tenant isolation — is 6–9 weeks of engineering work.

GetHook packages all of this as a managed service, so your team can focus on your product instead of maintaining webhook plumbing.

Get started free →

Stop losing webhook events.

GetHook gives you reliable delivery, automatic retry, and full observability — in minutes.