Skip to content

CONCEPTS

Webhooks

Hashproof emits provenance events to URLs you register. Every delivery is a signed POST your server can verify with a single HMAC-SHA256 check.

Event types

  • manifest.stored: a manifest was persisted (via /v1/manifests or /v1/sign).
  • manifest.signed: fired on top of manifest.stored when the user used managed signing.
  • manifest.resolved: your manifest was matched via soft binding by a /v1/resolve call.
  • manifest.verified: your manifest passed verification on a /v1/verify call (regardless of who triggered it).
  • manifest.forensic-verified: your manifest went through /v1/verify/forensic (insurance / fraud workflows).
  • anchor.completed: your manifests were included in a Merkle anchor batch on Base L2. One delivery per user per batch (the payload lists all of your manifests in that batch).

Recipient semantics: events fire to the OWNER of the affected manifest, not to whoever triggered the event. So if you signed a manifest and someone else verifies it, you get the manifest.verified.

Delivery shape

Every delivery is a JSON POST with these headers:

X-Hashproof-Signature:    sha256=<hex>
X-Hashproof-Timestamp:    <ISO 8601>
X-Hashproof-Event:        <event-name>
X-Hashproof-Delivery-Id:  <uuid>
Content-Type:             application/json
User-Agent:               Hashproof-Webhooks/1.0

And a body shaped like:

{
  "id":        "<delivery uuid, matches X-Hashproof-Delivery-Id>",
  "event":     "manifest.signed",
  "timestamp": "<ISO 8601, matches X-Hashproof-Timestamp>",
  "data": {
    "manifestId":      "ce29...c4f1",
    "title":           "...",
    "format":          "image/jpeg",
    "algorithm":       "ES256+ML-DSA-65",
    "hardBindingHash": "..."
  }
}

Verification recipe

Compute HMAC-SHA256 over the raw request body using your webhook secret, hex-encode it, prefix with sha256=, and constant-time compare against the X-Hashproof-Signature header. Reject the request if it does not match.

// Node / Bun
import { createHmac, timingSafeEqual } from 'node:crypto';

function verifyWebhook(rawBody: string, header: string, secret: string) {
  const expected = 'sha256=' + createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(header);
  if (a.length !== b.length) return false;
  return timingSafeEqual(a, b);
}
# Python
import hmac, hashlib

def verify_webhook(raw_body: bytes, header: str, secret: str) -> bool:
    expected = "sha256=" + hmac.new(
        secret.encode(), raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, header)

Retry policy

Each delivery has up to 5 attempts with exponential backoff (5s → 10s → 20s → 40s → 80s, ±jitter). 2xx is treated as success. 4xx is treated as a final failure (no retries: the recipient said “don't bother”). 5xx and network errors trigger retries.

The full delivery log per webhook (including the request ID, status code, latency, attempt number, and error message) is available in the dashboard at /dashboard/webhooks and via GET /v1/webhooks/:id/deliveries.

Best practices

  • Respond 2xx within 10 seconds. Long-running work belongs on a queue.
  • Verify the signature BEFORE trusting any field in the body.
  • Treat deliveries as idempotent: store X-Hashproof-Delivery-Id and skip duplicates. Network blips can cause repeats.
  • Use a different secret per webhook subscription so revoking one does not invalidate others.
  • If your endpoint is temporarily down, leave it. Hashproof retries; you do not need to back-fill.