Webhooks
authn.sh emits webhooks for the lifecycle events your backend needs to react to (a user signing up, a session ending, an email being verified). Endpoints are configured per environment from the Dashboard.
In the Dashboard, Webhooks → Add endpoint. You give:
- The receiving URL (HTTPS in production, HTTP allowed in local).
- The events to subscribe to. The default is
*(everything).
The Dashboard returns a signing secret once on creation. Store it on your backend; we hash it at rest.
Delivery
Section titled “Delivery”Each event fires a POST <url> with a JSON body:
{ "object": "event", "id": "evt_01K…", "type": "user.created", "occurred_at": 1733428800000, "environment_id": "env_01K…", "data": { "user": { "object": "user", "id": "user_01K…", "first_name": "Jane", ... } }}Headers worth caring about:
| Header | Notes |
|---|---|
Authn-Signature | One or more v1,<base64-hmac> segments separated by ,. |
Authn-Webhook-Id | The delivery ID. Idempotent retries reuse this. |
Authn-Webhook-Timestamp | Unix-ms timestamp signed into the body. Reject if older than 5 minutes. |
Authn-Event-Type | Convenience copy of data.type. |
Retries
Section titled “Retries”Failed deliveries (anything that doesn’t return 2xx within 30s) retry with exponential backoff: 1m, 5m, 30m, 1h, 6h, 12h, then a final attempt at 24h. After the last attempt, the delivery is marked failed and visible in the Dashboard’s Webhooks → Deliveries table.
Signature scheme
Section titled “Signature scheme”The signature is HMAC-SHA256(secret, "<id>.<timestamp>.<body>"), base64-encoded:
signed_payload = $webhook_id . "." . $webhook_timestamp . "." . $raw_bodysignature = base64(hmac_sha256($secret, $signed_payload))header = "v1,$signature"During a rotation window, two v1,... segments are sent — verify against either. See verify webhook signatures for code.
Event types
Section titled “Event types”user.createduser.updateduser.deletedsession.createdsession.endedsession.removedsession.revokedemail.createdinvitation.createdinvitation.acceptedinvitation.revokedsession.touchedorganization.createdorganization.updatedorganization.deletedorganizationMembership.createdorganizationMembership.updatedorganizationMembership.deletedorganizationInvitation.createdorganizationInvitation.acceptedorganizationInvitation.revokedorganizationDomain.createdorganizationDomain.updatedorganizationDomain.deletedorganizationMembershipRequest.createdorganizationMembershipRequest.acceptedorganizationMembershipRequest.rejectedrole.createdrole.updatedrole.deletedpermission.createdpermission.updatedpermission.deletedThe data field of each event carries the full resource snapshot — Organization, OrganizationMembership, OrganizationInvitation, OrganizationDomain, OrganizationMembershipRequest, Role, or Permission — so handlers don’t need a follow-up fetch in most cases. Magic-link sign-in/sign-up does not introduce its own event type; the outgoing email is reported via the existing email.created event.
oauthProvider.createdoauthProvider.updatedoauthProvider.deletedexternalAccount.connectedexternalAccount.unlinkedphoneNumber.createdphoneNumber.verifiedphoneNumber.removeddata carries OauthProvider, ExternalAccount, or PhoneNumber. The externalAccount.* pair fires when a user links / unlinks an IdP via <SocialButtons />; phoneNumber.verified fires when the verify-challenge resolves (separate from the phoneNumber.created row creation, since the row is created unverified).
passkey.addedpasskey.removedinstance.config.appearance_updatedlocalization.updatedThe two passkey events carry a Passkey resource on data. The two configuration events carry a { previous, current, diff } triple — the diff is shaped like the corresponding PATCH request body (Appearance for appearance, LocalizationUpdateRequest for localization), so audit handlers can replay the change without diffing the full blobs themselves.
enterpriseConnection.createdenterpriseConnection.updatedenterpriseConnection.deletedenterpriseAccount.connectedenterpriseAccount.unlinkedscimToken.issuedscimToken.revokedscimUser.provisionedscimUser.deprovisioneddata carries:
enterpriseConnection.*→EnterpriseConnection. The write-only secrets (oidc_client_secret,saml_signing_key) are never in the payload — encrypted at rest.enterpriseAccount.*→EnterpriseAccount. Fires on first SSO sign-in (connected) and on operator unlink (unlinked).scimToken.*→ScimToken. The plaintexttokenis never in the payload — only theprefix. Captures token issuance / revocation for audit handlers.scimUser.*→{ user: User, enterprise_connection_id, organization_id }. The triple carries the connection that drove the SCIM operation plus the scoping org, so audit handlers can route without a follow-up lookup.
jwtTemplate.createdjwtTemplate.updatedjwtTemplate.deletedoauthApplication.createdoauthApplication.updatedoauthApplication.deletedauthorizationGrant.grantedauthorizationGrant.revokeddata carries:
jwtTemplate.*→JwtTemplate. The write-onlycustom_signing_keyis never in the payload — same write-only contract as the GET responses.oauthApplication.*→OauthApplication. The write-onlyclient_secretis never in the payload — even onoauthApplication.created, the only place plaintext is surfaced is the BAPI create response.authorizationGrant.*→AuthorizationGrant. Fires when a user first consents (granted) and on revocation (revoked— both user-driven via<UserProfile />and operator-driven via BAPI).meta.surfacedistinguishes"user"from"operator".
The live list is published via GET /v1/event-types against the BAPI.