Cuckoo

Set Up Cuckoo Alerts: Email, Slack, Discord and Webhooks

Updated

Monitoring a cron job only helps if the alert reaches someone who can act on it. An alert in an inbox nobody reads is as useless as no monitoring. This guide explains Cuckoo's alert channels, who receives which alert, and how to keep the volume low enough that people still react to them.

When Cuckoo sends an alert

Your jobs send pings to https://cuckoo.metastic.ch/ping/YOUR-UUID (plus /start, /fail or an exit code). Cuckoo sends a notification when:

  • a ping is late: the period or cron schedule plus the grace time passed without a success ping,
  • a failure is reported: a /fail ping or a non-zero exit code arrived,
  • max runtime is exceeded: a /start arrived, but no success or failure followed in time,
  • the check recovers: a success ping arrives after the check was down.

Each alert goes to every integration of the organisation. You get one alert per incident, not one per missed run, and one recovery message.

Email

Email is the simplest channel and a good default for low-volume, non-urgent checks. Use a team address or a ticketing inbox rather than a personal mailbox, so alerts are not lost when someone is on holiday. Add a filter rule in your mail client so Cuckoo messages are not sorted into a newsletter or promotions folder.

Slack incoming webhook

Slack delivers messages from external services through incoming webhooks:

  1. In Slack, create an app at api.slack.com/apps (or use an existing internal app).
  2. Under Incoming Webhooks, turn the feature on and click Add New Webhook to Workspace.
  3. Choose the channel, for example #ops-alerts, and copy the webhook URL. It starts with https://hooks.slack.com/services/.
  4. In the Cuckoo dashboard, add a Slack channel and paste the URL.

Treat the webhook URL as a secret: anyone who has it can post to that channel. If it leaks, revoke it in Slack.

You can test a Slack webhook independently of Cuckoo:

curl -fsS -X POST -H 'Content-Type: application/json' \
  --data '{"text":"Test from the ops team"}' \
  "$SLACK_WEBHOOK_URL"

Discord webhook

In Discord, open Server Settings → Integrations → Webhooks → New Webhook, pick the channel, and copy the webhook URL. Add a Discord channel in the Cuckoo dashboard and paste it. As with Slack, the URL is a credential. Delete the webhook in Discord to revoke it.

Generic signed webhook

The generic webhook sends an HTTP POST with a JSON body to a URL you control, for example to open a ticket, page an on-call tool, or trigger an automatic retry. The URL must be public https. When you add the integration, Cuckoo shows a signing secret (whsec_…) once; store it in your receiver's secret store.

Every request carries these headers:

  • X-Cuckoo-Event: check.down or check.up
  • X-Cuckoo-Delivery: a delivery ID; the same ID is reused when a delivery is retried
  • X-Cuckoo-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>

A down alert looks like this:

{
  "event": "check.down",
  "occurred_at": "2026-11-05T02:47:00Z",
  "text": "nightly-backup is DOWN (no ping within 30 minutes of the expected time)",
  "reason": "no ping within 30 minutes of the expected time",
  "org": {"id": 12, "name": "Acme"},
  "check": {"id": 345, "name": "nightly-backup", "slug": "nightly-backup", "status": "down",
            "schedule": "cron 15 2 * * * (UTC)", "grace_seconds": 1800,
            "url": "https://cuckoo.metastic.ch/checks/345", "last_ping_at": "2026-11-04T02:16:03Z"}
}

Verifying the signature

Recompute the HMAC over the timestamp, a dot and the raw body, compare it in constant time, and reject old timestamps so a captured request cannot be replayed later:

import hashlib
import hmac
import os
import time

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["CUCKOO_WEBHOOK_SECRET"].encode()

@app.post("/hooks/cuckoo")
def receive():
    raw = request.get_data()  # raw bytes, before any JSON parsing
    parts = dict(p.split("=", 1) for p in request.headers.get("X-Cuckoo-Signature", "").split(",") if "=" in p)
    t, sig = parts.get("t", ""), parts.get("v1", "")
    if not t.isdigit() or abs(time.time() - int(t)) > 300:
        abort(401)
    expected = hmac.new(SECRET, t.encode() + b"." + raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, sig):
        abort(401)
    event = request.get_json()
    # handle event["event"] == "check.down" / "check.up": open a ticket, notify on-call, ...
    return "", 204

Rules for any receiver:

  • Sign the raw body. Parsing and re-serialising JSON changes whitespace and key order, and the signature no longer matches.
  • Compare in constant time with hmac.compare_digest (Python), crypto.timingSafeEqual (Node.js) or hash_equals (PHP). A plain == can leak timing information.
  • Respond quickly. Return a 2xx status within 10 seconds and do slow work in the background. Other statuses are retried with backoff for several hours; 401, 403, 404 and 410 stop the retries.
  • Expect duplicates. Use X-Cuckoo-Delivery to make the handler idempotent, so a retried delivery does not open two tickets.

Who gets which alert

Integrations belong to the organisation, and every down and recovery alert goes to all of them. Email goes to every member who has alert emails turned on in their account settings. To separate urgent from routine jobs today, use two organisations (for example "Production" and "Internal") with their own integrations. Per-check routing is on the roadmap.

One check per job, with a clear name such as prod-db-backup-nightly, makes every alert self-explanatory.

Avoiding alert fatigue

  • Set realistic grace times. If a job sometimes runs 20 minutes longer, set the grace time above that. A check that goes down every few days for no reason teaches people to ignore it.
  • Watch for flapping. A check that alternates between down and up usually points to a schedule mismatch, such as the wrong time zone, a period that is too short or overlapping runs. Fix the cause rather than muting it.
  • Report retries carefully. If your scheduler retries failed runs, send the fail ping only on the final attempt, otherwise you get a failure and a recovery for a run that ended fine.
  • Plan for maintenance. Pause the affected checks before planned downtime; the next ping resumes them.

Troubleshooting

  • No Slack messages: the app may have been removed from the channel, or the webhook revoked. Test the URL with the curl command above.
  • Webhook signature never matches: you are probably hashing parsed JSON instead of the raw body, or you forgot the <t>. prefix.
  • Emails missing: check spam folders, and allow-list the sender address in your mail system.

FAQ

Can one check alert several channels?

Yes. Every down and recovery alert goes to all integrations of the organisation, for example Slack plus a webhook, and by email to every member who has alert emails turned on.

Where do I find the webhook payload format?

The JSON body and the X-Cuckoo-Event, X-Cuckoo-Delivery and X-Cuckoo-Signature headers are shown above and on the docs page. The signature covers the timestamp and the raw body, like Stripe's scheme.

Will I get a message when the job recovers?

Yes. When a success ping arrives after the check was down, Cuckoo sends a recovery notification to the same channels, so you know the issue is resolved without opening the dashboard.

Related guides

All guides · Compare Cuckoo with other tools

Send feedback

Ideas, bugs, questions: we read every message. Or write to hello@metastic.ch.