# Guide to dbt Cloud Webhooks: Features and Best Practices

dbt Cloud webhooks tell your other systems what your dbt jobs are doing: a run started, it finished, or it errored. If you run dbt on the dbt platform from dbt Labs, webhooks are how you page someone when the nightly build fails, kick off a downstream refresh when models finish, or post run results to a team channel without polling the Admin API.

This guide covers how dbt Cloud webhooks are created, the unusual way dbt signs them, what each of the three events means, and how to build a receiver that keeps dbt from archiving your webhook.

## What are dbt Cloud webhooks?

A dbt Cloud webhook is an account-level subscription to job run events. You choose the events, optionally limit it to specific jobs, and give it an endpoint URL. When a matching run starts, completes or errors, dbt sends a JSON payload to that URL. Webhooks are available on the Starter and Enterprise plans. dbt Core, the open-source CLI, has no webhooks.

Each payload is signed, but not with a dedicated signature header. dbt puts the signature in the `Authorization` header: the bare hex HMAC-SHA256 of the request body, keyed with the webhook's secret token. There is no `Bearer` prefix or scheme name, just the digest.

## dbt Cloud webhook features

| Feature | Details |
| --- | --- |
| Configuration | **Account settings > Webhooks**, or the v3 webhooks API |
| Plans | Starter, Enterprise and Enterprise+ |
| Scope | Account-wide by default, or limited to specific job IDs |
| Event field | `eventType` in the body |
| Signature header | `Authorization` |
| Scheme | HMAC-SHA256, lowercase hex digest, no prefix |
| Signed content | The raw request body, nothing else |
| Secret | A secret token per webhook, shown when you save it and returned as `hmac_secret` by the API |
| Replay window | None. The body's `timestamp` is signed but no tolerance is defined |
| Timeout | 10 seconds |
| Retries | Five per event |
| Delivery log | **Recent Deliveries** per webhook, kept for 7 days |
| Auto-archive | After 1,000 consecutive failures or 7 consecutive days of failures |

dbt publishes outbound IP addresses per region on its Regions & IP addresses page, if you need to allowlist them.

## Common events

| Event | Fires when |
| --- | --- |
| `job.run.started` | A run starts |
| `job.run.completed` | A run finishes, whether it succeeded or failed |
| `job.run.errored` | A run errors |

`job.run.errored` is a subset of `job.run.completed`. If you subscribe to both, an errored run notifies you twice, at different times. `job.run.errored` fires immediately, so the run's metadata and artifacts may not be available from the Admin or Discovery APIs yet. `job.run.completed` fires only after they have been ingested. If your integration needs the run's logs or model results, use `job.run.completed` and filter on `runStatus` or `runStatusCode`. If you only need to know quickly that something broke, use `job.run.errored`.

Every payload has the same envelope. Here is dbt's example for an errored run:

```json
{
  "accountId": 1,
  "webhookId": "wsu_12345abcde",
  "eventId": "wev_2L6m5BggBw9uPNuSmtg4MUiW4Re",
  "timestamp": "2023-01-31T21:15:20.419714619Z",
  "eventType": "job.run.errored",
  "webhookName": "test",
  "data": {
    "jobId": "123",
    "jobName": "dbt Vault",
    "runId": "12345",
    "environmentId": "1234",
    "environmentName": "dbt Vault Demo",
    "dbtVersion": "1.0.0",
    "projectName": "Snowflake Github Demo",
    "projectId": "167194",
    "runStatus": "Errored",
    "runStatusCode": 20,
    "runStatusMessage": "None",
    "runReason": "Kicked off from the UI by test@test.com",
    "runStartedAt": "2023-01-31T21:14:41Z",
    "runErroredAt": "2023-01-31T21:15:20Z"
  }
}

```

In dbt's examples, `runStatusCode` is `3` with `runStatus` `Running` on a started run, `10` with `Success` on a successful completion, and `20` with `Errored` on an error. The finishing timestamp is named for the outcome: `runFinishedAt` on a completed run and `runErroredAt` on an errored one. IDs inside `data` are strings, while `accountId` is a number.

## Setting up dbt Cloud webhooks

Your endpoint must accept requests that carry an `Authorization` header. dbt suggests checking with `curl -H 'Authorization: 123' -X POST https://<your-webhook-endpoint>` before you start.

1. In dbt, open **Account settings** and go to the **Webhooks** section.
2. Click **Create webhook** and enter a name, a description, the events, the jobs (leave empty for every job in the account) and your endpoint URL.
3. Click **Save**. dbt shows the secret token; store it server-side, for example as `DBT_CLOUD_WEBHOOK_SECRET`.

To create a webhook through the API, authenticate with a service token (or a personal access token while developing) and send:

```
POST https://{your_access_url}/api/v3/accounts/{account_id}/webhooks/subscriptions

```

```json
{
  "event_types": ["job.run.completed", "job.run.errored"],
  "name": "Run alerts",
  "client_url": "https://example.com/webhooks/dbt",
  "active": true,
  "description": "Alert on finished and errored runs",
  "job_ids": []
}

```

The response includes `hmac_secret`, the key for verifying deliveries. To check that dbt can reach your endpoint, call `GET .../webhooks/subscription/{webhook_id}/test`, which returns the status code your endpoint answered with in `verification_status_code`.

## Securing dbt Cloud webhooks

Read the `Authorization` header, compute an HMAC-SHA256 of the raw request body with the secret token as the key, encode it as hex, and compare the two in constant time.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyDbtCloudSignature(rawBody, authorizationHeader, secret) {
  if (!authorizationHeader || !secret) return false;
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  const received = Buffer.from(authorizationHeader.trim().toLowerCase(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

app.post('/webhooks/dbt', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.DBT_CLOUD_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyDbtCloudSignature(req.body, req.get('authorization'), secret)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on event.eventId, route on event.eventType, then process async.
  res.sendStatus(200);
});

```

The same check in Python, based on dbt's own sample:

```python
import hashlib
import hmac
import os

def verify_dbt_cloud_signature(raw_body: bytes, authorization_header, secret: str) -> bool:
    if not authorization_header or not secret:
        return False
    expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
    received = authorization_header.strip().lower().encode("utf-8")
    return hmac.compare_digest(received, expected.encode("utf-8"))

# In FastAPI: raw_body = await request.body()
# verify_dbt_cloud_signature(raw_body, request.headers.get("authorization"),
#                            os.environ["DBT_CLOUD_WEBHOOK_SECRET"])

```

Because the signature lives in `Authorization`, keep any authentication middleware away from this route. Middleware that expects a `Bearer` token or Basic credentials will reject dbt's request before your verifier sees it.

> **Make dbt Cloud webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the HMAC in dbt's `Authorization` header, answers inside dbt's 10-second timeout, and durably queues every run event before your service sees it.

## dbt Cloud webhook limitations and pain points

### The signature is in the Authorization header

**The Problem:** Webhooks never arrive, or arrive and fail verification, even though the URL and secret are correct.

**Why It Happens:** Many platforms and proxies treat `Authorization` as their own. dbt names Azure Logic Apps and Power Automate as services that may not accept it, and API gateways or auth middleware can strip the header or reject the unrecognised value.

**Workarounds:**

* Test the endpoint with dbt's curl command or the test API before relying on it.
* Exclude the webhook route from authentication middleware.
* Confirm that any proxy in front of the app passes `Authorization` through untouched.

**How Hookdeck Can Help:** Hookdeck's dbt Cloud source type reads and verifies the `Authorization` header itself, so the services behind it receive already-verified events and never have to accept dbt's non-standard `Authorization` value.

### A slow response counts as a failure

**The Problem:** Your handler finishes and returns `200`, but dbt records the delivery as failed and retries it.

**Why It Happens:** dbt times out after 10 seconds. A response that arrives later is a failure on dbt's side, even if your server logged a success.

**Workarounds:**

* Verify, store the event, return `200`, then do the work.
* Deduplicate on `eventId`, which is the same across retries of an event.

**How Hookdeck Can Help:** Hookdeck responds to dbt as soon as it has stored the event, well within the timeout, and delivers to your service on its own schedule.

### Sustained failures archive the webhook

**The Problem:** After an outage, events stop arriving altogether.

**Why It Happens:** dbt archives a webhook after 1,000 consecutive failed deliveries or 7 consecutive days of failures, whichever comes first. An archived webhook sends nothing until someone reactivates it.

**Workarounds:**

* Watch for the archive banner on **Account settings > Webhooks**, or poll the webhooks API.
* Reactivate by changing the endpoint URL in the UI, or by sending `PUT` to the webhook with `active: true`.

**How Hookdeck Can Help:** Hookdeck accepts deliveries even while your service is down, so the outage never counts against dbt's archive threshold, and you replay the queued run events once you're back.

### Errored runs notify twice

**The Problem:** An alerting integration pages twice for the same failed run.

**Why It Happens:** `job.run.errored` is a subset of `job.run.completed`, so a webhook subscribed to both sends both for an errored run.

**Workarounds:**

* Subscribe to only one of the two for alerting.
* If you need both, deduplicate alerts on `data.runId`.

**How Hookdeck Can Help:** Hookdeck filters can route `job.run.errored` to your alerting destination and `job.run.completed` to your data pipeline, so each consumer only sees the event it needs.

## Best practices

### Verify against the raw body

The HMAC covers the exact bytes dbt sent. Verify before parsing, and compare against the hex digest with no prefix.

### Deduplicate on `eventId`

Each event has a `wev_`-prefixed `eventId`. Store it before processing so a retried delivery is ignored. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Choose completed or errored deliberately

Use `job.run.completed` when you need artifacts or logs, and `job.run.errored` when speed matters more. Expect API calls made straight after `job.run.errored` to return incomplete data for a short time.

### Scope webhooks to the jobs that matter

Pass `job_ids` to keep development and ad-hoc runs out of production alerts. An empty list means every job in the account.

### Acknowledge fast and process asynchronously

Ten seconds is the hard limit. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Use the delivery log

Each webhook's **Recent Deliveries** section shows successes and failures for the last 7 days. Check it after any deploy that touches the receiver.

## Conclusion

dbt Cloud signs each job webhook with a hex HMAC-SHA256 of the raw body, sent bare in the `Authorization` header and keyed with the webhook's secret token. Make sure that header reaches your verifier, answer within 10 seconds, deduplicate on `eventId`, and keep an eye on the archive threshold.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, verifies dbt's signature, and holds every run event until your service has processed it, so a slow or unavailable handler never gets your webhook archived.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle dbt Cloud webhooks reliably in minutes.