# Receive user events

Lessly Users tells you when something happens to one of your end-users: a record appears, a record changes, a record goes, a session ends. It tells you by making an HTTP request to a URL you own, signed with a secret only the two of you hold.

This is how a product keeps its own tables in step with the directory. When someone signs up, you create their row; when someone is erased, you delete theirs. You could poll instead, but you would be late and the traffic would be wasted.

## Subscribe an endpoint

An endpoint is a URL of yours, the list of events it wants, and a description so your colleagues know what it is for.

1. Add the endpoint. The URL must be `https` and reachable from the public internet.
2. Copy the signing secret it shows you — a string starting `whsec_`. It is shown once. Put it in your backend's environment next to your server key; it is exactly as sensitive.
3. Choose the events it wants, from the catalog below.

### UI

In the management App (`app.lessly.com`), **Webhooks → Add endpoint**. The dialog takes the URL, a checkbox per event with a line saying what each one means, and an optional description. The secret appears once, in a dialog of its own, with a button to copy it.

### MCP

Ask your agent:

<CodeBlock lang="text" code="Add a webhook endpoint at https://api.example.com/webhooks/lessly-users for Acme, subscribed to user.created and user.deleted." />

The agent needs to be allowed to manage your product's webhook endpoints. The signing secret is returned once and is not retrievable afterwards.

You may register several endpoints — one per environment, or one per consumer. Each has its own secret and its own delivery log, and each can be paused from its row on **Webhooks**: a paused endpoint receives nothing and keeps both its secret and its history, which is the gentler thing to do to a receiver you are repairing.

Clicking an endpoint's URL opens its delivery log. It carries one row per attempt — the event, the status, how many attempts it took, the response your endpoint answered with, when it was last tried — and a failed row prints its error in full underneath. It is where you look when your tables and the directory disagree. What it does not carry is a replay button: the log is a record to read, and a delivery your handler dropped is one you reconcile from the directory rather than ask for again. The window is a size control, newest first, with no paging.

## Choose your events

Subscribe to what you use. Every event is a request your endpoint has to answer in time, and a subscription to everything is mostly traffic you throw away.

There are four. Three of them mirror the directory, and the fourth is about sessions.

| Event | Sent when |
|---|---|
| `user.created` | an end-user record appears — a sign-up, an invitation, or an import |
| `user.updated` | a user's profile, flags or metadata change |
| `user.deleted` | a user is deleted or erased |
| `session.revoked` | a session ends — a sign-out, a ban, an erasure, or a refresh token replayed |

Note what that means for the events you might expect to be separate. A ban and an erasure both reach you as `session.revoked` for the sessions they ended, and an erasure as `user.deleted`. Key your handler on what the payload says rather than on a type you were hoping for.

> **NOTE**
> New event types are added over time — the vocabulary is additive, and none is ever renamed. Treat an unrecognised `type` as something to ignore rather than something to fail on, and subscribe only to the ones in the table: registering an endpoint for a type outside it is refused.

## Read a delivery

The request is a `POST` with a JSON body in a fixed envelope: four keys, with everything about the subject inside `data`.

```json
{
  "id": "evt_2c9f1a5b8d3e4f60",
  "type": "user.created",
  "timestamp": "2026-08-02T09:14:22.418Z",
  "data": {
    "productId": "prd_8f21c0",
    "userId": "usr_7Kq2mZ1xR4"
  }
}
```

- **`id`** identifies this delivery, and is what you store to make your handler idempotent. It is also in the `webhook-id` header.
- **`type`** is one of the events above.
- **`timestamp`** is when the thing happened, not when the request was made. A retry carries the original value.
- **`data.productId`** is your product. Worth asserting on if one endpoint serves several of your environments. It is **inside `data`**, not at the top level.
- **`data.userId`** is the subject: the stable opaque id, never the email address as the key. Emails change hands; the id never does.

A session event carries the session too:

```json
{
  "id": "evt_51de07b2a9c34a18",
  "type": "session.revoked",
  "timestamp": "2026-08-02T11:02:40.006Z",
  "data": {
    "productId": "prd_8f21c0",
    "userId": "usr_7Kq2mZ1xR4",
    "sessionId": "ses_Rb9wY3nT",
    "reason": "signout"
  }
}
```

`sessionId` and `reason` ride on `session.revoked` alone. `reason` is why the session ended — `signout`, `ban`, `erase`, or `reuse` for a refresh token that was replayed and cost the session its whole family.

**The payload tells you what happened, not what the record now says.** There is no profile, no address and no metadata in it: read the user back from the directory when you need the state. Fields are only ever added, so parse leniently and ignore what you do not recognise.

## Verify a delivery

Anyone can `POST` JSON at your URL. Only we can sign it. Verify every request before you act on it, and reject the ones that do not check out.

Three headers carry the proof:

| Header | Contents |
|---|---|
| `webhook-id` | the delivery id, the same value as `id` in the body |
| `webhook-timestamp` | when the request was signed, in seconds since the epoch |
| `webhook-signature` | one or more space-separated `v1,<signature>` values |

A signature is the base64 HMAC-SHA256, keyed with the part of your secret after `whsec_`, of the string `{webhook-id}.{webhook-timestamp}.{raw body}`. The wire format is Standard Webhooks, so any off-the-shelf verifier for it works — and `@lessly/users` ships one, so a handler needs no crypto of its own:

```ts

const app = express()

app.post(
  '/webhooks/lessly-users',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    let event
    try {
      event = verifyWebhook(
        req.body.toString('utf8'),
        req.headers,
        process.env.LESSLY_USERS_WEBHOOK_SECRET!,
      )
    } catch (error) {
      if (error instanceof WebhookVerificationError) return res.status(400).send(error.code)
      throw error
    }

    await enqueue(event)     // do the work elsewhere, see below
    res.sendStatus(200)
  },
)
```

`verifyWebhook` takes the raw body, the header bag and the secret, and returns the parsed event — `id`, `type`, `timestamp` and `data`. It refuses a delivery whose headers are missing, whose timestamp is outside `WEBHOOK_TOLERANCE_SECONDS` (±300, the Standard Webhooks default, overridable with `toleranceSeconds`), whose signature does not match any `v1,` entry in the header, or whose body is not an event envelope it recognises. Every refusal is a `WebhookVerificationError` with `code: 'webhook_invalid'`.

Three details decide whether verification works at all, and the helper gets them right for you:

- It signs over **the exact bytes you received**. Parsing the JSON and re-serialising it changes the whitespace and the signature will never match, which is why the route is mounted with `express.raw` and the body is handed over as text.
- It compares in **constant time**, and checks the timestamp first, so a captured request cannot be replayed at leisure.
- It accepts **any** matching `v1,` signature in the header, which is what makes a secret rotation invisible to your handler.

> **WARNING**
> Reject an unverified request with `400`. Do not answer `401` or `403` — those read as "come back later" and will be retried.

## Absorb retries

**Delivery is at least once.** Any `2xx` is an acknowledgement and the delivery is done. Anything else — a `4xx` other than a signature rejection, a `5xx`, a timeout, a connection your server closed — is retried with an exponential backoff over the next several hours, then given up on and left in the delivery log with its last error against it. A handler that answers slowly is treated as a handler that failed. Nothing re-sends a given-up delivery, so what the log gives you is the diagnosis, and the state you missed comes back from the directory.

Your endpoint will therefore sometimes see the same event twice, after a response we never received. That is normal, and it is something to absorb rather than to prevent.

**Order is not guaranteed.** Two events about the same user can arrive in either order, and a retry can land long after events that came later. Do not write a handler that assumes `user.created` precedes `user.updated`.

Two habits make this a non-issue:

- Compare `timestamp` against what you already stored, and drop an event that is older than the state you hold.
- When an event only tells you that something changed, read the user back from the directory with `@lessly/users` and store that. The read is authoritative; the event is a nudge.

> **WARNING**
> `session.revoked` says a session ended, and it usually arrives within a second — but your backend does not need it to be safe. Access tokens are verified locally against their ten-minute lifetime, and the checked mode in [Sessions and tokens](/ship/users/sessions-and-tokens) is what makes a revocation take effect immediately. Use the event to tidy up your own state, not as your revocation mechanism.

## Make your handler idempotent

The rule is one line: **key on `webhook-id`, and do the work once.**

```ts
async function enqueue(event: WebhookEvent) {
  const inserted = await db
    .insertInto('processed_webhooks')
    .values({ id: event.id, receivedAt: new Date() })
    .onConflict((c) => c.doNothing())
    .executeTakeFirst()

  if (Number(inserted.numInsertedOrUpdatedRows ?? 0) === 0) return  // already handled

  await handle(event)
}
```

A unique index on the id turns a duplicate into a no-op, whatever your handler does. Keep those rows for longer than the retry window — a few days is plenty — and delete the old ones on a schedule.

Where you can, make the work itself idempotent too, so a crash between the insert and the effect is harmless: upsert the user row instead of inserting it, set a `bannedAt` timestamp instead of incrementing a counter, and use `data.userId` as the key everywhere.

**Answer fast and do the work afterwards.** Verify, record the id, push the event onto your own queue, and return `200`. A handler that sends an email or calls a third party inline will eventually be slower than the timeout, and every one of those turns into a retry you did not need.

## Rotate a secret

Rotating mints a new secret and keeps the old one verifying for twenty-four hours, so a deployment does not have to be simultaneous:

1. Rotate, from the key button on the endpoint's row on **Webhooks**, and copy the new secret out of the dialog. It is shown once.
2. Add it to your configuration and deploy, inside the day.

There is no third step to finish: the old secret expires on its own when the window is up. During the overlap a delivery is signed with both, which the verification above already handles — it accepts any matching signature in the header.

## Next steps

- [Use the client libraries](/ship/users/client-libraries): reading a user back from the directory after an event tells you something changed.
- [Read the token contract](/ship/users/sessions-and-tokens): revocation, and why it does not depend on a webhook arriving.
- [Manage your end-users](/ship/users/user-management): the management App, whose **Webhooks** screen carries the endpoints and their delivery logs.
- [Migrate users in and out](/ship/users/migration): subscribing before a cutover, so your tables are in step from the first real sign-in.
