> Lettrove docs 1.x · https://docs.lettrove.com/docs/server/webhooks

# 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:

```js title="webhooks.mjs"
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

1. lettrove.com → **Settings → Embed** → your project → **Webhooks**.
2. Paste your endpoint's address, e.g. `https://api.acme.com/lettrove/webhooks`, and tick the
   **Events** you want. Click **Add webhook**.
3. Copy the **signing secret** (`whsec_…`). It is shown **once**. Put it in your server's environment
   as `LETTROVE_WEBHOOK_SECRET`.
4. 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

```http
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.

```python title="verify_webhook.py"
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 `2xx` in 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 `id` stays the same: skip an `id` you have already
  handled.
- Deliveries can arrive **out of order**. Use `revision` (it only goes up) to ignore an older
  `design.saved` after 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.
