Webhooks
Rather than polling for a score, let us tell you. Register an endpoint in the dashboard under Webhooks, choose the events you want, and we POST to it.
Headers
Every delivery carries three:
| Header | Meaning |
|---|---|
X-DeliverSight-Event | the event type, e.g. test.scored |
X-DeliverSight-Delivery | unique id for this delivery attempt’s message — use it to deduplicate |
X-DeliverSight-Signature | t=<unix>,v1=<hex hmac-sha256> — see below |
Verify the signature before you trust the body
Your endpoint is a public URL. Anyone can POST to it, so the signature is the only thing that makes a delivery ours. Verify it before doing anything with the payload.
The signed value is the timestamp, a literal ., and the exact raw request body:
signed = "<t>." + <raw body bytes>
v1 = hex( hmac_sha256( endpoint_secret, signed ) ) Two rules that are easy to get wrong:
- Use the raw body, exactly as received. Do not parse and re-serialise it first — key order and whitespace change, and the signature will not match.
- Compare in constant time. A plain
==on the hex string leaks timing information.
Reject deliveries whose t is far from now (five minutes is a reasonable window); otherwise a
captured request can be replayed at leisure.
Node
import crypto from 'node:crypto';
// express.raw({ type: 'application/json' }) — req.body must be a Buffer.
export function verify(req, secret, toleranceSeconds = 300) {
const header = req.get('X-DeliverSight-Signature') ?? '';
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=', 2)));
if (!parts.t || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSeconds) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(parts.t + '.')
.update(req.body) // the raw Buffer
.digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(parts.v1, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
} Python
import hashlib, hmac, time
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1:
return False
if abs(time.time() - int(t)) > tolerance:
return False
expected = hmac.new(
secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, v1) Go
func Verify(rawBody []byte, header, secret string, tolerance time.Duration) bool {
var ts, v1 string
for _, p := range strings.Split(header, ",") {
k, v, ok := strings.Cut(p, "=")
if !ok {
continue
}
switch k {
case "t":
ts = v
case "v1":
v1 = v
}
}
sec, err := strconv.ParseInt(ts, 10, 64)
if err != nil || v1 == "" {
return false
}
if d := time.Since(time.Unix(sec, 0)); d > tolerance || d < -tolerance {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
fmt.Fprintf(mac, "%s.", ts)
mac.Write(rawBody)
return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(v1))
} Events
| Event | Fires when |
|---|---|
test.scored | a test finished scoring |
monitor.alert | a monitoring incident opened or resolved |
dmarc.source.discovered | a sending source appeared in your DMARC data for the first time |
dmarc.sender.failing | a source you marked authorized started failing DMARC |
New event types may be added under v1. Ignore types you do not recognise rather than
erroring on them.
placement.completed is reserved and does not fire yet. You can subscribe to it, because it is
still accepted as a valid event type, but inbox-placement testing is not part of the product today
and nothing emits it. It is listed here so you are not surprised to see it accepted, and so nobody
builds a handler expecting deliveries. We would rather tell you than let you find out by waiting.
Payloads
Every delivery has the same shape:
{
"type": "test.scored",
"account_id": "018f3c2a-7b41-7e3d-9c02-5a1f6d8e4b77",
"data": { "token": "ds-wjhme5khuo3qgqiw", "score": 9 },
"created_at": "2026-07-27T21:51:28.417Z"
} type matches the X-DeliverSight-Event header, data holds the event’s fields, and created_at is when we generated it. Read your fields from data.
Deprecated: fields mirrored at the top level. Monitoring and DMARC events used to arrive as a bare object with no envelope. They are now enveloped like everything else, and their fields are also still present at the root so handlers written against the old shape keep working:
{
"type": "monitor.alert",
"account_id": "018f3c2a-7b41-7e3d-9c02-5a1f6d8e4b77",
"data": { "domain": "example.com", "check": "dkim", "state": "opened" },
"created_at": "2026-07-27T21:51:28.417Z",
"domain": "example.com",
"check": "dkim",
"state": "opened"
} The duplicated top-level fields go away in /v2. Read from data.
Use X-DeliverSight-Event as the authoritative event type — it is present on every delivery,
whichever shape the body takes.
Retries
A delivery succeeds on any 2xx. Anything else is retried with exponential backoff — 1 minute,
2, 4, 8, and so on, capped at 6 hours — for up to 8 attempts. Requests time out after 10 seconds,
so acknowledge quickly and do the work afterwards: a handler that scores something itself
before replying will start failing under load and get retried.
An endpoint that keeps failing is disabled after 20 consecutive failures, and we tell the account owners. Re-enable it in the dashboard once it is healthy.
Retries mean deliveries are at-least-once. The same event can arrive twice — after a timeout
where your handler actually succeeded, for instance. Deduplicate on X-DeliverSight-Delivery, and
make handlers idempotent.
Slack, Teams and Telegram
An endpoint can be set to one of those formats instead, in which case we send that platform’s own message shape rather than the JSON above — and no signature, because those endpoints are authenticated by their secret URL. Keep those URLs secret accordingly.