Webhooks
Send events to your endpoints when documents are viewed, downloaded, or interacted with.
Create HTTPS endpoints to receive events from EveryPage. When someone views a file, completes a form, or takes another action, a delivery is queued to each of your matching endpoints and POSTed asynchronously, normally within a few seconds. Webhooks work on every plan.
Event kinds
EveryPage fires nine subscribable event kinds. You subscribe to the ones you care about when you create a webhook.
| Event | Fires when |
|---|---|
file.viewed | A visitor completes a viewing session on a document |
file.downloaded | A visitor downloads a PDF from the share page |
gate.completed | A visitor passes an email-capture or custom form gate |
note.created | A visitor leaves a public comment or note on a document |
receipt.confirmed | A visitor marks the document as received |
file.burned | A view-limited document's encrypted bytes are destroyed by the burn sweep, 30 minutes after the cap is reached |
content.replaced | You replace a document's content while keeping the same link |
invite.viewed | An email invitee opens the document for the first time |
proofing.updated | A viewer leaves their first page mark or annotation on a document — once per viewer per file, not once per action |
A tenth kind, webhook.test, exists but is not subscribable: it is only ever sent by the /test endpoint described below, and rejecting it in events[] at creation is deliberate.
Important: Owner actions never fire events. Every visitor-triggered kind (file.viewed, file.downloaded, gate.completed, note.created, receipt.confirmed, invite.viewed, proofing.updated) is guarded on "acting user is not the file owner", so your own views, downloads, notes, receipts and marks are never delivered. If you're testing a webhook, visit the document while logged out or from a different account. content.replaced is an owner action by definition and does fire; file.burned has no actor.
Gate-completed deliveries and plan limits
All event kinds fire on every plan. The single exception: gate.completed payloads carry captured lead data (email, form responses), which requires a Pro account. The check runs at event time, when the delivery would be queued — a non-Pro owner's gate completions are never enqueued at all, rather than queued and later dropped. Capture itself stays plan-ungated: a downgraded account's gates keep recording, and deliveries resume on re-upgrade.
Creating and configuring a webhook
POST to /api/webhooks or /api/v1/webhooks with the following body:
{
"url": "https://yourapp.com/webhooks/everypage",
"events": ["file.viewed", "file.downloaded"],
"format": "json"
}
URL: HTTPS only, no userinfo, 2048 characters maximum. Private IPs, loopback addresses, and link-local hosts are rejected at registration and re-checked by the dispatcher before every send. Paste a Slack Incoming Webhook URL and choose format: "slack".
Events: An array of one or more event kinds from the table above. Unknown kinds are a 400.
Format: Either json (signed HTTP POST) or slack (Block Kit message, unsigned). Defaults to json when omitted.
Optional file scope: Add "fileUuid": "..." to receive events only for that file. The value may be the canonical UUID or the short ID; a file you don't own is a uniform 404. Unscoped webhooks receive all events for your account.
The response includes a secret (shown exactly once), of the form whsec_<64 hex characters>. For JSON webhooks, save this secret to verify the request signature on delivery. Slack webhooks have no signature—the webhook URL itself is the secret.
Signature verification
JSON format webhooks include an X-Everypage-Signature header with every delivery:
X-Everypage-Signature: t=1721340000,v1=6e4c37a7c8e9f2b1d3a5c8e1f4a7b9c1d3e5f6a8b9c0d1e2f3a4b5c6d7e8f9a0
The signature is an HMAC-SHA256 of the string <t>.<request_body> — the decimal t value from the header, a literal ., then the raw body bytes — keyed with your webhook secret. v1 is the MAC hex-encoded in lowercase. Verify the signature to confirm the delivery came from EveryPage.
Two details an implementation must get right:
- The key is the full secret string, including the
whsec_prefix, taken as UTF-8 bytes. Do not strip the prefix and do not hex-decode the value. tis the time the request was signed, taken fresh on each attempt — not thetimestampfield inside the body. A retry of the same event carries a differenttand a differentv1. Usetfor your replay window; use the body'stimestampfor the event's own time.
The body is the raw JSON bytes, including line breaks and spacing exactly as we sent it. Most webhook libraries parse the JSON after verification, which changes the byte representation and makes the signature fail. Verify against the raw request body, then parse it for your application.
Every delivery also carries Content-Type: application/json and User-Agent: EveryPage-Webhooks/1.0.
Implementation pseudocode:
const crypto = require('crypto');
function verifyWebhookSignature(rawBody, secret, signatureHeader) {
const [tsStr, sigStr] = signatureHeader.split(',').reduce((acc, part) => {
const [key, val] = part.split('=');
if (key === 't') acc[0] = val;
if (key === 'v1') acc[1] = val;
return acc;
}, [null, null]);
const timestamp = parseInt(tsStr, 10);
const signedString = `${timestamp}.${rawBody}`;
const hmac = crypto.createHmac('sha256', Buffer.from(secret, 'utf8'));
hmac.update(signedString, 'utf8');
const computed = hmac.digest('hex');
if (computed.length !== sigStr.length) return false;
return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(sigStr));
}
rawBody here is the request body as received. If your framework hands it to you as a Buffer, hash the Buffer rather than interpolating it into a template string.
Slack webhooks carry no signature—authentication happens through the webhook URL itself.
Request body
JSON format payloads contain three top-level fields:
{
"event": "file.viewed",
"timestamp": "2024-07-18T15:30:45Z",
"data": {
"fileUuid": "550e8400-e29b-41d4-a716-446655440000",
"fileName": "Quarterly Report.pdf",
"pagesViewed": 8,
"timeMs": 125000,
"sessionId": 41822
}
}
timestamp is RFC 3339 in UTC. The data object always contains fileUuid and fileName, plus kind-specific keys:
| Event | Additional data keys |
|---|---|
file.viewed | pagesViewed, timeMs, sessionId; variantUuid and variantLabel when the session came through a link variant |
file.downloaded | variantUuid, variantLabel when the download came through a link variant; otherwise none |
gate.completed | source; email when an address was captured; fields when a custom form was submitted |
note.created | author, body; pageNumber when the note is anchored to a page |
receipt.confirmed | name |
file.burned | none |
content.replaced | contentVersion |
invite.viewed | email, name |
proofing.updated | kind (mark or annotation), pageNumber, viewer |
Treat new keys as additive: they are appended over time and your parser should ignore unknown ones. Slack payloads render the same events as Block Kit cards, with the file name, the kind-specific keys as fields, and a link to the file's readership page.
Delivery and retries
EveryPage attempts to deliver each webhook up to 5 times on a fixed backoff schedule:
- 1st failure: retry after 1 minute
- 2nd failure: retry after 10 minutes
- 3rd failure: retry after 1 hour
- 4th failure: retry after 1 hour
- 5th failure: abandoned
A failure counter is incremented once per abandoned delivery — that is, once per event that burned all 5 attempts, not once per attempt. After 20 consecutive abandoned deliveries the webhook is automatically disabled. You'll receive an email notification, and the webhook row shows Active: false with the failure count.
A successful delivery (HTTP 2xx response) resets the failure counter to zero.
Delivery timeout: 10 seconds per request. The dispatcher polls for due deliveries every 5 seconds, so a queued event is normally sent within a few seconds of the action that produced it.
Testing a webhook
Use the /test endpoint to send a sample webhook.test event through the same delivery pipeline:
POST /api/webhooks/{uuid}/test
The test fires regardless of per-file scope (it's an endpoint-reachability check) and returns the result synchronously:
{
"delivered": true,
"status": 200,
"latencyMs": 145
}
The test delivery uses the same body builders and the same signature as a real one, so it is a valid end-to-end check of your verification code.
The test endpoint is rate limited to 10 calls per minute. On /api/v1/webhooks/{uuid}/test the bucket is keyed on the bearer token; on the session route /api/webhooks/{uuid}/test it is keyed on the caller's IP.
Notion format (internal only)
A third format, notion, is used by the built-in Notion integration. It syncs reading sessions to a Notion database, is unsigned (it authenticates with the connection's Notion token), and is not available for direct subscription — notion is rejected at creation and its rows are hidden from the webhook list, the account cap, and /test.
Special cases: Make and n8n
Make does not verify webhook signatures. Make's webhook steps pre-parse the JSON body and then re-serialize it, which changes the byte representation and makes HMAC verification fail. The Slack format works reliably with Make because it carries no signature.
n8n provides native signature verification. The n8n webhook trigger receives the raw body, verifies the X-Everypage-Signature header, and fails the trigger on signature mismatch.
Limits and quota
- Maximum webhooks per account: 25 (file-scoped and account-wide endpoints share this one cap)
- Maximum delivery attempts: 5
- Delivery timeout: 10 seconds
- Auto-disable threshold: 20 consecutive abandoned deliveries
- Test endpoint rate limit: 10 per minute (per token on
/api/v1, per IP on the session route)
See also
- Events API — long-term event history and backfill
- API keys — authentication and scopes
- Plans and limits