Webhooks
Webhooks allow you to receive real-time HTTP POST notifications when events occur in your UniAuth instance. Instead of polling the API for changes, your application receives instant callbacks whenever users register, log in, update their profiles, or interact with OAuth applications.
Note: Webhooks require admin access to configure. Events are delivered with cryptographic signatures so your endpoint can verify authenticity.
Creating a Webhook
Navigate to the Admin Panel and select Webhooks from the sidebar. Click Create Webhook and fill in the following fields:
- Name — A descriptive label for this webhook (e.g., "Production CRM sync")
- URL — The HTTPS endpoint that will receive POST requests
- Events — Select which event types should trigger this webhook
- Secret — A shared secret used to sign payloads (auto-generated if left blank)
You can also create webhooks programmatically via the /api/admin/webhooks API endpoint.
POST /api/admin/webhooks
Content-Type: application/json
Authorization: Bearer <admin-session-token>
{
"name": "Production CRM sync",
"url": "https://your-app.com/webhooks/uniauth",
"events": ["user.created", "user.updated", "user.deleted"],
"secret": "whsec_your_optional_secret_here"
}Event Types
The following events are available for webhook subscriptions:
| Event | Description |
|---|---|
user.created | New user registered |
user.login | User logged in |
user.logout | User logged out |
user.updated | Profile updated |
user.deleted | Account deleted |
user.password_changed | Password changed |
oauth.client_created | New OAuth app registered |
oauth.consent_granted | User authorized an app |
Payload Format
Every webhook delivery sends a JSON payload via HTTP POST. The payload follows a consistent structure across all event types:
{
"id": "evt_1a2b3c4d5e6f",
"event": "user.created",
"timestamp": "2026-02-26T14:30:00.000Z",
"data": {
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"email": "[email protected]",
"display_name": "Jane Doe",
"role": "user",
"created_at": "2026-02-26T14:30:00.000Z"
}
}The data field varies by event type. For user.* events it contains user details; for oauth.* events it contains client and consent information.
Signature Verification
Every webhook request includes an X-UniAuth-Signature header containing an HMAC-SHA256 signature of the request body. Always verify this signature before processing the webhook to ensure the request originated from UniAuth.
The signature is computed as an HMAC-SHA256 hash of the raw request body using your webhook secret, prefixed with sha256=. The header value format is:
sha256=HMAC-SHA256(webhook_secret, request_body)When verifying, strip the sha256= prefix before comparing the hex digest, or compare the full prefixed string.
Node.js
import crypto from "crypto";
function verifyWebhookSignature(body, signatureHeader, secret) {
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(body, "utf8")
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(signatureHeader),
Buffer.from(expected)
);
}
// In your Express/Next.js handler:
app.post("/webhooks/uniauth", (req, res) => {
const signature = req.headers["x-uniauth-signature"];
const rawBody = JSON.stringify(req.body);
if (!verifyWebhookSignature(rawBody, signature, process.env.WEBHOOK_SECRET)) {
return res.status(401).send("Invalid signature");
}
const event = req.body;
console.log("Received event:", event.event);
// Process the event asynchronously
processEvent(event).catch(console.error);
// Respond immediately with 200
res.status(200).send("OK");
});Python
import hmac
import hashlib
def verify_webhook_signature(body: bytes, signature_header: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(
secret.encode("utf-8"),
body,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature_header, expected)
# In your Flask/FastAPI handler:
@app.post("/webhooks/uniauth")
async def handle_webhook(request: Request):
body = await request.body()
signature = request.headers.get("X-UniAuth-Signature", "")
if not verify_webhook_signature(body, signature, WEBHOOK_SECRET):
raise HTTPException(status_code=401, detail="Invalid signature")
event = await request.json()
print(f"Received event: {event['event']}")
# Process asynchronously
background_tasks.add_task(process_event, event)
return {"status": "ok"}Retry Policy
If your endpoint does not respond with a 2xx status code, UniAuth retries the delivery with exponential backoff:
- Attempt 1 — Immediate
- Attempt 2 — After 1 minute
- Attempt 3 — After 5 minutes
After all retry attempts are exhausted, the delivery is marked as failed. You can view failed deliveries and manually retry them from the admin panel.
Testing Webhooks
You can send test events to verify your endpoint is working correctly. From the admin panel, navigate to your webhook and click Send Test Event. This sends a synthetic event with test data to your configured URL.
You can also trigger test events via the API:
POST /api/admin/webhooks/:id/test
Content-Type: application/json
Authorization: Bearer <admin-session-token>
{
"event": "user.created"
}For local development, use a tunneling service like ngrok or localtunnel to expose your local server to the internet so UniAuth can reach your endpoint.
Delivery Logs
Every webhook delivery is logged with full details. Navigate to Admin Panel → Webhooks → [Your Webhook] → Deliveries to view:
- Event type and timestamp
- Request payload sent
- Response status code and body
- Delivery duration (ms)
- Retry attempt number
- Success or failure status
Failed deliveries can be retried manually by clicking Retry on any delivery log entry.
Best Practices
- Respond quickly — Return a 2xx response within 5 seconds. Process the event asynchronously after acknowledging receipt.
- Always verify signatures — Never process a webhook without verifying the
X-UniAuth-Signatureheader. This prevents spoofed requests. - Handle idempotency — Webhooks may be delivered more than once during retries. Use the
idfield to deduplicate events. Store processed event IDs and skip duplicates. - Use HTTPS — Always configure HTTPS endpoints for webhooks. HTTP endpoints are rejected in production.
- Monitor delivery health — Check the admin panel regularly for failed deliveries. Persistent failures may indicate an endpoint issue.
- Rotate secrets periodically — Update your webhook secret via the admin panel. Both the old and new secret are accepted for a 24-hour grace period during rotation.
Full Payload Examples
Below are example payloads for each event type. The outer structure (event, timestamp, data) is consistent across all events — only the data field varies.
user.created
{
"event": "user.created",
"timestamp": "2026-02-26T14:30:00.000Z",
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "[email protected]",
"created_at": "2026-02-26T14:30:00.000Z"
}
}user.login
{
"event": "user.login",
"timestamp": "2026-02-26T15:00:00.000Z",
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "[email protected]",
"ip": "203.0.113.42",
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ...",
"risk_score": 0.12
}
}user.updated
{
"event": "user.updated",
"timestamp": "2026-02-26T16:00:00.000Z",
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"changes": {
"display_name": { "old": "Jane Doe", "new": "Jane Smith" },
"company": { "old": null, "new": "Acme Corp" }
}
}
}user.deleted
{
"event": "user.deleted",
"timestamp": "2026-02-26T17:00:00.000Z",
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "[email protected]"
}
}user.password_changed
{
"event": "user.password_changed",
"timestamp": "2026-02-26T18:00:00.000Z",
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "[email protected]"
}
}oauth.client_created
{
"event": "oauth.client_created",
"timestamp": "2026-02-26T19:00:00.000Z",
"data": {
"client_id": "app_abc123def456",
"name": "My Production App"
}
}oauth.consent_granted
{
"event": "oauth.consent_granted",
"timestamp": "2026-02-26T20:00:00.000Z",
"data": {
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"client_id": "app_abc123def456",
"scopes": ["openid", "profile", "email"]
}
}Delivery Details
- Timeout: Each delivery attempt has a 10-second timeout. If your endpoint does not respond within 10 seconds, the delivery is considered failed and will be retried.
- Ordering: There is no guaranteed ordering between events. If a user registers and logs in within the same second, you may receive
user.loginbeforeuser.created. Use timestamps for ordering if needed. - Parallelism: Events are delivered in parallel across all subscribed webhooks. Multiple webhooks subscribed to the same event receive deliveries simultaneously.
More Verification Examples
Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
)
func verifyWebhook(body []byte, signatureHeader, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(signatureHeader), []byte(expected))
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
defer r.Body.Close()
signature := r.Header.Get("X-UniAuth-Signature")
if !verifyWebhook(body, signature, "your-webhook-secret") {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
// Process the event asynchronously
go processEvent(body)
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}Ruby
require 'openssl'
require 'json'
# Sinatra example
post '/webhooks/uniauth' do
request.body.rewind
body = request.body.read
signature = request.env['HTTP_X_UNIAUTH_SIGNATURE']
expected = "sha256=" + OpenSSL::HMAC.hexdigest(
'sha256',
ENV['WEBHOOK_SECRET'],
body
)
unless Rack::Utils.secure_compare(signature, expected)
halt 401, 'Invalid signature'
end
event = JSON.parse(body)
puts "Received event: #{event['event']}"
# Process asynchronously
Thread.new { process_event(event) }
status 200
'OK'
end