Gareth Wilson Gareth Wilson

Guide to Doppler Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationPer project, under Webhooks > + Add in the Doppler dashboard
TriggerSecret changes in the configs you select
Event typeconfig.secrets.update (the only type)
PayloadDefault JSON, or a static custom JSON body you provide
Signature headerX-Doppler-Signature, only when a Secret is set
Schemesha256= followed by the lowercase hex HMAC-SHA256
Signed contentThe request body
Replay windowNone. No timestamp is sent or signed
Delivery"Generally delivered once", exactly-once not guaranteed
RetriesUp to 5, interval not published
LogsPer-attempt status, attempt number, response headers, response body and payload
Source IPsNot published

Common events

Doppler sends one event type, config.secrets.update. Doppler's docs describe the type field as "currently always config.secrets.update".

FieldContents
typeconfig.secrets.update
configThe config that changed: name, root, locked, environment, project, slug and timestamps
projectThe project's id, slug, name, description and created_at
workplaceThe workplace id and name
diffSecret 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

  1. Open the project in Doppler and click Webhooks in the project menu.
  2. Click + Add to open the configuration drawer.
  3. Enter the webhook URL and choose which configs trigger it. Root configs are shown in bold.
  4. Optionally provide a custom JSON payload. Leave it empty to receive the default payload.
  5. Enter a Secret. Doppler uses it to sign every request. Doppler calls this "highly encouraged" when the webhook's Authentication setting is None.
  6. 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-Signature header, 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 sorted diff and 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.