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.
Quick Start
Section titled “Quick Start”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-secretThen 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).
Common Tasks
Section titled “Common Tasks”Manage webhooks from an SDK
Section titled “Manage webhooks from an SDK”The TypeScript, Python, PHP, and Go SDKs expose the same webhook surface programmatically:
import { Queue } from 'bunqueue-client';
const queue = new Queue('emails');
// Registered webhooks are scoped to this queue unless you pass `queue`const { webhookId } = await queue.addWebhook({ url: 'https://api.example.com/hooks/bunqueue', events: ['job.completed', 'job.failed'], secret: 'my-webhook-secret', // optional});
const hooks = await queue.listWebhooks();await queue.setWebhookEnabled(webhookId, false); // pause deliveryawait queue.removeWebhook(webhookId);from bunqueue import Queue
queue = Queue("emails")
# queue=None registers a global webhook; pass queue="emails" to scope itwebhook_id = queue.add_webhook( "https://api.example.com/hooks/bunqueue", ["job.completed", "job.failed"], secret="my-webhook-secret", # optional)
hooks = queue.list_webhooks()queue.set_webhook_enabled(webhook_id, False) # pause deliveryqueue.remove_webhook(webhook_id)use Bunqueue\Queue;
$queue = new Queue('emails');
$webhookId = $queue->addWebhook( 'https://api.example.com/hooks/bunqueue', ['job.completed', 'job.failed']);
$hooks = $queue->listWebhooks();$queue->setWebhookEnabled($webhookId, false); // pause delivery$queue->removeWebhook($webhookId);queue := bunqueue.NewQueue("emails", bunqueue.Options{})
webhookID, _ := queue.AddWebhook( "https://api.example.com/hooks/bunqueue", []string{"job.completed", "job.failed"},)
hooks, _ := queue.ListWebhooks()_ = queue.SetWebhookEnabled(webhookID, false) // pause delivery_ = queue.RemoveWebhook(webhookID)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. The Bun package, Rust, and Elixir have no typed webhook helpers yet: use the CLI above, the HTTP API, or the raw TCP commands.
Scope a webhook to one queue
Section titled “Scope a webhook to one queue”bunqueue webhook add https://api.example.com/hooks/emails \ --events job.completed,job.failed --queue emailsList and remove webhooks
Section titled “List and remove webhooks”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-2f9d1c4b6e10Each entry starts with the webhook ID; that ID is what remove takes. Disabled webhooks are marked [disabled].
Temporarily disable a webhook
Section titled “Temporarily disable a webhook”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.
Event Types
Section titled “Event Types”These five events are the only valid ones; anything else is rejected at registration time:
| Event | When it fires |
|---|---|
job.pushed | Job added to a queue |
job.started | A worker picked the job up |
job.completed | The worker finished successfully |
job.failed | The worker threw an error |
job.progress | The processor called job.updateProgress() |
Payload
Section titled “Payload”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:datais the result returned by the workerjob.failed:datais the job’s input data, plus anerrormessagejob.progress: carries aprogressnumber instead ofdatajob.pushedandjob.started: base fields only
Verifying Signatures
Section titled “Verifying Signatures”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 }); },});import { createHmac, timingSafeEqual } from 'node: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);}
// In your HTTP handler: read the RAW body first, verify, then JSON.parse it.import hashlibimport hmac
def verify_signature(raw_body: bytes, signature: str, secret: str) -> bool: expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature)
# In your HTTP handler: read the RAW body first, verify, then json.loads it.function verifySignature(string $rawBody, string $signature, string $secret): bool{ $expected = hash_hmac('sha256', $rawBody, $secret); return hash_equals($expected, $signature);}
// Read the raw body with file_get_contents('php://input'), verify, then json_decode it.import ( "crypto/hmac" "crypto/sha256" "encoding/hex")
func verifySignature(rawBody []byte, signature, secret string) bool { mac := hmac.New(sha256.New, []byte(secret)) mac.Write(rawBody) expected := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(expected), []byte(signature))}
// In your HTTP handler: read the RAW body first, verify, then json.Unmarshal it.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).
Delivery and Retries
Section titled “Delivery and Retries”A delivery succeeds when your endpoint returns a 2xx status within 10 seconds. Failed deliveries are retried with linear backoff:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 1 second |
| 3 | 2 seconds |
The totals are configurable with WEBHOOK_MAX_RETRIES (default 3 attempts) and WEBHOOK_RETRY_DELAY_MS (default 1000). 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.
Gotchas
Section titled “Gotchas”- 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
--eventsand--queuefilters, and rememberjob.progressonly fires when a processor callsjob.updateProgress(). - Nothing delivered: run
bunqueue webhook listand look at theDelivered: N ok / N failedcounters 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.