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.

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