Webhooks
Get a signed HTTPS POST when a run finishes, when something real changes, or when a shipped fix gets a verdict.
Webhooks are on paid plans (Starter, Growth and Agency)A webhook endpoint is a URL on your server. Onym sends it a JSON event each time something happens in your workspace, so you can post to Slack, open a ticket or refresh a report without polling the API.
Events
| Event | Sent when |
|---|---|
run.completed | A scheduled run finished (headline numbers and change count) |
change.detected | A run produced real changes (beyond noise) |
opportunity.measured | A shipped opportunity reached a verdict (lift, drop or no change) |
Events fire after scheduled runs, for every domain in the workspace. A one-off grade doesn't send them.
1. Create an endpoint
In the dashboard, open Settings → Webhooks, paste an https:// URL and pick the events you want. You need the admin role. Onym shows the endpoint's signing secret once; it starts with whsec_. Store it with your other secrets.
The URL must be reachable from the internet. Onym refuses localhost, private IP ranges and hosts that don't resolve to a public address, checks again before every delivery, and doesn't follow redirects.
2. Handle the event
Every event has the same envelope:
{
"id": "evt_8f3a1c0b9d2e4f6a7b81",
"event": "change.detected",
"created_at": "2026-10-14T06:41:09Z",
"data": {
"domain": {
"id": "47adc410-7772-48cb-b07d-898978697153",
"host": "acme.example",
"brand": "Acme",
"dashboard": "https://getonym.com/app/d/47adc410-7772-48cb-b07d-898978697153"
},
"run_date": "2026-10-14",
"events": [
{
"kind": "competitor",
"tone": "bad",
"title": "Northwind gained on Perplexity",
"detail": "41% → 58% of answers (n=74)",
"link": "/competitors"
}
]
}
}Return any 2xx status within 10 seconds, then do the slow work afterwards, for example on a queue.
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = "whsec_..." # from Settings → Webhooks
@app.post("/hooks/onym")
def onym():
if not verify(request.get_data(), request.headers.get("X-Onym-Signature", ""), SECRET):
abort(400)
event = request.get_json()
if event["event"] == "change.detected":
for change in event["data"]["events"]:
print(change["tone"], change["title"])
return "", 204import express from "express";
const app = express();
const SECRET = "whsec_..."; // from Settings → Webhooks
// Keep the raw body: the signature covers the exact bytes Onym sent.
app.post("/hooks/onym", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.body, req.get("X-Onym-Signature") || "", SECRET)) return res.sendStatus(400);
const event = JSON.parse(req.body);
if (event.event === "change.detected") {
for (const c of event.data.events) console.log(c.tone, c.title);
}
res.sendStatus(204);
});3. Verify the signature
Each request has these headers:
| Header | Value |
|---|---|
X-Onym-Event |
The event type, like run.completed |
X-Onym-Signature |
t=<unix seconds>,v1=<hex HMAC> |
User-Agent |
Onym-Webhooks/1.0 |
To verify, compute an HMAC-SHA256 of the timestamp, a period and the raw request body, keyed with your secret, and compare it with v1 in constant time. Reject timestamps more than five minutes old so a captured request can't be replayed.
import hashlib, hmac, time
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
try:
parts = dict(p.split("=", 1) for p in header.split(","))
ts, sig = int(parts["t"]), parts["v1"]
except (KeyError, ValueError):
return False
if abs(time.time() - ts) > tolerance:
return False # too old: possibly a replay
expected = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig)import crypto from "node:crypto";
export function verify(rawBody, header, secret, tolerance = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
if (!parts.t || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > tolerance) return false;
const expected = crypto.createHmac("sha256", secret)
.update(`${parts.t}.`).update(rawBody).digest("hex");
return expected.length === parts.v1.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}Verify the raw bytes. If your framework parses the JSON and serializes it again before you verify, the signature won't match. Read the body before any JSON middleware touches it.
4. Send a test
Click Send test next to the endpoint in Settings. Onym posts a run.completed event whose data contains "test": true, and shows the status code your server returned.
Payloads
run.completed
{
"domain": { "id": "…", "host": "acme.example", "brand": "Acme", "dashboard": "…" },
"run_date": "2026-10-14",
"compared_with": "2026-10-13",
"visibility": { "k": 61, "n": 133, "rate": 0.459, "low": 0.376, "high": 0.543 },
"changes": 2
}compared_with is null after a domain's first run.
change.detected
Sent only when a run produced at least one change. events lists them; see Changes for every kind.
opportunity.measured
{
"domain": { "id": "…", "host": "acme.example", "brand": "Acme", "dashboard": "…" },
"opportunity": {
"id": "…",
"title": "Publish a comparison for “Enterprise SSO”",
"kind": "owned",
"shipped_at": "2026-09-02T00:00:00+00:00",
"shipped_url": "https://acme.example/blog/sso-compared"
},
"verdict": "up",
"before": { "k": 9, "n": 84, "rate": 0.107, "low": 0.057, "high": 0.191 },
"after": { "k": 31, "n": 88, "rate": 0.352, "low": 0.262, "high": 0.455 },
"change": { "delta": 0.245, "significant": true, "direction": "up", "pairs": 21 }
}Each verdict (up, down or none) is sent once per opportunity. See Opportunities.
Retries and failures
| Attempt | When |
|---|---|
| 1 | As soon as the run finishes |
| 2 | 2 seconds after a failure |
| 3 | 8 seconds after that |
A status of 300 or more, a timeout after 10 seconds, or a connection error counts as a failure. If all three attempts fail, the event counts as one failed delivery. After 20 failed deliveries in a row, Onym turns the endpoint off and marks it disabled in Settings. Fix the endpoint, then click Re-enable; that resets the count.
The event id stays the same across attempts. Events can arrive out of order and, rarely, twice, so record the ids you've handled and skip repeats.
Debugging
| What you see | Likely cause | Fix |
|---|---|---|
| Signature never matches | The body was parsed before verifying | Verify against the raw bytes |
| Signature fails sometimes | Your server's clock is more than 5 minutes off | Sync it with NTP |
Last status is 301 or 302 |
The URL redirects | Use the final URL, including any trailing slash |
Last status starts with error: |
DNS, TLS or connection failure | Check the certificate and that the host is public |
| Endpoint shows as disabled | 20 failed deliveries in a row | Fix it, then re-enable |
| Nothing arrives | Your plan changed to Free, or no scheduled run has finished | Check Plan & billing and the Changes page |