Skip to content
On this page

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.

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.

Bash
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.createdA job is enqueued
job.startedA worker claims a job
job.completedA job completes
job.failedA 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.deadletteredA job moves to the dead-letter queue. Sent next to every job.failed whose data.status is deadletter, with the same data
job.retriedA 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.cancelledA job is cancelled
workflow.completedA workflow finished with no failed or dead-lettered job. Sent at most once
workflow.failedA workflow finished with at least one failed or dead-lettered job (a cancelled workflow sends neither). Sent at most once
queue.paused, queue.resumedA queue is paused or resumed
worker.registered, worker.deregisteredA worker connects or disconnects
schedule.triggeredA schedule created jobs

The body of every event is the same envelope, {id, event, created_at, data}; only data differs. The newer events carry:

JSON
// 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:

Bash
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 data fields (name, queue, job, status, error, reason and so on); a value is cut at 300 characters.
  • format is case-insensitive; any value but the four is a 400. On PUT /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, and POST /api/v1/outgoing-webhooks/{id}/test sends the test in the chat format as well. With a chat format the body has no id, 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)

Stripe webhook handler (verify + enqueue job)
# 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

Process Stripe 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.

GitHub webhook handler (receive + enqueue 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:

  1. Receive the webhook at your endpoint
  2. Optionally verify the signature
  3. Queue the payload in Spooled
  4. Return 200 immediately
  5. 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.

Bash
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
# 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

Dashboard → Jobs

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

Last updated 2026-10-16