Skip to content

Receive user events

Receive signed user events and keep application data synchronized safely.

Receive signed HTTP events when users are created, updated, deleted, or have a session revoked. Use them to update your application’s records without polling the directory.

For example, create an application row after sign-up and remove personal data after erasure. Read current directory state when the event payload does not contain the fields you need.

Before you start

  • Provide a publicly reachable HTTPS endpoint that preserves the raw request body. Signature verification must use the bytes received, before JSON parsing changes them.
  • Store the signing secret securely on the backend.
  • Prepare durable storage for event IDs and queued work so retries cannot duplicate effects.

Step 1 — Subscribe an endpoint

An endpoint combines your receiver URL, selected events, and an optional description.

  1. Open Users → Webhooks and choose Add endpoint. Supply an HTTPS URL without embedded credentials.
  2. Select the events below and add a description identifying the consumer.
  3. Save the whsec_ signing secret in backend configuration. It is shown once; rotate it if lost.

The endpoint list shows URLs, subscriptions, secret prefixes, and creation dates. Row actions pause or resume delivery, edit URL/description/events, rotate secrets, or delete the endpoint. These actions are also available to an agent: creation and updates require product-scoped write access; rotation and deletion require admin access.

You can register separate endpoints for different consumers or environments. Each has its own secret and delivery log. Pause an endpoint while repairing a receiver to retain its configuration and history without receiving new deliveries.

Open an endpoint’s URL in the list to inspect deliveries. The log shows event details, status, attempt count, last response, last attempt, and any error. It supports inspecting and replaying dropped deliveries. The newest-first window shows up to 100 entries and does not paginate.

Step 2 — Choose your events

Select only events your handler uses. Every selected event creates a delivery that needs a timely response.

Outbound webhook subscriptions support these four types:

Event
Sent when
user.created
An end-user record is created through sign-up or invitation
user.updated
Profile, flags, or metadata change
user.deleted
A user is deleted or erased
session.revoked
A session ends through sign-out, ban, erasure, or refresh-token reuse

Ban and erasure produce session.revoked for affected sessions; erasure also produces user.deleted. Bulk import emits a separate batch event, not one user.created per row. Use the import report to reconcile those records; the batch event is not an additional outbound subscription type.

Step 3 — Read a delivery

A delivery is a JSON POST with four top-level fields. These IDs and timestamps are example values:

{
  "id": "evt_2c9f1a5b8d3e4f60",
  "type": "user.created",
  "timestamp": "2026-08-02T09:14:22.418Z",
  "data": {
    "productId": "prd_8f21c0",
    "userId": "usr_7Kq2mZ1xR4"
  }
}
  • id identifies the delivery and matches the webhook-id header. Store it for deduplication.
  • type identifies the event.
  • timestamp records when the event occurred and stays unchanged on retries.
  • data.productId identifies the product. Validate it when a receiver serves multiple environments.
  • data.userId is the stable user ID. Use it as your application key rather than an email address.

A revoked-session event also identifies the session and reason:

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

session.revoked adds sessionId and reason: signout, ban, erase, or reuse. Refresh-token reuse revokes the affected session’s token family.

Payloads describe the event, not the current profile. They omit addresses and metadata. Read the directory through the management API when you need those fields, and ignore unknown payload fields.

Step 4 — Verify every signature

Verify the signature before processing any delivery. A public URL can receive requests from anyone; a valid signature proves possession of the shared signing secret.

Read these headers:

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

Compute HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{raw body} using the base64-decoded part of the secret after whsec_. The signature is base64 encoded. This Express example assumes a durable enqueue implementation, described below:

import crypto from 'node:crypto'
import express from 'express'

const secret = Buffer.from(
  process.env.LEPSTO_USERS_WEBHOOK_SECRET!.replace(/^whsec_/, ''),
  'base64',
)
const TOLERANCE_SECONDS = 300

function verify(rawBody: Buffer, headers: Record<string, string>): boolean {
  const id = headers['webhook-id']
  const timestamp = headers['webhook-timestamp']
  const received = headers['webhook-signature']
  if (!id || !timestamp || !received) return false

  const age = Math.abs(Date.now() / 1000 - Number(timestamp))
  if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${id}.${timestamp}.${rawBody.toString('utf8')}`)
    .digest()

  // More than one signature during a secret rotation: any match is enough.
  return received.split(' ').some((part) => {
    const [version, value] = part.split(',')
    if (version !== 'v1' || !value) return false
    const candidate = Buffer.from(value, 'base64')
    return (
      candidate.length === expected.length &&
      crypto.timingSafeEqual(candidate, expected)
    )
  })
}

const app = express()

app.post(
  '/webhooks/lepsto-users',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    if (!verify(req.body, req.headers as Record<string, string>)) {
      return res.status(400).send('bad signature')
    }

    const event = JSON.parse(req.body.toString('utf8'))
    await enqueue(event)     // do the work elsewhere, see below
    res.sendStatus(200)
  },
)

Keep these checks in the receiver:

  • Preserve the exact raw body. Mount express.raw before middleware that consumes JSON on this route; re-serializing parsed JSON can change the signature input.
  • Reject timestamps outside the five-minute tolerance and compare signatures in constant time.
  • Accept any matching v1, signature. Rotation sends signatures for both active secrets during the overlap.

Verify it worked

  1. Create a test user and confirm receipt of user.created with the expected data.userId.
  2. Check the endpoint’s delivery log for your response status. If your receiver has no corresponding request, inspect DNS, public reachability, TLS, service availability, and rejected-request logs.
  3. Change one signature byte and replay the body. Confirm 400 and no queued work.
  4. Replay a valid delivery within the signature tolerance. Confirm the duplicate creates no additional effect.

Absorb retries

Delivery is at least once. Any 2xx acknowledges success. Other responses, except the signature-rejection 400, and network failures or timeouts trigger exponential-backoff retries over several hours. Exhausted deliveries remain in the log for inspection and replay.

Expect duplicates when an acknowledgment is lost or a delivery is replayed. Deduplicate them in your application.

Delivery order is not guaranteed. A retry can arrive after newer events, and user.updated can arrive before user.created.

Handle ordering explicitly:

  • Compare event timestamps with stored state before applying an older update.
  • For a change notification, fetch current directory state through the management API. The server authentication subpath does not read the directory; see User management.

Make your handler idempotent

Use a unique delivery ID and atomically store both deduplication and queued work. This database-shaped example assumes your own processed_webhooks and webhook_jobs tables and transaction API:

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

    if (Number(inserted.numInsertedOrUpdatedRows ?? 0) === 0) return

    await trx
      .insertInto('webhook_jobs')
      .values({ id: event.id, payload: JSON.stringify(event) })
      .execute()
  })
}

Put a unique index on the delivery ID. Commit the ID and job in one transaction so a crash cannot mark an event handled without preserving its work. Keep deduplication records beyond the retry window and any replay period you support, then expire them on a schedule.

Make worker effects idempotent too. Upsert user rows, set state such as bannedAt instead of incrementing counters, and key records by data.userId. A worker retry should produce the same result.

After signature verification and durable enqueueing, return 200. Perform slow work, including third-party calls and email, in the worker. Do not acknowledge before the event is safely stored.

Rotate a secret

Rotation returns a new secret and keeps the previous one valid for twenty-four hours:

  1. Use the key action on the endpoint row in Users → Webhooks and copy the new secret from its one-time dialog.
  2. Deploy the new secret to your receiver within the overlap window.

The old secret expires automatically. During overlap, deliveries contain both signatures, and the verifier above accepts either matching value.

Next steps

Was this page helpful?
Esc

Start typing to search the docs.

↑↓navigate↵select