
Webhook Architecture
- 62 installs
- 41 repo stars
- Updated March 13, 2026
- finsilabs/awesome-ecommerce-skills
Builds a reliable event-delivery system with automatic retries, HMAC signature verification, and dead-letter queues so no webhook is lost.
About
Provides a reliable webhook receiver and sender with HMAC verification, idempotent handlers, exponential-backoff retries, and dead-letter queues. A developer uses it when handling platform webhooks like Shopify or Stripe or building event-driven services.
- HMAC signature verification and idempotent handlers
- Retry logic, dead-letter queue, and outbox pattern
Webhook Architecture by the numbers
- 62 all-time installs (skills.sh)
- Ranked #3,145 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/finsilabs/awesome-ecommerce-skills --skill webhook-architectureAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 62 |
|---|---|
| repo stars | ★ 41 |
| Last updated | March 13, 2026 |
| Repository | finsilabs/awesome-ecommerce-skills ↗ |
What it does
Builds a reliable event-delivery system with automatic retries, HMAC signature verification, and dead-letter queues so no webhook is lost.
Files
Webhook Architecture
Overview
Webhooks are HTTP callbacks used by commerce platforms (Shopify, Stripe) to push real-time event notifications to your application. Reliable webhook infrastructure requires: HMAC signature verification to prevent spoofed events, idempotent handlers that tolerate duplicate delivery, exponential backoff retry logic, and a dead-letter queue for events that exhaust all retries. This skill covers building a reliable webhook receiver and, for custom platforms, a webhook sender using the Outbox Pattern.
When to Use This Skill
- When receiving webhooks from Shopify, Stripe, Square, or other platforms
- When debugging missed events or duplicate processing caused by webhook delivery issues
- When building a commerce platform or app that needs to notify external systems of events
- When designing event-driven architecture between commerce microservices
- When setting up webhook fanout (single event delivered to multiple consumers)
Core Instructions
Step 1: Determine your platform and what webhooks you need to handle
| Platform | Where Webhooks Are Configured | Most Important Topics to Subscribe |
|---|---|---|
| Shopify | Settings → Notifications → Webhooks (or via Admin API) | orders/create, orders/paid, orders/cancelled, inventory_levels/update, refunds/create |
| WooCommerce | Install WP Webhooks plugin (free, wordpress.org) or use WooCommerce's built-in webhooks under WooCommerce → Settings → Advanced → Webhooks | Order status changes (processing, completed, refunded), stock updates |
| BigCommerce | Advanced Settings → Legacy API Settings → Webhooks or via API | store/order/statusUpdated, store/product/inventory/updated, store/cart/abandoned |
| Custom / Headless | Build your own webhook system | Use the Outbox Pattern for sending; HMAC verification + idempotency for receiving; see implementation below |
Step 2: Platform-specific webhook setup
---
Shopify
Register webhooks via the Shopify admin:
1. Go to Settings → Notifications and scroll to Webhooks 2. Click Create webhook 3. Select the event topic (e.g., Order creation) and enter your endpoint URL 4. Choose JSON as the format
Get your webhook secret for HMAC verification:
The secret is shown when you create the webhook. Store it as an environment variable — Shopify signs each webhook with this secret using HMAC-SHA256.
For apps using the Admin API, register webhooks programmatically:
const res = await fetch(`https://${shopDomain}/admin/api/2025-01/webhooks.json`, {
method: 'POST',
headers: { 'X-Shopify-Access-Token': accessToken, 'Content-Type': 'application/json' },
body: JSON.stringify({ webhook: {
topic: 'orders/create',
address: `${process.env.APP_URL}/api/webhooks/shopify/order-created`,
format: 'json',
}}),
});---
WooCommerce
Use WooCommerce's built-in webhooks:
1. Go to WooCommerce → Settings → Advanced → Webhooks 2. Click Add webhook 3. Set Name, Status: Active, Topic (e.g., Order Created), and your Delivery URL 4. The Secret field generates an HMAC-SHA256 signature for each delivery — copy it for your endpoint's verification
Or use WP Webhooks plugin for more control:
1. Install WP Webhooks (free, wordpress.org) for advanced trigger conditions and payload customization 2. Go to Settings → WP Webhooks and configure triggers for WooCommerce order events 3. WP Webhooks supports retry logic and delivery logs out of the box
---
Custom / Headless
For custom storefronts, implement both reliable receiving (for incoming webhooks from Stripe, Shopify, etc.) and reliable sending (Outbox Pattern for notifying your own integrations).
HMAC signature verification (Shopify and generic):
// lib/webhooks/verify.ts
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyShopifyWebhook(rawBody: Buffer, hmacHeader: string, secret: string): boolean {
const expected = createHmac('sha256', secret).update(rawBody).digest('base64');
const received = Buffer.from(hmacHeader);
const expectedBuffer = Buffer.from(expected);
if (received.length !== expectedBuffer.length) return false;
return timingSafeEqual(received, expectedBuffer);
}
export function verifyStripeWebhook(rawBody: Buffer, signatureHeader: string, secret: string): boolean {
const parts = signatureHeader.split(',');
const timestamp = parts.find(p => p.startsWith('t='))?.replace('t=', '');
const v1 = parts.find(p => p.startsWith('v1='))?.replace('v1=', '');
if (!timestamp || !v1) return false;
// Reject events older than 5 minutes (replay attack protection)
if (Math.abs(Date.now() / 1000 - parseInt(timestamp)) > 300) return false;
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${rawBody.toString('utf8')}`)
.digest('hex');
return timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}Idempotent webhook receiver — deduplicate using the platform's event ID:
// app/api/webhooks/shopify/route.ts
export async function POST(req: NextRequest) {
const rawBody = Buffer.from(await req.arrayBuffer());
const hmac = req.headers.get('x-shopify-hmac-sha256') ?? '';
const topic = req.headers.get('x-shopify-topic') ?? '';
const eventId = req.headers.get('x-shopify-webhook-id') ?? '';
// 1. Verify signature — reject invalid requests immediately
if (!verifyShopifyWebhook(rawBody, hmac, process.env.SHOPIFY_WEBHOOK_SECRET!)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
// 2. Idempotency check — deduplicate by event ID
const alreadyProcessed = await db.processedWebhooks.exists(eventId);
if (alreadyProcessed) return NextResponse.json({ received: true, status: 'already_processed' });
// 3. Mark as received BEFORE processing (prevents duplicate on concurrent delivery)
await db.processedWebhooks.insert({ id: eventId, topic, receivedAt: new Date(), status: 'processing' });
// 4. Return 200 immediately, process asynchronously
processWebhookAsync(topic, rawBody, eventId); // Don't await — return fast
return NextResponse.json({ received: true });
}
async function processWebhookAsync(topic: string, rawBody: Buffer, eventId: string) {
try {
const payload = JSON.parse(rawBody.toString('utf8'));
switch (topic) {
case 'orders/create': await importOrder(payload); break;
case 'orders/cancelled': await cancelOrder(payload.id); break;
case 'inventory_levels/update': await syncInventory(payload); break;
}
await db.processedWebhooks.update(eventId, { status: 'processed', processedAt: new Date() });
} catch (err: any) {
await db.processedWebhooks.update(eventId, { status: 'failed', error: err.message });
}
}Outbox Pattern for reliable webhook sending — guarantees at-least-once delivery even if your sender crashes:
// lib/webhooks/outbox.ts
// Write to outbox in the SAME transaction as the business event
export async function publishEvent(trx: Transaction, eventType: string, payload: object) {
await trx.webhookOutbox.insert({
id: crypto.randomUUID(),
eventType,
payload: JSON.stringify(payload),
status: 'pending',
attempts: 0,
nextRetryAt: new Date(),
});
}
// Outbox poller — runs every 10 seconds, separate from your main app
export async function processOutbox() {
const pending = await db.webhookOutbox.findPending({ status: ['pending', 'retrying'], nextRetryAt: { $lte: new Date() }, limit: 100 });
for (const event of pending) await deliverEvent(event);
}
// Retry schedule: 1min, 5min, 30min, 2hr, 8hr → DLQ after 5 attempts
const RETRY_DELAYS_MS = [60_000, 300_000, 1_800_000, 7_200_000, 28_800_000];
async function handleDeliveryFailure(event: OutboxEvent, error: string) {
const nextAttempts = event.attempts + 1;
if (nextAttempts >= RETRY_DELAYS_MS.length) {
await db.webhookOutbox.update(event.id, { status: 'dead_letter', lastError: error });
await db.webhookDeadLetters.insert({ eventId: event.id, failedAt: new Date(), reason: error });
await alertOpsTeam(`Webhook permanently failed after ${nextAttempts} attempts`, { eventType: event.eventType, error });
} else {
const nextRetryAt = new Date(Date.now() + RETRY_DELAYS_MS[nextAttempts - 1]);
await db.webhookOutbox.update(event.id, { status: 'retrying', attempts: nextAttempts, nextRetryAt, lastError: error });
}
}Replay dead-letter events (after fixing the subscriber endpoint):
export async function replayDeadLetter(deadLetterId: string) {
const deadLetter = await db.webhookDeadLetters.findById(deadLetterId);
await db.webhookOutbox.update(deadLetter.eventId, {
status: 'pending', attempts: 0, nextRetryAt: new Date(),
});
await db.webhookDeadLetters.update(deadLetterId, { replayedAt: new Date() });
}Best Practices
- Always return 2xx immediately — a slow webhook handler blocks delivery and may cause the sender to time out and retry; enqueue events on receipt and process asynchronously
- Use the Outbox Pattern for reliable sending — writing to an outbox table in the same DB transaction as your domain event guarantees at-least-once delivery even if your webhook sender crashes
- Make handlers idempotent — use the event's unique ID to deduplicate; "at-least-once" delivery is the standard for all webhook platforms; you must tolerate receiving the same event twice
- Log every delivery attempt — store delivery attempts with response codes, timing, and errors; this is essential for debugging and provides audit evidence for compliance
- Implement a dead-letter queue with alerting — events that exhaust retries need human intervention; alert via Slack/PagerDuty and provide a replay mechanism
Common Pitfalls
| Problem | Solution |
|---|---|
| Duplicate order processing from retried webhooks | Implement idempotency using the webhook event ID as a unique key in a processed_webhooks table with a TTL of 30 days |
| Webhook handler times out causing retries | Process webhooks async: write to queue on receipt, return 200 immediately, process from queue |
| WooCommerce webhook delivery failures | Check the WooCommerce → System Status → Logs for delivery errors; common causes are SSL certificate issues and timeout on slow shared hosting |
| Shopify webhook HMAC mismatch | Compute HMAC over the raw request body; do NOT parse the JSON first — body parsers may reformat the JSON and change the signature |
| Dead letters pile up silently | Alert when the dead letter count exceeds a threshold (e.g., 10 events); dead letters indicate a systematic subscriber failure requiring investigation |
Related Skills
- @analytics-integration
- @erp-integration
- @marketplace-connectors
- @monitoring-alerting-commerce
{
"context": "Tests whether the agent implements a webhook sender using the Outbox Pattern with correct retry delays, DLQ promotion with alerting, delivery signing, required headers, correct payload structure, timeout handling, delivery attempt logging, and a replay mechanism for dead-letter events.",
"type": "weighted_checklist",
"checklist": [
{
"name": "Outbox written in transaction",
"max_score": 8,
"description": "The publishEvent (or equivalent) function inserts the outbox record inside the same database transaction passed in as a parameter, not as a separate connection/call"
},
{
"name": "Outbox poller batch limit",
"max_score": 6,
"description": "The poller fetches pending events with a batch limit of 100 (or specifies limit: 100 or equivalent)"
},
{
"name": "SKIP LOCKED for poller",
"max_score": 8,
"description": "The README or schema.sql or outbox code references FOR UPDATE SKIP LOCKED (PostgreSQL) to avoid poller contention, or explicitly documents the equivalent approach"
},
{
"name": "AbortSignal.timeout used",
"max_score": 8,
"description": "Outbound fetch calls include signal: AbortSignal.timeout(...) rather than no timeout or a manual setTimeout approach"
},
{
"name": "10-second fetch timeout",
"max_score": 6,
"description": "The AbortSignal.timeout value is 10000 ms (10 seconds) for outbound webhook delivery"
},
{
"name": "Correct retry delay schedule",
"max_score": 8,
"description": "The retry delay array contains exactly [60000, 300000, 1800000, 7200000, 28800000] ms (1m, 5m, 30m, 2h, 8h) or an equivalent representation of these 5 values"
},
{
"name": "DLQ after 5 attempts",
"max_score": 8,
"description": "Events are moved to the dead-letter queue after the 5th failed attempt (RETRY_DELAYS_MS.length or equivalent threshold of 5)"
},
{
"name": "DLQ alert sent",
"max_score": 6,
"description": "A notification (Slack, PagerDuty, or generic alert function) is called when an event is moved to the dead-letter queue"
},
{
"name": "Payload signed with sha256",
"max_score": 8,
"description": "Outbound payloads are signed using HMAC-SHA256, with the signature included in the X-Webhook-Signature header as 'sha256=<hex>'"
},
{
"name": "Required delivery headers",
"max_score": 8,
"description": "Outbound requests include all three of: X-Webhook-Signature, X-Webhook-Event, and X-Webhook-Delivery headers"
},
{
"name": "Payload structure {id, type, created, data}",
"max_score": 8,
"description": "The outbound JSON payload is structured as an object with fields: id (event ID), type (event type), created (ISO string timestamp), and data (the event payload object)"
},
{
"name": "Delivery attempts logged",
"max_score": 6,
"description": "Each delivery attempt (both successful and failed) is stored/inserted into a deliveries table or log with at minimum: delivery ID, event ID, subscription ID, status, and response status or error"
},
{
"name": "Replay resets attempts to 0",
"max_score": 6,
"description": "The DLQ replay function resets the event's attempts counter to 0 and sets nextRetryAt to now (or immediately), not preserving the previous attempt count"
},
{
"name": "Replay records admin audit",
"max_score": 6,
"description": "The replay function records replayedBy (admin identifier) and replayedAt on both the outbox record and/or the dead-letter record"
}
]
}
Reliable Webhook Delivery System for a SaaS Platform
Problem/Feature Description
You are building the webhook delivery infrastructure for a multi-tenant SaaS platform. Partners register endpoints to receive events (such as invoice.paid, subscription.cancelled, user.created) whenever relevant things happen in the platform. The previous implementation called partner endpoints directly during the request lifecycle — this caused partner timeouts to slow down the main application and meant events were silently lost whenever a partner's server was down during the moment an event fired.
The platform team wants a robust, at-least-once delivery system. Events must survive application crashes between generation and delivery, failed deliveries must be retried automatically with increasing delays, and events that ultimately cannot be delivered must be moved to a dead-letter queue where an alert is triggered and an operator can manually re-queue them later. The system must also correctly authenticate outbound requests so partners can verify that payloads come from your platform.
Output Specification
Produce the following files:
src/webhooks/outbox.ts— The outbox writer (called from within business transactions) and the outbox poller that delivers pending eventssrc/webhooks/delivery.ts— HTTP delivery logic including signing, headers, and timeout handlingsrc/webhooks/retry.ts— Retry scheduling and dead-letter queue promotion logicsrc/webhooks/schema.sql— Database schema for all required tablessrc/webhooks/replay.ts— Admin function to re-queue a dead-letter event for deliveryREADME.md— Architecture overview explaining the reliability guarantees and the retry schedule
The implementation should be TypeScript. You do not need a running server or real database — focus on the logic, structure, and correctness of the code. Stub out database calls as needed (e.g., db.webhookOutbox.findPending(...)) but implement the full business logic around them.
{
"context": "Tests whether the agent implements a Stripe webhook receiver with correct timing-safe HMAC verification, replay attack protection via timestamp validation, and idempotent processing using event IDs marked before processing begins.",
"type": "weighted_checklist",
"checklist": [
{
"name": "timingSafeEqual used",
"max_score": 12,
"description": "The HMAC comparison uses timingSafeEqual (from node:crypto) rather than a plain string/buffer equality check (=== or Buffer.compare)"
},
{
"name": "Stripe timestamp extraction",
"max_score": 8,
"description": "Extracts the t= timestamp value from the Stripe-Signature header by splitting on ',' and finding the part starting with 't='"
},
{
"name": "Replay attack protection",
"max_score": 12,
"description": "Rejects events where the absolute difference between now and the t= timestamp exceeds 300 seconds (5 minutes)"
},
{
"name": "Signed payload format",
"max_score": 8,
"description": "Constructs the signed payload as timestamp + '.' + rawBody (string), matching Stripe's format, before computing the HMAC"
},
{
"name": "Idempotency check before processing",
"max_score": 10,
"description": "Checks whether the event ID has already been processed (query against a stored table/set) before attempting to handle it"
},
{
"name": "Mark received before processing",
"max_score": 12,
"description": "Records/marks the event ID as received BEFORE executing the business logic (not after), to prevent duplicate processing under concurrent delivery"
},
{
"name": "30-day retention noted",
"max_score": 6,
"description": "The schema or README notes that processed webhook IDs should be retained for 30 days (e.g., via TTL index or cleanup job), not indefinitely or a shorter window"
},
{
"name": "Return 200 on processing error",
"max_score": 12,
"description": "When internal processing fails (catch block), the handler still returns a 2xx status code rather than 4xx/5xx, to prevent Stripe from retrying"
},
{
"name": "Invalid signature returns 401",
"max_score": 8,
"description": "Returns HTTP 401 (or 400) when signature verification fails, not 200 or 500"
},
{
"name": "node:crypto import",
"max_score": 6,
"description": "Imports createHmac and timingSafeEqual from 'node:crypto' (not a third-party crypto library)"
},
{
"name": "Length check before timingSafeEqual",
"max_score": 6,
"description": "Handles or guards the case where the two buffers differ in length before calling timingSafeEqual (which throws on mismatched lengths), either via explicit length check or try/catch"
}
]
}
Stripe Webhook Handler for Payment Processing
Problem/Feature Description
Your company has integrated Stripe as its payment processor. The engineering team has been getting reports of duplicate order fulfillments and failed payments being silently ignored. Investigation revealed that the webhook handler doesn't guard against duplicate deliveries (Stripe retries for up to 72 hours), and the current HMAC verification implementation is vulnerable to timing-based attacks. Additionally, security review flagged that the system isn't rejecting replayed events — a credential that was briefly exposed could allow an attacker to replay captured webhook payloads indefinitely.
Your task is to implement a production-quality Stripe webhook receiver endpoint in TypeScript. The handler must correctly verify Stripe's HMAC signature format, protect against replay attacks, deduplicate repeated event deliveries, and respond correctly so that Stripe stops retrying events even when internal processing fails.
Output Specification
Produce the following files:
src/webhooks/verify.ts— HMAC verification utilitiessrc/webhooks/stripe-handler.ts— The webhook handler endpoint logic (framework-agnostic: export a function that accepts rawBody: Buffer, headers: Record<string, string>, and returns {status: number, body: object})src/webhooks/schema.sql— SQL schema for any tables needed to support the implementationREADME.md— Brief explanation of the security properties provided by your implementation and why each design decision was made
The implementation should be demonstrably correct TypeScript. You do not need a running server — focus on the handler logic and its correctness.
{
"context": "Tests whether the agent implements webhook subscription management with a cryptographically secure signing secret that is shown only once, URL reachability validation with correct error handling, and DLQ monitoring that alerts when the queue depth exceeds 10 events.",
"type": "weighted_checklist",
"checklist": [
{
"name": "32-byte random secret",
"max_score": 12,
"description": "The signing secret is generated using crypto.randomBytes(32).toString('hex'), producing a 64-character hex string"
},
{
"name": "Secret shown once only",
"max_score": 10,
"description": "The API response includes the signingSecret value, AND the code/README explicitly states that the secret will not be shown again after creation (not stored retrievably or re-exposed in subsequent API calls)"
},
{
"name": "URL reachability check",
"max_score": 10,
"description": "Before creating a subscription, the code attempts a POST request to the provided URL to verify it is reachable"
},
{
"name": "URL validation timeout",
"max_score": 8,
"description": "The URL validation fetch uses AbortSignal.timeout(5000) or equivalent 5-second timeout"
},
{
"name": "Any status acceptable for validation",
"max_score": 8,
"description": "The URL validation accepts any HTTP response status as valid (does NOT reject based on non-2xx status), only failing if the request itself throws/times out"
},
{
"name": "400 returned on unreachable URL",
"max_score": 8,
"description": "Returns HTTP 400 (not 500 or other status) when the URL validation request fails/times out"
},
{
"name": "DLQ threshold alert at 10",
"max_score": 12,
"description": "The monitoring logic triggers an alert when the dead-letter queue count for a subscriber meets or exceeds 10 events (not a higher or unspecified threshold)"
},
{
"name": "DLQ alert mechanism",
"max_score": 10,
"description": "The monitoring function calls an alerting function (Slack, PagerDuty, or generic notification) when the threshold is exceeded, not just logs to console"
},
{
"name": "node:crypto used for secret",
"max_score": 8,
"description": "Uses the built-in node:crypto module (randomBytes) rather than a third-party library or Math.random() for secret generation"
},
{
"name": "Subscription stores events list",
"max_score": 6,
"description": "The subscription record stores a list of event types the subscriber is interested in (e.g., events: string[])"
},
{
"name": "isActive flag on subscription",
"max_score": 8,
"description": "The subscription record includes an isActive (or equivalent enabled/active) boolean field"
}
]
}
Partner Webhook Subscription API
Problem/Feature Description
Your company is launching a partner integration program that allows third-party developers to subscribe to platform events and receive real-time notifications at their own endpoints. The developer relations team has asked for a self-service API where partners can register, update, and manage their webhook endpoints without requiring manual intervention from your team.
The security team has a firm requirement: each webhook subscription must be independently secured with a unique credential that partners use to verify that deliveries originate from your platform. This credential must be generated securely and may never be retrieved after creation — if a partner loses it, they must rotate or recreate the subscription. The team also wants the system to be observable: the support team needs to be notified automatically if the dead-letter queue for any subscriber grows large enough to indicate a systematic integration failure, rather than finding out from a frustrated partner days later.
Your task is to implement the webhook subscription management API and the DLQ monitoring logic.
Output Specification
Produce the following files:
src/webhooks/subscriptions.ts— The subscription creation endpoint logic (framework-agnostic: export a function accepting request data and returning a response object)src/webhooks/monitoring.ts— DLQ monitoring function that checks queue depth and sends an alert when a threshold is exceededsrc/webhooks/schema.sql— Database schema for webhook subscriptions and any supporting tablesREADME.md— Document the API contract, the security model for signing secrets, and the monitoring behaviour
{
"name": "finsi/webhook-architecture",
"version": "0.1.0",
"summary": "Reliable webhook delivery with retries, signatures, dead-letter queues",
"skills": {
"webhook-architecture": {
"path": "SKILL.md"
}
}
}