dist/plugins/api-messaging-webhooks/skills/api-messaging-webhooks/SKILL.md
Webhook patterns — receiving, sending, signature verification, and retry logic
npx skillsauth add agents-inc/skills api-messaging-webhooksInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Quick Guide: Verify signatures with HMAC-SHA256 using
crypto.createHmac+crypto.timingSafeEqualon the raw body bytes -- never parsed JSON. Enforce idempotency by storing processed webhook IDs. Protect against replay attacks with timestamp validation. Return 200 immediately, process asynchronously. When sending, use exponential backoff with jitter and move exhausted retries to a dead letter queue.
<critical_requirements>
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST verify signatures against the RAW request body -- never parsed/re-serialized JSON)
(You MUST use crypto.timingSafeEqual for signature comparison -- never === which leaks timing information)
(You MUST return 2xx immediately and process webhooks asynchronously -- synchronous processing causes timeouts and duplicate deliveries)
(You MUST enforce idempotency by checking a stored webhook ID before processing -- retries WILL send the same event multiple times)
</critical_requirements>
Auto-detection: webhook, webhooks, HMAC, signature verification, createHmac, timingSafeEqual, webhook-signature, webhook-id, webhook-timestamp, idempotency key, replay attack, exponential backoff, dead letter queue, event routing, webhook handler, webhook endpoint, webhook delivery, webhook retry
When to use:
When NOT to use:
Key patterns covered:
Detailed Resources:
Webhooks are HTTP callbacks -- a producer POSTs a payload to a consumer's URL when an event occurs. The fundamental challenge is trust and reliability: the consumer must verify the payload is authentic (signature verification), not replayed (timestamp validation), and not processed twice (idempotency). The producer must handle delivery failures gracefully (retries with backoff) and not lose events permanently (dead letter queues).
Core security principle: The signature is computed over the exact bytes transmitted. Any transformation -- JSON parsing, re-serialization, whitespace normalization -- invalidates the signature. Always verify against the raw body.
Core reliability principle: Networks are unreliable. The consumer should acknowledge receipt immediately (return 2xx) and process asynchronously. The producer should retry with exponential backoff and eventually move to a dead letter queue.
When to implement webhooks:
When NOT to implement webhooks:
The foundation of webhook security. The producer signs the payload with a shared secret; the consumer recomputes the signature and compares using timing-safe equality.
import { createHmac, timingSafeEqual } from "node:crypto";
const SIGNATURE_ALGORITHM = "sha256";
const SIGNATURE_ENCODING = "hex";
function verifySignature(rawBody: string, signature: string, secret: string): boolean {
const expected = createHmac(SIGNATURE_ALGORITHM, secret)
.update(rawBody)
.digest(SIGNATURE_ENCODING);
const expectedBuffer = Buffer.from(expected, "utf8");
const receivedBuffer = Buffer.from(signature, "utf8");
if (expectedBuffer.length !== receivedBuffer.length) return false;
return timingSafeEqual(expectedBuffer, receivedBuffer);
}
Why good: uses timingSafeEqual to prevent timing attacks, operates on raw body bytes, length check before comparison prevents timingSafeEqual throwing on mismatched lengths
See examples/core.md for the full handler with raw body extraction and error responses.
Timestamp validation prevents attackers from re-sending captured webhook payloads. Reject payloads older than a tolerance window.
const MAX_TIMESTAMP_AGE_SECONDS = 300; // 5 minutes
const MS_PER_SECOND = 1000;
function isTimestampValid(timestampHeader: string): boolean {
const webhookTime = parseInt(timestampHeader, 10);
if (Number.isNaN(webhookTime)) return false;
const currentTime = Math.floor(Date.now() / MS_PER_SECOND);
return Math.abs(currentTime - webhookTime) <= MAX_TIMESTAMP_AGE_SECONDS;
}
Why good: uses absolute difference to handle minor clock skew in both directions, rejects NaN timestamps, named constants for tolerance window
When to use: When the webhook producer includes a timestamp header (most major providers do). Combine with signature verification -- sign the timestamp.payload concatenation so the timestamp itself is authenticated.
See examples/core.md for timestamp-inclusive signature verification.
Webhook producers retry on failure, sending the same event multiple times. Store processed webhook IDs and skip duplicates.
const IDEMPOTENCY_TTL_DAYS = 7;
async function processWebhookIdempotently(
webhookId: string,
handler: () => Promise<void>,
): Promise<{ status: "processed" | "duplicate" }> {
const alreadyProcessed = await hasBeenProcessed(webhookId);
if (alreadyProcessed) return { status: "duplicate" };
await handler();
await markAsProcessed(webhookId, IDEMPOTENCY_TTL_DAYS);
return { status: "processed" };
}
Why good: checks before processing, stores with TTL to prevent unbounded storage growth, returns status for logging/monitoring
Storage options: Any key-value store with TTL support works -- in-memory cache, database table, or dedicated cache service.
See examples/core.md for the complete idempotent webhook handler.
Route webhook events to handlers using a discriminated union on the event type. The type system enforces exhaustive handling.
type WebhookEvent =
| { type: "order.created"; data: { orderId: string; total: number } }
| { type: "order.cancelled"; data: { orderId: string; reason: string } }
| { type: "payment.completed"; data: { paymentId: string; amount: number } };
type EventHandlerMap = {
[E in WebhookEvent as E["type"]]: (data: E["data"]) => Promise<void>;
};
const handlers: EventHandlerMap = {
"order.created": async (data) => { /* handle */ },
"order.cancelled": async (data) => { /* handle */ },
"payment.completed": async (data) => { /* handle */ },
};
async function routeEvent(event: WebhookEvent): Promise<void> {
const handler = handlers[event.type] as (data: WebhookEvent["data"]) => Promise<void>;
await handler(event.data);
}
Why good: adding a new event type to the union causes a compile error until a handler is added, each handler receives correctly typed data
See examples/core.md for full event routing with Zod validation and unknown event handling.
When sending webhooks, sign the payload and deliver with exponential backoff. Classify failures by HTTP status to decide whether to retry.
const MAX_RETRIES = 5;
const BASE_DELAY_MS = 1000;
const MAX_DELAY_MS = 3600000; // 1 hour
function calculateDelay(attempt: number): number {
const exponential = BASE_DELAY_MS * Math.pow(2, attempt);
const capped = Math.min(exponential, MAX_DELAY_MS);
const jitter = Math.random() * BASE_DELAY_MS;
return capped + jitter;
}
Why good: exponential backoff gives failing endpoints recovery time, cap prevents absurd delays, jitter prevents thundering herd when many deliveries retry simultaneously
See examples/sending.md for complete delivery with signing, status classification, and dead letter queues.
After all retry attempts are exhausted, preserve the event for manual investigation rather than dropping it silently.
interface DeadLetterEntry {
webhookId: string;
targetUrl: string;
payload: string;
lastAttempt: Date;
attempts: number;
lastError: string;
}
Why good: preserves full context for debugging, enables manual replay after the target recovers
When to move to DLQ immediately (no retry): 400 Bad Request, 401 Unauthorized, 404 Not Found, 422 Unprocessable Entity -- these indicate a configuration or payload problem that retrying won't fix. Exception: 429 Too Many Requests should be retried with the Retry-After header value.
See examples/sending.md for dead letter queue implementation and status code classification.
</patterns><decision_framework>
Incoming webhook request:
|
+-> Is the raw body available (not parsed)?
| +-> NO -> Fix your middleware to preserve raw body FIRST
| +-> YES -> Continue
|
+-> Does the provider send a signature header?
| +-> YES -> Verify HMAC signature with timing-safe comparison
| +-> NO -> Use IP allowlisting or mutual TLS instead
|
+-> Does the provider send a timestamp?
| +-> YES -> Validate timestamp within tolerance (e.g., 5 min)
| +-> NO -> Skip replay protection (rely on idempotency)
|
+-> Does the provider send a unique event ID?
| +-> YES -> Check idempotency store, skip if duplicate
| +-> NO -> Generate a hash of the payload as a dedup key
|
+-> Process asynchronously, return 200 immediately
Need to notify external consumers of events?
|
+-> Sign every payload with HMAC-SHA256
+-> Include: webhook-id, webhook-timestamp, webhook-signature headers
+-> Deliver with retry on failure:
|
+-> Is it a 2xx response?
| +-> YES -> Delivery succeeded, done
|
+-> Is it 429, 503, or a network error?
| +-> YES -> Retry with exponential backoff + jitter
|
+-> Is it 400, 401, 404, 422?
| +-> YES -> Move to dead letter queue immediately (not retriable)
|
+-> Max retries exhausted?
+-> YES -> Move to dead letter queue
| Status | Meaning | Action |
|--------|---------|--------|
| 200-299 | Success | Mark delivered |
| 400 | Bad request | DLQ immediately |
| 401 | Unauthorized | DLQ immediately |
| 404 | Not found | DLQ immediately |
| 410 | Gone | Disable endpoint |
| 422 | Validation error | DLQ immediately |
| 429 | Rate limited | Retry with Retry-After |
| 500 | Server error | Retry with backoff |
| 502/503 | Unavailable | Retry with backoff |
| Timeout | No response | Retry with backoff |
| Connection refused | Unreachable | Retry with backoff |
</decision_framework>
<red_flags>
High Priority Issues:
=== for signature comparison instead of crypto.timingSafeEqual -- leaks timing information that allows attackers to incrementally guess signatures byte by byteMedium Priority Issues:
Gotchas & Edge Cases:
crypto.timingSafeEqual throws if buffers have different lengths -- always check .length equality first or convert both to the same encoding before comparisonsha256=abc123) -- strip the prefix before comparisonMath.abs() for the time difference and allow 5 minutes tolerance</red_flags>
<critical_reminders>
All code must follow project conventions in CLAUDE.md
(You MUST verify signatures against the RAW request body -- never parsed/re-serialized JSON)
(You MUST use crypto.timingSafeEqual for signature comparison -- never === which leaks timing information)
(You MUST return 2xx immediately and process webhooks asynchronously -- synchronous processing causes timeouts and duplicate deliveries)
(You MUST enforce idempotency by checking a stored webhook ID before processing -- retries WILL send the same event multiple times)
Failure to follow these rules will cause signature verification failures, timing attack vulnerabilities, duplicate event processing, and lost webhook events.
</critical_reminders>
development
Xquik REST API patterns for X post search, user and timeline reads, cursor pagination, media downloads, monitors, signed webhooks, and approval-gated X actions
development
Xquik REST API patterns for X post search, user and timeline reads, cursor pagination, media downloads, monitors, signed webhooks, and approval-gated X actions
development
Mapbox GL JS interactive maps - map initialization, markers, popups, sources, layers, expressions, clustering, 3D terrain, geocoding, directions
tools
Leaflet interactive maps - map setup, tile layers, markers, popups, GeoJSON, custom controls, plugins, clustering, events