# Guide to ConfigCat Webhooks: Features and Best Practices

ConfigCat webhooks tell your systems that a feature flag or setting has changed. Someone clicks **Save & Publish Changes** in the ConfigCat dashboard, and within moments ConfigCat calls a URL you chose. Applications use that call to refresh their cached flag values straight away instead of waiting for the next poll, and teams use it to post change notices to chat or trigger a deploy.

This guide covers how ConfigCat webhooks are configured, what the request body contains, how requests are signed and why signing key rotation needs care, and the practices that keep a ConfigCat receiver reliable.

## What are ConfigCat webhooks?

A ConfigCat webhook is tied to one Config and one Environment. When settings in that pair change, ConfigCat sends an HTTP request to the webhook's URL with the method you picked, `GET` or `POST`. You can add custom headers, and you can write the request body yourself, with placeholders such as `##ConfigName##` and `##ChangeDetails##` that ConfigCat fills in at send time.

That makes ConfigCat different from most webhook providers. There are no event types and no fixed payload. Every request means "settings changed here", and the body is whatever template you wrote, or nothing at all.

Each request is signed. ConfigCat concatenates the `X-ConfigCat-Webhook-ID` value, the `X-ConfigCat-Webhook-Timestamp` value and the raw body, with no separators, computes an HMAC-SHA256 with your signing key and sends the base64 result in `X-ConfigCat-Webhook-Signature-V1`.

## ConfigCat webhook features

| Feature | Details |
| --- | --- |
| Configuration | Dashboard: **Webhooks** screen, **+ ADD WEBHOOK** |
| Trigger | Changes saved and published in the chosen Config and Environment |
| HTTP method | `GET` or `POST`, set per webhook |
| Request body | Optional, written by you, with `##Variable##` placeholders |
| Custom headers | Optional, set per webhook (not signed) |
| Signature header | `X-ConfigCat-Webhook-Signature-V1`: comma-separated base64 signatures, one per signing key |
| Other signed headers | `X-ConfigCat-Webhook-ID` (different for every request), `X-ConfigCat-Webhook-Timestamp` (Unix seconds) |
| Scheme | HMAC-SHA256, base64 |
| Signed content | `{webhook ID}{timestamp}{raw body}`, no separators |
| Signing keys | `configcat_whsk_...`, on the webhook's page, with an optional secondary key for rotation |
| Retries | Up to 5 retries with variable delays, on network issues or a non-`2xx` response |

ConfigCat doesn't publish a response timeout or source IP addresses. It recommends a timestamp check but doesn't set a window.

## The request body

Because you write the body, you decide what your receiver gets. These placeholders are available:

| Placeholder | Replaced with |
| --- | --- |
| `##ConfigName##`, `##ConfigId##` | The Config where the change happened |
| `##EnvironmentName##`, `##EnvironmentId##` | The Environment where the change happened |
| `##URL##` | A link to the Config in the ConfigCat dashboard |
| `##ChangeNotes##` | The mandatory notes on the changeset |
| `##ChangedBy##` | The name and email address of the person who made the change |
| `##ChangeDetails##` | A JSON array of changes: setting key, event and details |
| `##ChangeDetailsTeams##`, `##ChangeDetailsSlack##` | The change details formatted for Microsoft Teams or Slack |

A body that a service of your own can parse might look like this:

```text
{
  "config": "##ConfigName##",
  "configId": "##ConfigId##",
  "environment": "##EnvironmentName##",
  "changedBy": "##ChangedBy##",
  "changes": ##ChangeDetails##
}

```

`##ChangeDetails##` becomes a JSON array, so it goes in without quotes. ConfigCat's own example of it:

```json
[
  {
    "settingKey": "myAwesomeFeature1",
    "event": "changed",
    "details": "\r\nmyAwesomeFeature1: false ➔ true"
  }
]

```

The `details` text is meant for people, with line breaks and arrows. Treat the array as a list of keys that changed, and read the current values from ConfigCat rather than parsing `details`.

## Setting up ConfigCat webhooks

1. In the ConfigCat dashboard, open the **Webhooks** screen and click **+ ADD WEBHOOK**.
2. Select the Config and Environment whose changes should trigger it.
3. Set the URL and the HTTP method. `POST` is the usual choice when you send a body.
4. Optionally add custom headers and a request body.
5. Copy the signing key from the webhook's page and store it, for example as `CONFIGCAT_WEBHOOK_SECRET`.
6. Test it: change a setting, click **SAVE & PUBLISH CHANGES**, and check that your endpoint was called.

If you use Hookdeck, create a source with the ConfigCat source type and use its URL as the webhook URL. Keep a single signing key on the ConfigCat webhook and paste it whole, including the `configcat_whsk_` prefix. With two keys, ConfigCat sends two signatures and verification fails. ConfigCat webhooks can use `GET` or `POST`, so the Hookdeck source pre-selects `POST` without fixing it: if your webhook uses `GET`, also allow `GET` on the source.

## Securing ConfigCat webhooks

Read the three headers. Concatenate the webhook ID, the timestamp and the raw body as bytes, with nothing between them. If the webhook has no body, sign the ID and timestamp alone. Compute an HMAC-SHA256 keyed with the whole signing key, `configcat_whsk_` prefix included, encode it as standard base64 and compare it with the header in constant time. Base64 is case-sensitive, so compare it exactly.

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

function verifyConfigCatSignature(rawBody, webhookId, timestamp, signature, secret) {
  if (!webhookId || !timestamp || !signature || !secret) return false;
  // One signing key means one 44-character base64 digest. Two keys send a comma-separated list.
  if (!/^[A-Za-z0-9+/]{43}=$/.test(signature)) return false;

  const expected = crypto
    .createHmac('sha256', secret) // the whole configcat_whsk_... key, as a string
    .update(webhookId, 'latin1')
    .update(timestamp, 'latin1')
    .update(rawBody)
    .digest('base64');

  const received = Buffer.from(signature, 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

// ConfigCat sends GET or POST. A GET has no body, so sign an empty buffer.
app.all('/webhooks/configcat', express.raw({ type: '*/*' }), (req, res) => {
  if (req.method !== 'GET' && req.method !== 'POST') return res.sendStatus(405);

  const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
  const ok = verifyConfigCatSignature(
    rawBody,
    req.get('x-configcat-webhook-id'),
    req.get('x-configcat-webhook-timestamp'),
    req.get('x-configcat-webhook-signature-v1'),
    process.env.CONFIGCAT_WEBHOOK_SECRET
  );
  if (!ok) return res.sendStatus(401);

  // Settings changed in this Config and Environment: refresh your cached flags.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import hashlib
import hmac
import re

SIGNATURE_PATTERN = re.compile(r"[A-Za-z0-9+/]{43}=")

def verify_configcat_signature(raw_body: bytes, webhook_id, timestamp, signature, secret: str) -> bool:
    if not webhook_id or not timestamp or not signature or not secret:
        return False
    if not SIGNATURE_PATTERN.fullmatch(signature):
        return False

    signed = webhook_id.encode("latin-1") + timestamp.encode("latin-1") + (raw_body or b"")
    digest = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).digest()
    expected = base64.b64encode(digest)
    return hmac.compare_digest(signature.encode("utf-8"), expected)

# In FastAPI: raw_body = await request.body()
# verify_configcat_signature(raw_body, request.headers.get("x-configcat-webhook-id"),
#                            request.headers.get("x-configcat-webhook-timestamp"),
#                            request.headers.get("x-configcat-webhook-signature-v1"),
#                            os.environ["CONFIGCAT_WEBHOOK_SECRET"])

```

To check your code, run ConfigCat's published example through it. With the signing key `configcat_whsk_VN3juirnVh5pNvCKd81RYRYchxUX4j3NykbZG2fAy88=`, the webhook ID `b616ca659d154a5fb907dd8475792eeb`, the timestamp `1669629035` and the body `examplebody`, the signature is:

```
Ks3cYsu9Lslfo+hVxNC3oQWnsF9e5d73TI5t94D9DRA=

```

The code above treats the whole header as one signature, which is what Hookdeck's source does too. If you verify only in your own service and want to keep deliveries flowing while two keys are set, split the header on commas and accept a match on any entry.

ConfigCat suggests rejecting old timestamps but names no window, so the samples don't check age. If you add a check, make it optional: ConfigCat retries failed requests, and Hookdeck retries and replays resend the original headers, so a tight window rejects legitimate redeliveries.

Hookdeck's ConfigCat source type verifies HMAC-SHA256 over `{X-ConfigCat-Webhook-ID}{X-ConfigCat-Webhook-Timestamp}{body}`, base64, against the whole `X-ConfigCat-Webhook-Signature-V1` value, and applies no timestamp tolerance. A request that fails the check is marked as failed verification and is not delivered to your destinations.

> **Make ConfigCat webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `X-ConfigCat-Webhook-Signature-V1` header on every flag change, stores each request, and fans it out to every service that needs to refresh its configuration.

## ConfigCat webhook limitations and pain points

### Key rotation sends two signatures

**The Problem:** Verification starts failing on every request as soon as someone adds a secondary signing key.

**Why It Happens:** For rotation, ConfigCat signs each request with both keys and sends `sigA,sigB` in `X-ConfigCat-Webhook-Signature-V1`. A verifier that treats the header as one digest, Hookdeck's source included, no longer finds a match.

**Workarounds:**

* Keep one signing key on the webhook. To rotate, generate the new key, update your receiver, then remove the old key, accepting that requests between those steps fail verification.
* In your own code, split the header on commas and accept any matching entry.

**How Hookdeck Can Help:** Requests that fail during a rotation are recorded in Hookdeck as failed verification rather than reaching your services, so you can see exactly when the second key was added.

### There are no events to route on

**The Problem:** Your handler can't tell what changed, or even which Config it is about.

**Why It Happens:** ConfigCat sends no event type, and the body is only what you put in the template. A webhook with no body tells you nothing but "something changed".

**Workarounds:**

* Write a JSON body with `##ConfigId##`, `##EnvironmentId##` and `##ChangeDetails##`.
* Use one webhook per Config and Environment pair, so the URL or a custom header tells you the scope.

**How Hookdeck Can Help:** Hookdeck filters can route requests on fields in your template body, so changes to different Configs or Environments can go to different destinations.

### GET or POST, and an empty body

**The Problem:** A webhook set to `GET` is rejected by a route that only accepts `POST`, or fails verification because the code expects a body.

**Why It Happens:** ConfigCat lets each webhook use `GET` or `POST`. A `GET` has no body, so only the ID and timestamp are signed.

**Workarounds:**

* Use `POST` with a body unless you have a reason not to.
* If you use `GET`, accept it on your route and sign an empty body. With Hookdeck, also allow `GET` on the source.

**How Hookdeck Can Help:** The Hookdeck source lets you choose which methods it accepts, so it can match how each ConfigCat webhook is set up.

### Up to five retries

**The Problem:** An outage longer than ConfigCat's retries means your services keep stale flag values until the next change or poll.

**Why It Happens:** ConfigCat retries a failed request up to 5 times with variable delays, and doesn't say how long that takes.

**Workarounds:**

* Keep your SDKs polling as a fallback, so a missed webhook only delays an update.
* Return `2xx` quickly after verification.

**How Hookdeck Can Help:** Hookdeck accepts the request as soon as ConfigCat sends it and then delivers to your services with [retries](/docs/retries) you configure.

## Best practices

### Verify against the raw body

The body is part of the signed content, so read it as raw bytes: `express.raw({ type: '*/*' })` in Express, `Buffer.from(await request.arrayBuffer())` in Next.js, `await request.body()` in FastAPI. Treat a missing body as empty bytes.

### Make the handler idempotent

ConfigCat describes `X-ConfigCat-Webhook-ID` as different for every request, so don't rely on it to spot a retry. Most ConfigCat handlers refresh configuration, which is safe to repeat. Design yours so that running it twice has no extra effect. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Keep polling as a fallback

ConfigCat describes webhooks as adding speed to the reliability of polling. Leave your SDK's polling on so a lost request only delays an update rather than losing it.

### Don't trust unsigned headers

Custom headers you add to the webhook are not part of the signature. Use them for routing hints, not for authentication.

## Conclusion

ConfigCat signs each webhook with a base64 HMAC-SHA256 of the webhook ID, timestamp and raw body, concatenated with no separators and keyed with the whole `configcat_whsk_` key. Write a body that tells your service what changed, verify against the raw bytes, keep a single signing key unless your own code accepts a list, and allow `GET` wherever a webhook uses it.

[Hookdeck Event Gateway](https://hookdeck.com) verifies every ConfigCat signature, stores each change notification and delivers it to every service that needs to refresh.

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