Webhook Integration
Receive real-time event notifications from AIdenID. This guide covers setup, signature verification, event handling, and best practices.
Overview
Webhooks push events to your HTTPS endpoints as they happen. Instead of polling the API, your server receives a POST request with the event payload whenever something occurs — an email arrives, a code is extracted, or an identity changes state.
Step 1: Create an endpoint
Set up an HTTPS endpoint on your server that accepts POST requests. The endpoint must return a 2xx status code to acknowledge receipt.
// Express.js example
const crypto = require("crypto");
const express = require("express");
const app = express();
const logger = {
warn: (_eventName, _fields) => {
// Replace with your structured logger sink (Datadog/Splunk/etc.).
},
};
const MAX_WEBHOOK_DEPTH = 8;
const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const REQUEST_ID_PATTERN = /^req_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
function createCanonicalRequestId() {
return "req_" + crypto.randomUUID();
}
function getRequestIdForLogContext(req) {
// Correlation only: do not use requestId for auth, replay defense, or authorization.
// Trust only the canonical req_<uuid> shape when propagating upstream IDs.
const candidate = typeof req.headers["x-request-id"] === "string"
? req.headers["x-request-id"].trim()
: "";
const uuidCandidate = candidate.startsWith("req_") ? candidate.slice(4) : "";
if (REQUEST_ID_PATTERN.test(candidate) && UUID_V4_PATTERN.test(uuidCandidate)) {
return candidate.toLowerCase();
}
return createCanonicalRequestId();
}
function redactWebhookFailureContext(requestId, reasonCode, hasUpstreamRequestId) {
return {
requestId,
reasonCode,
hasUpstreamRequestId: Boolean(hasUpstreamRequestId),
};
}
function logWebhookFailure(requestId, reasonCode, hasUpstreamRequestId) {
logger.warn(
"aidenid_webhook_failure",
redactWebhookFailureContext(requestId, reasonCode, hasUpstreamRequestId)
);
}
function maxDepth(value, depth = 0) {
if (depth > MAX_WEBHOOK_DEPTH || value === null || typeof value !== "object") {
return depth;
}
if (Array.isArray(value)) {
return value.reduce((m, item) => Math.max(m, maxDepth(item, depth + 1)), depth + 1);
}
return Object.values(value).reduce((m, item) => Math.max(m, maxDepth(item, depth + 1)), depth + 1);
}
function parseWebhookEvent(rawBody, requestId) {
let parsed;
try {
parsed = JSON.parse(rawBody);
} catch {
const err = new Error("invalid_payload");
err.envelope = {
error: { code: "invalid_payload", message: "Malformed JSON payload." },
requestId,
};
throw err;
}
if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string" || typeof parsed.type !== "string") {
const err = new Error("invalid_payload");
err.envelope = {
error: { code: "invalid_payload", message: "Payload schema validation failed." },
requestId,
};
throw err;
}
if (maxDepth(parsed) > MAX_WEBHOOK_DEPTH) {
const err = new Error("invalid_payload");
err.envelope = {
error: { code: "invalid_payload", message: "Payload nesting exceeds allowed depth." },
requestId,
};
throw err;
}
return parsed;
}
app.post("/webhooks/aidenid", express.raw({ type: "application/json", limit: "256kb" }), (req, res) => {
const rawBody = req.body.toString("utf8");
const requestId = getRequestIdForLogContext(req);
const hasUpstreamRequestId = typeof req.headers["x-request-id"] === "string";
const endpointScope = "wh_endpoint_abc123"; // resolve from server-side endpoint config
try {
verifyWebhook(
rawBody,
req.headers["x-signature"],
req.headers["x-timestamp"],
process.env.AIDENID_WEBHOOK_SECRET
);
} catch (err) {
// Log the specific failure reason internally for debugging,
// but return a single canonical error to callers to prevent
// the response from becoming an oracle for endpoint state.
const reason = err instanceof Error ? err.message : "unknown";
logWebhookFailure(requestId, reason, hasUpstreamRequestId);
// Always return a stable non-retryable auth failure.
// Missing-secret misconfiguration should be handled via internal alerting,
// not upstream retries that can amplify traffic.
return res.status(403).json({
error: {
code: "invalid_webhook_signature",
message: "Webhook signature verification failed.",
},
requestId,
});
}
let event;
try {
event = parseWebhookEvent(rawBody, requestId);
} catch (err) {
const envelope = err && err.envelope
? err.envelope
: {
error: {
code: "invalid_payload",
message: "Payload schema validation failed.",
},
requestId,
};
return res.status(400).json(envelope);
}
const wasInserted = tryInsertWebhookEventId(endpointScope, event.id);
if (!wasInserted) {
return res.status(200).json({ received: true, duplicate: true });
}
// tryInsertWebhookEventId must be a single atomic insert:
// INSERT INTO webhook_events (webhook_endpoint_id, event_id) VALUES (?, ?)
// ON CONFLICT (webhook_endpoint_id, event_id) DO NOTHING;
// Keep dedupe rows only for a bounded replay window (for example 7-30 days)
// and run a scheduled purge/partition-drop job so the replay store cannot grow without bound.
enqueueWebhookEvent(event); // durable queue (SQS, Kafka, etc.)
// Acknowledge immediately to avoid provider retry storms.
res.status(200).json({ received: true, queued: true });
});
app.listen(3000);In AIdenID itself, webhook mutation replay protection is enforced with a project-scoped idempotency store on the webhook routes; mirror that pattern for your receiver dedupe table to keep delivery semantics bounded and auditable.
Step 2: Register the webhook
Register your endpoint with AIdenID via the API. Choose which event types you want to receive.
curl -X POST https://api.aidenid.com/v1/webhooks \
-H "Authorization: Bearer aid_your_api_key" \
-H "X-Org-Id: org_abc123" \
-H "X-Project-Id: proj_def456" \
-H "Idempotency-Key: <GENERATED_UNIQUE_KEY>" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-server.com/webhooks/aidenid",
"events": [
"email.received",
"extraction.completed",
"identity.squashed"
]
}'Reuse an idempotency key only for an exact retry of the same payload. Generate a new key for every new webhook intent.
Store secret material only in backend secret management, rotate on a fixed cadence (for example every 30 days), and support short overlap windows so old+new secrets can validate during rollout.
Step 3: Verify signatures
Every webhook delivery includes two headers for verification:
X-Signature— HMAC-SHA256 of{timestamp}.{payload}X-Timestamp— Unix timestamp of the delivery
Node.js verification
const crypto = require("crypto");
function verifyWebhook(rawBody, signature, timestamp, secret) {
if (!secret || typeof secret !== "string") {
throw new Error("webhook_secret_not_configured");
}
if (!signature || !timestamp) {
throw new Error("Missing webhook signature headers");
}
if (!/^[a-f0-9]{64}$/i.test(signature)) {
throw new Error("Malformed webhook signature");
}
if (!/^[0-9]{10}$/.test(timestamp)) {
throw new Error("Malformed webhook timestamp");
}
// 1. Check timestamp is recent (prevent replay attacks)
const tsSec = Number(timestamp);
if (!Number.isSafeInteger(tsSec)) {
throw new Error("Malformed webhook timestamp");
}
const nowSec = Math.floor(Date.now() / 1000);
const age = Math.abs(nowSec - tsSec);
if (age > 300) {
throw new Error("Webhook timestamp too old");
}
// 2. Verify HMAC signature
const signed = `${timestamp}.${rawBody}`;
const expected = crypto
.createHmac("sha256", secret)
.update(signed)
.digest("hex");
const provided = Buffer.from(signature, "hex");
const computed = Buffer.from(expected, "hex");
if (provided.length !== computed.length) {
throw new Error("Invalid webhook signature");
}
const valid = crypto.timingSafeEqual(provided, computed);
if (!valid) {
throw new Error("Invalid webhook signature");
}
return true;
}Python verification
import hashlib
import hmac
import os
import re
import time
def load_webhook_secret() -> str:
secret = os.getenv("AIDENID_WEBHOOK_SECRET")
if not secret:
raise RuntimeError("webhook_secret_not_configured")
return secret
def verify_webhook(payload: str, signature: str, timestamp: str, secret: str):
if not secret or not isinstance(secret, str):
raise ValueError("webhook_secret_not_configured")
if not timestamp or not re.fullmatch(r"[0-9]{10}", timestamp):
raise ValueError("Malformed webhook timestamp")
if not signature or not re.fullmatch(r"[a-fA-F0-9]{64}", signature):
raise ValueError("Malformed webhook signature")
# Check timestamp freshness
ts_sec = int(timestamp)
if ts_sec < 1_500_000_000 or ts_sec > 4_102_444_800:
raise ValueError("Malformed webhook timestamp")
age = abs(time.time() - ts_sec)
if age > 300:
raise ValueError("Webhook timestamp too old")
# Verify HMAC signature
signed = f"{timestamp}.{payload}"
expected = hmac.new(
secret.encode(), signed.encode(), hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected):
raise ValueError("Invalid webhook signature")Resolve webhook secrets from a server-side secret manager or encrypted environment variable (for example AIDENID_WEBHOOK_SECRET), never inline constants in source, and rotate with overlap so old/new signatures both validate during cutover.
Map all webhook auth failures to a stable error envelope such as { error: { code, message }, requestId } and avoid returning raw parser exceptions to callers.
Event handling
Switch on the type field to handle different events:
app.post("/webhooks/aidenid", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = req.body.toString("utf8");
const signature = req.headers["x-signature"];
const timestamp = req.headers["x-timestamp"];
const webhookSecret = process.env.AIDENID_WEBHOOK_SECRET;
const requestId = getRequestIdForLogContext(req);
if (!webhookSecret) {
logWebhookFailure(requestId, "webhook_secret_not_configured", false);
return res.status(403).json({
error: {
code: "invalid_webhook_signature",
message: "Webhook signature verification failed.",
},
requestId,
});
}
const hasUpstreamRequestId =
typeof req.headers["x-request-id"] === "string";
const endpointScope = "wh_endpoint_abc123"; // resolved from server-side endpoint config
let event;
try {
verifyWebhook(rawBody, signature, timestamp, webhookSecret);
event = parseWebhookEvent(rawBody, requestId);
} catch (err) {
if (err && err.envelope) {
return res.status(400).json(err.envelope);
}
const reason = err instanceof Error ? err.message : "unknown";
logWebhookFailure(requestId, reason, hasUpstreamRequestId);
return res.status(403).json({
error: {
code: "invalid_webhook_signature",
message: "Webhook signature verification failed.",
},
requestId,
});
}
const wasInserted = tryInsertWebhookEventId(endpointScope, event.id);
if (!wasInserted) {
return res.status(200).json({ received: true, duplicate: true });
}
// tryInsertWebhookEventId must perform one atomic statement scoped to endpoint identity:
// INSERT INTO webhook_events (webhook_endpoint_id, event_id) VALUES (?, ?)
// ON CONFLICT (webhook_endpoint_id, event_id) DO NOTHING;
enqueueWebhookEvent(event); // hand off heavy work to worker
res.status(200).json({ received: true, queued: true });
});Do not log raw extraction secrets (OTP codes, magic links, reset tokens). Persist event IDs for deduplication and log only minimal, redacted metadata required for operations.
Run downstream processing in a worker queue with bounded retries and a dead-letter queue. Keep synchronous webhook handler time well under provider timeout budgets.
Consumer notification events
If your project serves human consumers, you may also receive consumer notification events. These fire when OTPs or magic links are forwarded to a consumer's personal email:
// Consumer notification handler
case "consumer.notification.sent":
handleConsumerNotificationSent(event.data);
break;
case "consumer.notification.failed":
handleConsumerNotificationFailed(event.data);
break;Delivery behavior
Use explicit bounded retry constants in your worker configuration so delivery behavior stays deterministic across environments:
const maxAttempts = 5;
const backoffMs = 1_000;
const maxBackoffMs = 30 * 60 * 1_000;
function computeRetryDelay(attempt) {
const exponential = backoffMs * (2 ** attempt);
return Math.min(exponential, maxBackoffMs);
}
// Persist delivery event IDs for dedupe and route terminal failures to DLQ.
// If attempt >= maxAttempts, enqueue to dead_letter_webhooks and stop retrying.- Timeout: AIdenID waits up to 10 seconds for your endpoint to respond. If the request times out, it is treated as a failure.
- Retries: Failed deliveries are retried with exponential backoff using bounded constants (
maxAttempts=5,backoffMs=1000,maxBackoffMs=1800000). Terminal failures should move to DLQ for operator replay. - Ordering: Events are delivered in chronological order but are not guaranteed to arrive in perfect sequence. Use the
timestampfield for ordering if needed. - Replay: You can manually replay any event via the Webhooks API.
Best practices
- Respond quickly. Return a
200response immediately and process the event asynchronously. Do not perform heavy computation in the webhook handler. - Handle duplicates. While rare, duplicate deliveries can occur. Use the event
idfield for deduplication. Persistreceived_atalongsideevent_id, and apply a bounded replay window (for example 24 hours) so old records expire deterministically. - Verify signatures. Always verify the HMAC signature before processing a webhook event. This prevents forged requests.
- Check timestamp freshness. Reject events with timestamps older than 5 minutes to prevent replay attacks.
- Rotate secrets periodically. Use the
/rotate-secretendpoint to generate new signing secrets. Update your verification code before rotating.
Testing locally
For local development, use a tunneling service to expose your local endpoint:
# Using ngrok
ngrok http 3000
# Register the ngrok URL as your webhook endpoint
# https://abc123.ngrok.io/webhooks/aidenid