Webhook subscriptions
Skill furkangonel/cowrangler/bundled_skills/devops/webhook-subscriptions
Autonomous terminal AI agent for workflows and feasible project procedures. Co-Worker Co-Wrangler π
npx -y skills add furkangonel/cowrangler --skill webhook-subscriptionsAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 2 stars2 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Design, implement, and debug webhook integrations with security and reliability.
SKILL.md
9.1 KB, as published. Nobody here has run it
Webhook Subscriptions SOP
When to Use
- User wants to receive events from an external service (Stripe, GitHub, Shopify, Twilio, etc.)
- User wants to send webhooks from their own service to subscribers
- User is debugging why webhooks aren't being received or processed
- User wants to validate webhook security or handle retries
Part 1 β Receiving Webhooks
Minimal Express.js Handler
import express from "express";
import crypto from "crypto";
const app = express();
// IMPORTANT: use raw body for signature validation, not parsed JSON
app.post(
"/webhooks/stripe",
express.raw({ type: "application/json" }),
async (req, res) => {
// 1. Validate signature first β reject early if invalid
const signature = req.headers["stripe-signature"] as string;
const isValid = validateStripeSignature(req.body, signature);
if (!isValid) {
console.warn("Invalid webhook signature", { signature });
return res.status(400).json({ error: "Invalid signature" });
}
// 2. Parse the payload
const event = JSON.parse(req.body.toString());
// 3. Respond 200 immediately β do not wait for processing
res.status(200).json({ received: true });
// 4. Process asynchronously (after responding)
await processEvent(event).catch((err) => {
console.error("Webhook processing failed", { eventId: event.id, err });
});
}
);
Why respond 200 immediately?
Most webhook providers retry on any non-2xx response or on timeout (typically 10β30 seconds). Long-running processing will cause unnecessary retries. Always acknowledge first, process after.
Part 2 β Signature Validation (HMAC)
Generic HMAC-SHA256 Validation
function validateWebhookSignature(
payload: Buffer,
receivedSig: string,
secret: string
): boolean {
const expectedSig = crypto
.createHmac("sha256", secret)
.update(payload)
.digest("hex");
// Constant-time comparison to prevent timing attacks
return crypto.timingSafeEqual(
Buffer.from(receivedSig),
Buffer.from(expectedSig)
);
}
Provider-Specific Signature Patterns
| Provider | Header | Format |
|---|---|---|
| Stripe | stripe-signature | t=timestamp,v1=signature |
| GitHub | x-hub-signature-256 | sha256=<hex> |
| Shopify | x-shopify-hmac-sha256 | Base64 encoded |
| Twilio | x-twilio-signature | Base64 HMAC-SHA1 of URL + sorted params |
| Slack | x-slack-signature | v0=<hex>, includes timestamp |
Stripe Signature Example
function validateStripeSignature(
payload: Buffer,
sigHeader: string,
secret: string = process.env.STRIPE_WEBHOOK_SECRET!
): boolean {
const parts = sigHeader.split(",").reduce((acc, part) => {
const [key, value] = part.split("=");
acc[key] = value;
return acc;
}, {} as Record<string, string>);
const timestamp = parts["t"];
const receivedSig = parts["v1"];
// Reject events older than 5 minutes (replay attack prevention)
const tolerance = 300; // seconds
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > tolerance) {
return false;
}
const signedPayload = `${timestamp}.${payload.toString()}`;
const expectedSig = crypto
.createHmac("sha256", secret)
.update(signedPayload)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(receivedSig),
Buffer.from(expectedSig)
);
}
Part 3 β Idempotency
Webhook providers may deliver the same event more than once. Always handle duplicates.
Strategy 1 β Event ID Deduplication (Redis)
import { Redis } from "ioredis";
const redis = new Redis(process.env.REDIS_URL);
async function processEvent(event: WebhookEvent): Promise<void> {
const lockKey = `webhook:processed:${event.id}`;
const ttl = 60 * 60 * 24; // 24 hours
// SET NX (only set if not exists) β atomic deduplication
const acquired = await redis.set(lockKey, "1", "EX", ttl, "NX");
if (!acquired) {
console.log("Duplicate webhook, skipping", { eventId: event.id });
return;
}
await handleEvent(event);
}
Strategy 2 β Database Upsert
INSERT INTO webhook_events (id, type, payload, processed_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (id) DO NOTHING;
-- Returns 0 rows affected if duplicate β check in application code
const result = await db.query(
`INSERT INTO webhook_events (id, type, payload, processed_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (id) DO NOTHING`,
[event.id, event.type, JSON.stringify(event)]
);
if (result.rowCount === 0) {
return; // already processed
}
Part 4 β Retry Handling
Exponential Backoff (for sending webhooks)
async function deliverWebhook(
url: string,
payload: object,
attempt = 1
): Promise<void> {
const maxAttempts = 5;
const baseDelay = 1000; // 1 second
try {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Webhook-Id": generateEventId(),
"X-Webhook-Signature": signPayload(payload),
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(10_000), // 10s timeout
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
} catch (err) {
if (attempt >= maxAttempts) {
console.error("Webhook delivery failed after max attempts", { url, attempt });
await saveFailedWebhook(url, payload, err); // dead-letter queue
return;
}
// Exponential backoff with jitter
const delay = baseDelay * Math.pow(2, attempt - 1) + Math.random() * 1000;
console.warn(`Webhook attempt ${attempt} failed, retrying in ${delay}ms`);
await sleep(delay);
return deliverWebhook(url, payload, attempt + 1);
}
}
Retry Schedule Reference
| Attempt | Delay (typical) |
|---|---|
| 1st retry | 5 seconds |
| 2nd retry | 30 seconds |
| 3rd retry | 5 minutes |
| 4th retry | 30 minutes |
| 5th retry | 2 hours |
Part 5 β Local Testing
Option A β ngrok
# Install ngrok, then:
ngrok http 3000
# Your local port 3000 is now accessible at:
# https://abc123.ngrok.io
# Use that URL in the Stripe / GitHub webhook settings
# ngrok dashboard: http://localhost:4040 β inspect all requests/responses
Option B β smee.io (GitHub-native)
npm install --global smee-client
smee --url https://smee.io/your-channel-id --target http://localhost:3000/webhooks/github
Option C β Webhook.site
- Go to webhook.site β get a unique URL
- Point the external service at that URL
- Inspect the raw payload format (headers, body)
- Copy the exact payload for local testing with curl:
curl -X POST http://localhost:3000/webhooks/stripe \
-H "Content-Type: application/json" \
-H "stripe-signature: t=1234567890,v1=your_test_sig" \
-d @payload.json
Part 6 β Error Response Codes
| Scenario | Response | Why |
|---|---|---|
| Success | 200 OK | Always β even if you'll process async |
| Invalid signature | 400 Bad Request | Signals misconfiguration to sender |
| Unsupported event type | 200 OK | Don't cause retries for events you ignore |
| Duplicate (already processed) | 200 OK | Idempotent success |
| Your server error | 500 Internal Server Error | Triggers provider retry |
| Rate limited (you're overloaded) | 429 Too Many Requests + Retry-After header | Tells sender to back off |
Part 7 β Outgoing Webhook Server Checklist
When building a service that sends webhooks to subscribers:
- Validate the subscriber URL is reachable before saving it
- Store the subscription with: URL, events list, secret, created_at, status
- Sign every outgoing payload with HMAC-SHA256 using a per-subscriber secret
- Include a unique event ID in headers (
X-Webhook-Id) - Implement retry with exponential backoff (max 5 attempts)
- Log all delivery attempts (status code, latency, attempt number)
- Disable subscriptions after N consecutive failures (e.g., 3 days of failures)
- Provide a UI for subscribers to view delivery logs and manually retry
- Implement a test delivery endpoint (
POST /webhooks/test)
Agent Instructions
- When helping with incoming webhooks, always ask which provider (Stripe, GitHub, etc.) β each has a different signature scheme
- Warn immediately if the user parses the body before signature validation β this invalidates the signature check
- Always include idempotency handling β assume delivery-at-least-once
- For local testing, recommend ngrok for quick sessions and smee for persistent dev setups
- When writing webhook handlers, put the
200response before async processing β it prevents retries - If a user's webhooks aren't arriving, check: URL accessibility, signature mismatch, SSL certificate, firewall rules, and request timeout