Skip to content
Everframe Docs
Documentation

Webhooks

Each report is delivered to your subscriber URL as a signed HTTP POST. Verify it, return 2xx, and the full bug context is yours.

Updated

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:

HeaderValue
X-Everframe-Signaturet=<unix-seconds>,v1=<hmac-hex>
X-Everframe-Eventreport.received, or report.triaged
X-Everframe-Delivery-IdIdentifies the DELIVERY. Stable across retries — see below
X-Everframe-Attempt1-indexed attempt number
X-Everframe-Subscriber-IdYour subscriber’s id
Content-Typeapplication/json
User-AgentEverframe-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:

FieldValue
data.reporter.tierverified, 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:

MarkerMeaning
"expired": truePast 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": truePermanently 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:

ResponseOutcome
2xxSuccess
408, 429, 5xx, timeouts, network errorsRetried until the schedule is exhausted
Other 4xxDead-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.