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.
{
"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.opened | A new problem: certificate expiring or invalid, renewal overdue, domain expiring or status change, site down, DNS changed. |
| incident.escalated | The same incident got worse, for example 14 days left became 3. |
| incident.resolved | Fixed (sent for downtime and certificate errors). |
| incident.test | You 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.
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"));
}// 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);
});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
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
- Create a Zap with the trigger Webhooks by Zapier → Catch Hook and copy the URL it gives you.
- In ExpiryOwl, add a Webhook alert channel with that URL and press Send test.
- Back in Zapier, pick the test request as sample data. Fields like
incident__titleandmonitor__hostnameare now available. - Add a Filter on
event(for example onlyincident.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
- Start a scenario with Webhooks → Custom webhook, press Add, and copy the address.
- Add it as a Webhook alert channel in ExpiryOwl and press Send test so Make learns the data structure.
- Route on
incident.severitywith a Router, then add your modules.
06 — n8n
n8n
- Add a Webhook node, method
POST, respond Immediately. Under options turn on Raw Body. - Copy the production URL into a Webhook alert channel in ExpiryOwl.
- Follow it with a Code node that runs the Node
verifySignaturefunction on the raw body and thex-expiryowl-signatureheader, and stops the workflow when it returns false.
Prefer pulling to being pushed? The REST API has the same data.