# Guide to Cloudflare Webhooks: Features and Best Practices

Cloudflare Notifications can alert you about DDoS attacks, origin errors, failing health checks, expiring certificates and Workers problems. Slack, PagerDuty and similar services have built-in destinations, but a generic webhook lets you send the same alerts to your own incident tooling, a SIEM or an automation that reacts to them.

This guide covers how Cloudflare generic webhook destinations and notification policies fit together, the alert payload, how to check Cloudflare's `cf-webhook-auth` secret, and what Cloudflare leaves undocumented about delivery.

## What are Cloudflare webhooks?

Cloudflare alerts are configured in two parts. A **destination** is a webhook URL with an optional secret, created once per account. A **notification policy** picks an alert type, such as an HTTP DDoS attack or a health check status change, and sends it to one or more destinations.

Each alert arrives as a JSON `POST`. Cloudflare doesn't sign the body. Instead, if you set a secret on the destination, Cloudflare sends it unchanged in the `cf-webhook-auth` header of every request, and tells you to reject requests where the header is missing or doesn't match.

## Cloudflare webhook features

| Feature | Details |
| --- | --- |
| Configuration | **Alerts** > **Destinations** > **Webhooks** > **Create**, or Terraform's `cloudflare_notification_policy_webhooks` |
| Subscription model | Notification policies, each with one alert type, send to chosen destinations |
| Limits | Available on all plans. Up to 100 webhook destinations for accounts without a paid zone |
| Authentication | Optional secret, sent as-is in `cf-webhook-auth` |
| Signature | None |
| Event field | `alert_type` in the body, which may be absent |
| Target restrictions | Publicly resolvable address on port 80 or 443 |
| Source IPs | Cloudflare's published IP ranges |
| Retries, timeout, ordering | Not documented |

## Common events

Cloudflare's payload reference gives examples for these alert types:

| `alert_type` | Fires when |
| --- | --- |
| `advanced_ddos_attack_l4_alert` | A Layer 3/4 DDoS attack is detected |
| `advanced_ddos_attack_l7_alert` | A Layer 7 DDoS attack is detected |
| `health_check_status_notification` | An origin health check changes status |
| `dedicated_ssl_certificate_event_type` | A dedicated or custom certificate event occurs, such as expiry |
| `access_custom_certificate_expiration_type` | An Access custom certificate is close to expiry |
| `workers_alert` | A Workers usage alert fires |
| `workers_observability_alert` | A Workers observability alert fires |

Cloudflare has many more notification types, and its field reference also names `http_alert_origin_error` as an example. Each uses the same envelope with its own `alert_type` and `data`:

```json
{
  "name": "string",
  "text": "string",
  "data": {},
  "ts": 1136214245,
  "account_id": "string",
  "policy_id": "string",
  "policy_name": "string",
  "alert_type": "string",
  "alert_correlation_id": "string",
  "alert_event": "string"
}

```

`text` is a readable summary with values filled in, and `data` holds the alert-specific fields. `ts` is the Unix time the notification was generated. `alert_event` is `ALERT_STATE_EVENT_START` or `ALERT_STATE_EVENT_END` for alerts that open and close, and `alert_correlation_id` groups related alerts so you can match an end to its start.

Cloudflare notes that `account_id`, `policy_id` and `alert_type` may be missing, depending on the alert. The **Save and Test** message is the extreme case: its body is only `{"text": "Hello World! This is a test message sent from https://cloudflare.com. ..."}`. Write handlers that fall back to `text` when `alert_type` is missing.

## Setting up Cloudflare webhooks

1. In the Cloudflare dashboard, go to **Alerts** > **Destinations**.
2. In the **Webhooks** card, select **Create** and give the webhook a name.
3. Enter your URL. It must resolve to a public address on port 80 or 443.
4. Enter a **Secret**. For a generic webhook, you choose the value. Use a long random string and store it, for example as `CLOUDFLARE_WEBHOOK_SECRET`.
5. Select **Save and Test**. Cloudflare sends the test message to your URL.
6. Go to the notifications list, add or edit a notification, and select this webhook as its destination.

If your endpoint sits on a private network or a different port, Cloudflare suggests forwarding alerts with a Worker or routing to it through Cloudflare Tunnel.

## Securing Cloudflare webhooks

Compare the `cf-webhook-auth` header with your stored secret in constant time, and reject the request if the header is missing:

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

function verifyCloudflareSecret(headerValue, secret) {
  if (!headerValue || !secret) return false;
  const received = Buffer.from(headerValue, 'utf8');
  const expected = Buffer.from(secret, 'utf8');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

const app = express();

app.post('/webhooks/cloudflare', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.CLOUDFLARE_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyCloudflareSecret(req.get('cf-webhook-auth'), secret)) return res.sendStatus(401);

  const alert = JSON.parse(req.body.toString('utf8'));
  // Route on alert.alert_type (may be missing), then process async.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hmac
import os

def verify_cloudflare_secret(header_value, secret: str) -> bool:
    if not header_value or not secret:
        return False
    return hmac.compare_digest(header_value.encode("utf-8"), secret.encode("utf-8"))

# In FastAPI:
# verify_cloudflare_secret(request.headers.get("cf-webhook-auth"), os.environ["CLOUDFLARE_WEBHOOK_SECRET"])

```

Because the secret travels in every request and nothing covers the body, it only proves the request came from something that knows the secret. Use HTTPS so it can't be read in transit.

> **Make Cloudflare alerts production-ready.** [Hookdeck Event Gateway](/event-gateway) checks the `cf-webhook-auth` secret, stores every alert, and retries delivery to your tooling, which Cloudflare doesn't document doing itself.

In Hookdeck, create a Cloudflare source and enter the same secret. Hookdeck compares it with `cf-webhook-auth` on each request. The secret is optional in Cloudflare, so leave verification off if you didn't set a secret on the destination.

## Cloudflare webhook limitations and pain points

### A shared secret, not a signature

**The Problem:** The secret proves who sent the request, but not that the body is unchanged.

**Why It Happens:** Cloudflare sends the secret itself in `cf-webhook-auth` rather than an HMAC of the payload, and there is no timestamp.

**Workarounds:**

* Use HTTPS only, and don't log the header.
* Allowlist [Cloudflare's IP ranges](https://www.cloudflare.com/ips/) as a second check, keeping in mind that the ranges are shared across Cloudflare services and change over time.

**How Hookdeck Can Help:** Only Hookdeck needs the Cloudflare secret. Your services receive alerts from Hookdeck and don't have to store or compare it.

### No documented retries

**The Problem:** An alert sent while your endpoint is down may be lost, and you won't know.

**Why It Happens:** Cloudflare documents no retry policy, timeout, ordering or delivery ID for generic webhooks.

**Workarounds:**

* Keep the receiver small and highly available, and return `2xx` quickly.
* Send critical alert types to a second destination, such as email or PagerDuty.

**How Hookdeck Can Help:** Hookdeck accepts and stores each alert, then retries delivery to your service on a schedule you set, so an outage on your side doesn't drop it.

### Inconsistent payloads

**The Problem:** Handlers that assume `alert_type` or `account_id` crash on some alerts.

**Why It Happens:** Cloudflare marks those fields as optional, and the test message has only `text`.

**Workarounds:**

* Check for each field before using it, and fall back to `text`.
* Log unknown shapes rather than returning an error.

**How Hookdeck Can Help:** Hookdeck filters can route on `alert_type` when it is present, and send everything else to a catch-all destination.

### Public ports only

**The Problem:** Internal alert handlers can't receive Cloudflare webhooks directly.

**Why It Happens:** Generic webhooks only go to publicly resolvable addresses on port 80 or 443.

**Workarounds:**

* Forward alerts with a Worker, as in Cloudflare's `cf-webhook-relay` example.
* Expose the handler through Cloudflare Tunnel.

**How Hookdeck Can Help:** Point Cloudflare at a public Hookdeck URL. For local development, the Hookdeck CLI forwards events to a port on your machine.

## Best practices

### Always set a secret

Cloudflare recommends it for generic webhooks, and it is the only authentication on offer.

### Pair start and end events

Use `alert_correlation_id` with `alert_event` to open an incident on `ALERT_STATE_EVENT_START` and close it on `ALERT_STATE_EVENT_END`.

### Use `ts` for timing

`ts` is when Cloudflare generated the notification. Use it to order alerts and to spot delays.

### Split policies by audience

Each notification policy can send to different destinations. Send security alerts and Workers alerts to the teams that act on them rather than one shared endpoint.

## Conclusion

Cloudflare generic webhooks carry notification alerts in a common envelope, authenticated by a secret you choose and Cloudflare sends back in `cf-webhook-auth`. Set the secret, compare it in constant time, handle missing fields, and plan for the retries Cloudflare doesn't document.

[Hookdeck Event Gateway](https://hookdeck.com) stores every alert and retries delivery so your incident tooling doesn't miss one.

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