BetterIAM
Server API

webhooks

Webhooks push a tenant's audit events to an HTTPS endpoint you run, signed so the endpoint can tell they came from Better IAM.

Webhooks push a tenant's audit events to an HTTPS endpoint you run, signed so the endpoint can tell they came from Better IAM. Every change, sign-in, and denial already produces an audit event; a subscription picks the ones you care about by event name, outcome, and resource, and delivers them through the transactional outbox with retries. Use them to feed a SIEM, alert on privilege elevation (binding:*), or keep another system in sync without polling audit.list. The webhooks guide walks through a full setup, and lifecycle events lists the event names beyond iam:*.

Delivery and signing

A delivery is queued in the same transaction as the event it carries, so a change that rolls back sends nothing and a committed change is never lost. The outbox worker (iam.auth.dispatchOutbox(), see scheduled jobs) sends it as a POST with a JSON body and these headers: X-Better-IAM-Event (the event name), X-Better-IAM-Delivery (the delivery id), X-Better-IAM-Webhook (the subscription id), X-Better-IAM-Timestamp (Unix seconds), and X-Better-IAM-Signature (v1= followed by the hex HMAC-SHA256 of ${timestamp}.${body} under the subscription's secret).

The body carries id (the audit event id), type, tenantId, actorId, resourceId, outcome, and timestamp, plus optional fields such as metadata, impersonatorId, and the event's sequence and hash in the audit chain. It never contains tokens, secrets, or passwords. Verify every request before trusting it:

import { verifyWebhookSignature } from 'better-iam';

const body = await request.text(); // the raw body, before JSON parsing
const valid = verifyWebhookSignature({
  secret: process.env.IAM_WEBHOOK_SECRET!,
  timestamp: request.headers.get('x-better-iam-timestamp')!,
  body,
  signature: request.headers.get('x-better-iam-signature')!,
}); // constant-time; rejects timestamps more than 300 seconds off (toleranceSeconds)

Any non-2xx response, a timeout (events.webhookTimeoutMs, ten seconds by default), or a redirect counts as a failed attempt; redirects are never followed. Failed deliveries retry with exponential backoff from 30 seconds up to one hour, and after authentication.maxDeliveryAttempts (25 by default) they are abandoned with failedAt and lastError. Delivery is at least once, so deduplicate on the body's id, which stays the same across retries and redeliveries. To hand deliveries to your own queue instead of HTTP, set events.deliverWebhook; it receives the already signed request.

Filters and scope

A subscription receives an event only when all of its filters match:

  • events: 1 to 50 name patterns with * and ? wildcards, such as iam:identities:*, binding:*, or auth:signin:fail. ['*'] means every event.
  • outcomes (optional): ['allow'], ['deny'], or both. ['deny'] turns a subscription into a feed of refused operations.
  • resources (optional): 1 to 20 glob patterns of up to 256 characters, matched against the event's resourceId, such as project/* for events about registered projects.

A subscription belongs to one tenant and receives that tenant's events. With scope: 'subtree' it also receives the events of every descendant tenant; only the platform root may create one, because membership in a parent tenant grants nothing in its children. A tenant holds at most 50 subscriptions, and its plan limit for webhooks may be lower.

Methods9
Serveriam.api.webhooks
Clientclient.webhooks
HTTPPOST /api/iam/webhooks/*
MethodWhat it doesAccess
createSubscribes an HTTPS endpoint to the tenant's audit events and returns the signing secret, which is shown only once.Credential
deleteDeletes a subscription and discards the deliveries still waiting to be sent.Credential
getReturns one subscription without its secret.Credential
listLists every subscription of the tenant, without secrets.Credential
listDeliveriesReturns the delivery history of one subscription, newest first, with status, attempt count, and the last error.Credential
pingQueues a synthetic webhook:ping delivery so you can check an endpoint and its signature verification end to end.Credential
redeliverQueues the event behind an earlier delivery again, rebuilt from the audit log and signed with the current secret.Credential
rotateSecretReplaces a subscription's signing secret and returns the new one, which is shown only once.Credential
updateChanges a subscription's URL, event patterns, filters, description, or active flag.Credential

create

Subscribes an HTTPS endpoint to the tenant's audit events and returns the signing secret, which is shown only once.

POST/api/iam/webhooks/create
client.webhooks.create()Credential

Used inLifecycle events,Webhooks,Change safety,Just-in-time elevation,Access lifecycle,Operations recipes

  • Permission: iam:webhooks:create on the tenant, with recent authentication.
  • Audited as: iam:webhooks:create.
  • Errors: INVALID_INPUT for a URL that is not absolute HTTPS, carries credentials or a fragment, or for invalid events, outcomes, or resources; LIMIT_EXCEEDED at 50 subscriptions or the tenant's plan limit; ACCESS_DENIED for scope: 'subtree' unless the caller is the platform root; RECENT_AUTH_REQUIRED when the caller has not authenticated recently or uses a temporary credential; IMPERSONATION_RESTRICTED in a "view as" session.

Store the returned secret (it starts with whsec_) in your endpoint's configuration right away: only a sealed copy is kept, and no call returns it again. Plain HTTP is accepted only for localhost, 127.0.0.1, or [::1] while the deployment itself does not run on HTTPS, which is enough for local development. The subscription starts active.

const { webhook, secret } = await iam.api.webhooks.create(credential, {
  tenantId,
  url: 'https://siem.example.com/hooks/iam',
  events: ['iam:*', 'binding:*', 'auth:signin:fail'],
  outcomes: ['deny'],
  description: 'Security feed',
});
Input

Prop

Type

Returns

Prop

Type

Example HTTP request

Only the required fields are shown; replace each <placeholder>. The response is { "data": … } on success or { "error": { "code", "message" } }.

curl -X POST "$IAM_URL/api/iam/webhooks/create" \
  -H "Authorization: Bearer $BETTER_IAM_TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-Better-IAM: 1" \
  -d '{
  "tenantId": "<tenantId>",
  "url": "<url>",
  "events": [
    "<event>"
  ]
}'
Signature
iam.api.webhooks.create(
  credential: CredentialInput,
  input: {
    tenantId: string;
    url: string;
    events: string[];
    description?: string;
    scope?: 'tenant' | 'subtree';
    outcomes?: ('allow' | 'deny')[];
    resources?: string[];
  },
): Promise<{
  webhook: {
    [key: string]: unknown;
    url: string;
    events: string[];
    description?: string;
    active: boolean;
    scope: 'tenant' | 'subtree';
    outcomes?: ('allow' | 'deny')[];
    resources?: string[];
    createdAt: number;
    updatedAt: number;
    createdBy: string;
    id: string;
    tenantId: string;
    uniqueKey?: string;
  };
  secret: string;
}>

delete

Deletes a subscription and discards the deliveries still waiting to be sent.

POST/api/iam/webhooks/delete
client.webhooks.delete()Credential

Used inWebhooks

  • Permission: iam:webhooks:delete on the webhook, with recent authentication.
  • Audited as: iam:webhooks:delete.
  • Errors: NOT_FOUND when the webhook is not in this tenant.

Delivered and abandoned deliveries stay in storage until the retention sweep removes them. To stop deliveries temporarily and keep the secret, pause the subscription with update instead.

Input

Prop

Type

Returns

Prop

Type

Example HTTP request

Only the required fields are shown; replace each <placeholder>. The response is { "data": … } on success or { "error": { "code", "message" } }.

curl -X POST "$IAM_URL/api/iam/webhooks/delete" \
  -H "Authorization: Bearer $BETTER_IAM_TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-Better-IAM: 1" \
  -d '{
  "tenantId": "<tenantId>",
  "webhookId": "<webhookId>"
}'
Signature
iam.api.webhooks.delete(
  credential: CredentialInput,
  input: { tenantId: string; webhookId: string },
): Promise<{ deleted: boolean }>

get

Returns one subscription without its secret.

POST/api/iam/webhooks/get
client.webhooks.get()Credential

Used inWebhooks

  • Permission: iam:webhooks:read on the webhook.
  • Audited as: iam:webhooks:read.
  • Errors: NOT_FOUND when the webhook is not in this tenant.
Input

Prop

Type

Returns

Prop

Type

Example HTTP request

Only the required fields are shown; replace each <placeholder>. The response is { "data": … } on success or { "error": { "code", "message" } }.

curl -X POST "$IAM_URL/api/iam/webhooks/get" \
  -H "Authorization: Bearer $BETTER_IAM_TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-Better-IAM: 1" \
  -d '{
  "tenantId": "<tenantId>",
  "webhookId": "<webhookId>"
}'
Signature
iam.api.webhooks.get(
  credential: CredentialInput,
  input: { tenantId: string; webhookId: string },
): Promise<{
  [key: string]: unknown;
  url: string;
  events: string[];
  description?: string;
  active: boolean;
  scope: 'tenant' | 'subtree';
  outcomes?: ('allow' | 'deny')[];
  resources?: string[];
  createdAt: number;
  updatedAt: number;
  createdBy: string;
  id: string;
  tenantId: string;
  uniqueKey?: string;
}>

list

Lists every subscription of the tenant, without secrets.

POST/api/iam/webhooks/list
client.webhooks.list()Credential

Used inWebhooks

  • Permission: iam:webhooks:read on the tenant.
  • Audited as: iam:webhooks:read.
Input

Prop

Type

Returns

An array of object.

Prop

Type

Example HTTP request

Only the required fields are shown; replace each <placeholder>. The response is { "data": … } on success or { "error": { "code", "message" } }.

curl -X POST "$IAM_URL/api/iam/webhooks/list" \
  -H "Authorization: Bearer $BETTER_IAM_TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-Better-IAM: 1" \
  -d '{
  "tenantId": "<tenantId>"
}'
Signature
iam.api.webhooks.list(
  credential: CredentialInput,
  input: { tenantId: string },
): Promise<{
  [key: string]: unknown;
  url: string;
  events: string[];
  description?: string;
  active: boolean;
  scope: 'tenant' | 'subtree';
  outcomes?: ('allow' | 'deny')[];
  resources?: string[];
  createdAt: number;
  updatedAt: number;
  createdBy: string;
  id: string;
  tenantId: string;
  uniqueKey?: string;
}[]>

listDeliveries

Returns the delivery history of one subscription, newest first, with status, attempt count, and the last error.

POST/api/iam/webhooks/listDeliveries
client.webhooks.listDeliveries()Credential

Used inWebhooks,Operations recipes

  • Permission: iam:webhooks:read on the webhook.
  • Audited as: iam:webhooks:read.
  • Errors: NOT_FOUND when the webhook is not in this tenant; INVALID_INPUT when limit is outside 1 to 1000.

Each entry has the delivery id, the event name, the audit eventId it carried, createdAt, attempts, deliveredAt, failedAt, lastError, and a status of pending, delivered, or failed. limit defaults to 100. Payloads are never returned. Use it to diagnose a failing endpoint (lastError holds the HTTP status or network error) and to find the deliveries to redeliver after an outage. Finished deliveries stay listed until iam.sweepExpired() removes them after its delivery retention period (30 days by default).

Input

Prop

Type

Returns

An array of object.

Prop

Type

Example HTTP request

Only the required fields are shown; replace each <placeholder>. The response is { "data": … } on success or { "error": { "code", "message" } }.

curl -X POST "$IAM_URL/api/iam/webhooks/listDeliveries" \
  -H "Authorization: Bearer $BETTER_IAM_TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-Better-IAM: 1" \
  -d '{
  "tenantId": "<tenantId>",
  "webhookId": "<webhookId>"
}'
Signature
iam.api.webhooks.listDeliveries(
  credential: CredentialInput,
  input: { tenantId: string; webhookId: string; limit?: number },
): Promise<{
  id: string;
  event: string;
  eventId: string | undefined;
  createdAt: number;
  attempts: number;
  deliveredAt: number | undefined;
  failedAt: number | undefined;
  lastError: string | undefined;
  status: DeliveryStatus;
}[]>

ping

Queues a synthetic webhook:ping delivery so you can check an endpoint and its signature verification end to end.

POST/api/iam/webhooks/ping
client.webhooks.ping()Credential

Used inWebhooks

  • Permission: iam:webhooks:update on the webhook.
  • Audited as: iam:webhooks:update.
  • Errors: INVALID_TRANSITION when the subscription is paused; NOT_FOUND when the webhook is not in this tenant.

The ping is sent whatever the subscription's event patterns are. Its body has type: 'webhook:ping', the caller as actorId, and the webhook id as resourceId. It is not an audit event, so it cannot be redelivered. The call returns the deliveryId; follow it with listDeliveries once the outbox worker has run.

Input

Prop

Type

Returns

Prop

Type

Example HTTP request

Only the required fields are shown; replace each <placeholder>. The response is { "data": … } on success or { "error": { "code", "message" } }.

curl -X POST "$IAM_URL/api/iam/webhooks/ping" \
  -H "Authorization: Bearer $BETTER_IAM_TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-Better-IAM: 1" \
  -d '{
  "tenantId": "<tenantId>",
  "webhookId": "<webhookId>"
}'
Signature
iam.api.webhooks.ping(
  credential: CredentialInput,
  input: { tenantId: string; webhookId: string },
): Promise<{ deliveryId: string }>

redeliver

Queues the event behind an earlier delivery again, rebuilt from the audit log and signed with the current secret.

POST/api/iam/webhooks/redeliver
client.webhooks.redeliver()Credential

Used inWebhooks,Operations recipes

  • Permission: iam:webhooks:update on the webhook.
  • Audited as: iam:webhooks:update.
  • Errors: NOT_FOUND when the delivery does not belong to this webhook, or when its audit event no longer exists (for example after pruneAudit); INVALID_TRANSITION when the subscription is paused or the delivery was a ping.

Use it after an endpoint outage outlasted the retries, or to replay an event your consumer lost. It works on any delivery, whatever its status, and creates a new delivery id, so your endpoint must deduplicate on the body's id (the audit event id, returned here as eventId).

const failed = (await iam.api.webhooks.listDeliveries(credential, { tenantId, webhookId }))
  .filter((delivery) => delivery.status === 'failed');
for (const delivery of failed)
  await iam.api.webhooks.redeliver(credential, { tenantId, webhookId, deliveryId: delivery.id });
Input

Prop

Type

Returns

Prop

Type

Example HTTP request

Only the required fields are shown; replace each <placeholder>. The response is { "data": … } on success or { "error": { "code", "message" } }.

curl -X POST "$IAM_URL/api/iam/webhooks/redeliver" \
  -H "Authorization: Bearer $BETTER_IAM_TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-Better-IAM: 1" \
  -d '{
  "tenantId": "<tenantId>",
  "webhookId": "<webhookId>",
  "deliveryId": "<deliveryId>"
}'
Signature
iam.api.webhooks.redeliver(
  credential: CredentialInput,
  input: { tenantId: string; webhookId: string; deliveryId: string },
): Promise<{ deliveryId: string; eventId: string }>

rotateSecret

Replaces a subscription's signing secret and returns the new one, which is shown only once.

POST/api/iam/webhooks/rotateSecret
client.webhooks.rotateSecret()Credential

Used inWebhooks

  • Permission: iam:webhooks:update on the webhook, with recent authentication.
  • Audited as: iam:webhooks:update.
  • Errors: NOT_FOUND when the webhook is not in this tenant; RECENT_AUTH_REQUIRED; IMPERSONATION_RESTRICTED.

Deliveries are signed when they are sent, not when they are queued, so every delivery sent after this call, including retries of older events, uses the new secret. There is no overlap period: update your endpoint right away, or let it accept either secret while you switch. Rotate when a secret may have leaked or when the person who configured the endpoint leaves.

Input

Prop

Type

Returns

Prop

Type

Example HTTP request

Only the required fields are shown; replace each <placeholder>. The response is { "data": … } on success or { "error": { "code", "message" } }.

curl -X POST "$IAM_URL/api/iam/webhooks/rotateSecret" \
  -H "Authorization: Bearer $BETTER_IAM_TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-Better-IAM: 1" \
  -d '{
  "tenantId": "<tenantId>",
  "webhookId": "<webhookId>"
}'
Signature
iam.api.webhooks.rotateSecret(
  credential: CredentialInput,
  input: { tenantId: string; webhookId: string },
): Promise<{
  webhook: {
    [key: string]: unknown;
    url: string;
    events: string[];
    description?: string;
    active: boolean;
    scope: 'tenant' | 'subtree';
    outcomes?: ('allow' | 'deny')[];
    resources?: string[];
    createdAt: number;
    updatedAt: number;
    createdBy: string;
    id: string;
    tenantId: string;
    uniqueKey?: string;
  };
  secret: string;
}>

update

Changes a subscription's URL, event patterns, filters, description, or active flag.

POST/api/iam/webhooks/update
client.webhooks.update()Credential

Used inWebhooks

  • Permission: iam:webhooks:update on the webhook, with recent authentication.
  • Audited as: iam:webhooks:update.
  • Errors: INVALID_INPUT when nothing is given to change or a value is invalid (the same rules as create); NOT_FOUND; RECENT_AUTH_REQUIRED; IMPERSONATION_RESTRICTED.

Pass null for description, outcomes, or resources to clear it. The scope cannot change after creation. active: false pauses the subscription: new events are not queued for it, and deliveries already queued are dropped rather than sent. Setting active: true resumes it for new events only; use redeliver for anything you still need from the paused period's queued deliveries.

Input

Prop

Type

Returns

Prop

Type

Example HTTP request

Only the required fields are shown; replace each <placeholder>. The response is { "data": … } on success or { "error": { "code", "message" } }.

curl -X POST "$IAM_URL/api/iam/webhooks/update" \
  -H "Authorization: Bearer $BETTER_IAM_TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-Better-IAM: 1" \
  -d '{
  "tenantId": "<tenantId>",
  "webhookId": "<webhookId>"
}'
Signature
iam.api.webhooks.update(
  credential: CredentialInput,
  input: {
    tenantId: string;
    webhookId: string;
    url?: string;
    events?: string[];
    description?: string | null;
    active?: boolean;
    outcomes?: ('allow' | 'deny')[] | null;
    resources?: string[] | null;
  },
): Promise<{
  [key: string]: unknown;
  url: string;
  events: string[];
  description?: string;
  active: boolean;
  scope: 'tenant' | 'subtree';
  outcomes?: ('allow' | 'deny')[];
  resources?: string[];
  createdAt: number;
  updatedAt: number;
  createdBy: string;
  id: string;
  tenantId: string;
  uniqueKey?: string;
}>

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page