Webhooks
GET /orgs/{orgId}/api/v1/admin/webhooks
POST /orgs/{orgId}/api/v1/admin/webhooks
GET /orgs/{orgId}/api/v1/admin/webhooks/{webhookId}
PUT /orgs/{orgId}/api/v1/admin/webhooks/{webhookId}
DELETE /orgs/{orgId}/api/v1/admin/webhooks/{webhookId}
POST /orgs/{orgId}/api/v1/admin/webhooks/{webhookId}/rotate-secret
POST /orgs/{orgId}/api/v1/admin/webhooks/{webhookId}/enable
POST /orgs/{orgId}/api/v1/admin/webhooks/{webhookId}/disable
GET /orgs/{orgId}/api/v1/admin/webhooks/{webhookId}/deliveries
GET /orgs/{orgId}/api/v1/admin/webhooks/{webhookId}/deliveries/{deliveryId}
POST /orgs/{orgId}/api/v1/admin/webhooks/{webhookId}/deliveries/{deliveryId}/replay
POST /orgs/{orgId}/api/v1/admin/webhooks/{webhookId}/test
GET /orgs/{orgId}/api/v1/admin/webhooks/events
Webhooks allow your systems to receive real-time notifications when events occur in LumoAuth. All webhook payloads are signed with a rotating HMAC secret.
Authentication
All webhook management endpoints require an admin API key or a Bearer token with
admin:webhooks:read / admin:webhooks:write, or a browser session.
Each endpoint needs the credential's scope and a permission. A session or Bearer caller must
hold the permission; for an API key its creator must still hold it. A missing permission
returns 403 forbidden with details.required_permissions. See
Required permissions.
| Method | Endpoint | Permission | API-key / token scope |
|---|---|---|---|
| GET | /admin/webhooks | webhooks.view | admin:webhooks:read |
| GET | /admin/webhooks/events | webhooks.view | admin:webhooks:read |
| GET | /admin/webhooks/{webhookId} | webhooks.view | admin:webhooks:read |
| POST | /admin/webhooks | webhooks.create | admin:webhooks:write |
| PUT PATCH | /admin/webhooks/{webhookId} | webhooks.edit | admin:webhooks:write |
| POST | /admin/webhooks/{webhookId}/enable | webhooks.edit | admin:webhooks:write |
| POST | /admin/webhooks/{webhookId}/disable | webhooks.edit | admin:webhooks:write |
| DELETE | /admin/webhooks/{webhookId} | webhooks.delete | admin:webhooks:write |
| POST | /admin/webhooks/{webhookId}/test | webhooks.edit | admin:webhooks:write |
| POST | /admin/webhooks/{webhookId}/rotate-secret | webhooks.edit | admin:webhooks:write |
| GET | /admin/webhooks/{webhookId}/deliveries | webhooks.view | admin:webhooks:read |
| GET | /admin/webhooks/{webhookId}/deliveries/{deliveryId} | webhooks.view | admin:webhooks:read |
| POST | /admin/webhooks/{webhookId}/deliveries/{deliveryId}/replay | webhooks.edit | admin:webhooks:write |
| POST | /admin/webhooks/tunnel/start | webhooks.edit | admin:webhooks:write |
| POST | /admin/webhooks/tunnel/{webhookId}/stop | webhooks.edit | admin:webhooks:write |
| GET | /admin/webhooks/tunnel/{webhookId}/stream | webhooks.view | admin:webhooks:read |
| POST | /ssf/stream | webhooks.create | admin:webhooks:write |
| GET | /ssf/stream | webhooks.view | admin:webhooks:read |
| DELETE | /ssf/stream | webhooks.delete | admin:webhooks:write |
| POST | /ssf/verify | webhooks.edit | admin:webhooks:write |
Webhook Object
{
"id": "01JF3KWBK...",
"url": "https://api.example.com/hooks/lumoauth",
"description": "User lifecycle events",
"events": ["user.created", "user.updated", "user.deleted"],
"enabled": true,
"signingSecret": "whsec_...",
"createdAt": "2026-01-15T10:30:00Z",
"updatedAt": "2026-03-01T08:00:00Z"
}
Supported Events
| Category | Event Types |
|---|---|
| User | user.created, user.updated, user.deleted, user.login, user.logout, user.blocked, user.unblocked, user.password_changed, user.mfa_enabled, user.mfa_disabled |
| MFA | mfa.enroll.started, mfa.enroll.completed, mfa.enroll.failed, mfa.enroll.deferred, mfa.challenge.issued, mfa.challenge.succeeded, mfa.challenge.failed, mfa.push.denied, mfa.push.reported_fraud, mfa.factor.removed, mfa.factor.default_changed, mfa.recovery_codes.generated, mfa.recovery_code.used, mfa.recovery.started, mfa.recovery.completed, mfa.tap.issued, mfa.locked, mfa.policy.changed, mfa.trusted_device.added, mfa.trusted_device.revoked — see MFA events |
| Identity | identity.linked, identity.unlinked |
| Roles & Groups | role.assigned, role.unassigned, group.member_added, group.member_removed |
| Clients | client.created, client.deleted |
| Tokens & Consent | token.issued, token.revoked, consent.granted, consent.revoked |
| AI agents | agent.created, agent.updated, agent.revoked (also sent when an agent is deleted), agent.attestation.accepted, agent.attestation.rejected |
| JIT permissions | jit.approval_required, jit.request.approved, jit.request.denied — decisions fire from every review channel (portal inbox, API, mobile app, approval channels) |
| Wildcard | * — subscribe to all events |
Create a Webhook
- curl
- Node
- Python
- Go
curl -X POST https://app.lumoauth.dev/orgs/acme-corp/api/v1/admin/webhooks \
-H "Authorization: ApiKey lmk_abc123" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.example.com/hooks/lumoauth",
"description": "User lifecycle events",
"events": ["user.created", "user.updated", "user.deleted", "user.login"]
}'
const resp = await fetch("https://app.lumoauth.dev/orgs/acme-corp/api/v1/admin/webhooks", {
method: "POST",
headers: { "Authorization": process.env.LUMO_AUTH, "Content-Type": "application/json" },
body: JSON.stringify({
"url": "https://api.example.com/hooks/lumoauth",
"description": "User lifecycle events",
"events": ["user.created", "user.updated", "user.deleted", "user.login"]
})
});
console.log(await resp.json());
import os, requests
resp = requests.post(
"https://app.lumoauth.dev/orgs/acme-corp/api/v1/admin/webhooks",
headers={"Authorization": os.environ["LUMO_AUTH"], "Content-Type": "application/json"},
json={
"url": "https://api.example.com/hooks/lumoauth",
"description": "User lifecycle events",
"events": ["user.created", "user.updated", "user.deleted", "user.login"]
}
)
print(resp.json())
import (
"io"
"net/http"
"os"
"strings"
)
body := strings.NewReader(`{
"url": "https://api.example.com/hooks/lumoauth",
"description": "User lifecycle events",
"events": ["user.created", "user.updated", "user.deleted", "user.login"]
}`)
req, _ := http.NewRequest("POST", "https://app.lumoauth.dev/orgs/acme-corp/api/v1/admin/webhooks", body)
req.Header.Set("Authorization", os.Getenv("LUMO_AUTH"))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil { panic(err) }
defer resp.Body.Close()
out, _ := io.ReadAll(resp.Body)
fmt.Println(string(out))
{
"id": "01JF3KWBK...",
"url": "https://api.example.com/hooks/lumoauth",
"description": "User lifecycle events",
"events": ["user.created", "user.updated", "user.deleted", "user.login"],
"enabled": true,
"signingSecret": "whsec_AbCdEf123..."
}
The signingSecret is only shown once at creation. Store it securely — use rotate-secret to generate a new one if lost.
Rotate Signing Secret
- curl
- Node
- Python
- Go
curl -X POST https://app.lumoauth.dev/orgs/acme-corp/api/v1/admin/webhooks/01JF3KWBK.../rotate-secret \
-H "Authorization: ApiKey lmk_abc123"
const resp = await fetch("https://app.lumoauth.dev/orgs/acme-corp/api/v1/admin/webhooks/01JF3KWBK.../rotate-secret", {
method: "POST",
headers: { "Authorization": process.env.LUMO_AUTH, "Content-Type": "application/json" }
});
console.log(await resp.json());
import os, requests
resp = requests.post(
"https://app.lumoauth.dev/orgs/acme-corp/api/v1/admin/webhooks/01JF3KWBK.../rotate-secret",
headers={"Authorization": os.environ["LUMO_AUTH"]}
)
print(resp.json())
import (
"io"
"net/http"
"os"
"strings"
)
req, _ := http.NewRequest("POST", "https://app.lumoauth.dev/orgs/acme-corp/api/v1/admin/webhooks/01JF3KWBK.../rotate-secret", nil)
req.Header.Set("Authorization", os.Getenv("LUMO_AUTH"))
resp, err := http.DefaultClient.Do(req)
if err != nil { panic(err) }
defer resp.Body.Close()
out, _ := io.ReadAll(resp.Body)
fmt.Println(string(out))
{
"signingSecret": "whsec_NewSecret..."
}
Delivery History
- curl
- Node
- Python
- Go
curl "https://app.lumoauth.dev/orgs/acme-corp/api/v1/admin/webhooks/01JF3KWBK.../deliveries" \
-H "Authorization: ApiKey lmk_abc123"
const resp = await fetch("https://app.lumoauth.dev/orgs/acme-corp/api/v1/admin/webhooks/01JF3KWBK.../deliveries", {
method: "GET",
headers: { "Authorization": process.env.LUMO_AUTH, "Content-Type": "application/json" }
});
console.log(await resp.json());
import os, requests
resp = requests.get(
"https://app.lumoauth.dev/orgs/acme-corp/api/v1/admin/webhooks/01JF3KWBK.../deliveries",
headers={"Authorization": os.environ["LUMO_AUTH"]}
)
print(resp.json())
import (
"io"
"net/http"
"os"
"strings"
)
req, _ := http.NewRequest("GET", "https://app.lumoauth.dev/orgs/acme-corp/api/v1/admin/webhooks/01JF3KWBK.../deliveries", nil)
req.Header.Set("Authorization", os.Getenv("LUMO_AUTH"))
resp, err := http.DefaultClient.Do(req)
if err != nil { panic(err) }
defer resp.Body.Close()
out, _ := io.ReadAll(resp.Body)
fmt.Println(string(out))
{
"data": [
{
"id": 4821,
"webhook_id": "01JF3KWBK...",
"delivery_id": "del_01JF3K...",
"event_name": "user.login",
"status": "success",
"attempt_count": 1,
"last_status_code": 200,
"last_response_body": "ok",
"last_error": null,
"next_attempt_at": null,
"succeeded_at": "2026-03-01T08:00:00Z",
"dead_lettered_at": null,
"created_at": "2026-03-01T08:00:00Z",
"updated_at": "2026-03-01T08:00:00Z"
}
],
"meta": {
"count": 1,
"dead_lettered_count": 0
}
}
Filter with ?status=pending|success|failed|dead_lettered and cap the result set with ?limit= (max 200, default 50).
Inspect or Replay a Single Delivery
Fetch one delivery (including its per-attempt history) by its delivery_id, or re-enqueue it for redelivery:
# Inspect a single delivery
curl "https://app.lumoauth.dev/orgs/acme-corp/api/v1/admin/webhooks/01JF3KWBK.../deliveries/del_01JF3K..." \
-H "Authorization: ApiKey lmk_abc123"
# Replay (re-enqueue) it — returns the new delivery id
curl -X POST "https://app.lumoauth.dev/orgs/acme-corp/api/v1/admin/webhooks/01JF3KWBK.../deliveries/del_01JF3K.../replay" \
-H "Authorization: ApiKey lmk_abc123"
Send a Test Delivery
Send a synthetic event to the webhook URL to confirm connectivity. The endpoint refuses non-HTTPS and private/internal URLs.
curl -X POST "https://app.lumoauth.dev/orgs/acme-corp/api/v1/admin/webhooks/01JF3KWBK.../test" \
-H "Authorization: ApiKey lmk_abc123"
{
"success": true,
"statusCode": 200,
"message": "Test webhook delivered successfully"
}
List Subscribable Event Types
Returns a map of every event type you can subscribe a webhook to, keyed by event name with a human-readable description (the same set shown in Supported Events).
curl "https://app.lumoauth.dev/orgs/acme-corp/api/v1/admin/webhooks/events" \
-H "Authorization: ApiKey lmk_abc123"
{
"data": {
"user.created": "Triggered when a new user is created",
"user.updated": "Triggered when a user is updated",
"user.login": "Triggered when a user logs in successfully"
}
}
Verifying Webhook Signatures
LumoAuth signs every webhook delivery with HMAC-SHA256. The signature is sent in the X-LumoAuth-Signature header as a comma-separated list of fields:
X-LumoAuth-Signature: kid=v1,t=1709280000,id=del_01JF3K...,v1=9f86d081884c7d659a2feaa0...
| Field | Meaning |
|---|---|
kid | Signing-key version (v1, or v2 after a secret rotation) — pick the matching secret during a rotate-with-grace-period cycle. |
t | Unix timestamp the delivery was signed (also sent as X-LumoAuth-Timestamp). |
id | Delivery ID (also sent as X-LumoAuth-Delivery). |
v1 | The HMAC-SHA256 hex digest. |
The HMAC is computed over the signing base "{t}.{id}.{rawBody}" — i.e. the timestamp, the delivery ID, and the raw request body joined by dots — not over the body alone. Reconstruct the same string and compare against v1:
const crypto = require("crypto");
function parseSignature(header) {
return Object.fromEntries(
header.split(",").map((kv) => {
const i = kv.indexOf("=");
return [kv.slice(0, i), kv.slice(i + 1)];
})
);
}
// `rawBody` MUST be the exact bytes received (do not re-serialize the JSON).
function verifyWebhook(rawBody, sigHeader, secret) {
const { t, id, v1 } = parseSignature(sigHeader);
const signingBase = `${t}.${id}.${rawBody}`;
const expected = crypto
.createHmac("sha256", secret)
.update(signingBase)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(v1, "hex")
);
}
import hmac, hashlib
def parse_signature(header: str) -> dict:
return dict(field.split("=", 1) for field in header.split(","))
# `raw_body` MUST be the exact bytes received (do not re-serialize the JSON).
def verify_webhook(raw_body: bytes, sig_header: str, secret: str) -> bool:
fields = parse_signature(sig_header)
signing_base = f"{fields['t']}.{fields['id']}.".encode() + raw_body
expected = hmac.new(secret.encode(), signing_base, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, fields["v1"])
Always use a constant-time comparison (timingSafeEqual / compare_digest) to prevent timing attacks. Reject deliveries whose t is too far from your server clock to defend against replay.
Payload Format
{
"id": "evt_01JF3K...",
"type": "user.login",
"orgId": "acme-corp",
"createdAt": "2026-03-01T08:00:00Z",
"data": {
"userId": "01JF3KUSER...",
"email": "alice@example.com",
"ip": "203.0.113.1",
"userAgent": "Mozilla/5.0 ..."
}
}
Webhook requests include the following headers:
| Header | Value |
|---|---|
X-LumoAuth-Signature | kid=<v1|v2>,t=<ts>,id=<deliveryId>,v1=<hex-hmac> (see Verifying Webhook Signatures) |
X-LumoAuth-Event | Event type (e.g. user.login) |
X-LumoAuth-Timestamp | Unix timestamp the delivery was signed |
X-LumoAuth-Delivery | Unique delivery ID |
User-Agent | LumoAuth-Webhook/1.0 |
Content-Type | application/json |