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}:

KeyWhen
tenant.created · tenant.updated · tenant.deletedA tenant is created, changed or deleted
membership.created · membership.updated · membership.deletedA tenant membership is created, changed or removed
user.updatedA user’s profile changes
page.created · page.updated · page.deletedA login page is created, changed or deleted
kit.created · kit.updated · kit.deletedA branding kit is created, changed or deleted
domain.created · domain.updated · domain.deletedA custom domain is added, verified, or removed
console.loginSomeone signs in to the LoginWith console
app.loginSomeone signs in to one of your apps
impersonation.startedAn org owner starts a session as one of the org’s users
org.created · org.deletedThe 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" }
}
FieldWhat
entityThe kind of thing: tenant, page, console…
verbcreated, updated, deleted, login or started
actorWho caused it (below)
atWhen, ISO 8601
dataThe 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
previousupdated only: the entity before the change
changesupdated only: the fields that moved, each with its new value

actor.kind is one of:

kindMeaning
userA 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
clientA confidential client acting on its own behalf (client credentials); id is its cli_… id
systemLoginWith 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 be https.

  • events — 1 to 50 routing-key patterns. * matches exactly one segment, and # on its own matches everything:

    PatternMatches
    tenant.createdThat key only
    tenant.*Every tenant event
    *.deletedEvery deletion
    *.loginconsole.login and app.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-2xx is 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:

FieldWhat
idThe entry’s id
keyThe 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.

ParameterFilters on
entityExact entity, e.g. tenant
verbcreated, updated, deleted or login
actor_idThe actor’s id, e.g. usr_…
key_prefixRouting keys starting with this text, e.g. membership.
fromat on or after this ISO date
toat 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.