← All posts

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

EventFires 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 endpointApps 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