---
title: "Webhooks"
description: "Send events to your endpoints when documents are viewed, downloaded, or interacted with."
canonical: "https://everypage.co/docs/developers/webhooks"
plan: "free"
last-updated: "2026-08-07"
---

# Webhooks

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:

```json
{
  "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.
- **`t` is the time the request was signed**, taken fresh on each attempt — not the `timestamp` field inside the body. A retry of the same event carries a different `t` and a different `v1`. Use `t` for your replay window; use the body's `timestamp` for 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:

```javascript
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:

```json
{
  "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:

```json
{
  "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](https://everypage.co/docs/integrations/notion). 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](https://everypage.co/docs/integrations/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](https://everypage.co/docs/integrations/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](https://everypage.co/docs/developers/events-feed) — long-term event history and backfill
- [API keys](https://everypage.co/docs/developers/api-keys) — authentication and scopes
- [Plans and limits](https://everypage.co/docs/getting-started/plans-and-limits)
