The contract your endpoint is written against: what we send, how it is signed, how long you have to answer, and what happens when you do not.
Haiyz posts an event to your endpoint as it happens: a data item finished its scan, a layer came online, a scheduled run failed. This page is the contract you write your receiver against.
A POST with a JSON body and these headers:
| Header | What it carries |
|---|---|
X-Haiyz-Signature |
t=<unix seconds>,v1=<hex> — see below |
X-Haiyz-Event-Id |
The event. Stable across every retry of the same event |
X-Haiyz-Event-Type |
The name, for example dataset.layer.ready |
X-Haiyz-Event-Version |
The payload version for that name |
X-Haiyz-Delivery-Id |
This delivery. A retry keeps it; a replay gets a new one |
X-Haiyz-Delivery-Attempt |
1 for the first send, then 2, 3… |
X-Haiyz-Environment |
live, or test for a delivery you asked for from the Console |
The body is the envelope:
{
"id": "evt_01J…",
"type": "dataset.layer.ready",
"version": 1,
"environment": "live",
"createdAt": "2026-09-20T09:14:02.441Z",
"deliveryId": "dlv_01J…",
"data": {
"subject": { "type": "dataset", "id": "66f…" },
"orgId": "66a…"
}
}
subject and orgId are the envelope's own statement about the event and always
win over anything of the same name inside the event's payload. Everything else
under data belongs to the event type.
The signature is an HMAC of the timestamp and the raw body, exactly as it arrived. Parse it before your JSON parser touches it: re-serialising the object changes the bytes and the signature will not match.
signed = "<t>.<raw body>"
v1 = hex( hmac_sha256(secret, signed) )
Compare in constant time, then check that t is within five minutes of now.
Both halves matter. Without the timestamp check a captured body can be replayed
at any point in the future, and the timestamp is inside the signed material
precisely so it cannot be swapped for a fresh one.
import crypto from "node:crypto";
export function verify(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=").map((s) => s.trim())),
);
const t = Number(parts.t);
if (!Number.isFinite(t)) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
// every v1= in the header, so a rotation does not need a flag day
return header
.split(",")
.filter((p) => p.trim().startsWith("v1="))
.some((p) =>
crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(p.trim().slice(3), "hex"),
),
);
}
A header may carry more than one v1=. That is a secret rotation in
progress: the old secret and the new one both sign for the length of the grace
window, so accept the request if any of them matches.
Answer 2xx and answer quickly. The whole send — DNS, connect, TLS and your
response — has ten seconds, and anything slower is a failure as far as we can
tell. Acknowledge first and do the work afterwards.
Anything that is not 2xx is retried on this curve, from the first failure:
immediately · 30s · 2m · 10m · 1h · 3h · 6h · 12h
After the last attempt the delivery is exhausted and dropped. An endpoint that
has failed without a single success for 24 hours is switched off
automatically and marked auto_disabled in the Console, with the reason. While
it is off, its events are not queued for it at all — they are gone, not waiting —
so an auto-disabled endpoint is worth an alert on your side.
The same event can arrive more than once: a retry after your 2xx was lost on
the way back, or a replay someone asked for. X-Haiyz-Event-Id is stable across
all of them, so record the ids you have processed and ignore one you have seen.
Assume at-least-once delivery, never exactly-once.
Order is not guaranteed either. Two events about the same object can arrive out
of order, so treat createdAt as the truth rather than arrival time.
Register it in the Console, under Organization › Webhooks. The address must be
https, on a public host — a private or loopback address is refused when you
save it, and the reason is shown.
The signing secret is displayed once, when the endpoint is created and again when you rotate. Haiyz cannot show it a second time; a secret the server could read back is a secret worth less.
The same screen carries the delivery log — every attempt with its status code, its duration and its error — and a Test button that sends a real signed delivery and tells you what your endpoint answered. Use it before you go looking for anything else.