Webhooks and audit export
Webhooks tell your server when something happens in your project's sign-in flows. Each request is signed, so your server can check it came from auth4.dev.
Add an endpoint
- Open Settings in a project and scroll to Webhooks.
- Select Add endpoint, enter the Endpoint URL, and choose theApplication (optional) and the Events to receive.
- Copy the signing secret into your server's configuration. It's shown once. If you lose it, use Rotate secret to get a new one; the old one keeps working for 24 hours (seeRotating the secret).
- Select Send test, then Show deliveries to see whether your server answered.
Owners and admins add and change endpoints. Viewers can see them and their deliveries. A project can have up to 10 endpoints.
Leave Application (optional) set to All applications (project-wide)to receive matching events from every application. Choose one application to limit player events to that application. Team member changes and player deletions are project-wide, so an application-filtered endpoint does not receive them. You can change or clear the filter when you edit an endpoint.
The URL must start with https://, use the default port and name a public host. IP addresses, localhost, private names such as .internal or.local, and auth4.dev hosts are refused. Redirects aren't followed.
Events
| Type | Sent when |
|---|---|
user.created | A player account is created. |
user.signed_in | Tokens are issued after a sign-in. |
guest.upgraded | A guest adds an email, Discord or Steam sign-in. |
session.revoked | A player signs out, or a session or refresh token is revoked. |
member.changed | Someone joins or leaves the project, or their role changes. |
user.deleted | A player account has been deleted, at the player's request or as an inactive guest. |
identity.linked | A signed-in player links a Steam, Discord or Epic Games account. |
identity.unlinked | A signed-in player removes a linked Steam, Discord or Epic Games account. |
The body is JSON. It names the project, application and player and says how the player signed in. A project-wide member event has a null application_id. It never contains tokens, codes, email addresses or IP addresses.
{
"event_id": "evt_7wp-mPdHTZ_ug4hvHKYjjaV31ZK8b79pVRLZ0EjCBNU",
"version": 1,
"type": "user.signed_in",
"tenant_id": "ten_…",
"application_id": "cli_…",
"user_id": "usr_…",
"occurred_at": "2026-10-06T18:27:19.079Z",
"context": { "country": "ZA", "provider": "discord" },
"data": { "method": "discord" }
}A test send has the type webhook.test and carries only the project and endpoint IDs.
Player deleted
user.deleted is published once, after auth4.dev has removed the player's account, sign-in methods, sessions and consent. Use it to delete what your game keeps under thatuser_id, such as saves, inventories and leaderboard rows.
data.reasonisuser_requestwhen the deletion was requested for the player, orinactive_guestwhen a guest account was removed under theretention rules.application_idis always null andcontextis empty, so only endpoints without an application filter receive it.- The event is sent after the deletion has finished, which can be several minutes after it was requested. Delivery is best effort like every other event, so don't rely on it alone to clean up.
{
"event_id": "evt_Q2xk3n1y0b9VqT7mH4sR8uWcE6aJpLzN5dGfXiKoYv0",
"version": 1,
"type": "user.deleted",
"tenant_id": "ten_…",
"application_id": null,
"user_id": "usr_…",
"occurred_at": "2026-10-17T02:10:04.512Z",
"context": {},
"data": { "reason": "inactive_guest" }
}Account linked or unlinked
identity.linked and identity.unlinked are published once each time a player links or unlinks an account on the hosted page, after the change is saved. The user_id stays the same.
data.providerissteam,discordorepic.data.identity_idnames the sign-in method on the player's account. The Steam, Discord or Epic Games account ID is never included.application_idis the application that started the link, so an application filter applies.
{
"event_id": "evt_m9Ck2Yt4Rz0aLq7VbN3xW8eHs5uJdG1pFiKoTyXc6E2",
"version": 1,
"type": "identity.linked",
"tenant_id": "ten_…",
"application_id": "cli_…",
"user_id": "usr_…",
"occurred_at": "2026-10-22T09:41:17.208Z",
"context": { "provider": "steam" },
"data": { "provider": "steam", "identity_id": "idn_…" }
}Check the signature
Requests follow the Standard Webhooks format. Each one has three headers:
webhook-id: the event ID. It's the same on every retry, so store it and ignore repeats.webhook-timestamp: when the request was signed, in Unix seconds.webhook-signature:v1,followed by a base64 HMAC-SHA256 of{id}.{timestamp}.{body}, keyed with the secret afterwhsec_.
Check the signature against the raw request body, before parsing it as JSON. Libraries for the Standard Webhooks format can do this for you. Without one, in Node.js:
import { createHmac, timingSafeEqual } from "node:crypto";
// secret is the whsec_… value the console showed when you added the endpoint.
export function verifyAuth4Webhook(rawBody, headers, secret) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
if (!id || !timestamp || !signatures) return false;
// Refuse requests signed more than five minutes ago or in the future.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const key = Buffer.from(secret.slice("whsec_".length), "base64");
const expected = createHmac("sha256", key)
.update(`${id}.${timestamp}.${rawBody}`)
.digest();
return signatures.split(" ").some((entry) => {
const [version, value] = entry.split(",");
const candidate = Buffer.from(value ?? "", "base64");
return (
version === "v1" &&
candidate.length === expected.length &&
timingSafeEqual(candidate, expected)
);
});
}Rotating the secret
After Rotate secret, deliveries are signed with both the new secret and the old one for 24 hours, including retries already queued. webhook-signature then holds two space-separated signatures, new first, and a request is genuine when either one matches, as the code above checks. Deploy the new secret to your server within that time.
The endpoint shows Old secret works until with the time. SelectEnd now to stop the old secret at once, for example after a leak. Rotating again stops any older secret straight away, so only one old secret is ever still signing. The management API ends the overlap withDELETE /management/tenants/{projectId}/webhooks/{endpointId}/previous-secret; see the overlap reference.
Retries and the delivery log
Answer with any 2xx status within 10 seconds. Anything else, including a redirect, a timeout or a refused connection, is retried after 30 seconds, 2 minutes, 10 minutes, 30 minutes and 2 hours. After six attempts the delivery is marked failed.
Show deliveries lists the 50 most recent deliveries with their status, attempts and the status code your server returned. Your server's response body is never stored. Deliveries older than 30 days are removed from the log.
Resending a failed delivery
If your server was down for longer than the retries last, open Show deliveries and select Resend on a failed row. Only project owners and admins see it. The delivery goes back in the queue with a fresh set of six attempts and shows as Queued. It keeps the same webhook-id and body, so a server that skips ids it has already handled won't process it twice, and it is signed with the endpoint's current secret. Turn the endpoint back on first: a disabled endpoint can't resend. You can resend any failed delivery still in the 30-day log. To resend from a backend through the management API, see the resend reference.
Export the audit log
Open Audit log, set any filters, and selectExport all events (NDJSON) (or the filtered version). The download has one JSON object per line, oldest first, with the same fields as the audit log. This covers the recent log (the last 30 days). To download older events that your plan keeps, select Archive and thenExport the archive (NDJSON); see Audit log and archive. Each export is itself recorded in the audit log.