Webhooks
A webhook is an address on your server that Lettrove calls when something happens in your project. Your server hears about it at once, without asking over and over.
| Event | When | data |
|---|---|---|
design.saved | A design was created or saved | { designId, userId, revision } |
design.deleted | A design was deleted | { designId, userId } |
export.created | A design was exported, from the page or your server | { exportId, designId, userId, revision, source: 'editor' | 'server', format } |
user.erased | A person's data was erased | { userId } |
ping | You clicked Send a test | { message } |
userId is your id for the person. Events name things by id only, never content: fetch a design
with designs.get when you need it.
1. Add an endpoint to your server
The body must reach your code exactly as it arrived — parse it only after checking its signature. With Express, read it as text:
import express from 'express';
import { verifyWebhook } from '@lettrove/node';
const app = express();
app.post('/lettrove/webhooks', express.text({ type: 'application/json' }), async (req, res) => {
let event;
try {
event = await verifyWebhook(req.body, req.get('lettrove-signature'), process.env.LETTROVE_WEBHOOK_SECRET);
} catch {
return res.sendStatus(400); // not from Lettrove, changed on the way, or too old
}
switch (event.type) {
case 'design.saved':
// e.g. refresh your own copy: await lettrove.designs.get(event.data.designId, { user: { id: event.data.userId } })
console.log('saved', event.data.designId, 'revision', event.data.revision);
break;
case 'user.erased':
console.log('erased', event.data.userId);
break;
case 'ping':
console.log('ping:', event.data.message);
break;
}
res.sendStatus(200);
});
app.listen(4000, () => console.log('Listening on http://localhost:4000'));
Answer with any 2xx status within 10 seconds. Do slow work after answering (or in a queue).
2. Add the webhook in the dashboard
- lettrove.com → Settings → Embed → your project → Webhooks.
- Paste your endpoint's address, e.g.
https://api.acme.com/lettrove/webhooks, and tick the Events you want. Click Add webhook. - Copy the signing secret (
whsec_…). It is shown once. Put it in your server's environment asLETTROVE_WEBHOOK_SECRET. - Click Send a test. Your endpoint receives a
ping; Deliveries shows what was sent and how your server answered.
The address must be https:// and reachable on the public internet. A project has up to five
webhooks.
Trying it on your own machine
Lettrove cannot reach localhost. To receive real deliveries while you build, expose your local
server with a tunnel such as cloudflared tunnel --url http://localhost:4000 or ngrok http 4000,
and add the https://… address it prints.
What a delivery looks like
POST /lettrove/webhooks HTTP/1.1
Content-Type: application/json
User-Agent: Lettrove-Webhooks/1
Lettrove-Signature: t=1791338400,v1=5f2b8c1e9a…
{
"id": "01J9ZV6K2D5S8N1P3Q7R4T0W9X",
"type": "design.saved",
"createdAt": "2026-10-07T16:00:00.000Z",
"projectId": "01J9X…",
"data": { "designId": "01J9ZQ3W8D2K7M5T1V4XG6HB0R", "userId": "u_123", "revision": 12 }
}
Verifying the signature yourself
verifyWebhook does this for you. In another language: the header is t=<unix seconds>,v1=<hex>,
and v1 is the HMAC-SHA256 of "<t>.<the raw body>" with your signing secret as the key. Compare in
constant time, and refuse a t more than five minutes from now, so a recorded delivery cannot be
replayed.
import hashlib, hmac, os, time
def verify_webhook(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(item.split("=", 1) for item in header.split(","))
timestamp, signature = parts.get("t", ""), parts.get("v1", "")
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
return False
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
Retries, order and duplicates
- A delivery that does not get a
2xxin time is retried with growing waits — 30 seconds, then 1, 2, 4 minutes and so on — eight tries in all. Deliveries in the dashboard shows each attempt and your server's answer, for 30 days. - A delivery may arrive more than once. Its
idstays the same: skip anidyou have already handled. - Deliveries can arrive out of order. Use
revision(it only goes up) to ignore an olderdesign.savedafter a newer one. - While a person edits, their design is saved often. Saves of one design that are still waiting to
be delivered are merged into one: you receive its newest
revision.
Rotating the secret
New secret (beside the webhook in the dashboard) gives a new signing secret, shown once, and the old one stops at once. Update your server's environment straight away. Remove deletes the webhook; the switch beside it pauses deliveries without removing it.