# Webhooks: Get Notified When Jobs Complete or Fail

bunqueue sends HTTP callbacks on job events, signed with HMAC-SHA256 and retried automatically. No polling needed.

Canonical: https://bunqueue.dev/guide/webhooks/

---

import { Tabs, TabItem } from '@astrojs/starlight/components';

<div class="bq-wrap bq-hero">
  <span class="bq-eyebrow">guide · webhooks</span>
  <h1 class="bq-hero-h1 bq-bench-h1">Job events, delivered as <em>webhooks.</em></h1>
  <p class="bq-hero-sub">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.</p>
</div>

## Quick Start

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

```bash
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:

```typescript
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

### Manage webhooks from an SDK

The TypeScript, Python, PHP, and Go SDKs expose the same webhook surface programmatically:

<Tabs syncKey="lang">
<TabItem label="Bun">

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.

</TabItem>
<TabItem label="Node.js / Deno">

```typescript
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 delivery
await queue.removeWebhook(webhookId);
```

</TabItem>
<TabItem label="Python">

```python
from bunqueue import Queue

queue = Queue("emails")

# queue=None registers a global webhook; pass queue="emails" to scope it
webhook_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 delivery
queue.remove_webhook(webhook_id)
```

</TabItem>
<TabItem label="PHP">

```php
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);
```

</TabItem>
<TabItem label="Go">

```go
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)
```

</TabItem>
<TabItem label="Rust">

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

</TabItem>
<TabItem label="Elixir">

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

</TabItem>
</Tabs>

*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.*

### Scope a webhook to one queue

```bash
bunqueue webhook add https://api.example.com/hooks/emails \
  --events job.completed,job.failed --queue emails
```

### List and remove webhooks

```bash
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]`.

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

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

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

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

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).

```json
{
  "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

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

<Tabs syncKey="lang">
<TabItem label="Bun">

```typescript
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 });
  },
});
```

</TabItem>
<TabItem label="Node.js / Deno">

```typescript
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.
```

</TabItem>
<TabItem label="Python">

```python
import hashlib
import 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.
```

</TabItem>
<TabItem label="PHP">

```php
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.
```

</TabItem>
<TabItem label="Go">

```go
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.
```

</TabItem>
<TabItem label="Rust">

```rust
// requires the hmac, sha2 and hex crates
use hmac::{Hmac, Mac};
use sha2::Sha256;

fn verify_signature(raw_body: &[u8], signature: &str, secret: &str) -> bool {
    let Ok(sig) = hex::decode(signature) else {
        return false;
    };
    let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("any key length");
    mac.update(raw_body);
    mac.verify_slice(&sig).is_ok() // constant-time comparison
}

// In your HTTP handler: read the RAW body first, verify, then deserialize it.
```

</TabItem>
<TabItem label="Elixir">

```elixir
# requires {:plug_crypto, "~> 2.0"} for the constant-time comparison
def verify_signature(raw_body, signature, secret) do
  expected =
    :crypto.mac(:hmac, :sha256, secret, raw_body)
    |> Base.encode16(case: :lower)

  Plug.Crypto.secure_compare(expected, signature)
end

# In your HTTP handler: read the RAW body first, verify, then decode it.
```

</TabItem>
</Tabs>

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).

:::caution[Unsigned webhooks]
Without `--secret`, payloads are sent unsigned and anyone can forge requests to your endpoint. Always set a secret in production.
:::

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

- **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.

:::tip[Related Guides]
- [Queue API](/guide/queue/) - In-process events without HTTP
- [Environment Variables](/guide/env-vars/) - Webhook retry configuration
- [Server Mode](/guide/server/) - Webhooks require server mode
:::