Webhooks and events API
Today LuxID emits a single event type, ClaimValuesChanged, and only through the pull model (Partner API - Event Hub). The webhook push model described on this page, the subscription-management endpoints, and the other event types are planned and not available yet. Do not build against them until you have confirmed availability with LuxID.
Two ways to receive LuxID events
LuxID delivers the same stream of user-related events two ways: push (webhooks, LuxID calls your endpoint) and pull (Event Hub, your back-end polls). Both use the same on-the-wire envelope.
LuxID exposes the same stream of user-related events through two complementary delivery models. Which one you pick depends on the operational model that fits your back-end best:
| Model | How it works | Where it is documented |
|---|---|---|
| Pull (Event Hub) | Your back-end polls a time-range query endpoint and reads events from the response. You control the cadence and back-pressure. | Partner API - Event Hub |
| Push (Webhooks) | LuxID calls a URL you host whenever an event occurs. You receive events without polling, but must keep the endpoint highly available. | This page |
Both models surface events with the same on-the-wire envelope (id, timestamp, type, version, sphere, subscriber, application, subscription, payload). The difference is who initiates the call and how retries are handled. The canonical envelope reference is the Partner API - Event Hub page.
For the event catalogue, see Events and Event Hub. The authoritative event types and payload shapes are maintained in the LuxID OpenAPI document.
Choosing between push and pull
When push (webhooks) is preferable
- Events are infrequent and you do not want to run a polling job at all.
- You want sub-minute latency from event to action without consuming request budget on polling.
- You already operate a webhook receiver for other vendors and can integrate LuxID with minimal new infrastructure.
When pull (Event Hub) is preferable
- Your back-end cannot guarantee high availability for a receiving endpoint.
- You prefer to control back-pressure and handle bursty volumes yourself.
- You need to back-fill historical events on demand - the Event Hub's time-range search handles this naturally.
- You are building an audit trail or reconciliation job where ordering and completeness matter more than latency.
You may use both in the same integration: for example, use webhooks for low-latency consent revocation reactions and the Event Hub for nightly audit reconciliation.
Webhook Subscription management
Webhook subscriptions are managed via the Events API, which is part of the LuxID Partner API. Authenticate every management call the same way as any other Partner API call: with your Partner's static X-Client-Id and X-Client-Secret headers (not an OAuth token or a user access token). See Partner API - getting started for how authentication works.
Verify the base URL with your LuxID Account manager before relying on it.
https://api.luxid.lu/events/subscriptions
Create a Subscription
POST /events/subscriptions HTTP/1.1
Host: api.luxid.lu
Content-Type: application/json
X-Client-Id: <your-client-id>
X-Client-Secret: <your-client-secret>
{
"target_url": "https://your-app.example.lu/webhooks/luxid",
"secret": "your-webhook-signing-secret-min-32-chars",
"event_filters": [
"user.consent.revoked",
"subscription.created",
"subscription.revoked",
"user.email.changed",
"user.phone.changed"
],
"description": "Production webhook for MyApp consent and subscription events"
}
Request fields
| Field | Type | Required | Description |
|---|---|---|---|
target_url | string | Yes | HTTPS URL that LuxID will POST events to. Must be publicly reachable and use TLS. |
secret | string | Yes | Shared secret used to sign event payloads. Minimum 32 characters. Store securely - treat as a credential. |
event_filters | array | Yes | List of event types to receive. An empty array subscribes to all event types. |
description | string | No | Human-readable label for the subscription (useful in the Console). |
Response
{
"id": "ws-7f3e9b1d-2a4c-4d5e-8f6a-1b2c3d4e5f6a",
"target_url": "https://your-app.example.lu/webhooks/luxid",
"event_filters": [
"user.consent.revoked",
"subscription.created",
"subscription.revoked",
"user.email.changed",
"user.phone.changed"
],
"status": "active",
"created_at": "2026-05-22T10:00:00Z"
}
List Subscriptions
curl -s \
-H "X-Client-Id: $LUXID_CLIENT_ID" \
-H "X-Client-Secret: $LUXID_CLIENT_SECRET" \
https://api.luxid.lu/events/subscriptions
Get a Subscription
curl -s \
-H "X-Client-Id: $LUXID_CLIENT_ID" \
-H "X-Client-Secret: $LUXID_CLIENT_SECRET" \
https://api.luxid.lu/events/subscriptions/ws-7f3e9b1d-2a4c-4d5e-8f6a-1b2c3d4e5f6a
Update a Subscription
curl -s -X PATCH \
-H "Content-Type: application/json" \
-H "X-Client-Id: $LUXID_CLIENT_ID" \
-H "X-Client-Secret: $LUXID_CLIENT_SECRET" \
--data-raw '{"event_filters":["subscription.revoked","user.consent.revoked"]}' \
https://api.luxid.lu/events/subscriptions/ws-7f3e9b1d-2a4c-4d5e-8f6a-1b2c3d4e5f6a
Delete a Subscription
curl -s -X DELETE \
-H "X-Client-Id: $LUXID_CLIENT_ID" \
-H "X-Client-Secret: $LUXID_CLIENT_SECRET" \
https://api.luxid.lu/events/subscriptions/ws-7f3e9b1d-2a4c-4d5e-8f6a-1b2c3d4e5f6a
Event payload structure
Every webhook delivery uses the same envelope regardless of event type:
{
"id": "evt-3a4b5c6d-7e8f-9a0b-c1d2-e3f4a5b6c7d8",
"timestamp": "2026-05-22T14:23:45.123Z",
"type": "subscription.revoked",
"version": "1",
"sphere": "MyApp",
"subscriber": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"application": "app-9z8y7x6w",
"subscription": "sub-app-9z8y7x6w-user-42",
"payload": {
"revoked_at": "2026-05-22T14:23:44.000Z",
"reason": "user_initiated"
}
}
This is the same envelope as the pull Partner API - Event Hub. Webhook-specific delivery metadata (event id, type, timestamp, nonce, and the matching subscription) is carried in the X-LuxID-* HTTP headers, not in the body - see Payload signing and verification.
Envelope fields
| Field | Type | Description |
|---|---|---|
id | string | Globally unique event identifier. Use for idempotency checks. |
timestamp | string | ISO 8601 timestamp of when the event occurred. |
type | string | Event type. The authoritative catalogue and payload shapes are in the LuxID OpenAPI document (see Events and Event Hub). |
version | string | Payload schema version - increments when the payload shape changes. |
sphere, subscriber, application, subscription | string | Optional context fields, filled in when applicable. subscriber matches the sub in your tokens. |
payload | object | Event-type-specific data. Shape varies by type. |
Payload signing and verification
LuxID signs each webhook payload using JWS (RS256) with the LuxID signing key. The signature is included in the X-LuxID-Signature HTTP header as a compact JWS token.
Signature header example
POST /webhooks/luxid HTTP/1.1
Host: your-app.example.lu
Content-Type: application/json
X-LuxID-Signature: eyJhbGciOiJSUzI1NiIsImtpZCI6IkxVWElELTIwMjYtMDEifQ...
X-LuxID-Event-ID: evt-3a4b5c6d-7e8f-9a0b-c1d2-e3f4a5b6c7d8
X-LuxID-Event-Type: subscription.revoked
X-LuxID-Timestamp: 2026-05-22T14:23:45.123Z
X-LuxID-Nonce: n8k2p7qx
The JWS token in X-LuxID-Signature carries:
digest: SHA-256 of the raw request body (hex-encoded), prefixedSHA-256:.timestamp: same asX-LuxID-Timestampin the header.nonce: same as theX-LuxID-Nonceheader.
Verify the signature using the LuxID public signing key from the JWKS endpoint:
https://login.luxid.lu/mga/sps/oauth/oauth20/jwks/OIDC-LUXID
Verification steps
- Fetch the LuxID JWKS (cache it; rotate only when
kidchanges). - Parse the JWS token in
X-LuxID-Signatureand extract thekidheader. - Find the matching key in the JWKS.
- Verify the JWS signature using RS256.
- Compare the
digestin the JWS payload against your own SHA-256 of the raw request body. - Verify the
timestampis within 5 minutes of your server clock. - Check the
noncehas not been seen before (within the replay window).
Respond with 400 Bad Request to signal the delivery failure.
Webhook verification
- Python
- Java
- PHP
- Node.js
- C#
import hashlib
from datetime import datetime, timezone
import jwt # PyJWT >= 2.0
JWKS_URL = "https://login.luxid.lu/mga/sps/oauth/oauth20/jwks/OIDC-LUXID"
REPLAY_WINDOW_SECONDS = 300 # 5 minutes
jwks_client = jwt.PyJWKClient(JWKS_URL)
seen_nonces = set() # Use Redis or a DB in production
def verify_luxid_webhook(request_body: bytes, headers: dict) -> bool:
signature_token = headers.get("X-LuxID-Signature")
event_timestamp = headers.get("X-LuxID-Timestamp")
nonce = headers.get("X-LuxID-Nonce")
if not all([signature_token, event_timestamp, nonce]):
return False
# Verify JWS signature
try:
signing_key = jwks_client.get_signing_key_from_jwt(signature_token)
claims = jwt.decode(
signature_token,
signing_key.key,
algorithms=["RS256"],
options={"verify_aud": False},
)
except jwt.PyJWTError:
return False
# Verify body digest
expected_digest = "SHA-256:" + hashlib.sha256(request_body).hexdigest()
if claims.get("digest") != expected_digest:
return False
# Verify timestamp within replay window
try:
event_time = datetime.fromisoformat(event_timestamp.replace("Z", "+00:00"))
age_seconds = (datetime.now(timezone.utc) - event_time).total_seconds()
if abs(age_seconds) > REPLAY_WINDOW_SECONDS:
return False
except ValueError:
return False
# Replay protection via nonce
if nonce in seen_nonces:
return False
seen_nonces.add(nonce)
return True
static final String JWKS_URL = "https://login.luxid.lu/mga/sps/oauth/oauth20/jwks/OIDC-LUXID";
static final long REPLAY_WINDOW_SECONDS = 300;
static final Set<String> seenNonces = ConcurrentHashMap.newKeySet(); // use Redis/DB in prod
boolean verifyLuxIdWebhook(byte[] rawBody, Map<String, String> headers) throws Exception {
String signatureToken = headers.get("X-LuxID-Signature");
String eventTimestamp = headers.get("X-LuxID-Timestamp");
String nonce = headers.get("X-LuxID-Nonce");
if (signatureToken == null || eventTimestamp == null || nonce == null) return false;
// Verify JWS signature against the remote JWKS (pin RS256)
JWTClaimsSet claims;
try {
JWKSource<SecurityContext> keys = JWKSourceBuilder.create(new URL(JWKS_URL)).build();
ConfigurableJWTProcessor<SecurityContext> proc = new DefaultJWTProcessor<>();
proc.setJWSKeySelector(new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, keys));
claims = proc.process(signatureToken, null);
} catch (Exception e) {
return false;
}
// Verify body digest
String digest = "SHA-256:" + HexFormat.of().formatHex(
MessageDigest.getInstance("SHA-256").digest(rawBody));
if (!digest.equals(claims.getStringClaim("digest"))) return false;
// Verify timestamp within the replay window
long age = Math.abs(Duration.between(Instant.parse(eventTimestamp), Instant.now()).getSeconds());
if (age > REPLAY_WINDOW_SECONDS) return false;
// Replay protection via nonce
return seenNonces.add(nonce);
}
use Firebase\JWT\JWT;
use Firebase\JWT\JWK;
const JWKS_URL = 'https://login.luxid.lu/mga/sps/oauth/oauth20/jwks/OIDC-LUXID';
const REPLAY_WINDOW_SECONDS = 300;
function verifyLuxIdWebhook(string $rawBody, array $headers): bool {
$signatureToken = $headers['x-luxid-signature'] ?? null;
$eventTimestamp = $headers['x-luxid-timestamp'] ?? null;
$nonce = $headers['x-luxid-nonce'] ?? null;
if (!$signatureToken || !$eventTimestamp || !$nonce) return false;
// Verify JWS signature against the JWKS (cache the key set in production)
try {
$jwks = json_decode(file_get_contents(JWKS_URL), true);
$claims = (array) JWT::decode($signatureToken, JWK::parseKeySet($jwks));
} catch (\Throwable $e) {
return false;
}
// Verify body digest
if (($claims['digest'] ?? null) !== 'SHA-256:' . hash('sha256', $rawBody)) return false;
// Verify timestamp within the replay window
if (abs(time() - strtotime($eventTimestamp)) > REPLAY_WINDOW_SECONDS) return false;
// Replay protection via nonce (use Redis/DB in production)
if (isset($GLOBALS['seen_nonces'][$nonce])) return false;
$GLOBALS['seen_nonces'][$nonce] = true;
return true;
}
const crypto = require("crypto");
const { createRemoteJWKSet, jwtVerify } = require("jose"); // jose >= 4.0
const JWKS_URL = "https://login.luxid.lu/mga/sps/oauth/oauth20/jwks/OIDC-LUXID";
const REPLAY_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
const JWKS = createRemoteJWKSet(new URL(JWKS_URL));
const seenNonces = new Set(); // Use Redis or a DB in production
async function verifyLuxIDWebhook(rawBody, headers) {
const signatureToken = headers["x-luxid-signature"];
const eventTimestamp = headers["x-luxid-timestamp"];
const nonce = headers["x-luxid-nonce"];
if (!signatureToken || !eventTimestamp || !nonce) return false;
let claims;
try {
const { payload } = await jwtVerify(signatureToken, JWKS, {
algorithms: ["RS256"],
});
claims = payload;
} catch {
return false;
}
const bodyHash = crypto.createHash("sha256").update(rawBody).digest("hex");
if (claims.digest !== `SHA-256:${bodyHash}`) return false;
const ageDiff = Math.abs(Date.now() - new Date(eventTimestamp).getTime());
if (ageDiff > REPLAY_WINDOW_MS) return false;
if (seenNonces.has(nonce)) return false;
seenNonces.add(nonce); // Persist in production
return true;
}
const string JwksUrl = "https://login.luxid.lu/mga/sps/oauth/oauth20/jwks/OIDC-LUXID";
static readonly TimeSpan ReplayWindow = TimeSpan.FromMinutes(5);
static readonly ConcurrentDictionary<string, byte> SeenNonces = new(); // use Redis/DB in prod
async Task<bool> VerifyLuxIdWebhookAsync(byte[] rawBody, IDictionary<string, string> headers)
{
if (!headers.TryGetValue("X-LuxID-Signature", out var signatureToken) ||
!headers.TryGetValue("X-LuxID-Timestamp", out var eventTimestamp) ||
!headers.TryGetValue("X-LuxID-Nonce", out var nonce))
return false;
// Verify JWS signature against the JWKS (pin RS256)
JsonWebToken jwt;
try
{
var config = await _configManager.GetConfigurationAsync();
var result = new JsonWebTokenHandler().ValidateToken(signatureToken,
new TokenValidationParameters
{
ValidateIssuer = false,
ValidateAudience = false,
ValidAlgorithms = new[] { "RS256" },
IssuerSigningKeys = config.SigningKeys,
});
if (!result.IsValid) return false;
jwt = (JsonWebToken)result.SecurityToken;
}
catch { return false; }
// Verify body digest
var digest = "SHA-256:" + Convert.ToHexString(SHA256.HashData(rawBody)).ToLowerInvariant();
if (jwt.GetClaim("digest").Value != digest) return false;
// Verify timestamp within the replay window
if ((DateTimeOffset.UtcNow - DateTimeOffset.Parse(eventTimestamp)).Duration() > ReplayWindow)
return false;
// Replay protection via nonce
return SeenNonces.TryAdd(nonce, 0);
}
Delivery semantics
LuxID webhook delivery guarantees at-least-once delivery: your endpoint may receive the same event more than once, so your handler must be idempotent.
This means:
- LuxID will retry failed deliveries - your endpoint may receive the same event more than once.
- Your handler must be idempotent: processing the same event twice must produce the same outcome as processing it once. Use the
idfield as your idempotency key.
Retry policy
LuxID retries delivery with exponential backoff on the following conditions:
- HTTP response code
5xx(server error). - HTTP response code
429(too many requests). - Connection timeout (no response within 10 seconds).
- No response at all (connection refused, DNS failure).
LuxID does not retry on 2xx (success) or 4xx other than 429.
Retry schedule (approximate)
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 30 seconds |
| 3 | 2 minutes |
| 4 | 10 minutes |
| 5 | 1 hour |
After all retries are exhausted, the event is marked as permanently failed. You can retrieve missed events via the Event Hub.
Replay protection
To prevent replay attacks (an attacker re-sending a captured webhook payload):
- Timestamp check: reject events where
X-LuxID-Timestampis more than 5 minutes in the past or future. Ensure your server clock is synchronised via NTP. - Nonce check: record the
X-LuxID-Noncevalue for every accepted event. Reject duplicates. Store nonces for at least 10 minutes. - Signature verification: the JWS signature binds the nonce and timestamp to the body. A replayed event with a modified body or timestamp will fail signature verification.
Responding to webhook deliveries
Your endpoint must respond with an HTTP 2xx status within 10 seconds. Acknowledge immediately and process asynchronously:
- Python
- Java
- PHP
- Node.js
- C#
# FastAPI example - immediate acknowledgement, async processing
from fastapi import FastAPI, Request, BackgroundTasks, HTTPException
import json
app = FastAPI()
@app.post("/webhooks/luxid")
async def receive_webhook(request: Request, background_tasks: BackgroundTasks):
raw_body = await request.body()
if not verify_luxid_webhook(raw_body, dict(request.headers)):
raise HTTPException(status_code=400, detail="Invalid signature")
event = json.loads(raw_body)
background_tasks.add_task(process_event, event)
return {"status": "accepted"}
async def process_event(event: dict):
if event.get("type") == "subscription.revoked":
pass # Clear user session, update subscription state, etc.
// Spring Boot - acknowledge on the request thread, process off it
@PostMapping("/webhooks/luxid")
public ResponseEntity<?> receiveWebhook(@RequestBody byte[] rawBody,
@RequestHeader Map<String, String> headers) throws Exception {
if (!verifyLuxIdWebhook(rawBody, headers)) {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid signature"));
}
Event event = objectMapper.readValue(rawBody, Event.class);
taskExecutor.execute(() -> processEvent(event)); // async, off the request thread
return ResponseEntity.ok(Map.of("status", "accepted"));
}
void processEvent(Event event) {
if ("subscription.revoked".equals(event.type())) {
// Clear user session, update subscription state, etc.
}
}
// Acknowledge immediately, then process out of band (queue/worker).
$rawBody = file_get_contents('php://input');
if (!verifyLuxIdWebhook($rawBody, array_change_key_case(getallheaders()))) {
http_response_code(400);
echo json_encode(['error' => 'Invalid signature']);
exit;
}
http_response_code(200);
echo json_encode(['status' => 'accepted']);
fastcgi_finish_request(); // flush the 200 to LuxID, keep running
$event = json_decode($rawBody, true);
enqueueForProcessing($event); // hand off to a queue/worker
// Express - ack first, then process asynchronously
app.post('/webhooks/luxid',
express.raw({ type: 'application/json' }),
async (req, res) => {
if (!(await verifyLuxIDWebhook(req.body, req.headers))) {
return res.status(400).json({ error: 'Invalid signature' });
}
const event = JSON.parse(req.body);
res.status(200).json({ status: 'accepted' }); // acknowledge first
await queue.add('luxid-event', event); // process out of band
});
// ASP.NET minimal API - ack first, then process out of band
app.MapPost("/webhooks/luxid", async (HttpRequest request) =>
{
using var ms = new MemoryStream();
await request.Body.CopyToAsync(ms);
var rawBody = ms.ToArray();
var headers = request.Headers.ToDictionary(h => h.Key, h => h.Value.ToString());
if (!await VerifyLuxIdWebhookAsync(rawBody, headers))
return Results.BadRequest(new { error = "Invalid signature" });
var ev = JsonSerializer.Deserialize<Event>(rawBody);
_ = Task.Run(() => ProcessEvent(ev)); // fire-and-forget; use a queue in prod
return Results.Ok(new { status = "accepted" });
});
Common event types
| Event type | Description |
|---|---|
subscription.created | A user has authorised your application for the first time |
subscription.revoked | A user has revoked your application's access |
user.consent.revoked | A user has withdrawn consent for specific claims |
user.email.changed | The user's email address has changed |
user.phone.changed | The user's phone number has changed |
user.account.deleted | The user's LuxID Account has been deleted |
user.identity.verified | The user has completed LuxID Verified (LuxTrust) |
For the full event catalogue with payload schemas, see Events and Event Hub.
Integration checklist
- Use HTTPS with a valid TLS certificate on your webhook endpoint.
- Verify the
X-LuxID-Signatureon every incoming request before processing. - Reject events older than 5 minutes (
X-LuxID-Timestampcheck). - Track nonces to prevent replay attacks.
- Return
200 OKwithin 10 seconds - process asynchronously if needed. - Make your handler idempotent using
idas the idempotency key. - Store your webhook
secretas a credential (secret manager, environment variable) - never in source code. - Back-fill missed events via the Event Hub after any downtime.
Related pages
- Events and Event Hub - event catalogue and payload schemas
- Partner API - Event Hub - pull-based event delivery
- Revocation - token revocation on subscription.revoked events
- Logs and audit trails - Console visibility for events