Verify X-Everypage-Signature in Node, Python, PHP, and Go.
Every verifier in this repo passes the same committed test vectors
(fixtures.json) - correctness is pinned, not promised.
Copy the verify file for your language into your project and run its test
against the fixtures before you trust it.
node/-verify.js, an Express receiver, zero-dependency testspython/-verify.py, a Flask receiver, zero-dependency testsphp/-verify.php, a plain-PHP receiver, CLI testsgo/-everypage.Verify, anhttp.Handler,go test
Webhooks are available on every plan. Manage them at everypage.co/developers or via the API.
You may not need to verify by hand. The official Node client
(everypage-node) ships
constructEvent/verifySignature, and the
n8n community node verifies every
delivery before the workflow runs. Reach for the examples below when you are
receiving webhooks in your own service, or porting to a language we don't
ship a client for.
Every JSON-format delivery carries one header:
X-Everypage-Signature: t=<unix seconds>,v1=<hex>
To verify:
- Read the raw body bytes - before any JSON parsing.
- Parse the header into
tandv1. Ignore pairs you don't recognize - future versions may add them. - Reject if
tis more than 300 seconds from your clock, either direction. Retries are re-signed with a freshtat send time, so the window never rejects a genuine retry. - Compute
HMAC-SHA256over<t>+.+ raw body. The key is your signing secret as ASCII bytes. Hex-encode, lowercase. - Compare against
v1in constant time -timingSafeEqual,compare_digest,hash_equals,hmac.Equal. Never==.
The whole secret string is the key. Your secret looks like
whsec_9f2c...- 70 characters, prefix included. The HMAC key is that entire string,whsec_and all. Stripping the prefix is the most common porting mistake, so the fixtures include a vector (prefix_stripped_key_must_fail) that a prefix-stripping verifier wrongly accepts.
The body is an envelope:
{
"event": "file.viewed",
"timestamp": "2026-07-27T12:00:00Z",
"data": {
"fileUuid": "0b5e9a1c-2f47-4c8d-9e3a-6d1b8f0c4a72",
"fileName": "quarterly-report.pdf",
"pagesViewed": 7,
"timeMs": 60000
}
}event is the kind, timestamp is RFC 3339 UTC, data always carries
fileUuid and fileName plus kind-specific extras. When the viewer arrived
through a link variant, file.viewed and file.downloaded additionally
carry variantUuid and variantLabel - additive keys, absent on
canonical-link events.
Slack-format webhooks are unsigned. They post Block Kit to a Slack incoming-webhook URL, which authenticates by URL secrecy. There is nothing to verify - this repo is about JSON-format webhooks only.
Don't take our verifiers' word for it. One fixture, plain openssl:
T=1785153600
SECRET='whsec_4f9d8a2b6c1e3f7a0b5d9c8e2f4a6b1d3c7e9f0a2b4d6c8e1f3a5b7d9c0e2f4a'
BODY='{"data":{"fileName":"quarterly-report.pdf","fileUuid":"0b5e9a1c-2f47-4c8d-9e3a-6d1b8f0c4a72","pagesViewed":7,"timeMs":60000},"event":"file.viewed","timestamp":"2026-07-27T12:00:00Z"}'
printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET"Output: e26e23667223756fc8d6fcadef6f75dcbc604da313c70270cf055ed566636b0f -
the v1 of the valid case in fixtures.json.
The signature covers the exact bytes we sent. Parse-then-re-serialize changes key order or whitespace and the signature dies. Every server example here shows the correct pattern:
| Stack | Raw body |
|---|---|
| Express | express.raw({ type: 'application/json' }) on the route - not express.json() |
| Flask | request.get_data() - not request.json |
| PHP | file_get_contents('php://input') - not $_POST |
| Go | io.ReadAll(r.Body) before any decoding |
What we promise, and what enforces it:
- HTTPS only. Plain-HTTP endpoints are rejected at creation and at send.
- Any 2xx is success. Everything else - other statuses, timeouts, connection failures - is a failed attempt. Respond fast and do slow work async; a delivery attempt times out after 10 seconds.
- 5 attempts per delivery, backing off 1 minute, 10 minutes, then
1 hour between retries. Each retry is re-signed with a fresh
t. - Auto-disable at 20 consecutive failures. After 20 exhausted deliveries in a row the endpoint is switched off and the account owner is emailed. Re-enable by fixing your endpoint and creating the webhook again.
- The secret is shown exactly once, in the create response. There is no rotation endpoint - to rotate, delete the webhook and create a new one, then retire the old secret.
There is no delivery id and no idempotency key in the payload. We won't pretend otherwise, and you should not build on a field that doesn't exist.
A retry re-sends the same envelope - same event, same timestamp, same
data - with only the signature's t refreshed. So the best available
dedupe key is:
(event, timestamp, data.fileUuid)
That collapses retries of one delivery. Two distinct events of the same kind on the same file in the same second would collide - rare, and for most consumers (notifications, counters, CRM sync) collapsing them is acceptable. If your handler must be strictly effect-once, make the effect itself idempotent.
Bearer-authenticated, base https://everypage.co. API keys hold every
scope; OAuth tokens need webhooks:manage. Full spec:
everypage.co/openapi.yaml.
| Endpoint | What it does |
|---|---|
GET /api/v1/webhooks |
List yours. Never includes secrets. |
POST /api/v1/webhooks |
Create. Returns the secret - once. Up to 10 per account. |
DELETE /api/v1/webhooks/{uuid} |
Delete. Queued deliveries are discarded. |
POST /api/v1/webhooks/{uuid}/test |
Synchronous webhook.test through the real delivery path - same body format, same signature. Rate-limited to 10/minute. |
Create takes url, events, optional format (json default, slack),
and optional fileUuid to scope delivery to one file.
Webhooks are available on every plan.
| Kind | Fires when |
|---|---|
file.viewed |
A non-owner view session ended |
file.downloaded |
A non-owner explicitly saved the file |
gate.completed |
An email or lead-form gate was passed (delivery requires Pro - the payload carries captured lead data) |
note.created |
A viewer left a public note |
receipt.confirmed |
A viewer marked the document as received |
file.burned |
The view limit destroyed the document |
content.replaced |
The owner swapped the document's content in place |
invite.viewed |
An email invitee opened the document for the first time |
proofing.updated |
A viewer left their first page mark or annotation |
webhook.test |
You called the test endpoint - not subscribable, test-only |
Owner actions never fire events - your own views and downloads are not echoed back at you.
Each folder is self-contained. The verify file is dependency-free in every
language; the server examples name their one framework.
# Node - test needs nothing; the server example needs express
node node/test.js
# Python - test needs nothing; the server example needs flask
python3 python/test_verify.py
# PHP - nothing needed
php php/test.php
# Go
cd go && go test ./...fixtures.json holds 8 deterministic vectors - genuine, prefix-stripped
key, tampered body, wrong secret, stale timestamp, future timestamp,
malformed header, unknown extra pair. Each case carries an explicit now so
your verifier tests the timestamp window with an injected clock and the
fixtures never go stale.
Porting to another language? Write verify, loop over cases, assert
verify(secret, body, header, now) == valid. All 8 green means your port
handles every failure mode we ship. PRs with new language ports are welcome -
they must pass the fixtures, and the verify function must stay
dependency-free.
The vectors are generated by tools/genfixtures, a
transcription of the production signer - regenerate with go run . from
that directory.
MIT.
EveryPage - secure PDF sharing with reader analytics.
Website · Developers · Docs · Status · support@everypage.co · LinkedIn