Webhooks
Spooled helps you process external events reliably. Most providers (Stripe/GitHub/Shopify) send fixed webhook formats, so the usual pattern is: receive webhook → verify signature → enqueue a job → return 200.
Related guides:
How It Works
Instead of processing webhooks synchronously in your endpoint, you queue them in Spooled. This provides reliability, retries, and observability for all your webhook processing.
Incoming webhook authentication
Spooled’s incoming custom webhook endpoint requires X-Webhook-Token.
Get it in the dashboard (Organization Settings) or via the API:
GET /api/v1/organizations/webhook-token.
To rotate it: POST /api/v1/organizations/webhook-token/regenerate.
%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#ecfdf5', 'primaryTextColor': '#065f46', 'primaryBorderColor': '#10b981', 'lineColor': '#6b7280'}}}%%
flowchart LR
STRIPE[Stripe] -->|webhook| ADAPTER[Your webhook endpoint / adapter]
GITHUB[GitHub] -->|webhook| ADAPTER
SHOPIFY[Shopify] -->|webhook| ADAPTER
ADAPTER -->|enqueue job| SP[Spooled]
CUSTOM[Your own service] -->|Spooled JSON format| SP
SP -->|queue| Q[(Queue)]
Q -->|process| W[Your Workers] Benefits
- Fast webhook responses — Return 200 immediately, process async
- Automatic retries — Failed processing retries with backoff
- Deduplication — Idempotency prevents duplicate processing
- Observability — Monitor all webhook processing in real-time
SSRF Protection
When configuring outgoing webhooks (webhooks that Spooled sends to your URLs), Spooled validates URLs in production to prevent Server-Side Request Forgery (SSRF) attacks.
Production URL Requirements
What to look for:
- → Private IP ranges (10.x.x.x, 172.16-31.x.x, 192.168.x.x) are blocked
- → Loopback addresses (127.x.x.x, localhost) are blocked
- → Cloud metadata endpoints (169.254.169.254) are blocked
- → HTTPS is required in production
For local development, use HTTPS URLs or test against a self-hosted instance where SSRF protection is relaxed.
Outgoing Webhook Delivery
Each event Spooled sends to your URL is one delivery. A delivery that does not get a
2xx is retried a bounded number of times — 5 attempts by default — and then counted as
one failure. failure_count on the webhook counts deliveries, not individual attempts,
so the number is roughly five times smaller than the raw attempt count for the same amount of
breakage. Any successful delivery resets it to 0, including a successful manual retry.
Endpoints are disabled after 20 consecutive failures
After 20 consecutive failed deliveries the webhook is disabled automatically:
enabled becomes false and last_status becomes
auto_disabled. It receives no further events until you turn it back on, so an outage
longer than 20 deliveries leaves you with a fixed endpoint and still no traffic.
Recover it with PUT /api/v1/outgoing-webhooks/{id} and body
{"enabled": true}. Re-enabling is charged against your plan’s webhook cap
like a creation, so it can fail with 429 QUOTA_EXCEEDED if you are already at the
cap — delete or disable another endpoint first, or
upgrade the plan.
last_status | Meaning |
|---|---|
success | Last delivery was accepted; failure_count is back to 0 |
failed | Last delivery exhausted its attempts; the endpoint is still enabled |
auto_disabled | 20 consecutive failures; enabled is false until you re-enable it |
Deliveries run against a process-wide concurrency cap (64 in flight by default), so a burst queues rather than firing all at once. Delivery order is not guaranteed — use the payload, not arrival order, to reconstruct what happened.
Delivery records are retained for your plan’s history retention window — 1 day on Free, 7 on Starter, 30 on Pro, 90 on Enterprise — and a per-organization sweep deletes them after that. Only the newest 100 deliveries per webhook are readable through the API in any case, so treat the history as a debugging aid rather than an audit log.
Changing the signing secret
PUT /api/v1/outgoing-webhooks/{id} treats secret as three
separate instructions:
| You send | Result |
|---|---|
| Field omitted | Current secret is kept |
"secret": null | Secret is cleared — deliveries go out unsigned, with no X-Spooled-Signature header |
"secret": "…" | Secret is replaced with the new value |
Explicit null wipes the secret
Many clients serialise a whole object on update, turning fields the user never touched into
explicit null. On this endpoint that silently removes signing from a live webhook.
Send only the fields you intend to change, and if your receiver requires
X-Spooled-Signature, keep rejecting unsigned requests — after a cleared secret they
will arrive without one.
Stripe Webhooks
Stripe sends webhooks for payment events. You should receive them in your own endpoint (so you can verify the signature), then enqueue the event into Spooled as a job.
Important
Stripe webhooks are not in “Spooled job format”, so you generally do not point Stripe directly at Spooled.
Use a tiny adapter endpoint that verifies Stripe-Signature and enqueues a job.
Example: Stripe adapter (verify, then enqueue)
# Queue a Stripe webhook event
curl -X POST https://api.spooled.cloud/api/v1/jobs \
-H "Authorization: Bearer sp_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"queue_name": "stripe-events",
"payload": {
"id": "evt_1234",
"type": "invoice.paid",
"data": {"object": {...}}
},
"idempotency_key": "evt_1234"
}'Worker to Process Events
import { SpooledClient, SpooledWorker } from '@spooled/sdk';
const client = new SpooledClient({
apiKey: process.env.SPOOLED_API_KEY!,
});
// Worker to process Stripe events
const worker = new SpooledWorker(client, {
queueName: 'stripe-events',
});
worker.process(async (ctx) => {
const event = ctx.payload;
switch (event.type) {
case 'invoice.paid':
await handleInvoicePaid(event.data.object);
break;
case 'customer.subscription.updated':
await handleSubscriptionUpdate(event.data.object);
break;
case 'payment_intent.succeeded':
await handlePaymentSuccess(event.data.object);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
return { processed: true };
});
await worker.start();Best Practice: Use Event IDs
Always use the webhook event ID (e.g., evt_1234) as the idempotency key.
This prevents duplicate processing if Stripe retries the webhook.
GitHub Webhooks
GitHub webhooks notify you about repository events like pushes, pull requests, and issues. The common pattern is the same: receive the webhook, verify it, enqueue a job.
import { SpooledClient } from '@spooled/sdk';
const client = new SpooledClient({
apiKey: process.env.SPOOLED_API_KEY!,
});
// GitHub webhook handler
app.post('/webhooks/github', async (req, res) => {
const event = req.headers['x-github-event'];
const delivery = req.headers['x-github-delivery'];
// Queue for processing
await client.jobs.create({
queueName: 'github-events',
payload: {
event,
data: req.body,
},
idempotencyKey: delivery, // Use delivery ID for deduplication
});
res.status(200).send('Queued');
});Custom HTTP Webhooks
For any source, the safe pattern is the same:
- Receive the webhook at your endpoint
- Optionally verify the signature
- Queue the payload in Spooled
- Return 200 immediately
- Process asynchronously with a worker
Signature Verification
Always verify webhook signatures to ensure requests come from the expected source. Provider → your adapter (inbound) and Spooled → your URL (outbound) use different headers:
| Service | Header | Algorithm |
|---|---|---|
| Stripe (inbound to you) | Stripe-Signature | HMAC-SHA256 |
| GitHub (inbound to you) | X-Hub-Signature-256 | HMAC-SHA256 |
| Shopify (inbound to you) | X-Shopify-Hmac-Sha256 | HMAC-SHA256 |
| Twilio (inbound to you) | X-Twilio-Signature | HMAC-SHA1 |
| Spooled org outgoing webhooks | X-Spooled-Signature | sha256= HMAC of {ts}.{rawBody} (only while an org secret is set — the header is absent once it is cleared) |
Per-job completion_webhook | X-Spooled-Signature (optional) | Unsigned by default; set completion_webhook_secret on create for the same sha256= HMAC as org outgoing webhooks (backend 0.1.109+) |
An org outgoing webhook’s secret can be added, replaced or removed at any time — see
changing the signing secret. If it is removed, deliveries continue
but arrive unsigned, so a receiver that requires X-Spooled-Signature will reject them
until a secret is set again.
Dashboard Tip
What to look for:
- → Filter by queue (e.g., stripe-events, github-events)
- → View webhook payload in job details
- → Track processing status and errors
Actions:
- Set up alerts for DLQ entries
- Monitor webhook processing latency
Next Steps
- Jobs & queues — Deep dive into job processing
- Building workers — Production-ready worker patterns
- Retry strategies — Handle failures gracefully