Foreshock

Integrate

Webhook delivery and signature verification

Three events, at-least-once, signed over the raw bytes — and a reconciliation path that does not depend on any of it working.

Updated
2026-08-24

The three events

EventSent when
exposure.matchA match involving one of your own addresses is created, or its stage or severity changes.
exposure.revokedA previously delivered match is removed from your active match set. Never silent.
coverage.health_changedAn authority in scope for your account becomes unobservable, becomes observable again, exceeds its lag threshold, or falls outside its verification window.

Every delivery carries event_id, event_type, and occurred_at from the common envelope.

Verify the signature before you parse the body

Foreshock-Signature is formatted as t=<unix_seconds>,v1=<hex>, where v1 is HMAC-SHA256(signing_secret, "<t>.<raw_request_body>") in lowercase hex.

  1. Compute the HMAC over the raw bytes of the request body, not over a re-serialized object. Any framework that hands you a parsed object has already destroyed the bytes you need — capture them before the body parser runs.
  2. Compare in constant time.
  3. Reject if |now - t| > 300 seconds, to bound replay.

During secret rotation a delivery may carry more than one v1= value. Accept the delivery if any of them verifies.

raw   := read_raw_body(request)
sig   := parse(request.header["Foreshock-Signature"])   # t, [v1...]

if abs(now() - sig.t) > 300:
    return 400

expected := hmac_sha256(signing_secret, sig.t + "." + raw)
if none of sig.v1 matches expected in constant time:
    return 401

event := parse_json(raw)                                 # only now

An unverified body is untrusted input. Do not log it, do not look at event_type, do not branch on anything inside it until the HMAC checks out.

Delivery headers

HeaderUse
Foreshock-SignatureVerify, as above.
Foreshock-Signature-TimestampUnix seconds at signing. Same value as t.
Foreshock-Delivery-IdUnique per attempt. Log on this.
Foreshock-Event-TypeMirrors event_type in the body.
Foreshock-Webhook-Attempt1-based attempt number. Increments on retry.

Deduplicate on event_id, log on Foreshock-Delivery-Id. Retries of the same event carry the same event_id and a different delivery id; conflating the two produces either duplicate work or lost audit trail.

Delivery semantics

At least once. The same event_id may arrive more than once, and out of order relative to other events. Deduplicate on event_id and order your own state by occurred_at.

Acknowledge within 5 seconds. A delivery is successful only on a 2xx returned within 5 seconds. Return the 2xx first and do the work asynchronously — a receiver that does the processing inline will start failing deliveries the moment your database is slow.

Retried for up to 24 hours. Anything that is not a timely 2xx is retried with exponential backoff and jitter for up to 24 hours from occurred_at. After that the event moves to a dead-letter queue, an alert is raised on your account, and the events remain replayable from the console.

A missed webhook is not an all-clear

This is the part that gets skipped.

The webhook stream is a latency optimisation over GET /v1/exposure. The feed is the authoritative reconciliation path. Run the cursor loop on a schedule regardless of webhook health, and run it after any period where your receiver was down, misconfigured, or behind a certificate that had expired.

If your only view of exposure is the webhook stream, then a silent receiver and a quiet world look identical from inside your system.

Handling exposure.revoked

A revocation carries customer_address_ref, a stage of REVOKED, EXPIRED, or STALE, and an evidence_ref.

Removals are announced explicitly so your local list cannot silently diverge from ours. None of the three stages is a clearance — REVOKED says the event was withdrawn, EXPIRED says it did not execute within its window, and STALE says the underlying data can no longer be trusted. Record the state change. Do not read it as permission.