Webhooks & Event Notifications
Receive HTTP callbacks when job events occur. Webhooks enable real-time notifications to external systems without polling.
Overview
When you register a webhook URL, bunqueue sends HTTP POST requests to that URL whenever specified events occur. This is useful for:
- Notifying external services when jobs complete
- Triggering downstream workflows
- Logging job events to external systems
- Alerting on failures
CLI Commands
Add Webhook
# Add webhook for specific events (--events is required)bunqueue webhook add https://api.example.com/webhooks/bunqueue \ --events job.completed,job.failed
# Add webhook for a specific queuebunqueue webhook add https://api.example.com/webhooks/emails \ --events job.completed,job.failed,job.progress --queue emails
# Add webhook with a signing secretbunqueue webhook add https://api.example.com/webhooks/failures \ --events job.failed --secret my-webhook-secretList Webhooks
bunqueue webhook listOutput:
URL QUEUE EVENTShttps://api.example.com/webhooks/bunqueue * allhttps://api.example.com/webhooks/emails emails allhttps://api.example.com/webhooks/failures * job.failed,job.stalledRemove Webhook
# Remove webhook by its ID (shown in webhook list output)bunqueue webhook remove wh_abc123Event Types
| Event | Description | When Triggered |
|---|---|---|
job.completed | Job finished successfully | Worker completes job |
job.failed | Job failed | Worker throws error |
job.progress | Progress updated | job.updateProgress() |
job.active | Job started processing | Worker picks up job |
job.waiting | Job added to queue | After queue.add() |
job.delayed | Job scheduled for later | Job with delay added |
Webhook Payload
Request Format
bunqueue sends POST requests with JSON body:
POST /webhooks/bunqueue HTTP/1.1Host: api.example.comContent-Type: application/jsonX-Webhook-Event: job.completedX-Webhook-Timestamp: 1704067200000X-Webhook-Signature: a1b2c3d4e5f6...
{ "event": "job.completed", "timestamp": 1704067200000, "jobId": "1001", "queue": "emails", "data": { "to": "user@example.com", "subject": "Welcome" }}Event-Specific Payloads
job.completed
{ "event": "job.completed", "timestamp": 1704067200000, "jobId": "1001", "queue": "emails", "data": { "to": "user@example.com", "subject": "Welcome" }}job.failed
{ "event": "job.failed", "timestamp": 1704067200000, "jobId": "1001", "queue": "emails", "data": { "to": "user@example.com", "subject": "Welcome" }, "error": "SMTP connection timeout"}job.progress
{ "event": "job.progress", "timestamp": 1704067200000, "jobId": "1001", "queue": "emails", "data": { "to": "user@example.com", "subject": "Welcome" }, "progress": 75}Signature Verification
When a webhook is registered with a --secret flag, bunqueue signs all payloads using HMAC-SHA256. Always verify signatures to ensure requests are authentic.
Setting the Secret
Secrets are configured per-webhook when adding them via the --secret flag:
bunqueue webhook add https://api.example.com/webhooks/bunqueue \ --events job.completed,job.failed --secret your-secret-keyVerifying Signatures
TypeScript/JavaScript:
import { createHmac, timingSafeEqual } from 'crypto';
function verifyWebhookSignature( payload: string, signature: string, secret: string): boolean { const expected = createHmac('sha256', secret) .update(payload) .digest('hex');
const expectedBuf = Buffer.from(expected); const receivedBuf = Buffer.from(signature);
if (expectedBuf.length !== receivedBuf.length) { return false; }
return timingSafeEqual(expectedBuf, receivedBuf);}
// Usage in webhook handlerapp.post('/webhooks/bunqueue', (req, res) => { const signature = req.headers['x-webhook-signature'] as string; const payload = JSON.stringify(req.body);
if (!verifyWebhookSignature(payload, signature, MY_WEBHOOK_SECRET)) { return res.status(401).json({ error: 'Invalid signature' }); }
// Process webhook... res.status(200).json({ received: true });});Python:
import hmacimport hashlib
def verify_webhook_signature(payload: bytes, signature: str, secret: str) -> bool: expected = hmac.new( secret.encode(), payload, hashlib.sha256 ).hexdigest()
return hmac.compare_digest(expected, signature)
# Usage in Flask@app.route('/webhooks/bunqueue', methods=['POST'])def handle_webhook(): signature = request.headers.get('X-Webhook-Signature') payload = request.get_data()
if not verify_webhook_signature(payload, signature, MY_WEBHOOK_SECRET): return jsonify({'error': 'Invalid signature'}), 401
# Process webhook... return jsonify({'received': True})Go:
import ( "crypto/hmac" "crypto/sha256" "encoding/hex")
func verifyWebhookSignature(payload []byte, signature, secret string) bool { mac := hmac.New(sha256.New, []byte(secret)) mac.Write(payload) expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))}Retry Behavior
bunqueue automatically retries failed webhook deliveries with linear backoff. The number of retries is controlled by the WEBHOOK_MAX_RETRIES environment variable (default: 3) and the base delay by WEBHOOK_RETRY_DELAY_MS (default: 1000ms):
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 1 second (WEBHOOK_RETRY_DELAY_MS * 1) |
| 3 | 2 seconds (WEBHOOK_RETRY_DELAY_MS * 2) |
After all attempts are exhausted, the webhook delivery is abandoned and logged.
Success Criteria
A webhook delivery is considered successful if:
- HTTP status code is 2xx (200-299)
- Response is received within 30 seconds
Failure Handling
Webhook failures are logged but don’t affect job processing. Use bunqueue webhook list to review registered webhooks.
Example Webhook Server
Complete example using Hono (works with Bun):
import { Hono } from 'hono';import { createHmac, timingSafeEqual } from 'crypto';
const app = new Hono();
// The secret you used when registering the webhook with --secretconst MY_WEBHOOK_SECRET = process.env.MY_WEBHOOK_SECRET || 'your-secret';
// Signature verification middlewareasync function verifySignature(c: any, next: any) { const signature = c.req.header('x-webhook-signature'); const body = await c.req.text();
if (!signature) { return c.json({ error: 'Missing signature' }, 401); }
const expected = createHmac('sha256', MY_WEBHOOK_SECRET) .update(body) .digest('hex');
const expectedBuf = Buffer.from(expected); const receivedBuf = Buffer.from(signature);
if (expectedBuf.length !== receivedBuf.length || !timingSafeEqual(expectedBuf, receivedBuf)) { return c.json({ error: 'Invalid signature' }, 401); }
// Store parsed body for handler c.set('webhookPayload', JSON.parse(body)); await next();}
// Webhook handlerapp.post('/webhooks/bunqueue', verifySignature, async (c) => { const payload = c.get('webhookPayload');
console.log(`Received ${payload.event} event for job ${payload.jobId}`);
switch (payload.event) { case 'job.completed': console.log('Job completed:', payload.jobId); // Notify downstream service await notifyCompletion(payload); break;
case 'job.failed': console.error('Job failed:', payload.error); // Alert on failure await sendAlert({ type: 'job_failed', jobId: payload.jobId, queue: payload.queue, error: payload.error, }); break;
case 'job.progress': console.log(`Job ${payload.jobId}: ${payload.progress}%`); // Update UI or notify await broadcastProgress(payload); break; }
return c.json({ received: true });});
// Health check endpointapp.get('/health', (c) => c.json({ status: 'ok' }));
export default { port: 3000, fetch: app.fetch,};Express.js Example
import express from 'express';import crypto from 'crypto';
const app = express();app.use(express.json({ verify: (req: any, res, buf) => { req.rawBody = buf; }}));
// The secret you used when registering the webhook with --secretconst MY_WEBHOOK_SECRET = process.env.MY_WEBHOOK_SECRET!;
function verifySignature(req: any, res: any, next: any) { const signature = req.headers['x-webhook-signature'];
if (!signature) { return res.status(401).json({ error: 'Missing signature' }); }
const expected = crypto .createHmac('sha256', MY_WEBHOOK_SECRET) .update(req.rawBody) .digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) { return res.status(401).json({ error: 'Invalid signature' }); }
next();}
app.post('/webhooks/bunqueue', verifySignature, async (req, res) => { const { event, jobId, queue, data, error } = req.body;
console.log(`[${queue}] ${event}: Job ${jobId}`);
if (event === 'job.completed') { // Handle completion console.log('Completed job:', jobId); } else if (event === 'job.failed') { // Handle failure console.error('Error:', error); }
res.json({ received: true });});
app.listen(3000, () => { console.log('Webhook server listening on port 3000');});Best Practices
1. Always Verify Signatures
Never trust webhook payloads without signature verification. Attackers could forge requests.
2. Respond Quickly
Return a 200 response as soon as possible. Process webhooks asynchronously if needed:
app.post('/webhooks/bunqueue', verifySignature, async (c) => { const payload = c.get('webhookPayload');
// Queue for async processing await processQueue.add('webhook', payload);
// Return immediately return c.json({ received: true });});3. Handle Duplicates
Webhooks may be delivered multiple times due to retries. Use the jobId and event combination to deduplicate:
const processedWebhooks = new Set<string>();
app.post('/webhooks/bunqueue', verifySignature, async (c) => { const payload = c.get('webhookPayload'); const dedupeKey = `${payload.jobId}:${payload.event}`;
if (processedWebhooks.has(dedupeKey)) { return c.json({ received: true, duplicate: true }); }
processedWebhooks.add(dedupeKey); // Process webhook...});4. Log Everything
Log all webhook events for debugging:
app.post('/webhooks/bunqueue', verifySignature, async (c) => { const payload = c.get('webhookPayload');
console.log(JSON.stringify({ timestamp: new Date().toISOString(), event: payload.event, queue: payload.queue, jobId: payload.jobId, }));
// Process...});5. Monitor Delivery
Monitor webhook registrations and server logs for delivery issues:
# Review registered webhooksbunqueue webhook listTroubleshooting
Webhooks Not Delivered
- Check webhook is registered:
bunqueue webhook list - Verify URL is accessible from server
- Check firewall rules
- Review server logs:
bunqueue logs --filter webhook
Invalid Signature Errors
- Verify secret matches on both ends
- Check payload isn’t modified by proxies
- Ensure raw body is used for signature verification
- Check for encoding issues (UTF-8)
Slow Webhook Processing
- Return 200 immediately, process async
- Add more webhook endpoints
- Implement request queuing
Missing Events
- Check event filter:
--eventsflag - Verify queue filter:
--queueflag - Check job options (some jobs skip events)