Skip to content
Back to skills

Webhooks

ASecurity

Use when designing a webhook delivery system, implementing HMAC signature verification on a receiver, handling retries and failures on the sender side, building idempotent webhook consumers, or testing webhook integrations locally.

  • 189 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added September 25, 2026
developmenttypescriptpythonrustgobashexpressfastapitestingapisecurity

Works with

  • cli
  • api

Security analysis

A100/100

Scanned September 25, 2026

npx -y skills add kid-sid/claude-spellbook --skill webhooks --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Webhooks?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Webhooks
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/kid-sid-webhooks/badge)](https://www.skillsdirectory.com/skills/kid-sid-webhooks)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: webhooks
description: Use when designing a webhook delivery system, implementing HMAC signature verification on a receiver, handling retries and failures on the sender side, building idempotent webhook consumers, or testing webhook integrations locally.
---

# Webhooks

Patterns for reliable webhook delivery, signature verification, idempotent consumption, and local testing.

## When to Activate

- Designing a system that pushes events to external URLs
- Implementing HMAC signature verification on a webhook receiver
- Building retry and failure handling on the webhook sender side
- Making a webhook consumer idempotent (safe to receive twice)
- Exposing and documenting a webhook API for third-party integrations
- Testing webhook integrations locally without a public URL
- Auditing an existing webhook implementation for security or reliability gaps

---

## Sender vs. Receiver Responsibilities

| Concern | Sender (you push) | Receiver (you consume) |
|---|---|---|
| Signing | Sign every payload with HMAC | Verify signature before processing |
| Delivery | Retry with backoff on non-2xx | Return 2xx immediately, process async |
| Ordering | Add sequence or timestamp | Don't assume ordered delivery |
| Idempotency | Include a stable event ID | Deduplicate on event ID |
| Schema | Version the event type | Handle unknown fields gracefully |

---

## Event Envelope Design

Every webhook payload should include a consistent envelope regardless of event type.

```json
{
  "id": "evt_01HX4K9MZQR8FVNJ2Y3BCD5",
  "type": "order.completed",
  "version": "2024-01",
  "created_at": "2024-01-15T10:30:00Z",
  "data": {
    "order_id": "ord_789",
    "amount": 4999,
    "currency": "usd"
  }
}
```

| Field | Purpose |
|---|---|
| `id` | Stable, unique event ID — used for deduplication |
| `type` | Namespaced event name (`resource.action`) |
| `version` | Schema version — allows evolving payloads without breaking receivers |
| `created_at` | When the event occurred (not when it was delivered) |
| `data` | Event-specific payload |

---

## HMAC Signature Verification

Never trust a webhook payload without verifying its signature. Use a **constant-time comparison** to prevent timing attacks.

### Signing (Sender)

```python
# Python
import hmac, hashlib, json, time

def sign_payload(payload: dict, secret: str) -> tuple[str, str]:
    body = json.dumps(payload, separators=(",", ":")).encode()
    timestamp = str(int(time.time()))
    signed = f"{timestamp}.{body.decode()}"
    sig = hmac.new(secret.encode(), signed.encode(), hashlib.sha256).hexdigest()
    return f"t={timestamp},v1={sig}", body
```

```typescript
// TypeScript
import { createHmac } from "crypto";

function signPayload(payload: object, secret: string): { signature: string; body: string } {
  const body = JSON.stringify(payload);
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const signed = `${timestamp}.${body}`;
  const sig = createHmac("sha256", secret).update(signed).digest("hex");
  return { signature: `t=${timestamp},v1=${sig}`, body };
}
```

```go
// Go
func SignPayload(payload []byte, secret string, now time.Time) string {
    timestamp := strconv.FormatInt(now.Unix(), 10)
    signed := timestamp + "." + string(payload)
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(signed))
    sig := hex.EncodeToString(mac.Sum(nil))
    return fmt.Sprintf("t=%s,v1=%s", timestamp, sig)
}
```

### Verifying (Receiver)

```python
# Python — FastAPI
import hmac, hashlib, time
from fastapi import Request, HTTPException

TOLERANCE_SECONDS = 300  # reject replays older than 5 minutes

async def verify_webhook(request: Request, secret: str) -> bytes:
    sig_header = request.headers.get("X-Webhook-Signature", "")
    body = await request.body()

    parts = dict(p.split("=", 1) for p in sig_header.split(","))
    timestamp = parts.get("t", "")
    received_sig = parts.get("v1", "")

    # Reject replayed requests
    if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
        raise HTTPException(400, "Webhook timestamp too old")

    expected = hmac.new(
        secret.encode(),
        f"{timestamp}.{body.decode()}".encode(),
        hashlib.sha256,
    ).hexdigest()

    # Constant-time comparison — prevents timing attacks
    if not hmac.compare_digest(expected, received_sig):
        raise HTTPException(400, "Invalid webhook signature")

    return body
```

```typescript
// TypeScript — Express
import { createHmac, timingSafeEqual } from "crypto";
import { Request, Response, NextFunction } from "express";

const TOLERANCE_SECONDS = 300;

export function verifyWebhook(secret: string) {
  return (req: Request, res: Response, next: NextFunction) => {
    const header = req.headers["x-webhook-signature"] as string ?? "";
    const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
    const { t: timestamp, v1: receivedSig } = parts;

    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) {
      return res.status(400).json({ error: "Webhook timestamp too old" });
    }

    const body = (req as any).rawBody as Buffer; // requires rawBody middleware
    const expected = createHmac("sha256", secret)
      .update(`${timestamp}.${body}`)
      .digest("hex");

    if (!timingSafeEqual(Buffer.from(expected), Buffer.from(receivedSig))) {
      return res.status(400).json({ error: "Invalid webhook signature" });
    }

    next();
  };
}
```

```go
// Go
func VerifyWebhook(r *http.Request, body []byte, secret string) error {
    header := r.Header.Get("X-Webhook-Signature")
    parts := parseHeader(header) // split "t=...,v1=..." into map
    timestamp, received := parts["t"], parts["v1"]

    ts, _ := strconv.ParseInt(timestamp, 10, 64)
    if math.Abs(float64(time.Now().Unix()-ts)) > 300 {
        return errors.New("webhook timestamp too old")
    }

    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(timestamp + "." + string(body)))
    expected := hex.EncodeToString(mac.Sum(nil))

    if !hmac.Equal([]byte(expected), []byte(received)) {
        return errors.New("invalid webhook signature")
    }
    return nil
}
```

---

## Reliable Delivery (Sender)

### Delivery Pipeline

```
Event occurs → persist to outbox → worker picks up → HTTP POST → record result
                                                              ↓
                                                    success: mark delivered
                                                    failure: schedule retry
```

Use the **transactional outbox pattern** — write the event to a DB table in the same transaction as the business change. A background worker reads and delivers it. This prevents events being lost if the app crashes between the DB write and the HTTP call.

```python
# Python — outbox record
@dataclass
class WebhookDelivery:
    id: str
    endpoint_url: str
    payload: dict
    attempt: int = 0
    max_attempts: int = 5
    next_attempt_at: datetime = field(default_factory=datetime.utcnow)
    delivered_at: datetime | None = None
    last_error: str | None = None
```

### Retry Schedule

| Attempt | Delay |
|---|---|
| 1st retry | 30 seconds |
| 2nd retry | 5 minutes |
| 3rd retry | 30 minutes |
| 4th retry | 2 hours |
| 5th retry | 8 hours |
| After max | Move to dead-letter queue |

```python
def next_retry_delay(attempt: int) -> int:
    schedule = [30, 300, 1800, 7200, 28800]
    return schedule[min(attempt, len(schedule) - 1)]
```

### Delivery Worker

```python
import httpx

async def deliver_webhook(delivery: WebhookDelivery, secret: str) -> None:
    sig, body = sign_payload(delivery.payload, secret)
    try:
        async with httpx.AsyncClient(timeout=10) as client:
            r = await client.post(
                delivery.endpoint_url,
                content=body,
                headers={
                    "Content-Type": "application/json",
                    "X-Webhook-Signature": sig,
                    "X-Webhook-ID": delivery.id,
                },
            )
        if r.status_code < 200 or r.status_code >= 300:
            raise ValueError(f"non-2xx: {r.status_code}")
        await mark_delivered(delivery.id)
    except Exception as exc:
        await schedule_retry(delivery, error=str(exc))
```

---

## Idempotent Consumers

Webhooks can be delivered more than once. Always deduplicate on `event.id`.

```python
# Python — Redis deduplication
import redis.asyncio as redis

async def handle_webhook(event: dict, r: redis.Redis) -> None:
    event_id = event["id"]
    key = f"webhook:seen:{event_id}"

    # SET NX — only set if not already present
    already_seen = not await r.set(key, "1", nx=True, ex=86400)
    if already_seen:
        return  # duplicate — safe to ignore

    # Process after deduplication
    await process_event(event)
```

```typescript
// TypeScript — DB-based deduplication
async function handleWebhook(event: WebhookEvent): Promise<void> {
  const inserted = await db.webhookEvent.upsert({
    where: { id: event.id },
    create: { id: event.id, type: event.type, processedAt: null },
    update: {}, // no-op if already exists
  });

  if (inserted.processedAt) return; // already processed

  await processEvent(event);
  await db.webhookEvent.update({ where: { id: event.id }, data: { processedAt: new Date() } });
}
```

---

## Receiver Response Contract

| Scenario | Response | Notes |
|---|---|---|
| Accepted for processing | `200 OK` | Body ignored by sender |
| Duplicate (already processed) | `200 OK` | Not an error — idempotent |
| Invalid signature | `400 Bad Request` | Do not retry |
| Unknown event type | `200 OK` | Accept and ignore unknown types |
| Downstream not ready | `503 Service Unavailable` | Sender will retry |

**Always return 2xx immediately.** Do the actual work asynchronously (queue it). A slow receiver causes the sender to timeout and retry unnecessarily.

```python
# FastAPI — accept immediately, process async
@router.post("/webhooks/stripe")
async def stripe_webhook(request: Request, background_tasks: BackgroundTasks):
    body = await verify_webhook(request, secret=STRIPE_SECRET)
    event = json.loads(body)
    background_tasks.add_task(process_stripe_event, event)
    return {"received": True}  # 200 immediately
```

---

## Local Testing

```bash
# Use a tunnel to expose localhost
npx localtunnel --port 3000 --subdomain my-app
# or
ngrok http 3000

# Replay a real webhook for testing
curl -X POST http://localhost:3000/webhooks \
  -H "Content-Type: application/json" \
  -H "X-Webhook-Signature: t=1234567890,v1=abc..." \
  -d '{"id":"evt_test","type":"order.completed","data":{}}'

# Stripe CLI — replay events against local server
stripe listen --forward-to localhost:3000/webhooks/stripe
stripe trigger payment_intent.succeeded
```

---

## Red Flags

- **No signature verification** — any caller can send a fake payload; always verify HMAC before touching the body.
- **String equality for signature comparison** — use `hmac.compare_digest` / `timingSafeEqual` / `hmac.Equal`; plain `==` leaks timing information.
- **No timestamp validation** — without a replay window check, an attacker can resend a captured payload indefinitely.
- **Synchronous processing in the handler** — a slow downstream makes the receiver timeout; the sender retries, creating a storm; always return 2xx immediately and queue the work.
- **Not idempotent** — at-least-once delivery is the norm; without deduplication a retry charges a customer twice or sends a duplicate email.
- **Retrying 4xx responses** — a 400 or 401 will never succeed; only retry 5xx and network errors.
- **No dead-letter queue** — events that exhaust retries silently disappear; always persist to a DLQ for inspection and manual replay.
- **Delivering directly from the request handler** — if the app crashes mid-delivery, the event is lost; use a transactional outbox instead.

---

## Checklist

- [ ] Every outgoing payload signed with HMAC-SHA256 and includes a timestamp
- [ ] Receiver verifies signature with constant-time comparison before processing
- [ ] Replay window enforced — payloads older than 5 minutes rejected
- [ ] Receiver returns 2xx immediately — actual processing is async
- [ ] Consumer is idempotent — deduplicates on `event.id` via Redis or DB upsert
- [ ] Sender uses transactional outbox — event persisted before delivery attempt
- [ ] Retry schedule uses increasing delays — no fixed-interval hammering
- [ ] 4xx responses not retried — only 5xx and network errors trigger retry
- [ ] Dead-letter queue captures events that exhaust all retry attempts
- [ ] Webhook endpoint URL and signing secret stored in env vars — not hardcoded
- [ ] Unknown event types accepted with 200 and silently ignored
- [ ] Local testing flow documented — tunnel or CLI replay tool configured

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…