Guide to Doppler Webhooks: Features and Best Practices
Doppler stores application secrets and syncs them to the places your code runs. When someone adds, removes or changes a secret, the services that depend on it usually need to restart or redeploy to pick it up. Doppler webhooks send that signal: a POST to your URL whenever secrets change in the configs you choose, so a pipeline or deploy hook can react without anyone remembering to trigger it.
This guide covers how Doppler webhooks are configured and signed, the default and custom payloads, how delivery and retries work, and the best practices for wiring secret changes into your deployment process.
What are Doppler webhooks?
A Doppler webhook belongs to a project. It has a URL, a list of configs that trigger it (for example dev, stg and prd), and two optional settings: a custom JSON payload and a signing secret. When secrets change in one of the selected configs, Doppler sends an HTTP POST to the URL.
By default the body describes the change: which config, project and workplace it happened in, and the names of the secrets that were added, removed or updated. Secret values are never included. If you set a secret on the webhook, every request carries an X-Doppler-Signature header with a sha256=-prefixed hex HMAC of the body.
Doppler webhook features
| Feature | Details |
|---|---|
| Configuration | Per project, under Webhooks > + Add in the Doppler dashboard |
| Trigger | Secret changes in the configs you select |
| Event type | config.secrets.update (the only type) |
| Payload | Default JSON, or a static custom JSON body you provide |
| Signature header | X-Doppler-Signature, only when a Secret is set |
| Scheme | sha256= followed by the lowercase hex HMAC-SHA256 |
| Signed content | The request body |
| Replay window | None. No timestamp is sent or signed |
| Delivery | "Generally delivered once", exactly-once not guaranteed |
| Retries | Up to 5, interval not published |
| Logs | Per-attempt status, attempt number, response headers, response body and payload |
| Source IPs | Not published |
Common events
Doppler sends one event type, config.secrets.update. Doppler's docs describe the type field as "currently always config.secrets.update".
| Field | Contents |
|---|---|
type | config.secrets.update |
config | The config that changed: name, root, locked, environment, project, slug and timestamps |
project | The project's id, slug, name, description and created_at |
workplace | The workplace id and name |
diff | Secret names under added, removed and updated |
Here is Doppler's example:
{
"type": "config.secrets.update",
"config": {
"name": "dev",
"root": true,
"locked": true,
"initial_fetch_at": "2023-01-01T00:00:00.000Z",
"last_fetch_at": "2023-01-01T00:00:00.000Z",
"created_at": "2023-01-01T00:00:00.000Z",
"environment": "dev",
"project": "backend",
"slug": "c9748dae-de1c-4971-b00c-f539970fee24"
},
"project": {
"id": "backend",
"slug": "backend",
"name": "backend",
"description": "",
"created_at": "2023-01-01T00:00:00.000Z"
},
"workplace": {
"id": "9f0c0c0d5d4e240ce26e",
"name": "My Workplace"
},
"diff": {
"added": ["FOO"],
"removed": ["BAR"],
"updated": ["BAZ"]
}
}
The diff tells you which secrets changed, which is enough to decide whether a given service needs a restart. To read the new values, your service fetches them from Doppler as usual.
Custom payloads
If you fill in the payload field, Doppler sends that JSON instead of the default body. It must be valid JSON and is sent exactly as written: no variables, no replacement values and no dynamic data. This suits targets that expect a fixed body, such as a CI system's pipeline trigger API, but it means the request no longer says which config or which secrets changed, and it has no type field.
Setting up Doppler webhooks
- Open the project in Doppler and click Webhooks in the project menu.
- Click + Add to open the configuration drawer.
- Enter the webhook URL and choose which configs trigger it. Root configs are shown in bold.
- Optionally provide a custom JSON payload. Leave it empty to receive the default payload.
- Enter a Secret. Doppler uses it to sign every request. Doppler calls this "highly encouraged" when the webhook's Authentication setting is None.
- Click Save. The webhook appears in the Active Webhooks list, and you can edit it later from its three-dot menu.
Store the secret you entered server-side, for example as DOPPLER_WEBHOOK_SECRET. Doppler documents no test-send button, so change a secret in a non-production config that the webhook covers to trigger a delivery.
Securing Doppler webhooks
When a Secret is set, the X-Doppler-Signature header looks like this:
X-Doppler-Signature: sha256=724cd2c1110335a8ea6207f74cd74fb198a8e59ca1b7b39d67678e0f97df832e
Compute an HMAC-SHA256 of the raw request body with the Secret, encode it as hex, prefix sha256= and compare with the header in constant time.
const crypto = require('crypto');
const express = require('express');
function verifyDopplerSignature(rawBody, signatureHeader, secret) {
if (!signatureHeader || !secret) return false;
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const received = Buffer.from(signatureHeader.trim(), 'utf8');
const computed = Buffer.from(expected, 'utf8');
return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}
const app = express();
app.post('/webhooks/doppler', express.raw({ type: '*/*' }), (req, res) => {
const secret = process.env.DOPPLER_WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500); // fail closed
if (!verifyDopplerSignature(req.body, req.get('x-doppler-signature'), secret)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
// Queue a redeploy for the services that use event.config.name.
res.sendStatus(204);
});
The same check in Python:
import hashlib
import hmac
def verify_doppler_signature(raw_body: bytes, signature_header, secret: str) -> bool:
if not signature_header or not secret:
return False
expected = "sha256=" + hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature_header.strip().encode("utf-8"), expected.encode("utf-8"))
# In FastAPI: raw_body = await request.body()
# verify_doppler_signature(raw_body, request.headers.get("x-doppler-signature"),
# os.environ["DOPPLER_WEBHOOK_SECRET"])
Doppler's Ruby sample hashes the raw body, while its Node sample hashes JSON.stringify(req.body) after bodyParser.json(). Re-serialising only matches when the parsed and re-encoded JSON is byte-identical to what Doppler sent. Hashing the raw body, as above, avoids that dependency.
If you did not set a Secret, Doppler sends no signature header. In Hookdeck, leave verification off if you didn't set a secret, though setting one is the better choice.
Make Doppler webhooks production-ready. Hookdeck Event Gateway verifies Doppler's
X-Doppler-Signatureheader, queues each secret-change event, and lets you fan one Doppler webhook out to several deploy targets.
Doppler webhook limitations and pain points
At-least-once delivery
The Problem: One secret change triggers two redeploys.
Why It Happens: Doppler says webhooks are "generally delivered once" but does not guarantee exactly-once delivery, and it retries failed attempts up to 5 times. There is no event ID to deduplicate on.
Workarounds:
- Make the action idempotent. Triggering a deploy that is already running, or restarting a service twice, should be harmless.
- If it is not, derive a key from
config.slug, the sorteddiffand a short time bucket. See our guide to webhook idempotency.
How Hookdeck Can Help: Hookdeck's deduplication can drop repeats with the same body inside a time window you choose.
Limited retries
The Problem: A secret change during an outage never triggers a redeploy, and the service keeps running with stale secrets.
Why It Happens: Doppler retries up to 5 times and does not publish the interval between attempts.
Workarounds:
- Check the Logs tab after any outage and filter for failed entries.
- Have services re-read secrets on start, so any manual restart picks up the latest values.
How Hookdeck Can Help: Hookdeck accepts the request straight away and retries towards your service on your own schedule. Failed events can be replayed in bulk once the target is back.
One URL per webhook
The Problem: Several pipelines need to react to the same config, and each needs its own webhook.
Why It Happens: Each Doppler webhook has one URL, and a custom payload is a single static body.
Workarounds:
- Create one webhook per target in the project.
- Point a single webhook at your own service, which then calls each pipeline.
How Hookdeck Can Help: One Doppler webhook pointed at a Hookdeck source can be connected to several destinations, each with its own filter on config.name or config.environment.
Custom payloads lose context
The Problem: A receiver cannot tell which config changed.
Why It Happens: A custom payload replaces the default body and cannot include dynamic values.
Workarounds:
- Use the default payload whenever the receiver is code you control.
- If a target needs a fixed body, create a separate webhook per config so the URL or payload identifies it.
How Hookdeck Can Help: Keep Doppler on the default payload and use a Hookdeck transformation to reshape it for each target, so the original fields are still available for routing.
No replay protection
The Problem: A captured, signed request still verifies if it is sent again.
Why It Happens: Only the body is signed, with no timestamp or nonce.
Workarounds:
- Make the triggered action idempotent so a replay has no effect.
How Hookdeck Can Help: Hookdeck logs every request with its headers and body, so repeated deliveries are visible.
Best practices
Always set a Secret
Without one, anyone who learns the URL can trigger your deploy pipeline. Set a Secret on every webhook and verify X-Doppler-Signature against the raw body.
Scope webhooks to the configs that matter
Select only the configs whose changes should trigger the target. A production deploy webhook should not fire for edits to dev.
Use diff to decide what to do
The default payload lists changed secret names. Use it to restart only the services that read those secrets, rather than redeploying everything.
Respond quickly and run the work elsewhere
A redeploy can take minutes. Verify, enqueue the job and return 2xx. See why to process webhooks asynchronously.
Conclusion
Doppler webhooks send one event, config.secrets.update, when secrets change in the configs you select. Set a Secret, verify the sha256=-prefixed HMAC in X-Doppler-Signature against the raw body, expect occasional duplicates, and keep an eye on the Logs tab, because Doppler stops after 5 retries.
Hookdeck Event Gateway sits between Doppler and your pipelines, verifies each request, fans changes out to every target that needs them and holds events until each target accepts them.
Get started with Hookdeck for free and handle Doppler webhooks reliably in minutes.