Webhooks
On the Pro and Business plans, GatiFlow pushes the alerts that match an organization's watchlist to the HTTPS endpoints registered in Settings, up to 5 per organization. The same contract is machine-readable in the webhooks section of /openapi.json and as AsyncAPI 3.0 in /asyncapi.json.
When alerts are raised
Every collection cycle, every six hours, compares its findings with the topics and companies on the organization's watchlist (Settings: Topics of Interest and Companies to Watch). Three kinds of alert exist:
• spike: a watched topic grew past the spike threshold against its own baseline.
• emerging: a watched topic appeared, with no baseline to compare against yet.
• new_hirer: a watched company appeared in the hiring feed for the first time.
A topic is not alerted again within 24 hours, and a company not within 14 days. Alerts are raised only while Signal Alerts is switched on in Settings, under Email Preferences: the switch covers the email and the webhook alike. One event type exists today, intelligence:alert, and every webhook receives it.
Delivery and retries
Delivery runs in the same cycle, right after the alerts are raised: one POST per webhook, carrying every alert the cycle raised for the organization. A cycle sends at most 20 deliveries within 30 seconds; the rest wait for the next one.
Answer within 10 seconds. Any status below 400 counts as delivered; redirects are not followed. A 4xx or 5xx, a timeout, a refused connection, an address that resolves to a private network or a certificate that does not match the pin (below) is a failed attempt. A failed attempt is retried at each following cycle, up to 5 attempts, with the same X-GatiFlow-Delivery-Id. After the fifth failure the webhook is switched off and the organization owner is emailed. Nothing is backfilled: the report endpoint still has the data. To resume, remove the webhook and add it again.
Ordering between deliveries is not guaranteed. Sort by data.generated_at if order matters.
The request
POST with Content-Type: application/json and these headers:
X-GatiFlow-Event The event type, intelligence:alert, the same value as event in the body.
X-GatiFlow-Signature t=<unix timestamp>,v1=<hex>: HMAC-SHA256 of '<timestamp>.<raw body>' under the webhook secret.
X-GatiFlow-Timestamp The t= value on its own. Fixed when the alert is raised and repeated on every retry.
X-GatiFlow-Delivery-Id One UUID per logical delivery, repeated on every retry. Use it to discard duplicates.
X-GatiFlow-Attempt Which attempt this is, 1 to 5.
Body
{
"event": "intelligence:alert",
"data": {
"alert_count": 2,
"alerts": [
{ "type": "spike", "topic": "rust", "change_pct": 80.0, "current_mentions": 16, "sources": 2 },
{ "type": "new_hirer", "company": "Acme", "title": "Acme — Rust Engineer", "source": "adzuna" }
],
"generated_at": "2026-09-26T11:52:07.114305+00:00"
}
}A spike carries topic, change_pct, current_mentions and sources; an emerging topic the same without change_pct; a new hirer company, title and source. The JSON Schema is /schemas/AlertEvent.json.
Verifying a delivery
Each webhook has its own secret, shown once when it is created; Settings shows only its first characters afterwards. Recompute the HMAC-SHA256 of <t>.<raw body> with it, over the raw bytes as received rather than re-serialized JSON, and compare in constant time. Reject a mismatch.
Python
import hashlib
import hmac
def verify(secret: str, raw_body: bytes, signature_header: str) -> bool:
fields = dict(part.split("=", 1) for part in signature_header.split(","))
signed = fields["t"].encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, fields["v1"])JavaScript
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, rawBody, signatureHeader) {
const fields = Object.fromEntries(signatureHeader.split(",").map((part) => part.split("=", 2)));
const expected = createHmac("sha256", secret).update(`${fields.t}.`).update(rawBody).digest();
const received = Buffer.from(fields.v1 ?? "", "hex");
return received.length === expected.length && timingSafeEqual(received, expected);
}The timestamp is fixed when the alert is raised and travels unchanged through every retry, so a retry arrives with an older t than the first attempt. Reject a stale timestamp on attempt 1 only; on later attempts, discard a X-GatiFlow-Delivery-Id you have already processed.
What the endpoint must be
HTTPS on port 443 or 8443, on a host that resolves to a public address. The host is resolved again on every attempt and the connection goes to the address that was checked.
The certificate the endpoint presents on the first attempt is pinned. A different certificate later, a routine renewal included, is refused as a possible interception and counts as a failed attempt. To accept a new certificate, remove the webhook and add it again; a new secret is issued with it.