| name | webhooks |
| description | Webhook design patterns — delivery, retry with exponential backoff, HMAC signature verification, payload validation, idempotency keys |
| layer | utility |
| category | backend |
| triggers | ["webhook","webhooks","webhook signature","hmac verification","idempotency key","webhook retry"] |
| linksTo | ["api-designer","message-queues","authentication"] |
| linkedFrom | ["microservices","stripe"] |
Webhooks Skill
Purpose
Design patterns for sending and receiving webhooks reliably: HMAC signature verification, exponential backoff retries, idempotency, and payload validation.
Receiving: Signature Verification
import { createHmac, timingSafeEqual } from 'crypto';
function verifyWebhookSignature(payload: string, signature: string, secret: string): boolean {
const expected = `sha256=${createHmac('sha256', secret).update(payload).digest('hex')}`;
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(signature, 'utf8');
return a.length === b.length && timingSafeEqual(a, b);
}
Receiving: Handler with Idempotency
export async function POST(req: NextRequest) {
const rawBody = await req.text();
const signature = req.headers.get('x-webhook-signature') ?? '';
if (!verifyWebhookSignature(rawBody, signature, process.env.WEBHOOK_SECRET!)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
const event = webhookEventSchema.parse(JSON.parse(rawBody));
const idempotencyKey = event.id;
const existing = await db.webhookEvent.findUnique({ where: { idempotencyKey } });
if (existing) return NextResponse.json({ status: 'already_processed' });
try {
await processWebhookEvent(event);
await db.webhookEvent.({
: { idempotencyKey, : event., : () },
});
.({ : });
} {
.({ : }, { : });
}
}
Receiving: Payload Validation
import { z } from 'zod';
const webhookEventSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('order.created'),
id: z.string().uuid(),
data: z.object({ orderId: z.string(), amount: z.number().positive() }),
}),
z.object({
type: z.literal('order.cancelled'),
id: z.string().uuid(),
data: z.object({ orderId: z.string(), reason: z.string().optional() }),
}),
]);
Sending: Delivery with Exponential Backoff
async function deliverWebhook(url: string, secret: string, event: WebhookEvent) {
const payload = JSON.stringify(event);
const signature = `sha256=${createHmac('sha256', secret).update(payload).digest('hex')}`;
for (let attempt = 0; attempt <= 5; attempt++) {
try {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Signature': signature,
'X-Webhook-Id': event.id,
},
body: payload,
signal: AbortSignal.timeout(10_000),
});
if (res.ok) return { success: true, attempts: attempt + 1 };
if (res.status >= 400 && res.status < 500 && res.status !== ) {
{ : , : attempt + };
}
} { }
(attempt < ) {
delay = .( * .(, attempt) + .() * , );
( (r, delay));
}
}
{ : , : };
}
Best Practices
- Always verify signatures with timing-safe comparison before processing
- Always use idempotency keys — webhooks may be delivered more than once
- Respond 200 quickly, then process asynchronously for heavy work
- Don't retry on 4xx (except 429) — the payload itself is the problem
- Include timestamps in signed payloads to prevent replay attacks
- Use a job queue (BullMQ, SQS) for production delivery instead of in-process retries
- Log all events with idempotency keys for debugging delivery issues