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:

HeaderMeaning
X-DeliverSight-Eventthe event type, e.g. test.scored
X-DeliverSight-Deliveryunique id for this delivery attempt’s message — use it to deduplicate
X-DeliverSight-Signaturet=<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:

  1. 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.
  2. 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

EventFires when
test.scoreda test finished scoring
monitor.alerta monitoring incident opened or resolved
dmarc.source.discovereda sending source appeared in your DMARC data for the first time
dmarc.sender.failinga 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.