Skip to content

Users

Receive user events

Keep your own tables in step with the directory instead of polling for changes.

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.

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.

EventSent when
user.createdan end-user record appears — a sign-up, an invitation, or an import
user.updateda user’s profile, flags or metadata change
user.deleteda user is deleted or erased
session.revokeda 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.

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.

{
  "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:

{
  "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:

HeaderContents
webhook-idthe delivery id, the same value as id in the body
webhook-timestampwhen the request was signed, in seconds since the epoch
webhook-signatureone 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:

import express from 'express'
import { verifyWebhook, WebhookVerificationError } from '@lessly/users'

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.

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.

Make your handler idempotent

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

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

Was this page helpful?
Esc

Start typing to search the docs.

navigateselect