Send verified subscribers to a webhook or a Google Sheet
Point a webhook at an HTTPS endpoint you
control and you get a signed POST the instant someone confirms
their address — into your CRM, your database, your onboarding queue. If you
don't run a service to receive it, point the same webhook at a Google Apps
Script deployment and it appends a row to a spreadsheet instead. Both
routes are the same delivery; the difference is what's listening. The part
you cannot skip either way is checking the signature.
Two events, and what's in them
| Event | Fires when |
|---|---|
subscriber.verified |
Someone entered the six-digit code and the address is confirmed. This is the one that means a new reader. |
document.downloaded |
The file was actually fetched. Fires on repeat visits too, since access is remembered per link. |
Neither fires for an address that didn't make it through the wall. Disposable domains are rejected and the domain's MX and SPF records are checked before a code is even sent, so what reaches your endpoint is a mailbox someone was holding thirty seconds ago.
The body is the same shape for both — an envelope with the event data
nested under data:
POST https://your-app.com/hooks/replilo
Content-Type: application/json
X-Replilo-Event: subscriber.verified
X-Replilo-Signature: t=1756468442,v1=6f1e…
{
"event": "subscriber.verified",
"id": "cm4b…",
"createdAt": "2026-08-29T10:14:02.317Z",
"data": {
"subscriber": { "id": "cm4a…", "email": "[email protected]" },
"link": { "id": "cm49…", "slug": "q3-report", "title": "Q3 Revenue Report" },
"document": { "id": "cm48…", "name": "q3-report.pdf" }
}
}
That id is the delivery, not the subscriber. Hold on to it —
it's what makes retries safe, and we'll come back to it.
An unverified endpoint is an open write to your database
Here is the failure mode nobody plans for. Your handler takes a JSON body
and creates a subscriber row, maybe fires a welcome email, maybe starts a
trial. The URL is not secret — it's in your deploy config, your logs, a
Slack message from eighteen months ago. Anyone who has it can
curl whatever they like into your CRM, at whatever rate they
feel like, and every record will look exactly like a real one.
So every delivery is signed. The X-Replilo-Signature header
carries a Unix timestamp and an HMAC-SHA256, keyed with your signing
secret, over the string <t>.<raw body>:
X-Replilo-Signature: t=1756468442,v1=6f1e…
The secret is generated when you create the webhook and shown once, right then. Copy it into your environment at that moment; it isn't displayed again.
import crypto from 'node:crypto'
import express from 'express'
const app = express()
const SECRET = process.env.REPLILO_WEBHOOK_SECRET
app.post('/hooks/replilo', express.raw({ type: 'application/json' }), (req, res) => {
const parts = Object.fromEntries(
(req.get('X-Replilo-Signature') ?? '').split(',').map((p) => p.split('=', 2)),
)
const t = Number(parts.t)
const signature = parts.v1 ?? ''
// Stale deliveries are replays. Reject before spending a hash on them.
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) {
return res.sendStatus(401)
}
const expected = crypto
.createHmac('sha256', SECRET)
.update(`${parts.t}.${req.body.toString('utf8')}`)
.digest('hex')
// Length first: timingSafeEqual throws on a length mismatch rather than
// returning false, and === on a hex digest leaks it a nibble at a time.
if (
signature.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
) {
return res.sendStatus(401)
}
const event = JSON.parse(req.body.toString('utf8'))
res.sendStatus(200) // answer first
handle(event).catch(console.error) // then do the slow part
})
Use express.raw, not express.json.
The HMAC covers the exact bytes that were sent. Parse the body and
re-serialise it and you'll get a different string — different key order,
different whitespace, different Unicode escaping — and a signature that
never matches, on a payload that is completely genuine. This is the
single most common hour lost to a webhook integration.
Why the timestamp check is not optional
The HMAC proves the body came from us. It does not prove it came from us just now. Anything that captured one valid request — a proxy log, a screenshot of a debug session, an old entry in a request-bin — can send those exact bytes again, with the same header, and the signature will still verify. Binding the timestamp into the signed string is what lets you reject that, and a five-minute window is plenty: deliveries are signed fresh at the moment they go out, retries included.
Retries, and the duplicates they produce
A delivery is a success if your endpoint answers 2xx within ten seconds. Anything else — a 500, a timeout, a connection refused because you were mid-deploy — is retried with exponential backoff: after roughly 30 seconds, then a minute, then two, four, eight. Six attempts in total, over about a quarter of an hour.
That's the practical argument for answering 2xx before you do the work. If your handler spends twelve seconds calling three internal services, the delivery times out and gets retried while the first attempt is still running. Now you have the same subscriber twice.
Which is where id earns its place. It identifies the delivery
and stays the same across every retry of it, so a unique index on that
column turns duplicate work into a no-op. Note that the rest of the
envelope does not stay the same: createdAt and the signature's
t are set per attempt, so each retry carries a different
v1. Never cache a signature; never key anything off
createdAt.
Before any of this is live, use the test button next to the webhook in the
dashboard. It sends a real, signed subscriber.verified
delivery with "test": true in the data and shows you the
status code that came back — which is how you find out your reverse proxy
is stripping the header, rather than finding out from a customer.
The Sheet route, for people who don't run a service
Plenty of people gating a file have nowhere to send a POST.
They want the addresses in a spreadsheet they can sort, filter and share
with a colleague. A Google Apps Script Web App is a receiver you can stand
up in five minutes: create a Sheet, open Extensions → Apps Script, paste a
doPost, deploy it as a Web App reachable by anyone, and use
the /exec URL it gives you as the webhook URL.
function doPost(e) {
const expected = PropertiesService.getScriptProperties().getProperty('TOKEN')
if (e.parameter.token !== expected) return ContentService.createTextOutput('no')
const event = JSON.parse(e.postData.contents)
if (event.event !== 'subscriber.verified') return ContentService.createTextOutput('ok')
SpreadsheetApp.getActiveSheet().appendRow([
event.createdAt,
event.data.subscriber.email,
event.data.link.title,
event.data.link.slug,
event.id,
])
return ContentService.createTextOutput('ok')
}
Note what that code is missing. Apps Script hands doPost the
body and the query string, and not the request headers — so
X-Replilo-Signature never reaches your function and there is
nothing to verify. The workaround is the token above: append a long random
string to the webhook URL (…/exec?token=…) and check it. That
is a shared secret in a URL, which is weaker than an HMAC and worth being
honest about. It's fine for appending rows to a sheet. It is not fine for
anything that spends money or sends mail.
Picking between them
| Your own endpoint | Apps Script → Sheet | |
|---|---|---|
| Signature | Verified properly | Can't be — headers aren't exposed; use a URL token |
| Setup | A route, a secret, a deploy | Paste and deploy, no server |
| Good for | Anything that triggers work: CRM records, welcome mail, provisioning | Seeing the list, sorting it, sending it to someone |
| Bad at | Nothing, but you have to run it | Volume, and anything where a forged row costs you |
There's no reason to choose only one. Two webhooks on the same account receive the same events, so the Sheet can stay as the thing a colleague looks at while your service does the actual work.
And if the file itself was shared by an assistant rather than by you, the subscribers still arrive the same way — see how an AI assistant uploads a file and hands back a gated link.
The short version
Two events, subscriber.verified and
document.downloaded, signed with HMAC-SHA256 over
<t>.<raw body>. Verify against the raw bytes with
a constant-time comparison, reject anything older than five minutes,
answer 2xx fast, and deduplicate on the delivery id because
retries are a feature and duplicates are their price. If you have no
service to point it at, a fifteen-line Apps Script puts the same data in a
spreadsheet — just don't pretend the URL token is a signature.
Get your next signup into your own stack
Free account, 100 MB of storage, no card. Webhooks included, signed and retried.
Start sharing free