Developer API

EveryPage API

Upload PDFs and get trackable share links programmatically — the same engine behind our integrations. Authenticate with an API key from your account settings.

Prefer a machine-readable spec? Grab the OpenAPI definition (OpenAPI 3.0).

Authentication

Pass your key as a bearer token. Keys are scoped to your account and respect your plan's upload limits.

Authorization: Bearer ep_live_xxxxxxxx

Every endpoint is guarded by a scope. Personal API keys (ep_live_…) hold every scope, including ones we add later; OAuth access tokens (ep_at_…) are narrowed to what the user granted.

  • files:read — list files, file details, QR codes, list link variants.
  • files:write — upload, import, settings, content replacement, delete, create/update/delete link variants.
  • readership:read — the events feed, gate responses, per-file readership reports.
  • webhooks:manage — create, list, test, and delete webhooks.
  • profile — your account and plan (GET /api/v1/user).

Upload a PDF

POST /api/v1/files — multipart form, field file. Returns the file's UUID and its short ID; the share link is https://everypage.co/<shortId> (the UUID form works too).

curl -X POST https://everypage.co/api/v1/files \
  -H "Authorization: Bearer ep_live_xxxxxxxx" \
  -F "[email protected]"

# {"uuid":"550e8400-…","shortId":"Ab3xY9kQ2mZ7","message":"PDF uploaded successfully"}

POST /api/v1/files/import — JSON body {"url", "filename"}. Creates a file by server-side download instead of an upload. The URL must be plain HTTPS on our import allowlist (design-tool export URLs, e.g. Canva exports — not arbitrary hosts), PDF only, capped at your plan's size limit. The response may include an advisory palette of hex swatches extracted from the document.

curl -X POST https://everypage.co/api/v1/files/import \
  -H "Authorization: Bearer ep_live_xxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://export.canva.com/…/design.pdf", "filename": "brochure.pdf"}'

# {"uuid":"550e8400-…","shortId":"Ab3xY9kQ2mZ7","shareUrl":"https://everypage.co/Ab3xY9kQ2mZ7"}

Configure a file

PUT /api/v1/files/<uuid>/settings — JSON body. Set the viewer mode, a password, the download permission, the expiry, and everything below. Omitted fields keep their current value.

curl -X PUT https://everypage.co/api/v1/files/<uuid>/settings \
  -H "Authorization: Bearer ep_live_xxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "viewerMode": "flipbook",
    "password": "hunter2",
    "allowDownload": false,
    "neverExpire": true,
    "viewLimit": 25,
    "pageRange": {"from": 1, "to": 10},
    "viewerSettings": {
      "background": {"type": "gradient", "gradient": "dusk"},
      "brand": {"accentColor": "#e63b2e"}
    }
  }'
  • viewerMode: standard, flipbook, swipe, or magazine.
  • password: non-empty to protect (Basic+), "" to clear; allowDownload: false to disable downloads.
  • requireEmail: gate viewing behind an email (Pro). notifyOnView: email you when someone opens it (Basic+).
  • Expiry: "deleteAt": "<ISO 8601>" for a fixed expiry (capped by plan: free 7 days, Basic 365 days), or "neverExpire": true for a permanent link (Pro). Omit both to keep the current expiry.
  • viewLimit: self-destruct after this many views (Basic+); 0 clears the limit. A burned link keeps its row and readership; only the bytes are destroyed — see content replacement below for the un-burn path.
  • pageRange: {"from": 1, "to": 10} limits non-owner viewers to that page window, trimmed server-side (Pro to set; files up to 100MB). {"from": 0, "to": 0} clears it on any plan. Enforcement deliberately survives a downgrade; owners always get the full document.
  • gateFields: a lead-capture form definition shown instead of the plain email prompt (Pro; null or [] clears on any plan; a non-empty form switches requireEmail on). gateDomains: restrict the email gate to listed domains (Pro).
  • askReceipt: ask viewers to confirm receipt (Basic+). commentsEnabled (+ commentsAllowPublic, notifyOnComment): reader feedback notes (Pro). marksEnabled / annotationsEnabled: proofing (Pro).
  • watermark: stamp viewer pages (Pro). Watermarked downloads are limited to files up to 100MB.
  • slug: a custom link name on your custom domain (Pro) — lowercase letters, digits, and hyphens; "" to clear. Returns 409 if another of your files already uses it.
  • Writes to an already-dead link — expired, or burned by its view limit — answer 410. Extend the expiry before it lapses, not after.

Viewer appearance (viewerSettings)

viewerSettings is a JSON object of appearance and behavior blocks. Any deviation from the defaults requires Basic+; the fields marked Pro require Pro. Unknown fields are dropped, out-of-range numbers are clamped, and the gates are re-checked when pages are served — a downgraded plan's Pro styling stops rendering for viewers immediately.

  • background: type solid (+ color #RRGGBB), gradient (preset: dusk sand slate forest blush mono), or image (+ assetId, fit cover|contain, blur 0–20, dim 0–80).
  • logo: assetId, position tl|tr|bl|br, size s|m|l|xl, linkUrl, hideBadge. Asset IDs come from the file's uploaded assets.
  • page: shadow 0–3, rounded 0–3, edges, coverAlone.
  • flip: speedMs 200–1200 (default 450), sound, rtl, layout adaptive|single|double.
  • swipe: autoAdvance with intervalMs 3000–30000 (default 6000).
  • protect: contextMenu, print, select (Basic+); blurOnLeave (Pro). Soft deterrents, not DRM.
  • ga4Id: your Google Analytics 4 measurement ID, G-XXXXXXXXXX (Pro).
  • brand: accentColor #RRGGBB, toolbarTheme light|dark|auto, badgePosition tl|tr|bl|br (Pro).
  • cta: a call-to-action button — label (1–40 chars) and an https url required, style solid|outline, color, position tl|tr|bl|br|bar (Pro).
  • watermarkStyle: opacity 0.05–0.5 (default 0.12), density sparse|normal|dense (Pro; the watermark setting above turns the watermark itself on).

Replace content (dynamic links)

POST /api/v1/files/<uuid>/content — multipart, field file (Pro). Swaps the PDF behind a share link in place: the UUID, short ID, slug, QR code, settings, and all readership history survive — only the bytes change. Each replacement increments contentVersion, and superseded versions appear as revisions on the file detail.

curl -X POST https://everypage.co/api/v1/files/<uuid>/content \
  -H "Authorization: Bearer ep_live_xxxxxxxx" \
  -F "[email protected]"

# {"uuid":"550e8400-…","contentVersion":2,"originalName":"proposal-v2.pdf",
#  "size":88123,"anchorsCleared":true,"clearedHotspots":2,"clearedNotes":0,
#  "message":"Content replaced"}
  • Page-anchored hotspots and notes are cleared by default (the new pages don't line up with the old ones); pass ?clearAnchors=false to keep them. Document-level notes always survive.
  • Fires a content.replaced webhook event carrying the new contentVersion.
  • Burned files (view limit reached) ARE replaceable — content replacement is the un-burn path, and the link comes back to life. Expired links answer 410.

Link variants (per-recipient links)

Mint child links off one file — one per recipient — each with its own short ID and URL (Pro; up to 200 per file). Viewers arriving through a variant are attributed to it in readership and in file.viewed/file.downloaded webhook payloads.

  • GET /api/v1/files/<uuid>/variants — list (revoked and redacted included)
  • POST /api/v1/files/<uuid>/variants — create
  • PUT /api/v1/files/<uuid>/variants/<variantUuid> — update label/overrides, revoke/un-revoke
  • DELETE /api/v1/files/<uuid>/variants/<variantUuid> — delete; add ?redact=1 to GDPR-redact instead
curl -X POST https://everypage.co/api/v1/files/<uuid>/variants \
  -H "Authorization: Bearer ep_live_xxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{"label": "Jane at Acme", "overrides": {"allowDownload": false}}'

# 201 {"uuid":"7c9e6679-…","shortId":"Xy7Qm2aB9kZ3",
#      "url":"https://everypage.co/Xy7Qm2aB9kZ3","label":"Jane at Acme",
#      "overrides":{"allowDownload":false},"revoked":false,"createdAt":"…"}
  • label (up to 200 chars) is the recipient tag — the only personal field on a variant.
  • overrides refine the file's settings for this variant's viewers: allowDownload and pageRange ({"from":0,"to":0} serves the full document even when the file has a range). On update, overrides REPLACES the stored set as a whole object — never a per-key merge — so send every override you want to keep; {} clears them all.
  • Revoking ("revoked": true) makes the variant's link answer 404 to viewers, reversibly, without revealing that a canonical link exists.
  • ?redact=1 erases the label for good — from the variant, analytics, and stored webhook payloads — while the link and its analytics keep working. That's the compliance path for GDPR erasure requests; a redacted label can never be set again (409).
  • Passwords and viewing gates are per-file, not per-variant: a viewer who passed the gate through one link has passed it for all of the file's links.

Other endpoints

  • GET /api/v1/files — list your files
  • GET /api/v1/files/<uuid> — file details (includes the replacement revisions history)
  • GET /api/v1/files/<uuid>/qr-code — QR code (PNG) for the share link
  • DELETE /api/v1/files/<uuid> — delete a file
  • GET /api/v1/user — your account + plan

A file object (returned by list and detail) looks like:

{
  "uuid": "550e8400-…",
  "shortId": "Ab3xY9kQ2mZ7",           // short share link; absent on older files
  "slug": "2026-pricing",              // custom link name; absent unless set
  "originalName": "proposal.pdf",
  "size": 91234,
  "mimeType": "application/pdf",
  "createdAt": "2026-06-15T14:00:00Z",
  "deleteAt": "2026-06-16T14:00:00Z",   // absent if it never expires
  "viewCount": 12,
  "viewerMode": "flipbook",
  "protected": true,
  "allowDownload": false,
  "requireEmail": false,
  "notifyOnView": true,
  "contentVersion": 1                   // increments on content replacement
}

Readership report

GET /api/v1/files/<uuid>/readership — the same aggregates the readership dashboard shows (scope readership:read). The response is shaped by your plan: a tier field (free, basic, pro) says which sections are present; sections above your tier are omitted entirely.

  • Every plan: summary — total views, sessions, unique visitors, unique countries, average read time, downloads — plus the file object.
  • Basic adds: median read time, completion rate, and re-read share in the summary, plus viewsOverTime, the read-through funnel, and referrers / browsers / sources / viewerModes / peakTimes / devices / countries breakdowns.
  • Pro adds: live activeReaders, new-vs-returning session counts, recent sessions with reader identities and captured emails, hotspotTaps, CTA clicks (ctaTaps), the per-variant breakdown, and contacts captured by the lead-form gate.
  • When a page range is active the response carries effectivePages — the shared window's size — so funnels over partial documents read honestly.

Read events (for automations)

GET /api/v1/events serves three polling streams selected by ?type=view (the default: finished read sessions), download (explicit saves), and gate (completed lead-form gates; Pro). The basis for "new read" automations like the Zapier trigger.

curl "https://everypage.co/api/v1/events?type=view&since=0" \
  -H "Authorization: Bearer ep_live_xxxxxxxx"

# [{"id":42,"fileUuid":"550e8400-…","fileName":"deck.pdf",
#   "readAt":"2026-06-15T14:00:00Z","country":"United Kingdom",
#   "pagesViewed":7,"timeMs":48000,"type":"view"}]
  • Each event has a stable id. Cursors are per-stream: every type has its own id sequence, so a since value from one stream is meaningless in another — never mix them.
  • Without a cursor (since=0) you get the newest events first — a peek window. With since=<id> results ascend from the cursor, so polling with the highest id you've seen walks the stream forward without gaps or duplicates.
  • ?limit= up to 100 (default 50). ?file= filters to one file (UUID or short ID; unknown or foreign identifiers return [], never an error).
  • The download stream includes your own downloads — unlike the file.downloaded webhook, which only fires for non-owners.
  • gate events carry the submitted form in fields. GET /api/v1/gate-responses is a resource-path alias of ?type=gate — same rows, cursor, and Pro gate.

Webhooks

Prefer push over polling? Register an HTTPS endpoint and we POST events to it as they happen. Webhooks are available on every plan — only gate.completed deliveries additionally require Pro at event time, because their payload carries captured lead data. Up to 25 webhooks per account; your own actions on your own files never fire them.

  • GET /api/v1/webhooks — list (never includes secrets)
  • POST /api/v1/webhooks — create
  • DELETE /api/v1/webhooks/<uuid> — delete
  • POST /api/v1/webhooks/<uuid>/test — synchronous test delivery (10/min per token)
curl -X POST https://everypage.co/api/v1/webhooks \
  -H "Authorization: Bearer ep_live_xxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/hooks/everypage",
       "events": ["file.viewed", "gate.completed"]}'

# 201 {"webhook":{"uuid":"…","url":"…","events":["file.viewed","gate.completed"],
#      "format":"json","active":true,"createdAt":"…"},
#      "secret":"whsec_9f2c…"}     <- shown exactly once; store it now
  • format: json (default, signed) or slack (Slack Block Kit for incoming webhooks — unsigned; Slack authenticates by URL).
  • fileUuid (optional, UUID or short ID of a file you own) scopes delivery to that one file; omit it for account-wide delivery.

Event kinds

  • file.viewed — a non-owner read session ended. Extras: pagesViewed, timeMs.
  • file.downloaded — an explicit save-to-disk by a non-owner. No extras.
  • gate.completed — a viewer passed the email/lead-form gate. Extras: source, email, fields (Pro delivery).
  • note.created — a viewer left a public feedback note. Extras: author, body, pageNumber (when page-anchored).
  • receipt.confirmed — a fresh "mark as received". Extra: name.
  • file.burned — the view limit destroyed the file's bytes. No extras.
  • content.replaced — the document behind the link was swapped. Extra: contentVersion.
  • invite.viewed — an email invitee opened the document for the first time. Extras: email, name.
  • proofing.updated — a viewer's first page mark or annotation on a document. Extras: kind, pageNumber, viewer.

Every json delivery is an envelope whose data always carries fileUuid and fileName plus the kind's extras. Sessions and downloads that arrived through a link variant additionally carry variantUuid and variantLabel:

POST <your url>
Content-Type: application/json
X-Everypage-Signature: t=1750000000,v1=5257a869e7…

{
  "event": "file.viewed",
  "timestamp": "2026-06-15T14:03:22Z",
  "data": {
    "fileUuid": "550e8400-…",
    "fileName": "deck.pdf",
    "pagesViewed": 7,
    "timeMs": 48000
  }
}

Verifying signatures

The X-Everypage-Signature header is t=<unix time>,v1=<hex> where v1 is HMAC-SHA256 over the string <t>.<raw body>. The HMAC key is the FULL secret string exactly as shown at creation — the whsec_ prefix is part of the key, not a wrapper to strip. Verify against the raw request bytes (before any JSON parsing), and reject stale timestamps (we suggest ±5 minutes) to prevent replays.

const crypto = require('crypto')

// secret is the full "whsec_…" string; rawBody is the unparsed request body
function verifyEverypage(secret, rawBody, header) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
  const expected = crypto.createHmac('sha256', secret)
    .update(parts.t + '.' + rawBody).digest('hex')
  return parts.v1.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(parts.v1, 'hex'), Buffer.from(expected, 'hex')) &&
    Math.abs(Date.now() / 1000 - Number(parts.t)) < 300
}
  • Delivery: any 2xx from your endpoint counts as delivered; the request times out after 10 seconds. Failures retry up to 5 attempts with 1 minute / 10 minutes / 1 hour backoff.
  • After 20 consecutive exhausted deliveries the webhook is switched off automatically and you get one email. Re-create it (or use /test) once your endpoint is healthy.
  • Deliveries carry no delivery ID and retries send the same body, so dedupe best-effort on (event, timestamp, data) if double-processing matters to you.
  • The signing secret is shown once, at creation, and is never retrievable again — delete and re-create the webhook if you lose it.

OAuth 2.0 (for apps)

Building an app that acts for other users? Use OAuth 2.0 (Authorization Code + PKCE) instead of asking them for an API key. Register your app with us to get a client_id (and a secret for server-side apps).

  • Send users to GET /oauth/authorize with response_type=code, client_id, redirect_uri, scope, state, and a PKCE code_challenge (S256).
  • After they approve, we redirect back with ?code=. Exchange it at POST /oauth/token (with code_verifier) for an access + refresh token.
  • Call /api/v1 with Authorization: Bearer <access token>. Refresh with the refresh_token grant (tokens rotate); revoke at POST /oauth/revoke.
  • Scopes: files:read, files:write, readership:read, webhooks:manage, profile (see the scope table above). Users manage authorized apps under their account.

QR codes & embedding

  • GET /api/v1/files/<uuid>/qr-code — a PNG QR code for the share link (scope files:read). The encoded link is durable — the short ID or UUID, never the renameable slug — and carries ?via=qr so scans show up in the readership source breakdown.
  • GET /api/files/<uuid>/qr-code — the same PNG, public and unauthenticated (rate-limited to 60 requests/minute per IP), for embedding a QR without exposing a token.
  • GET /oembed?url=<share link> — public oEmbed provider endpoint: turns any pasted share link (UUID, short ID, or slug form) into an embeddable rich object.

Client libraries & examples

Errors

Errors come back as the HTTP status with a short plain-text message:

  • 400 — bad request (e.g. the file isn't a PDF, or a malformed body)
  • 401 — missing, malformed, invalid, or revoked API key
  • 403 — not allowed: you don't own the file, or the setting is above your plan (e.g. password on Free)
  • 404 — no such file (or not owned by you)
  • 409 — conflict: the slug is already used by another of your files, or a redacted variant's label was re-set
  • 410 — the link is dead: expired, or burned by its view limit (settings writes; content replacement 410s only on expiry — burned links replace fine)
  • 413 — the file is larger than your plan allows
  • 429 — rate limit exceeded (120 requests per minute per key; webhook tests 10 per minute)
  • 502 — an import's export source couldn't be fetched or returned an error

Notes

  • PDF first: Word (.docx) uploads are converted to PDF where conversion is enabled; everything else is rejected. Max file size follows your plan (20MB free, 200MB Basic, 2GB Pro).
  • Rate limit: 120 requests per minute per key (per token, not per IP — integrations may share an egress IP).
  • Revoke a key anytime from account settings — it stops working immediately.
  • Readership analytics on API-uploaded files work exactly as they do in the app.
  • Webhooks work on every plan; the plan only gates gate.completed deliveries.