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.
- Open Users → Webhooks and choose Add endpoint. Supply an HTTPS URL without embedded credentials.
- Select the events below and add a description identifying the consumer.
- 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"
}
}ididentifies the delivery and matches thewebhook-idheader. Store it for deduplication.typeidentifies the event.timestamprecords when the event occurred and stays unchanged on retries.data.productIdidentifies the product. Validate it when a receiver serves multiple environments.data.userIdis 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.rawbefore 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
- Create a test user and confirm receipt of
user.createdwith the expecteddata.userId. - 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.
- Change one signature byte and replay the body. Confirm
400and no queued work. - 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:
- Use the key action on the endpoint row in Users → Webhooks and copy the new secret from its one-time dialog.
- 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
- Client libraries for SDK helpers and error handling.
- Sessions and tokens for revocation enforcement.
- User management for directory reads and delivery logs.
- Migration for reconciling imports and subsequent sign-ups.