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:
{
"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.
- In dbt, open Account settings and go to the Webhooks section.
- 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.
- 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
{
"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_, 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.
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:
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 verifies the HMAC in dbt's
Authorizationheader, 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
Authorizationthrough 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
PUTto the webhook withactive: 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.
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.
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 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 for free and handle dbt Cloud webhooks reliably in minutes.