Gareth Wilson Gareth Wilson

Guide to Checkly Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationDashboard alert channel settings, or the WebhookAlertChannel construct in the Checkly CLI
Subscription modelAttach the channel to checks or groups, and choose failure, recovery, degraded and SSL expiry alerts
MethodGET, POST, PUT, PATCH, HEAD or DELETE, your choice
BodyA Handlebars template you write, usually JSON
Signature headerx-checkly-signature, only when a webhook secret is set
SchemeHMAC-SHA256, hex digest, no prefix
Signed contentThe rendered request body
Timestamp headerx-checkly-timestamp, sent on every webhook but not signed
RetriesUp to 5 retries, 20 seconds apart, on any response above 399
Custom headersOptional headers and query parameters, which can reference variables and secrets
TestingSend 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_TYPEFires when
ALERT_FAILUREA passing check fails
ALERT_DEGRADEDA passing check becomes degraded
ALERT_RECOVERYA failing check passes again
ALERT_DEGRADED_RECOVERYA degraded check passes again, if a degraded alert was sent
ALERT_DEGRADED_FAILUREA degraded check escalates to failing
ALERT_FAILURE_DEGRADEDA failing check de-escalates to degraded
ALERT_FAILURE_REMAIN, ALERT_DEGRADED_REMAINA check stays failing or degraded, with a threshold such as "alert after 2 failures"
ALERT_SSLA 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:

{
  "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:

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 (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.

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:

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 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.

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 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.

Make processing idempotent

Deduplicate on the result ID and alert type from your template before acting. See our guide to 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 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 for free and handle Checkly 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.