The delivery request
A POST to your subscriber URL. The body is an event wrapper, and the
report is nested inside it — not the envelope at the top level:
{
"id": "<uuid>",
"type": "report.received",
"schemaVersion": "1.2",
"createdAt": "2026-06-15T12:00:00.000Z",
"data": {
"report": { },
"reporter": { }
}
}
data.report is the envelope. schemaVersion covers this
wrapper only — the envelope carries its own protocolVersion. It went from
1.0 to 1.1 when data.reporter was added, then 1.1 to 1.2 when
data.report.payload.resources joined the report payload — both bumps are
additive only (nothing renamed, removed, or re-typed), so a 1.0 or 1.1
receiver keeps working untouched.
A wrapper may also carry "test": true. That is a synthetic ping from the
dashboard’s Send test ping action: acknowledge it with a 2xx, but do not
create downstream artifacts for it.
There are two event types. report.received is the one above. report.triaged
fires after AI triage completes, only to subscribers that opted in, and has a
different data: { reportId, eventId, triage: { summary, extraction, suggestedSeverity, severityConfidence, suggestions[] } } — no envelope and no
attachments. Dispatch on type (or the X-Everframe-Event header) before
reading data.
Headers:
| Header | Value |
|---|---|
X-Everframe-Signature | t=<unix-seconds>,v1=<hmac-hex> |
X-Everframe-Event | report.received, or report.triaged |
X-Everframe-Delivery-Id | Identifies the DELIVERY. Stable across retries — see below |
X-Everframe-Attempt | 1-indexed attempt number |
X-Everframe-Subscriber-Id | Your subscriber’s id |
Content-Type | application/json |
User-Agent | Everframe-Webhook/1.0 |
Verifying the signature
HMAC-SHA256 over <timestamp>.<raw-request-body>, keyed with your subscriber’s
signing secret. Same scheme as Stripe, so existing helpers translate directly.
import crypto from 'node:crypto';
function verifyEverframe(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const t = Number(parts.t);
// 1. Reject stale timestamps (replay guard)
if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
// 2. Recompute and compare in constant time
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Use the raw body. Frameworks that auto-parse JSON re-serialise it
differently, which breaks the HMAC. Capture the raw bytes for the signed route
(express.raw(), for instance), verify, then parse.
Who filed the report
Alongside data.report sits data.reporter — Everframe’s own answer about who
filed it, resolved server-side:
| Field | Value |
|---|---|
data.reporter.tier | verified, self_declared, or anonymous |
data.reporter.identity | { id, label, verified }, or null when the tier is anonymous |
Use data.reporter, not data.report.reporter.user. The latter is whatever
the app passed to setUser — an unauthenticated claim in every case, even when
a valid identity token was also presented. Only tier: "verified" is proof.
And anonymous does not mean “unverified” — it means no person was resolved at
all. Treat the three tiers as three different states, not a confidence gradient.
Responding
Return any 2xx quickly to acknowledge receipt, then process asynchronously.
Anything else counts as a failure and is retried.
Attachments
Each entry in data.report.attachments[] is the envelope’s reference plus a
presigned url and expiresAt, minted at delivery time. Download what you
need promptly — the URLs expire about an hour after delivery — and copy
anything you want to keep into your own storage; Everframe is not the system of
record.
When the bytes cannot be served the reference is kept, the url is omitted,
and one marker says why:
| Marker | Meaning |
|---|---|
"expired": true | Past the plan’s retention window. The bytes still exist until the grace period ends. |
"gated": true, "gatedReason": "plan_limit" | Withheld because a Free org is over its monthly active install limit. Reversible: a normal URL returns once the org upgrades or drops back under the limit. |
"erased": true | Permanently deleted. Nothing brings these back. |
Only attachments are affected — breadcrumbs, network bodies and crash details
in data.report.payload are delivered regardless. See
Data & retention for the windows.
Retries
On failure, delivery is retried on an increasing backoff: immediately, then
after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h, 36 h, and 72 h. After 10
failed attempts — a little over six days — the delivery is marked
dead-letter. A numeric Retry-After on a 429 is honoured as a floor on the
next delay.
How responses are classified:
| Response | Outcome |
|---|---|
2xx | Success |
408, 429, 5xx, timeouts, network errors | Retried until the schedule is exhausted |
Other 4xx | Dead-lettered immediately — this is bad config, not bad luck |
| Blocked destination (SSRF protection) | Dead-lettered immediately |
Circuit breaker
If a subscriber accumulates three consecutive dead-letters it is
automatically disabled, with the reason circuit_breaker, so a broken endpoint
cannot silently swallow your reports. Test pings do not count towards it.
Re-enable it from the admin console once the receiver is fixed; the delivery
log keeps the response code and attempt history for every delivery.
Identifying a retry
X-Everframe-Delivery-Id is the id of the delivery, not of the attempt: it
is the same value on every retry of that delivery. X-Everframe-Attempt
increments.
So the delivery id is exactly the key to deduplicate on — a receiver that has already processed one should ignore the rest, whatever the attempt number says. Do not treat it as an attempt identity.