# Guide to Checkly Webhooks: Features and Best Practices

Checkly webhooks turn monitoring alerts into HTTP requests. When an API check or browser check fails, degrades, recovers or a certificate nears expiry, a webhook alert channel can call your own service, open an incident in a tool Checkly has no native integration for, or feed an internal status page.

This guide covers how Checkly webhook channels are configured and templated, how to sign and verify them, how retries and timestamps interact, and the best practices for running a Checkly receiver in production.

## What are Checkly webhooks?

A Checkly webhook is an alert channel. You attach it to checks or check groups, and when one of them changes alert state, Checkly sends an HTTP request to the channel's URL. Checkly describes it as "the most flexible alert channel type".

The flexibility is in the body. Checkly has no fixed webhook payload. You write a request body template, usually JSON, using Handlebars variables such as `{{ALERT_TYPE}}` and `{{CHECK_NAME}}`, and Checkly renders it for each alert. Your template decides which fields arrive and what they are called, so your receiver and your template have to agree.

If you set a webhook secret on the channel, Checkly signs the rendered body with HMAC-SHA256 and sends the hex digest in `x-checkly-signature`. Every webhook also carries an `x-checkly-timestamp` header, which is not signed.

## Checkly webhook features

| Feature | Details |
| --- | --- |
| Configuration | Dashboard alert channel settings, or the `WebhookAlertChannel` construct in the Checkly CLI |
| Subscription model | Attach the channel to checks or groups, and choose failure, recovery, degraded and SSL expiry alerts |
| Method | `GET`, `POST`, `PUT`, `PATCH`, `HEAD` or `DELETE`, your choice |
| Body | A Handlebars template you write, usually JSON |
| Signature header | `x-checkly-signature`, only when a webhook secret is set |
| Scheme | HMAC-SHA256, hex digest, no prefix |
| Signed content | The rendered request body |
| Timestamp header | `x-checkly-timestamp`, sent on every webhook but not signed |
| Retries | Up to 5 retries, 20 seconds apart, on any response above 399 |
| Custom headers | Optional headers and query parameters, which can reference variables and secrets |
| Testing | Send test messages from the webhook editor |

Checkly documents no event ID header, response timeout or source IP list for webhooks.

## Common events

Because you write the body, Checkly's "events" are the alert types you choose to put in it with `{{ALERT_TYPE}}`. Checkly's alert states page lists these:

| `ALERT_TYPE` | Fires when |
| --- | --- |
| `ALERT_FAILURE` | A passing check fails |
| `ALERT_DEGRADED` | A passing check becomes degraded |
| `ALERT_RECOVERY` | A failing check passes again |
| `ALERT_DEGRADED_RECOVERY` | A degraded check passes again, if a degraded alert was sent |
| `ALERT_DEGRADED_FAILURE` | A degraded check escalates to failing |
| `ALERT_FAILURE_DEGRADED` | A failing check de-escalates to degraded |
| `ALERT_FAILURE_REMAIN`, `ALERT_DEGRADED_REMAIN` | A check stays failing or degraded, with a threshold such as "alert after 2 failures" |
| `ALERT_SSL` | A certificate is close to expiry |

On the channel, failure and recovery alerts are on by default. Degraded and SSL expiry alerts are off by default, and SSL alerts use a threshold that defaults to 30 days before expiry.

Other useful variables include `ALERT_TITLE`, `CHECK_ID`, `CHECK_NAME`, `CHECK_RESULT_ID`, `CHECK_TYPE`, `RESULT_LINK`, `STARTED_AT`, `RESPONSE_TIME`, `RUN_LOCATION`, `REGION`, `TAGS`, `IS_REMINDER` and, for SSL alerts, `SSL_CHECK_DOMAIN` and `SSL_DAYS_REMAINING`. With Rocky AI analysis enabled, `AI_ANALYSIS_ROOT_CAUSE` and related fields are available too. A template that gives your receiver what it needs looks like this:

```json
{
  "alertType": "{{ALERT_TYPE}}",
  "alertTitle": "{{ALERT_TITLE}}",
  "checkId": "{{CHECK_ID}}",
  "checkName": "{{CHECK_NAME}}",
  "checkResultId": "{{CHECK_RESULT_ID}}",
  "startedAt": "{{STARTED_AT}}",
  "resultLink": "{{RESULT_LINK}}"
}

```

Templates can also use Handlebars helpers. `{{#eq ALERT_TYPE 'ALERT_FAILURE'}}DOWN{{/eq}}` translates Checkly's terms into your own, `{{tagValue TAGS "team" "UNKNOWN"}}` pulls a value out of a `team:payments` tag, and `{{$UUID}}` generates a random UUID.

## Setting up Checkly webhooks

1. Generate a random secret and store it server-side, for example as `CHECKLY_WEBHOOK_SECRET`.
2. In the Checkly dashboard, add a **Webhook** alert channel.
3. Set the URL to your endpoint and the method to `POST`. A `GET` or `HEAD` request has no body, so there is nothing to sign.
4. Enter the webhook secret.
5. Paste your body template, choose which alert types trigger the channel, and attach it to checks or groups.
6. Send a test message from the webhook editor and inspect the rendered request before saving.

With the Checkly CLI, the same channel is a construct:

```typescript
import { WebhookAlertChannel } from 'checkly/constructs';

new WebhookAlertChannel('hookdeck-webhook', {
  name: 'Alerts to our service',
  method: 'POST',
  url: new URL('https://example.com/webhooks/checkly'),
  template: JSON.stringify({
    alertType: '{{ALERT_TYPE}}',
    checkId: '{{CHECK_ID}}',
    checkResultId: '{{CHECK_RESULT_ID}}',
  }),
  webhookSecret: process.env.CHECKLY_WEBHOOK_SECRET,
  sendDegraded: true,
});

```

For local development, the [Hookdeck CLI](/docs/cli) (`hookdeck listen 3000 checkly --path /webhooks/checkly`) gives you a public HTTPS URL that forwards to your local server, with no account required.

## Securing Checkly webhooks

Read `x-checkly-signature`, compute an HMAC-SHA256 of the raw request body with your secret as the key, encode it as hex and compare in constant time.

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

function verifyChecklySignature(rawBody, signatureHeader, secret) {
  if (!signatureHeader || !secret) return false;
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  const received = Buffer.from(signatureHeader.trim().toLowerCase(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

// The body is whatever your template renders, so read it raw for every content type.
app.post('/webhooks/checkly', express.raw({ type: '*/*' }), (req, res) => {
  if (!verifyChecklySignature(req.body, req.get('x-checkly-signature'), process.env.CHECKLY_WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }

  const alert = JSON.parse(req.body.toString('utf8'));
  // Route on the field your template carries ALERT_TYPE in, then process asynchronously.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac
import os

def verify_checkly_signature(raw_body: bytes, signature_header, secret: str) -> bool:
    if not signature_header or not secret:
        return False
    expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
    received = signature_header.strip().lower().encode("utf-8")
    return hmac.compare_digest(received, expected.encode("utf-8"))

# In FastAPI: raw_body = await request.body()
# verify_checkly_signature(raw_body, request.headers.get("x-checkly-signature"),
#                          os.environ["CHECKLY_WEBHOOK_SECRET"])

```

To check your verifier, use the secret `checkly-test-secret` and this exact body:

```
{"alertType":"ALERT_FAILURE","checkName":"My API check","checkId":"08437f9c-df8c-45ed-975a-a3f9e24d626d"}

```

The signature should be:

```
ec6002642ded5f83bc1ce99ef7ad4e7588b18c2acd2049495863c28aa4bdfbb1

```

Checkly's Ruby and Django examples hash the raw body. Its Node example parses the body with `bodyParser.json` and hashes `JSON.stringify(request.body)` instead, which only matches when the rendered template happens to be compact JSON with the same key order. Hash the raw bytes, as above.

> **Make Checkly webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies `x-checkly-signature` against the rendered body, answers Checkly straight away so its retries never fire, and durably queues every alert before your service sees it.

## Checkly webhook limitations and pain points

### The payload is whatever the template says

**The Problem:** A teammate edits the template, and the receiver starts failing on a missing field.

**Why It Happens:** Checkly has no fixed payload. Field names, nesting and even the content type come from a template stored in Checkly, not in your code. A pretty-printed template, a stray trailing comma or an unescaped quote in a check name all change what arrives.

**Workarounds:**

* Keep the template in code with the `WebhookAlertChannel` construct, so changes go through review alongside the receiver.
* Validate the parsed body against a schema and log anything that fails, rather than throwing.
* Use triple braces (`{{{VAR}}}`) where you need a value without HTML escaping, and test the rendered output with a test message.

**How Hookdeck Can Help:** Hookdeck stores every request with its headers and body, so when a template change breaks the receiver you can see exactly what Checkly sent and replay it once the receiver is fixed.

### Copying Checkly's Node sample breaks verification

**The Problem:** Verification works in testing and then fails when the template changes.

**Why It Happens:** Checkly's Node example re-serialises the parsed body before hashing. That reproduces the original bytes only for compact JSON in the same key order. The Ruby and Django examples hash the raw body, which is what Checkly signs.

**Workarounds:**

* Mount `express.raw({ type: '*/*' })` on the route and hash `req.body` as a buffer.
* Parse JSON only after verification.

**How Hookdeck Can Help:** Hookdeck's Checkly source type verifies the signature against the raw bytes Checkly sent, whatever your template looks like.

### No secret, no signature

**The Problem:** Requests arrive without `x-checkly-signature`.

**Why It Happens:** Checkly calls the webhook secret optional, and a channel without one sends no signature.

**Workarounds:**

* Set a secret on every webhook channel and reject unsigned requests.
* If you can't, put a long random token in a custom header or the URL and check it, knowing it protects nothing if the URL leaks.

**How Hookdeck Can Help:** Hookdeck verifies the signature when you give the source your secret. If you didn't set a secret in Checkly, leave verification off on the source.

### Retries can reorder failure and recovery

**The Problem:** Your incident tool shows a check as down after it has already recovered.

**Why It Happens:** Checkly retries any response above 399 up to 5 times, 20 seconds apart, for 100 seconds in all. On a check that runs every minute, a retried failure alert can arrive after the recovery alert that followed it. Checkly says this is why it adds `x-checkly-timestamp`: "to ignore any webhooks that come in 'late'".

**Workarounds:**

* Store the latest `x-checkly-timestamp` you have processed per check, and ignore any alert older than it.
* Template `{{STARTED_AT}}` into the body as well. It is part of the signed body, unlike the header.
* Answer `200` quickly so retries are rare in the first place.

**How Hookdeck Can Help:** Hookdeck acknowledges each alert as soon as it arrives, so Checkly does not retry because of your downtime, and then delivers to your service on [your own retry schedule](/docs/retries).

### No event ID

**The Problem:** A retried alert creates a second incident.

**Why It Happens:** Checkly documents no delivery ID header, and the body contains only what your template puts in it.

**Workarounds:**

* Template `{{CHECK_RESULT_ID}}` and `{{ALERT_TYPE}}` into the body and deduplicate on the pair.
* Don't rely on `{{$UUID}}` as an idempotency key. Checkly does not say whether a retry renders the template again, which would generate a new UUID.

**How Hookdeck Can Help:** Hookdeck's [deduplication](/docs/deduplication) can drop repeats based on the body fields you choose, such as your `checkResultId` and `alertType`.

## Best practices

### Design the template for the receiver

Include the alert type, check ID, result ID, start time and a link in every template, under stable names. Treat the template as part of your API and change it with the same care.

### Verify against the raw body

Use `express.raw({ type: '*/*' })` in Express, `await request.text()` in a Next.js route handler, or `await request.body()` in FastAPI, and parse only after verifying.

### Handle every alert state

Route on your `alertType` field and include the escalation and de-escalation states (`ALERT_DEGRADED_FAILURE`, `ALERT_FAILURE_DEGRADED`), not just failure and recovery. Acknowledge types you do not handle with a `200` so they are not retried.

### Acknowledge fast and process asynchronously

Any response above 399 triggers a retry. Verify, store and return `200`, then open the incident or send the notification from a queue. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Make processing idempotent

Deduplicate on the result ID and alert type from your template before acting. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

## Conclusion

Checkly sends a request you design, signed with a hex HMAC-SHA256 of the rendered body when you set a secret. Keep the template stable and in code, verify `x-checkly-signature` against the raw bytes rather than re-serialised JSON, use timestamps to drop late retries, and deduplicate on the result ID.

[Hookdeck Event Gateway](https://hookdeck.com) checks every signature, answers Checkly before its retry clock starts, and holds each alert until your service has processed it, so the alert that says production is down is the one alert you do not lose.

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