Webhooks API

Register HTTPS endpoints to receive real-time events from AIdenID. Webhook payloads are signed with HMAC-SHA256 for verification.

Create webhook endpoint

POST/v1/webhooks

Register a new webhook endpoint. The create/rotate response carries one-time secret material and should be handled only by trusted backend code paths.

Request body

FieldTypeRequiredDescription
urlstringYesHTTPS URL to receive webhook events
eventsstring[]YesEvent types to subscribe to
descriptionstringNoHuman-readable description
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-app.com/webhooks/aidenid",
    "events": ["email.received", "extraction.completed"],
    "description": "Production webhook"
  }'

Use each idempotency key for one exact mutation intent. Keep the same key only when retrying the same request payload.

Response

{
  "id": "wh_p1q2r3s4",
  "url": "https://your-app.com/webhooks/aidenid",
  "events": ["email.received", "extraction.completed"],
  "secret_issued": true,
  "secretPreview": "whsec_••••••••••••",
  "description": "Production webhook",
  "created_at": "2026-04-04T10:00:00Z"
}
Important

Treat create/rotate responses as non-cacheable secret envelopes: return Cache-Control: no-store and Pragma: no-cache from your proxy, return full secret material once, immediately store it in backend secret management, and only persist secretPreview in normal app records.

HTTP/1.1 200 OK
Cache-Control: no-store
Pragma: no-cache

List webhook endpoints

GET/v1/webhooks

List all registered webhook endpoints for the current project.

Rotate webhook secret

POST/v1/webhooks/{webhook_id}/rotate-secret

Generate a new signing secret for a webhook endpoint. Rotate with a controlled overlap window: deploy verifier code that accepts both active and previous secrets first, then switch traffic to the new secret, and finally retire the previous secret after the overlap TTL.

Treat rotate responses exactly like create responses: include Cache-Control: no-store and Pragma: no-cache, return full secret material only once, and redact it after initial delivery.

Required: include a unique Idempotency-Key header for every rotate request. Reusing a key returns the original rotation response instead of generating another secret.

curl -X POST https://api.aidenid.com/v1/webhooks/wh_p1q2r3s4/rotate-secret \
  -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>"

Replay event

POST/v1/webhooks/{webhook_id}/replay

Re-deliver a specific event to a webhook endpoint. Useful for debugging or recovering from delivery failures.

Required: include a unique Idempotency-Key header for each replay attempt to prevent duplicate side effects during retries.

Request body

FieldTypeRequiredDescription
event_idstringYesThe event ID to replay
curl -X POST https://api.aidenid.com/v1/webhooks/wh_p1q2r3s4/replay \
  -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 '{ "event_id": "evt_x1y2z3" }'

Event types

Subscribe to any combination of these event types when creating a webhook:

Event typeDescription
email.receivedNew email arrived at an identity inbox
extraction.completedAuthentication code or link successfully extracted
identity.provisionedNew identity created
identity.extendedIdentity TTL extended
identity.squashedIdentity revoked
identity.expiredIdentity TTL elapsed

Consumer notification events

When a human consumer's disposable email receives a verification code, AIdenID generates additional events for consumer notification delivery:

Event typeDescription
consumer.notification.sentOTP or magic link forwarded to consumer's personal email
consumer.notification.failedNotification delivery to personal email failed

Webhook payload format

All webhook deliveries include two signature headers:

HeaderDescription
X-SignatureHMAC-SHA256 signature of the payload
X-TimestampUnix timestamp of the delivery

Payload example

{
  "id": "evt_x1y2z3",
  "type": "extraction.completed",
  "timestamp": "2026-04-04T10:05:02Z",
  "data": {
    "identity_id": "ident_a1b2c3d4e5",
    "extraction": {
      "type": "otp",
      "has_secret": true,
      "redacted_value": "***",
      "confidence": 0.99
    }
  }
}

Verifying signatures

To verify a webhook delivery, compute the HMAC-SHA256 of {timestamp}.{payload} using your webhook secret, and compare it to the X-Signature header using a timing-safe comparison. Also enforce timestamp freshness and event-id replay protection before enqueueing any work.

import { createHmac, timingSafeEqual } from "crypto";

function verifyDelivery(
  payload: string,
  eventId: string,
  signature: string | undefined,
  timestamp: string | undefined,
  secret: string
): boolean {
  if (!signature || !timestamp) return false;
  if (!/^[a-f0-9]{64}$/i.test(signature)) return false;
  if (!/^[0-9]{10}$/.test(timestamp)) return false;

  const ts = Number(timestamp);
  const now = Math.floor(Date.now() / 1000);
  if (!Number.isSafeInteger(ts) || Math.abs(now - ts) > 300) {
    return false; // stale or malformed timestamp
  }

  const replayRecorded = tryRecordReplayEvent(eventId, ts);
  if (!replayRecorded) {
    return false; // event id already processed in freshness window
  }

  const signed = `${timestamp}.${payload}`;
  const expected = createHmac("sha256", secret).update(signed).digest("hex");
  const provided = Buffer.from(signature, "hex");
  const computed = Buffer.from(expected, "hex");
  if (provided.length !== computed.length) return false;
  return timingSafeEqual(provided, computed);
}

// Replay guard must be one atomic write scoped to endpoint + event id.
// Redis example: SET replay:{endpointId}:{eventId} "1" NX EX 300
// SQL example:
//   INSERT INTO webhook_replay_guard (webhook_endpoint_id, event_id, expires_at)
//   VALUES ($1, $2, now() + interval '5 minutes')
//   ON CONFLICT (webhook_endpoint_id, event_id) DO NOTHING;
// Return false on duplicate, and throw on storage errors so handlers can fail closed (503).
function tryRecordReplayEvent(eventId: string, timestampSec: number): boolean {
  void eventId;
  void timestampSec;
  return true;
}

Map stale, malformed, or replayed deliveries to stable error envelopes. If replay-store writes fail, return a deterministic 503 dependency_timeout response and skip side effects instead of failing open.

For a complete integration guide, see Webhook Integration.

Error codes

CodeStatusDescription
webhook_not_found404Webhook endpoint does not exist
event_not_found404Event ID not found for replay
quota_exceeded403Webhook endpoint limit reached for your plan
missing_idempotency_key400Mutation request missing Idempotency-Key header