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:
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 header —
Stripe-Signature(each provider has a different header name) - ›Event type —
payment_intent.succeeded - ›Event ID —
evt_1234567890(use for idempotency) - ›Timestamp —
created: 1614556800(Unix timestamp)
Step 1: Your Webhook Handler
The most basic webhook handler in 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:
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
| Provider | Header |
|---|---|
| Stripe | Stripe-Signature (format: t=...,v1=...) |
| GitHub | X-Hub-Signature-256 (format: sha256=...) |
| Shopify | X-Shopify-Hmac-SHA256 (base64-encoded) |
| Twilio | X-Twilio-Signature (proprietary format) |
| PagerDuty | X-Webhook-Token (simple bearer) |
| Slack | X-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:
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:
- ›Mark the original event as
replayed - ›Create a new event with a reference to the original
- ›Queue the new event for delivery
- ›Track replay delivery separately from original delivery
Common Mistakes and How to Avoid Them
| Mistake | Consequence | Fix |
|---|---|---|
| Processing before responding 200 | Duplicate deliveries on timeout | Enqueue first, respond immediately |
| No signature verification | Fake events from bad actors | Always verify HMAC |
| String comparison for HMAC | Timing attack vulnerability | Use hmac.Equal() |
| No idempotency | Duplicate charges, duplicate emails | Check event ID before processing |
| Synchronous fan-out | One slow service blocks all others | Fan out to independent queues |
| No retry logic | Permanent event loss on transient failure | Exponential backoff + DLQ |
| Logging raw payloads | Secret leakage in logs | Redact 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.