> Markdown version of https://authpi.com/docs/guides/webhooks/ — fetch the complete AuthPI docs index at https://authpi.com/llms.txt to discover all available pages.

# Webhooks

Learn how to set up webhooks to receive real-time notifications about events in your AuthPI account.

Webhooks allow you to receive real-time HTTP notifications when events occur in your AuthPI account. Instead of polling the API to check for changes, webhooks push data to your server as things happen.

When a user signs up, logs in, changes their password, or joins an organization, AuthPI can immediately notify your application by sending an HTTP POST request to a URL you specify.

## Why Use Webhooks?

**Real-time updates:** Know immediately when something happens instead of discovering it later through polling.

**Sync external systems:** Keep your database, CRM, analytics, or other systems in sync with user data in AuthPI.

**Trigger workflows:** Automatically send welcome emails, provision resources, notify team members, or update billing systems.

**Audit and compliance:** Log security-relevant events to your own systems for audit trails.

## Creating a Webhook

Webhooks are created through the Core API. API keys authenticate with HTTP Basic auth — the key ID is the username and the secret is the password (curl's `-u` flag builds the header for you). Here's a basic example:

```bash
curl -X POST https://api.authpi.com/v1/accounts/{account_id}/webhooks \
  -u "{key_id}:{key_secret}" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "User Events",
    "url": "https://your-server.com/webhooks/authpi",
    "events": ["user.created", "user.updated", "user.deleted"],
    "auth": {
      "type": "signature",
      "signature_algorithm": "hmac-sha256"
    }
  }'
```

The response includes a one-time plaintext secret:

```json
{
  "id": "wh_abc123xyz",
  "name": "User Events",
  "url": "https://your-server.com/webhooks/authpi",
  "status": "active",
  "events": ["user.created", "user.updated", "user.deleted"],
  "auth": {
    "type": "signature",
    "signature_algorithm": "hmac-sha256",
    "signature_secret_hint": "...xyz789"
  },
  "signature_secret_plain": "whs_a1b2c3d4e5f6g7h8i9j0..."
}
```

**Important:** The `signature_secret_plain` field is only returned once at creation. Store it securely—you won't be able to retrieve it again.

### Subject Filters

By default a webhook receives every subscribed event in your account. To narrow delivery to specific entities, set `subjects` — an array of up to 50 filters matched against each event's subject identifiers:

```bash
curl -X POST https://api.authpi.com/v1/accounts/{account_id}/webhooks \
  -u "{key_id}:{key_secret}" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Acme Org Events",
    "url": "https://your-server.com/webhooks/acme",
    "events": ["organization.membership.created", "organization.membership.deleted"],
    "subjects": [{ "type": "org", "id": "org_abc123" }]
  }'
```

An event is delivered if it matches **any** filter (OR). Within a filter:

| Filter | Matches events where... |
|--------|------------------------|
| `{ "type": "org", "id": "org_abc" }` | the subject's `org_id` equals `org_abc` |
| `{ "type": "org" }` | the subject has an `org_id` (any organization event) |
| `{ "id": "org_abc" }` | any subject identifier equals `org_abc` |

`type` accepts either the entity name (`org`, `user`, `issuer`) or the full subject key (`org_id`, `user_id`, `issuer_id`). Webhooks without `subjects` receive all subscribed events.

## Security Settings

AuthPI supports four authentication modes for webhooks. Choose based on your security requirements.

### No Authentication

```json
{
  "auth": {
    "type": "none"
  }
}
```

No authentication headers are added to webhook requests. Use this only for testing or for endpoints on private networks that have other authentication mechanisms.

**When to use:** Development, testing, or endpoints behind a VPN/private network.

**Risk:** Anyone who discovers your webhook URL can send fake events.

### Bearer Token

```json
{
  "auth": {
    "type": "bearer"
  }
}
```

AuthPI generates a random token and includes it in every request:

```
Authorization: Bearer wht_a1b2c3d4e5f6g7h8...
```

Your server validates that the token matches the one you stored during webhook creation.

**When to use:** Simple authentication where you just need to verify requests come from AuthPI.

**How to validate:**
```javascript
const expectedToken = process.env.AUTHPI_WEBHOOK_TOKEN;
const receivedToken = req.headers.authorization?.replace('Bearer ', '');

if (receivedToken !== expectedToken) {
  return res.status(401).send('Unauthorized');
}
```

### HMAC Signature

```json
{
  "auth": {
    "type": "signature",
    "signature_algorithm": "hmac-sha256"
  }
}
```

AuthPI signs every request with HMAC-SHA256 and includes the signature in a header:

```
authpi-signature: t=1705330496,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

The signature is computed as: `HMAC-SHA256(timestamp.payload, secret)`

**When to use:** When you need to verify both authenticity and payload integrity.

**Advantages over bearer token:**
- Proves the payload wasn't tampered with
- Includes timestamp to prevent replay attacks
- Secret is never transmitted (only used for signing)

### Bearer Token + Signature (Recommended)

```json
{
  "auth": {
    "type": "bearer+signature",
    "signature_algorithm": "hmac-sha256"
  }
}
```

Combines both authentication methods for maximum security. Requests include both headers:

```
Authorization: Bearer wht_a1b2c3d4e5f6g7h8...
authpi-signature: t=1705330496,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

**When to use:** Production environments where security is important.

## Verifying Signatures

When using signature authentication, verify every webhook request to ensure it's authentic and hasn't been tampered with.

### Step 1: Extract the Signature Header

```javascript
const signatureHeader = req.headers['authpi-signature'];
// Format: t=1705330496,v1=5257a869...

const [timestampPart, signaturePart] = signatureHeader.split(',');
const timestamp = parseInt(timestampPart.split('=')[1]);
const signature = signaturePart.split('=')[1];
```

### Step 2: Prevent Replay Attacks

Reject requests with timestamps too far in the past:

```javascript
const currentTime = Math.floor(Date.now() / 1000);
const tolerance = 300; // 5 minutes

if (currentTime - timestamp > tolerance) {
  return res.status(400).send('Request too old');
}
```

### Step 3: Compute Expected Signature

```javascript
const crypto = require('crypto');

const payload = await req.text(); // Raw request body
const signedPayload = `${timestamp}.${payload}`;
const secret = process.env.AUTHPI_WEBHOOK_SECRET;

const expectedSignature = crypto
  .createHmac('sha256', secret)
  .update(signedPayload)
  .digest('hex');
```

### Step 4: Compare Signatures

Use constant-time comparison to prevent timing attacks:

```javascript
const signatureBuffer = Buffer.from(signature, 'hex');
const expectedBuffer = Buffer.from(expectedSignature, 'hex');

if (!crypto.timingSafeEqual(signatureBuffer, expectedBuffer)) {
  return res.status(401).send('Invalid signature');
}

// Signature valid - process the webhook
const event = JSON.parse(payload);
```

### Complete Example (Node.js/Express)

```javascript
const crypto = require('crypto');
const express = require('express');
const app = express();

// Use raw body for signature verification
app.use('/webhooks/authpi', express.raw({ type: 'application/json' }));

app.post('/webhooks/authpi', (req, res) => {
  const secret = process.env.AUTHPI_WEBHOOK_SECRET;
  const signatureHeader = req.headers['authpi-signature'];

  if (!signatureHeader) {
    return res.status(400).send('Missing signature');
  }

  // Parse header
  const parts = signatureHeader.split(',');
  const timestamp = parseInt(parts[0].split('=')[1]);
  const signature = parts[1].split('=')[1];

  // Check timestamp (5 minute tolerance)
  const age = Math.floor(Date.now() / 1000) - timestamp;
  if (age > 300) {
    return res.status(400).send('Request too old');
  }

  // Verify signature
  const payload = req.body.toString();
  const signedPayload = `${timestamp}.${payload}`;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  const sigBuffer = Buffer.from(signature, 'hex');
  const expBuffer = Buffer.from(expected, 'hex');

  if (sigBuffer.length !== expBuffer.length ||
      !crypto.timingSafeEqual(sigBuffer, expBuffer)) {
    return res.status(401).send('Invalid signature');
  }

  // Process the webhook
  const event = JSON.parse(payload);
  console.log('Received event:', event.type, event.id);

  // Acknowledge receipt immediately
  res.status(200).send('OK');

  // Process asynchronously (recommended)
  processWebhookAsync(event);
});
```

## Retry Behavior

AuthPI uses exponential backoff to retry failed webhook deliveries.

Webhook delivery is **at least once**. Your endpoint may receive the same event more than once, especially around retry or timeout boundaries, so use the event `id` for idempotency. Ordering is not guaranteed across events or across different webhook endpoints.

### What Triggers a Retry?

- HTTP response status is not 2xx (e.g., 400, 500, 503)
- Connection timeout (requests must complete within 30 seconds)
- Network error (DNS failure, connection refused, etc.)

### Default Retry Schedule

The first delivery is attempted immediately. If it fails, a retry workflow waits `initial_delay_ms`, then continues with exponential backoff using `backoff_factor`, capped by `max_delay_ms`.

With the default configuration, AuthPI makes up to 40 total delivery attempts over roughly 28 hours:

| Stage | Delay After Previous | Cumulative Time |
|-------|---------------------|-----------------|
| 1st attempt | Immediate | 0 |
| Early retries | 1s, 2s, 4s, 8s, ... | First minutes |
| Capped retries | 1 hour each | Up to ~28 hours |

After the final attempt fails, the delivery is marked permanently failed and is not retried automatically again.

If you update a webhook's URL or authentication settings while retries are pending, future retry attempts use the latest configuration. An attempt that is already in progress may still have used the previous URL or credentials.

### Custom Retry Configuration

You can customize retry behavior when creating or updating a webhook:

```json
{
  "retry": {
    "max_attempts": 5,
    "initial_delay_ms": 2000,
    "backoff_factor": 3,
    "max_delay_ms": 120000
  }
}
```

| Setting | Default | Range | Description |
|---------|---------|-------|-------------|
| `max_attempts` | 40 | 1-100 | Total delivery attempts, including the immediate first attempt |
| `initial_delay_ms` | 1000 | 100-60,000 | First retry delay (ms) |
| `backoff_factor` | 2 | 1-10 | Multiplier applied after each failed attempt |
| `max_delay_ms` | 3600000 | 1,000-3,600,000 | Maximum delay between attempts (1 hour default) |

**Example:** With `max_attempts: 5`, `initial_delay_ms: 2000`, `backoff_factor: 3`, and `max_delay_ms: 120000`: AuthPI makes the immediate first attempt, then up to 4 retries with delays of 2s, 6s, 18s, and 54s before marking the delivery permanently failed.

## Circuit Breaker

The circuit breaker protects both your server and AuthPI from wasted resources when a webhook endpoint is consistently failing.

### How It Works

**Closed (normal):** Deliveries proceed normally. Failures are counted.

**Open (tripped):** After too many consecutive failures, the circuit opens. New events are accepted into the delivery queue but are not attempted until the reset period elapses.

**Half-Open (testing):** After a recovery period, one request is attempted. If successful, the circuit closes. If it fails, it reopens.

### Default Configuration

```json
{
  "circuit_breaker": {
    "failure_threshold": 10,
    "reset_after_ms": 300000
  }
}
```

| Setting | Default | Range | Description |
|---------|---------|-------|-------------|
| `failure_threshold` | 10 | 1-100 | Consecutive failures before opening |
| `reset_after_ms` | 300000 | 1,000-86,400,000 | Time before testing recovery (5 min default) |

### When the Circuit Opens

- Events that occur while the circuit is open are recorded as pending deliveries
- The webhook status remains "active" while retries wait for the reset period
- After the reset period, the circuit transitions to half-open for testing
- A single successful delivery closes the circuit and resumes normal operation

**To manually reset:** Update the webhook or toggle its status to force a reset.

## Event Types

Webhooks can subscribe to over 100 event types. Here are the most common ones:

### User Events

| Event | Description |
|-------|-------------|
| `user.created` | New user account created |
| `user.updated` | User profile or settings changed |
| `user.deleted` | User account deleted |
| `user.blocked` | User was blocked |
| `user.unblocked` | User was unblocked |
| `user.email.verified` | User verified their email address |
| `user.password.changed` | User changed their password |
| `user.password.reset` | User reset their password |

### Session Events

| Event | Description |
|-------|-------------|
| `session.created` | New session created |
| `session.terminated` | Session ended (logout or revocation) |
| `session.expired` | Session expired |

### Organization Events

| Event | Description |
|-------|-------------|
| `organization.created` | New organization created |
| `organization.updated` | Organization settings changed |
| `organization.deleted` | Organization deleted |
| `organization.suspended` | Organization suspended — newly issued tokens stop carrying its claims |
| `organization.reactivated` | Suspended organization reactivated |
| `organization.membership.created` | User added to organization |
| `organization.membership.updated` | Membership role/scopes changed |
| `organization.membership.deleted` | User removed from organization |
| `organization.invitation.created` | Invitation sent |
| `organization.invitation.accepted` | Invitation accepted |
| `organization.invitation.declined` | Invitation declined |

### Client Events

| Event | Description |
|-------|-------------|
| `client.created` | New OAuth client registered |
| `client.updated` | Client configuration changed |
| `client.deleted` | Client deleted |
| `client.secret.rotated` | Client secret was rotated |

### Issuer Events

| Event | Description |
|-------|-------------|
| `issuer.created` | New issuer created |
| `issuer.updated` | Issuer configuration changed |
| `issuer.deleted` | Issuer deleted |

### Webhook Events

| Event | Description |
|-------|-------------|
| `webhook.created` | New webhook created |
| `webhook.updated` | Webhook configuration changed |
| `webhook.deleted` | Webhook deleted |

Subscriptions use exact event names — wildcard patterns are not supported. List each event you need. The API rejects unknown event names and internal-only event types when you create or update a webhook.

## Payload Format

All webhooks are delivered as [CloudEvents 1.0](https://cloudevents.io/) JSON payloads:

```json
{
  "specversion": "1.0",
  "id": "evt_12345678-1234-1234-1234-123456789012",
  "source": "com.authpi.webhooks/v2/wh_abc123xyz",
  "subject": "usr_abcd1234",
  "type": "user.created",
  "datacontenttype": "application/json",
  "time": "2024-01-15T14:22:33.123Z",
  "data": {
    "account_id": "acc_xyz789",
    "issuer_id": "iss_xyz789",
    "user_id": "usr_abcd1234",
    "email": "john@example.com",
    "first_name": "John",
    "last_name": "Doe",
    "verified": false,
    "created_at": 1705330953123
  }
}
```

### CloudEvents Fields

| Field | Description |
|-------|-------------|
| `specversion` | Always `"1.0"` |
| `id` | Unique event ID (use for idempotency) |
| `source` | Identifies the webhook that sent this event |
| `subject` | ID of the entity the event is about (e.g., the org ID for `organization.deleted`). Omitted for events without a subject |
| `type` | Event type (e.g., `user.created`) |
| `datacontenttype` | Always `"application/json"` |
| `time` | ISO 8601 timestamp when the event occurred |
| `data` | Event-specific payload, plus the subject context IDs (`account_id`, `issuer_id`, and `org_id`/`user_id` where applicable) |

### HTTP Request

```
POST https://your-webhook-endpoint.com
Content-Type: application/json
User-Agent: AuthPI-Webhooks/2.0
Authorization: Bearer {token}           # If bearer auth enabled
authpi-signature: t={timestamp},v1={sig} # If signature auth enabled

{CloudEvents JSON payload}
```

## Handling Webhooks

### Respond Quickly

Return a 2xx response within 30 seconds. If your processing takes longer, acknowledge receipt immediately and process asynchronously:

```javascript
app.post('/webhooks/authpi', (req, res) => {
  // Acknowledge immediately
  res.status(200).send('OK');

  // Process in background
  setImmediate(() => {
    processEvent(req.body);
  });
});
```

### Implement Idempotency

Webhooks provide **at-least-once delivery**. The same event may be delivered multiple times (due to retries or network issues). Use the event `id` to deduplicate:

```javascript
async function processEvent(event) {
  // Check if already processed
  const existing = await db.events.findOne({ eventId: event.id });
  if (existing) {
    console.log('Event already processed:', event.id);
    return;
  }

  // Process the event
  await handleEvent(event);

  // Mark as processed
  await db.events.insertOne({
    eventId: event.id,
    processedAt: new Date()
  });
}
```

### Handle Failures Gracefully

If you can't process an event, return a non-2xx status to trigger a retry:

```javascript
app.post('/webhooks/authpi', async (req, res) => {
  try {
    await processEvent(req.body);
    res.status(200).send('OK');
  } catch (error) {
    console.error('Webhook processing failed:', error);
    // Return 500 to trigger retry
    res.status(500).send('Processing failed');
  }
});
```

## Managing Webhooks

### API Endpoints

| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/v2/accounts/{account_id}/webhooks` | Create webhook |
| `GET` | `/v2/accounts/{account_id}/webhooks` | List webhooks |
| `GET` | `/v2/accounts/{account_id}/webhooks/{id}` | Get webhook |
| `PATCH` | `/v2/accounts/{account_id}/webhooks/{id}` | Update webhook |
| `DELETE` | `/v2/accounts/{account_id}/webhooks/{id}` | Delete webhook |
| `GET` | `/v2/accounts/{account_id}/webhooks/{id}/deliveries` | List delivery logs |

### Listing Webhooks

```bash
curl https://api.authpi.com/v1/accounts/{account_id}/webhooks \
  -u "{key_id}:{key_secret}"
```

Supports pagination with `limit` and `cursor` parameters, and filtering by `status`.

### Updating a Webhook

```bash
curl -X PATCH https://api.authpi.com/v1/accounts/{account_id}/webhooks/{webhook_id} \
  -u "{key_id}:{key_secret}" \
  -H "Content-Type: application/json" \
  -d '{
    "events": ["user.created", "user.updated", "user.deleted", "user.verification.succeeded"],
    "status": "active"
  }'
```

### Pausing and Resuming Delivery

Set a webhook's `status` to `disabled` to pause delivery. Pending or failing deliveries stop retrying while the webhook is disabled, and they do not consume retry attempts or become permanently failed just because delivery is paused.

Set `status` back to `active` to resume retrying retained pending and failing deliveries. Delivery logs are retained for 14 days, so paused deliveries older than the retention window may be purged before you re-enable the webhook.

### Rotating Secrets

To rotate your webhook secret, update the auth configuration:

```bash
curl -X PATCH https://api.authpi.com/v1/accounts/{account_id}/webhooks/{webhook_id} \
  -u "{key_id}:{key_secret}" \
  -H "Content-Type: application/json" \
  -d '{
    "auth": {
      "type": "signature",
      "signature_algorithm": "hmac-sha256"
    }
  }'
```

The response includes the new plaintext secret. Update your server immediately—the old secret is invalidated.

Pending retries use the new secret for future attempts. An attempt already in progress may still have been signed with the previous secret.

### Viewing Delivery Logs

Check the delivery history for a webhook:

```bash
curl "https://api.authpi.com/v1/accounts/{account_id}/webhooks/{webhook_id}/deliveries?limit=20" \
  -u "{key_id}:{key_secret}"
```

Filter by `status` (success/failed), `event_type`, or time range (`after`/`before` timestamps).

Delivery logs are retained for 14 days, then automatically purged. Export anything you need for longer-term audit before then.

### Deleting a Webhook

```bash
curl -X DELETE https://api.authpi.com/v1/accounts/{account_id}/webhooks/{webhook_id} \
  -u "{key_id}:{key_secret}"
```

Deleting a webhook stops retry work for that webhook. Pending and failing deliveries are marked failed, and the webhook is soft-deleted before being permanently purged after 31 days.

## Limits

| Resource | Limit |
|----------|-------|
| Webhooks per account | 50 |
| Events per webhook | 200 |
| Subject filters per webhook | 50 |
| Request timeout | 30 seconds |
| Max retry attempts | 100 (configurable) |
| Delivery log retention | 14 days |

## Best Practices

### Security

1. **Use signature authentication** in production—verify every request
2. **Check timestamps** to prevent replay attacks (5-minute tolerance)
3. **Use HTTPS** for your webhook endpoint
4. **Store secrets securely** in environment variables or a secret manager
5. **Rotate secrets regularly** and after any potential exposure

### Reliability

1. **Respond within 30 seconds** to avoid timeouts
2. **Process asynchronously** for long-running tasks
3. **Implement idempotency** using the event ID
4. **Monitor delivery logs** for failures
5. **Configure alerts** when circuit breaker opens

### Performance

1. **Subscribe only to needed events** to reduce traffic
2. **Use subject filters** to narrow down events further
3. **Process in batches** if handling high volume
4. **Don't block on external services** in your webhook handler

## Troubleshooting

### Webhooks Not Arriving

1. Check webhook status is `active`
2. Verify the URL is correct and publicly accessible
3. Check delivery logs for errors
4. Ensure your server returns 2xx status
5. Check if circuit breaker has opened

### Signature Verification Failing

1. Ensure you're using the raw request body (not parsed JSON)
2. Verify the secret matches (check for extra whitespace)
3. Check timestamp format (Unix seconds, not milliseconds)
4. Ensure signature comparison is constant-time

### Too Many Retries

1. Fix the underlying error causing non-2xx responses
2. Increase timeout if processing is slow
3. Return 200 immediately and process asynchronously
4. Check server logs for exceptions

### Circuit Breaker Tripping

1. Check your endpoint health
2. Review recent delivery logs for error patterns
3. Fix the underlying issue
4. Update the webhook to reset the circuit breaker

## Next Steps

- View available [Events](/docs/concepts/events) your webhooks can subscribe to
- Learn about [Users](/docs/concepts/users), [Organizations](/docs/concepts/organizations), and other resources that emit events
- Explore the [Core API reference](/docs/reference/core-api/) for detailed endpoint documentation