Webhooks & events
Every change and sign-in is an event — read it from the audit log, or have it pushed to you.
Whatever changes in your organization — a tenant created, a page edited, a member removed — and every sign-in is recorded as an event. Events land in an append-only audit log you can query, and can be pushed to your own HTTPS endpoints as webhooks.
Events
Each event has a routing key, {entity}.{verb}:
| Key | When |
|---|---|
tenant.created · tenant.updated · tenant.deleted | A tenant is created, changed or deleted |
membership.created · membership.updated · membership.deleted | A tenant membership is created, changed or removed |
user.updated | A user’s profile changes |
page.created · page.updated · page.deleted | A login page is created, changed or deleted |
kit.created · kit.updated · kit.deleted | A branding kit is created, changed or deleted |
domain.created · domain.updated · domain.deleted | A custom domain is added, verified, or removed |
console.login | Someone signs in to the LoginWith console |
app.login | Someone signs in to one of your apps |
impersonation.started | An org owner starts a session as one of the org’s users |
org.created · org.deleted | The organization itself is created or deleted. Audit log only — not sent to webhooks |
A sign-in is its own verb, login, split by surface: console access is admin
access and an app sign-in is everyday traffic, so you can bind them separately.
An updated event is emitted only when something actually moved. A PATCH that
rewrites a field with the value it already had produces no event.
The envelope
{
"entity": "tenant",
"verb": "updated",
"actor": { "kind": "user", "id": "usr_…" },
"at": "2026-10-18T09:12:44.512Z",
"data": { "id": "tnt_…", "orgId": "org_…", "name": "Acme EU", "…": "…" },
"previous": { "id": "tnt_…", "orgId": "org_…", "name": "Acme", "…": "…" },
"changes": { "name": "Acme EU" }
}
| Field | What |
|---|---|
entity | The kind of thing: tenant, page, console… |
verb | created, updated, deleted, login or started |
actor | Who caused it (below) |
at | When, ISO 8601 |
data | The entity as it now stands — as it last stood, for a delete. For a login: userId, orgId, clientId and amr (how the first factor authenticated, e.g. ["pwd"], ["mail"], [] for SSO). For an impersonation: impersonatorId, targetUserId, orgId, tenantId |
previous | updated only: the entity before the change |
changes | updated only: the fields that moved, each with its new value |
actor.kind is one of:
kind | Meaning |
|---|---|
user | A signed-in person; id is their usr_… id. During an impersonation id is the owner doing it and via the user they are acting as |
client | A confidential client acting on its own behalf (client credentials); id is its cli_… id |
system | LoginWith itself, when no caller is the cause — e.g. a first-ever sign-in provisioning the user it would name |
Webhooks
Register an endpoint
curl -X POST https://acme.loginwith.dev/organizations/acme/webhooks \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"url": "https://hooks.acme.com/loginwith", "events": ["tenant.*", "*.deleted"]}'
{
"id": "whk_…",
"url": "https://hooks.acme.com/loginwith",
"events": ["tenant.*", "*.deleted"],
"active": true,
"created_at": "2026-10-18T09:00:00.000Z",
"secret": "3f9c…"
}
-
url— must behttps. -
events— 1 to 50 routing-key patterns.*matches exactly one segment, and#on its own matches everything:Pattern Matches tenant.createdThat key only tenant.*Every tenant event *.deletedEvery deletion *.loginconsole.loginandapp.login#Every event -
secret— 256 bits, hex. It is in this response once; listing the endpoints never shows it again. Keep it: it is how you check a delivery came from us.
Only an org owner can register or delete an endpoint. There is no update: delete and re-create to change the URL or the patterns. Endpoints are also manageable from the console and the MCP server. Full request and response tables are in the management API reference.
Delivery
Each matching event is sent as:
POST /loginwith HTTP/1.1
Host: hooks.acme.com
Content-Type: application/json
X-LoginWith-Signature: sha256=5d41402abc4b2a76b9719d911017c592…
{"entity":"tenant","verb":"created","actor":{…},"at":"…","data":{…}}
The body is the envelope above. Respond with any 2xx.
- Retries. A network error or a non-
2xxis retried, up to 3 attempts in total, waiting 200 ms then 400 ms between them. After the third failure the delivery is dropped. - No queue. Deliveries are sent as the event happens and are not stored; a delivery that fails all three attempts is not retried later. The audit log has every event, so use it to catch up.
- No ordering or deduplication guarantees. Each event is posted independently, and the body carries no event id.
Verify the signature
X-LoginWith-Signature is sha256= followed by the hex HMAC-SHA256 of the
raw request body, keyed with your endpoint’s secret — the secret string
exactly as you received it (its UTF-8 bytes, not hex-decoded).
import { createHmac, timingSafeEqual } from "node:crypto"
export function verifyLoginWith(rawBody: string, header: string | undefined, secret: string): boolean {
if (!header) return false
const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex")
const a = Buffer.from(header)
const b = Buffer.from(expected)
return a.length === b.length && timingSafeEqual(a, b)
}
Compute it over the bytes you received, before any JSON parsing — re-serialising a parsed body will not reproduce them.
The audit log
curl "https://acme.loginwith.dev/organizations/acme/events?entity=tenant&per_page=50" \
-H "Authorization: Bearer $TOKEN"
Every event for the organization, org.* included, newest first. Reading it
requires being an org owner; anyone else gets the same 404 as an unknown
organization.
Each entry is the envelope plus two fields:
| Field | What |
|---|---|
id | The entry’s id |
key | The routing key, e.g. tenant.updated |
actor here carries kind and id only.
Filters
All optional, all combinable. An unknown or repeated parameter is a 400.
| Parameter | Filters on |
|---|---|
entity | Exact entity, e.g. tenant |
verb | created, updated, deleted or login |
actor_id | The actor’s id, e.g. usr_… |
key_prefix | Routing keys starting with this text, e.g. membership. |
from | at on or after this ISO date |
to | at strictly before this ISO date |
verb does not accept started; find impersonations with
entity=impersonation.
Pagination
per_page is 1, 50 (default) or 100; page starts at 0. The response
is a JSON array, with a Link header carrying first, prev, next and
last as they apply — no prev on the first page, no next on the last, and
no header at all when everything fits on one page. Filters are kept on every
link.