Agent Skills: Webhooks

Webhook implementation and consumption patterns. Use when building webhook endpoints, receivers, or senders — covering HMAC signature verification, retry with exponential backoff, idempotency keys, delivery guarantees, replay protection, dead letter queues, payload design, and monitoring.

UncategorizedID: cosmix/claude-loom/loom-webhooks

Install this agent skill to your local

pnpm dlx add-skill https://github.com/cosmix/loom/tree/HEAD/skills/loom-webhooks

Skill Files

Browse the full folder contents for loom-webhooks.

Download Skill

Loading file tree…

skills/loom-webhooks/SKILL.md

Skill Metadata

Name
loom-webhooks
Description
Webhook implementation and consumption patterns. Use when building webhook endpoints, receivers, or senders — covering HMAC signature verification, retry with exponential backoff, idempotency keys, delivery guarantees, replay protection, dead letter queues, payload design, and monitoring.

Webhooks

Overview

HTTP callbacks that push events to external systems instead of polling. The hard parts are all correctness/security: signing over the right bytes, timing-safe verification, replay protection, at-least-once delivery + idempotent consumers, and SSRF on the sender. This skill is organized sender-side vs receiver-side, with the traps called out.

The non-negotiables (read first)

  • Sign/verify over the RAW request body, not re-serialized JSON. JSON.parseJSON.stringify reorders keys and changes whitespace, breaking the HMAC. Receiver must read raw bytes before any JSON body parser runs.
  • Timing-safe compare for signatures — never ===. And guard length first: crypto.timingSafeEqual throws on unequal-length buffers.
  • Timestamped signatures + a tolerance window to blunt replay; dedup by event id for the rest.
  • At-least-once delivery is the only realistic guarantee ⇒ duplicates will arrive ⇒ consumers must be idempotent.
  • Verify before you trust or parse. For security-critical actions, treat the payload as a hint and refetch canonical state from your API.
  • Sender makes outbound requests to user-supplied URLs ⇒ SSRF surface. Validate destinations; block internal ranges.

Event & subscription model

interface WebhookEvent {
  id: string;            // stable, unique → idempotency + replay key
  type: string;          // "order.created" — resource.action
  created: number;       // Unix seconds
  apiVersion: string;    // payload schema version
  data: {
    object: Record<string, unknown>;
    previousAttributes?: Record<string, unknown>; // deltas on updates
  };
}

interface WebhookEndpoint {
  id: string;
  url: string;
  secret: string;        // per-endpoint, rotatable
  events: string[];      // ["order.*", "payment.completed", "*"]
  status: "active" | "disabled";
}

Naming: resource.action (order.created, payment.failed). Support wildcards (order.*, *).

Thin vs fat events (design decision)

| | Fat (embed full object) | Thin (id + type only) | | --- | --- | --- | | Extra fetch | none | receiver GETs canonical state | | Freshness | can be stale/out-of-order at delivery | always current (fetch at process time) | | Ordering | receiver must handle reordering via created/version | naturally tolerant — fetch reflects latest | | PII/size | more data in transit/logs; size caps (<256KB) | minimal exposure | | Reliability | works if your API is down | needs your API up to process |

Don't trust a fat payload for security/authorization decisions — an attacker who forges or replays a delivery controls its contents. Verify signature, then for money/permissions refetch by id. Thin events sidestep this and out-of-order delivery, at the cost of a round-trip.

Sender side

HMAC signing (over raw payload + timestamp)

import crypto from "crypto";

function sign(secret: string, payload: string, ts: number): string {
  return crypto.createHmac("sha256", secret).update(`${ts}.${payload}`).digest("hex");
}

function signedHeaders(secret: string, payload: string): Record<string, string> {
  const ts = Math.floor(Date.now() / 1000);
  return {
    "Content-Type": "application/json",
    "X-Webhook-Timestamp": String(ts),
    "X-Webhook-Signature": `v1=${sign(secret, payload, ts)}`, // scheme-versioned for rotation
  };
}

Signing the timestamp inside the HMAC (Stripe-style t=...,v1=...) prevents an attacker from replaying a captured body with a fresh timestamp. Version the scheme (v1=) so you can add v2= and support both during rotation.

Retry with exponential backoff + jitter

const retry = { maxAttempts: 5, initialDelay: 1000, maxDelay: 3_600_000, factor: 2,
                retryable: [408, 429, 500, 502, 503, 504] };

function nextDelay(attempt: number): number {           // attempt starts at 0
  const base = Math.min(retry.initialDelay * retry.factor ** attempt, retry.maxDelay);
  return base + base * Math.random() * 0.25;            // 0–25% jitter → no thundering herd
}
  • Retry only network errors + retryable statuses. Don't retry 4xx (400/401/422) — the request is broken; retrying just hammers the receiver.
  • Honor the receiver's Retry-After on 429/503 over your computed backoff.
  • 30s delivery timeout. Deliver asynchronously via a queue — never block the event-producing transaction on HTTP to a third party.

At-least-once delivery

async function dispatch(event: WebhookEvent, endpoints: WebhookEndpoint[]) {
  await db.events.create(event);                         // persist FIRST (durability)
  for (const ep of endpoints) {
    if (ep.status !== "active" || !matches(event.type, ep.events)) continue;
    await queue.add("deliver", { eventId: event.id, endpointId: ep.id },
      { attempts: 5, backoff: { type: "exponential", delay: 1000 }, removeOnFail: false });
  }
}
function matches(type: string, filters: string[]): boolean {
  return filters.some(f => f === "*" || (f.endsWith(".*") ? type.startsWith(f.slice(0, -2)) : f === type));
}

Persist the event before enqueuing so a crash can't lose it. Keep failed jobs (removeOnFail: false) for inspection/replay. Include the immutable event.id in every delivery so receivers can dedup.

SSRF: webhook URLs are attacker-controlled

Users register the destination URL, so your sender becomes an SSRF vector into your own network.

  • Reject non-HTTPS and non-standard ports at registration.
  • Resolve the hostname and block private/loopback/link-local/metadata ranges: 127.0.0.0/8, 10/8, 172.16/12, 192.168/16, 169.254.0.0/16 (incl. 169.254.169.254 cloud metadata), ::1, fc00::/7, fe80::/10.
  • DNS rebinding: validate the IP you actually connect to, not just the one resolved at registration — resolve again at request time and pin, or use an egress proxy / allowlist.
  • Disable HTTP redirects (or re-validate each hop) — a redirect to http://169.254.169.254/ bypasses the initial check.
  • Cap response body size and timeout; you don't need the receiver's response body.

DLQ + auto-disable

async function onExhausted(delivery: WebhookDelivery) {
  await db.deadLetter.create({ ...delivery, movedAt: new Date() });
  const failures1h = await db.deadLetter.count({ endpointId: delivery.endpointId, since: hoursAgo(1) });
  if (failures1h >= 10) await alerts.warn("Webhook endpoint failing", delivery.endpointId);
  const failures24h = await db.deadLetter.count({ endpointId: delivery.endpointId, since: hoursAgo(24) });
  if (failures24h >= 100) await db.endpoints.disable(delivery.endpointId, "too many failures");
}

After max attempts, move to a DLQ (never silently drop). Alert on rising failure rates; auto-disable chronically dead endpoints to stop wasting deliveries, and expose a manual replay path.

Receiver side

Verification middleware (raw body first!)

import express from "express";

function verify(secret: string, rawBody: Buffer, sigHeader: string, tsHeader: string,
                toleranceSec = 300): void {
  const ts = parseInt(tsHeader, 10);
  if (!ts || Math.abs(Math.floor(Date.now() / 1000) - ts) > toleranceSec)
    throw new WebhookError("timestamp outside tolerance", "TIMESTAMP_EXPIRED"); // replay guard

  const provided = sigHeader.split(",").find(p => p.startsWith("v1="))?.slice(3);
  if (!provided) throw new WebhookError("no v1 signature", "INVALID_SIGNATURE");

  const expected = sign(secret, rawBody.toString("utf8"), ts);
  const a = Buffer.from(provided), b = Buffer.from(expected);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b))  // length guard: timingSafeEqual throws otherwise
    throw new WebhookError("signature mismatch", "INVALID_SIGNATURE");
}

// express.raw keeps the body as bytes — a global express.json() would corrupt the HMAC
app.post("/webhooks", express.raw({ type: "application/json" }), (req, res) => {
  try {
    verify(process.env.WEBHOOK_SECRET!, req.body,
           req.header("x-webhook-signature")!, req.header("x-webhook-timestamp")!);
  } catch { return res.status(401).json({ error: "invalid signature" }); }

  const event = JSON.parse(req.body.toString()) as WebhookEvent;
  res.status(200).json({ received: true });   // ACK fast (<1s)…
  enqueueForProcessing(event).catch(logger.error); // …then process out of band
});

Traps: (1) any body parser that runs before this handler replaces req.body and the raw bytes are gone — mount express.raw on this route only, or capture raw via a verify hook. (2) timingSafeEqual throws RangeError on length mismatch — compare lengths first (also prevents a length-based side channel). (3) verify before JSON.parse.

Idempotent processing (dedup by event id)

async function process(event: WebhookEvent) {
  // NX set → true only for the first arrival of this id
  const first = await redis.set(`wh:${event.id}`, "1", "EX", 86400, "NX");
  if (!first) return;                          // duplicate delivery → no-op
  try {
    await handlers[event.type]?.(event.data.object);
  } catch (e) {
    await redis.del(`wh:${event.id}`);         // allow retry to reprocess
    throw e;                                    // 5xx → sender retries
  }
}

Redis SET key val EX ttl NX is the atomic dedup primitive (no read-then-write race). ⚠ Redis dedup is best-effort — for money-movement use a DB unique constraint on event_id (or an inbox table) so dedup is transactional with the side effect. Delete the key on failure so a legitimate retry can reprocess.

Ordering & staleness

Delivery is not ordered. order.updated can arrive before order.created, or a stale update after a newer one. Defend with the event created/a monotonically increasing version: ignore an update whose version ≤ the version you already applied. Thin events dodge this by always fetching current state.

Receiver status-code contract

| Status | Meaning to sender | | ------ | ----------------- | | 2xx | Received (and durably queued) — do not retry | | 400 | Malformed payload — permanent, do not retry | | 401 | Bad/missing signature — permanent, do not retry | | 409 | Duplicate (optional) — do not retry | | 5xx / timeout | Transient — sender retries |

Return 2xx only after you've durably stored/queued the event — a 2xx before persistence means a crash loses it and the sender won't retry. Store the raw payload before processing for replay/debugging.

Monitoring

Track per endpoint: total deliveries, success rate, avg/p95/p99 latency, error breakdown by status, DLQ depth, retry backlog. Alert on success-rate drop and DLQ growth; auto-disable after a threshold. Give customers a delivery log + self-serve replay for failed events.

function p95(values: number[]): number {
  if (!values.length) return 0;
  const s = [...values].sort((a, b) => a - b);
  return s[Math.ceil(s.length * 0.95) - 1];
}

Reference values

  • Retries: 5 attempts, 1s initial, 1h max, factor 2, 0–25% jitter; retryable 408, 429, 500, 502, 503, 504.
  • Delivery timeout 30s; receiver should ACK <1s and process async.
  • Signature tolerance ~300s; idempotency/dedup TTL ~24h; payload cap <256KB.
  • Rotate secrets periodically; support two active secrets during rotation (accept either).

Checklists

Sender — verify before shipping:

  • [ ] Event persisted before enqueue; delivery async via durable queue
  • [ ] HMAC over raw payload with timestamp inside the signed string; scheme versioned (v1=)
  • [ ] Backoff + jitter; retries limited to network errors + retryable 5xx/429/408; Retry-After honored; 4xx not retried
  • [ ] SSRF guard: HTTPS-only, private/link-local/metadata IPs blocked, redirects disabled/re-validated, DNS-rebinding handled at connect time
  • [ ] DLQ on exhaustion (nothing silently dropped); failure alerting + auto-disable + manual replay
  • [ ] Per-endpoint rotatable secrets; monitoring (success rate, p95, DLQ depth)

Receiver — verify before shipping:

  • [ ] Raw body captured before any JSON parser; signature verified before JSON.parse
  • [ ] Timing-safe compare with length guard; timestamp tolerance enforced (replay window)
  • [ ] Consumer idempotent — dedup by event.id (DB unique constraint for state-changing/financial ops)
  • [ ] ACK 2xx only after durable persist/enqueue; then process out of band
  • [ ] Out-of-order tolerated via created/version; security-critical actions refetch canonical state, don't trust payload
  • [ ] Correct status codes: 2xx received, 400/401 permanent, 5xx retryable