Install
$ agentstack add skill-furkangonel-cowrangler-webhook-subscriptions Open-source listing — not yet scanned by AgentStack. Follow the source repository for install instructions.
Security review
⚠ Flagged1 finding(s); flagged for manual review. · v0.1.0 How review works →
- • Prompt-injection patterns
- • Secret / credential exfiltration
- • Dangerous shell & filesystem operations
- • Untrusted network calls
- • Known-malicious package signatures
- high Reads credentials/environment and may exfiltrate them.
What it can access
- ● Network access Used
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ● Environment & secrets Used
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
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= | | Shopify | x-shopify-hmac-sha256 | Base64 encoded | | Twilio | x-twilio-signature | Base64 HMAC-SHA1 of URL + sorted params | | Slack | x-slack-signature | v0=, 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);
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 {
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 {
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
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: furkangonel
- Source: furkangonel/cowrangler
- License: MIT
- Homepage: https://cowrangler.com
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.