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

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

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

```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](/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](/webhooks/guides/implement-webhook-idempotency).

**How Hookdeck Can Help:** Hookdeck's [deduplication](/docs/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](/docs/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](/docs/filters) 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](/docs/transformations) 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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## 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](https://hookdeck.com) 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](https://dashboard.hookdeck.com/signup) for free and handle Doppler webhooks reliably in minutes.