Skip to content
Get started
Get started
Webhooks: Get Notified When Jobs Complete or Fail
View Markdown
guide · webhooks

Job events, delivered as webhooks.

A webhook is an HTTP POST that bunqueue sends to your URL when something happens to a job. Instead of polling for status, your service gets told, with a signed payload and automatic retries.

Register a URL and pick the events you care about (--events is required):

bunqueue webhook add https://api.example.com/hooks/bunqueue \
--events job.completed,job.failed --secret my-webhook-secret

Then receive the POSTs. A minimal Bun server:

Bun.serve({
port: 3000,
async fetch(req) {
const payload = await req.json();
console.log(`${payload.event} for job ${payload.jobId} on ${payload.queue}`);
return Response.json({ received: true });
},
});

That is enough to see events flowing. In production, always verify the signature first (see below).

The TypeScript (legacy entry), Python, PHP, and Go SDKs expose the same webhook surface programmatically:

The Bun package has no typed webhook helpers yet. Manage webhooks with the CLI above, the HTTP API, or the raw AddWebhook / ListWebhooks / RemoveWebhook TCP commands.

The PHP and Go helpers register global, unsigned webhooks (url + events only); use the CLI or HTTP API to set a secret or a queue scope from those languages.

bunqueue webhook add https://api.example.com/hooks/emails \
--events job.completed,job.failed --queue emails
bunqueue webhook list
# 01920b5e-7c4a-...: https://api.example.com/hooks/bunqueue
# Events: job.completed, job.failed
# Delivered: 42 ok / 0 failed
bunqueue webhook remove 01920b5e-7c4a-7000-8a3e-2f9d1c4b6e10

Each entry starts with the webhook ID; that ID is what remove takes. Disabled webhooks are marked [disabled].

Toggling is not a CLI command. Use the SDK helpers shown above (setWebhookEnabled / set_webhook_enabled / SetWebhookEnabled), the TCP command SetWebhookEnabled, the HTTP API, or the MCP tool:

bunqueue_set_webhook_enabled({ id: "01920b5e-...", enabled: false })

Disabling stops delivery but keeps the configuration, useful during maintenance.

These five events are the only valid ones; anything else is rejected at registration time:

EventWhen it fires
job.pushedJob added to a queue
job.startedA worker picked the job up
job.completedThe worker finished successfully
job.failedThe worker threw an error
job.progressThe processor called job.updateProgress()

Every delivery is a JSON POST with three headers: X-Webhook-Event, X-Webhook-Timestamp, and X-Webhook-Signature (only when a secret is set).

{
"event": "job.completed",
"timestamp": 1704067200000,
"jobId": "1001",
"queue": "emails",
"data": { "sent": true }
}

All payloads carry event, timestamp, jobId, and queue. The rest depends on the event:

  • job.completed: data is the result returned by the worker
  • job.failed: data is the job’s input data, plus an error message
  • job.progress: carries a progress number instead of data
  • job.pushed and job.started: base fields only

When a webhook is registered with --secret, bunqueue signs each payload with HMAC-SHA256 (a keyed hash: only someone who knows the secret can produce a valid signature). The signature is the hex-encoded HMAC of the raw JSON body. Verify it before trusting the request; the receiver can be written in any language:

import { createHmac, timingSafeEqual } from 'crypto';
function verifySignature(rawBody: string, signature: string, secret: string): boolean {
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && timingSafeEqual(a, b);
}
Bun.serve({
port: 3000,
async fetch(req) {
const signature = req.headers.get('x-webhook-signature');
const rawBody = await req.text();
if (!signature || !verifySignature(rawBody, signature, process.env.WEBHOOK_SECRET!)) {
return Response.json({ error: 'Invalid signature' }, { status: 401 });
}
const payload = JSON.parse(rawBody);
// process payload ...
return Response.json({ received: true });
},
});

Two things matter in any language: compute the HMAC over the raw request body (not a re-serialized object), and compare with a constant-time function (timingSafeEqual in Node/Bun, hmac.compare_digest in Python, hash_equals in PHP, hmac.Equal in Go, Mac::verify_slice in Rust, Plug.Crypto.secure_compare in Elixir).

A delivery succeeds when your endpoint returns a 2xx status within 10 seconds. Failed deliveries are retried with linear backoff:

AttemptDelay
1Immediate
21 second
32 seconds

The totals are configurable with WEBHOOK_MAX_RETRIES (default 3 attempts, the first try included; at least 1) and WEBHOOK_RETRY_DELAY_MS (default 1000; attempt n + 1 waits n × this delay). The webhooks.maxRetries and webhooks.retryDelay keys in bunqueue.config.ts are ignored (the server logs a warning). An invalid value (for example abc or 0 retries, or a 1e3 delay) stops the server at startup with an error naming the setting, instead of never delivering. A delay without a number or below zero (abc, -1) retries at once, as earlier releases did, and the server logs a warning. After all attempts, the delivery is abandoned and logged. Webhook failures never affect job processing.

Because of retries, the same event can arrive twice. Deduplicate on jobId + event if that matters to your handler. Also return 2xx quickly and do heavy work asynchronously, so slow processing does not get counted as a failed delivery.

  • SSRF protection: URLs pointing to localhost, private or link-local IP ranges, or cloud metadata endpoints are rejected at registration time. Only http:/https: URLs up to 2048 characters are accepted. This means you cannot register a webhook to a local dev server from the same machine.
  • Missing events: check the --events and --queue filters, and remember job.progress only fires when a processor calls job.updateProgress().
  • Nothing delivered: run bunqueue webhook list and look at the Delivered: N ok / N failed counters and the [disabled] marker; delivery failures are also logged by the server with the target URL.
  • Invalid signature errors: verify against the raw body, make sure no proxy rewrites the payload, and confirm both sides use the same secret.