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
Inbound requests vs outgoing deliveries
GET /api/v1/webhooks/deliveries does not list the webhooks Spooled sends
It is the log of inbound requests: calls made to your custom webhook endpoint
(POST /api/v1/webhooks/{org_id}/custom). If you created an outgoing webhook, sent a test and
got an empty list here, nothing is wrong: outgoing deliveries are never in it. The attempts Spooled makes
to your URL are at GET /api/v1/outgoing-webhooks/{id}/deliveries.
| Direction | Route | What it lists |
|---|---|---|
| Inbound: someone calls Spooled | GET /api/v1/webhooks/deliveries | Requests your custom webhook endpoint accepted, newest first, without payloads. Filters status, since, queue_name, limit, offset. See Inbound request log |
| Outgoing: Spooled calls your URL | GET /api/v1/outgoing-webhooks/{id}/deliveries | The deliveries to that one webhook, newest first, at most 100, with the status code and response. No query parameters. See Reading outgoing deliveries |
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.
Reading outgoing deliveries
GET /api/v1/outgoing-webhooks/{id}/deliveries lists the delivery attempts Spooled made
to this webhook's URL, newest first. {id} is the webhook's id from
GET /api/v1/outgoing-webhooks. It takes no query parameters and returns at most the
newest 100 deliveries. It needs an unrestricted API key (a queue-scoped key gets 403) and answers
404 for a webhook that is not yours.
curl "https://api.spooled.cloud/api/v1/outgoing-webhooks/WEBHOOK_ID/deliveries" \
-H "Authorization: Bearer YOUR_API_KEY"
[
{
"id": "5f0c1c2e-8b7a-4d0e-9a53-1f2b3c4d5e6f",
"webhook_id": "WEBHOOK_ID",
"event": "job.completed",
"payload": {"id": "5f0c1c2e-8b7a-4d0e-9a53-1f2b3c4d5e6f", "event": "job.completed", "created_at": "2026-10-15T09:15:00Z", "data": {"job_id": "job_xxx", "queue_name": "emails"}},
"status": "success",
"status_code": 200,
"response_body": "ok",
"error": null,
"attempts": 1,
"created_at": "2026-10-15T09:15:00Z",
"delivered_at": "2026-10-15T09:15:01Z"
}
] status is success when your endpoint answered 2xx and failed
otherwise; status_code, response_body (truncated) and error say what your
endpoint returned or why the request did not go through, and attempts counts the tries so far.
payload is the JSON envelope. A failed delivery can be re-sent with
POST /api/v1/outgoing-webhooks/{id}/retry/{delivery_id} (a delivery stops accepting
retries once it has had 10 attempts in total, automatic ones included; a successful one cannot be retried).
Not to be confused with GET /api/v1/webhooks/deliveries, which lists
inbound requests to your custom webhook endpoint and never contains these.
Events you can subscribe to
Pick the events when you create the webhook (events is a list). Each delivery names its event in the
event field of the body and in the X-Spooled-Event header.
| Event | Sent when |
|---|---|
job.created | A job is enqueued |
job.started | A worker claims a job |
job.completed | A job completes |
job.failed | A job fails; data.status is failed (it will retry) or deadletter. A job dead-lettered because its lease expired or its worker went offline sends it too, with the reason in data.error |
job.deadlettered | A job moves to the dead-letter queue. Sent next to every job.failed whose data.status is deadletter, with the same data |
job.retried | A failed, deadletter or cancelled job is put back to pending by POST /api/v1/jobs/{id}/retry, POST /api/v1/jobs/dlq/retry or POST /api/v1/workflows/{id}/retry |
job.cancelled | A job is cancelled |
workflow.completed | A workflow finished with no failed or dead-lettered job. Sent at most once |
workflow.failed | A workflow finished with at least one failed or dead-lettered job (a cancelled workflow sends neither). Sent at most once |
queue.paused, queue.resumed | A queue is paused or resumed |
worker.registered, worker.deregistered | A worker connects or disconnects |
schedule.triggered | A schedule created jobs |
The body of every event is the same envelope, {id, event, created_at, data}; only
data differs. The newer events carry:
// job.deadlettered: the data of the job.failed it follows
{"job_id": "job_xxx", "queue_name": "emails", "status": "deadletter", "error": "Failed after 3 retries"}
// job.retried
{"job_id": "job_xxx", "queue_name": "emails", "status": "pending", "old_status": "deadletter",
"source": "dlq_retry", "workflow_id": "wf_xxx"}
// workflow.completed / workflow.failed
{"workflow_id": "wf_xxx", "name": "nightly-export", "status": "failed", "total_jobs": 5,
"completed_jobs": 3, "failed_jobs": 2, "created_at": "2026-10-12T00:00:00Z",
"started_at": "2026-10-12T00:00:12Z", "completed_at": "2026-10-12T00:03:40Z",
"metadata": null} job.deadlettered is sent once per dead-lettering, whichever path did it (a failure the worker reported
over REST or gRPC, a lease that expired on the last retry, a worker that went offline), right after the matching
job.failed. Subscribe to only one of the two unless you want both. This is the event to alert on when
you want to hear about every dead-lettered job.
Workflow outcomes are announced at most once
A scheduler pass every 10 seconds finds the workflows that ended, marks each outcome as announced, and only then
queues the event for delivery. So no outcome is sent twice, but one can be lost:
if the process stops between those two steps, if the delivery queue is full at that moment, or if recording the
delivery fails. A failed workflow that you retry with POST /api/v1/workflows/{id}/retry within
about 10 seconds never announces that workflow.failed (only the outcome it reaches after the retry),
and an outcome more than an hour old at the pass is never sent.
Treat the event as a prompt, not as the record: confirm with GET /api/v1/workflows/{id} and
reconcile now and then with GET /api/v1/workflows.
Slack, Discord and Teams formats
By default a webhook receives the signed JSON envelope ("format": "json"). Set format to
slack, discord or teams and Spooled sends a short chat message instead, so the
incoming-webhook URL of the chat service can be used as it is:
curl -X POST https://api.spooled.cloud/api/v1/outgoing-webhooks \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Slack alerts",
"url": "https://hooks.slack.com/services/T000/B000/XXXX",
"events": ["job.deadlettered", "workflow.failed"],
"format": "slack"
}'format | Body sent | Notes |
|---|---|---|
json (default) | {id, event, created_at, data} | The signed envelope, unchanged. Every webhook created before 0.1.122 is json |
slack | {"text": "..."} | mrkdwn; &, < and > in values are escaped, so a value cannot become a link or an <!channel> |
discord | {"content": "...", "allowed_mentions": {"parse": []}} | At most 2,000 characters, markdown escaped; allowed_mentions is empty, so an @everyone in an error text pings nobody |
teams | A MessageCard | The legacy Office 365 connector card. Teams Workflows (Power Automate) webhooks expect an Adaptive Card and reject it |
- The message has a title for the event ("Spooled: job failed") and the event's most useful
datafields (name, queue, job, status, error, reason and so on); a value is cut at 300 characters. formatis case-insensitive; any value but the four is a400. OnPUT /api/v1/outgoing-webhooks/{id}, omitting it keeps the current format.- The headers are the same for every format, and
X-Spooled-Signature(when a secret is set) signs the bytes actually sent. Chat services ignore it, so a secret is rarely worth setting for them. - The message is built when the delivery is sent: the delivery history keeps the JSON envelope in
payload, a manual retry sends with the webhook's current format, andPOST /api/v1/outgoing-webhooks/{id}/testsends the test in the chat format as well. With a chat format the body has noid, so read the delivery id from the delivery history.
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
Inbound request log
GET /api/v1/webhooks/deliveries lists the inbound requests your custom webhook endpoint
(POST /api/v1/webhooks/{org_id}/custom) accepted, newest first, so you can check that a provider
is calling and what became of each call. It does not list the deliveries Spooled sends to your
outgoing webhooks: those are at
GET /api/v1/outgoing-webhooks/{id}/deliveries.
curl "https://api.spooled.cloud/api/v1/webhooks/deliveries?status=duplicate&limit=20" \
-H "Authorization: Bearer YOUR_API_KEY"
[
{
"id": "d3a1c0f2-5b7e-4e8a-9c41-2f6d8a1b7c30",
"provider": "custom",
"event_type": "order.created",
"matched_queue": "my-events",
"job_id": "job_550e8400e29b41d4",
"status": "created",
"created_at": "2026-10-12T09:15:00Z"
}
] status is created when the request made a new job and duplicate when its
idempotency_key matched an existing one (job_id and matched_queue then name that
job and its queue). Filters: status, since (RFC 3339), queue_name (alias
queue), limit (default 50, at most 100) and offset. Only accepted requests are
logged: a wrong token or an over-limit request writes nothing. The log never returns the payload, a signature or the
token, it needs an unrestricted API key (a queue-scoped key gets 403), and entries from before API 0.1.122
have a null job_id and status. Entries are removed with the rest of your
history retention window.
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+) |
Verifying a Spooled signature
Spooled signs {X-Spooled-Timestamp}.{raw body} with HMAC-SHA256 using the
webhook's secret and sends sha256=<hex> in X-Spooled-Signature. Verify
against the raw request bytes — a parsed-and-reserialized body will not match — and compare in
constant time. A Node.js version is in
Troubleshooting.
# Python — verify an outgoing Spooled webhook (e.g. Flask: request.get_data())
import hashlib, hmac, time
def verify_spooled_webhook(raw_body: bytes, headers, secret: str, tolerance_secs: int = 300) -> bool:
timestamp = headers.get("X-Spooled-Timestamp")
signature = headers.get("X-Spooled-Signature") # "sha256=<hex>"
if not timestamp or not signature:
return False
if abs(time.time() - int(timestamp)) > tolerance_secs:
return False # each delivery attempt is signed with a fresh timestamp
expected = "sha256=" + hmac.new(
secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
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:
- Check the dead-letter queue for failed jobs
- Review processing status and errors
Next Steps
- Jobs & queues — Deep dive into job processing
- Building workers — Production-ready worker patterns
- Retry strategies — Handle failures gracefully