Developers · Webhooks

Webhooks

When something about a client's site changes, we POST a small JSON document to your URL. Use it to open tickets, post to a chat we don't support, or feed an automation.

01 — Setting one up

Setting one up

In the dashboard go to Alerts, add a channel of type Webhook, paste your HTTPS URL and, ideally, a secret. Choose the lowest severity it should receive. Press Send test to see a real delivery. We only deliver to public HTTPS addresses.

02 — What we send

What we send

POST with Content-Type: application/json, the event name in X-ExpiryOwl-Event and, when the channel has a secret, a signature in X-ExpiryOwl-Signature.

body
{
  "event": "incident.opened",
  "incident": {
    "id": "In7aP2sD9fG4hJ6kL1zX0",
    "kind": "ssl_expiring",
    "severity": "warning",
    "title": "Certificate expires in 14 days: shop.northwind.example",
    "details": {
      "summary": "Let's Encrypt certificate expires 2026-10-09 (14 days).",
      "daysLeft": 14,
      "validTo": "2026-10-09T23:59:59.000Z"
    },
    "openedAt": "2026-09-25T08:00:12.000Z",
    "resolvedAt": null
  },
  "monitor": {
    "id": "Vx3kQ9mPz1aB7cD2eF4gH",
    "hostname": "shop.northwind.example",
    "port": 443,
    "clientId": "c8Lr2Tq6Wn0Ys4Uv9Xz1A"
  },
  "org": {
    "id": "o5Hn8Jk2Lm4Np6Qr8St0U"
  },
  "sentAt": "2026-09-25T08:00:13.000Z"
}
incident.openedA new problem: certificate expiring or invalid, renewal overdue, domain expiring or status change, site down, DNS changed.
incident.escalatedThe same incident got worse, for example 14 days left became 3.
incident.resolvedFixed (sent for downtime and certificate errors).
incident.testYou pressed Send test on the channel.

Answer with any 2xx within 10 seconds. Don't rely on delivery order; use incident.id to de-duplicate.

03 — Verifying the signature

Verifying the signature

The header looks like t=1790380812,v1=5f2b…. t is the unix time we signed at; v1 is the hex HMAC-SHA256 of <t>.<raw body> with your secret. Compute it over the raw bytes (not re-serialised JSON), compare in constant time, and reject anything older than five minutes to stop replays.

Node.js
const crypto = require("node:crypto");

// Returns true when the signature is valid and less than 5 minutes old.
function verifySignature(secret, rawBody, header, toleranceSec = 300) {
  const parts = {};
  for (const piece of String(header).split(",")) {
    const [k, v] = piece.trim().split("=", 2);
    if (k && v) parts[k] = v;
  }
  const t = Number(parts.t);
  if (!Number.isInteger(t) || !/^[0-9a-f]{64}$/.test(parts.v1 || "")) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
  const expected = crypto.createHmac("sha256", secret).update(t + "." + rawBody).digest();
  return crypto.timingSafeEqual(expected, Buffer.from(parts.v1, "hex"));
}
Node.js · Express route
// Express: keep the raw body, the signature covers the exact bytes we sent.
app.post("/hooks/expiryowl", express.raw({ type: "application/json" }), (req, res) => {
  const ok = verifySignature(process.env.EXPIRYOWL_WEBHOOK_SECRET, req.body.toString("utf8"), req.get("X-ExpiryOwl-Signature") || "");
  if (!ok) return res.sendStatus(401);
  const event = JSON.parse(req.body.toString("utf8"));
  console.log(event.event, event.monitor.hostname, event.incident.title);
  res.sendStatus(204);
});
Python
import hashlib, hmac, time

def verify_signature(secret: str, raw_body: bytes, header: str, tolerance: int = 300) -> bool:
    parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
    try:
        t = int(parts["t"])
    except (KeyError, ValueError):
        return False
    if abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

# Flask: verify_signature(os.environ["EXPIRYOWL_WEBHOOK_SECRET"], request.get_data(), request.headers.get("X-ExpiryOwl-Signature", ""))
PHP
<?php
function verify_signature(string $secret, string $rawBody, string $header, int $tolerance = 300): bool {
    $parts = [];
    foreach (explode(',', $header) as $piece) {
        [$k, $v] = array_pad(explode('=', trim($piece), 2), 2, '');
        $parts[$k] = $v;
    }
    if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])) return false;
    if (abs(time() - (int) $parts['t']) > $tolerance) return false;
    $expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
    return hash_equals($expected, $parts['v1']);
}

$raw = file_get_contents('php://input');
if (!verify_signature(getenv('EXPIRYOWL_WEBHOOK_SECRET'), $raw, $_SERVER['HTTP_X_EXPIRYOWL_SIGNATURE'] ?? '')) {
    http_response_code(401);
    exit;
}
$event = json_decode($raw, true);

04 — Zapier

Zapier

  1. Create a Zap with the trigger Webhooks by Zapier → Catch Hook and copy the URL it gives you.
  2. In ExpiryOwl, add a Webhook alert channel with that URL and press Send test.
  3. Back in Zapier, pick the test request as sample data. Fields like incident__title and monitor__hostname are now available.
  4. Add a Filter on event (for example only incident.opened), then your action: a Jira issue, a Trello card, an SMS.

Catch Hook can't check signatures. Keep the Zapier URL private, or add a Code by Zapier step with the Node function above.

05 — Make

Make

  1. Start a scenario with Webhooks → Custom webhook, press Add, and copy the address.
  2. Add it as a Webhook alert channel in ExpiryOwl and press Send test so Make learns the data structure.
  3. Route on incident.severity with a Router, then add your modules.

06 — n8n

n8n

  1. Add a Webhook node, method POST, respond Immediately. Under options turn on Raw Body.
  2. Copy the production URL into a Webhook alert channel in ExpiryOwl.
  3. Follow it with a Code node that runs the Node verifySignature function on the raw body and the x-expiryowl-signature header, and stops the workflow when it returns false.

Prefer pulling to being pushed? The REST API has the same data.