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

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)

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

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

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:

  • Set up alerts for DLQ entries
  • Monitor webhook processing latency

Next Steps

Last updated 2026-09-10