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/manifestsor/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/resolvecall.manifest.verified: your manifest passed verification on a/v1/verifycall (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-Idand 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.