# API security and webhooks

Lock API access to your infrastructure, read mandatory audit events, receive Onlo events, and verify webhook signatures against the raw request body.

- **Audience:** Backend engineers and security owners operating an Onlo integration
- **Intent:** Build with Onlo
- **Active work:** 20 minutes

Canonical page: https://onlo.ai/docs/developers/conversations-api/security-webhooks

## Manage API security and webhooks

Dashboard → Integrations → API

## Before you start

- Owner or admin access to Dashboard → Integrations → API
- A public HTTPS endpoint for webhook delivery

## Lock API access to your backend

> **Avoid locking out every caller:** Onlo refuses to enable an empty allowlist. Still, add the production address first and test immediately. If your provider rotates egress IPs, use its fixed-egress feature before enabling this control.

1. **Confirm HTTPS from production.** Keep Require HTTPS enabled and call `/ping` from the same deployed service that will make API requests.
   - **Expected result:** A `200` response proves the request reached Onlo through HTTPS.
2. **Save your public egress IP.** Enter the exact IPv4 or IPv6 address used by your deployed service. Save the list before enabling it.
   - **Expected result:** The dashboard shows the saved address count.
3. **Enable and prove the allowlist.** Enable IP allowlisting, call `/ping` from production, then make the same call from a different public IP.
   - **Expected result:** Production receives `200`; the unlisted source receives `403 ip_not_allowed`.

## Read the mandatory audit trail

Every authenticated `/api/v1` request records its request id, key id, operation, status, outcome, optional denial reason, and safe resource ids. The control is always on and has no dashboard toggle.

### List audit events

```shell
curl --request GET "$ONLO_API_BASE_URL/audit?per_page=50" \
  --header "Authorization: Bearer $ONLO_API_TOKEN" \
  --header "Accept: application/json"
```

### Response

```json
{
  "type": "audit_log.list",
  "audit_logs": [
    {
      "type": "audit_log",
      "id": "603a3cd8-43be-43fa-97ea-181b97d1afdd",
      "request_id": "977fba8b-1b57-45c4-a965-bb62e6eebd1d",
      "api_key_id": "12137dde-394b-4e30-aa72-b5bf5f5b9671",
      "operation": "conversation.search",
      "http_status": 200,
      "outcome": "allowed",
      "denial_reason": null,
      "conversation_id": null,
      "contact_id": "29894c6a-7187-45cc-af77-8c58a9dd1511",
      "created_at": 1787740200
    }
  ],
  "pages": { "type": "pages", "per_page": 50 }
}
```

## Supported webhook events

Add an endpoint in Dashboard → Integrations → API and select at least one event. Onlo accepts only a public HTTPS URL and rejects private, loopback, local-network, and internal DNS destinations.

A delivery times out after 8 seconds. The dashboard counts attempts, records the latest failure, marks repeated failures as failing, and returns to healthy after a successful response. Automatic retries are not yet part of the contract, so make your receiver highly available.

| Event | When it is sent |
| --- | --- |
| `conversation.created` | A new WebChat conversation or Conversations API conversation is created. Idempotent API replays do not send it again. |
| `ticket.created` | A ticket is created through Onlo’s supported ticket flows. |
| `ticket.resolved` | A ticket transitions from an open state to closed. |
| `ai.handoff` | An operator explicitly takes over a conversation. |

### Delivery body

```json
{
  "id": "evt_2ca441b4611333b9edc63c20ea9655fa",
  "type": "conversation.created",
  "created_at": 1787740200,
  "data": {
    "id": "9a4f18f5-93ac-4476-b87f-6af63c700c64",
    "channel": "api",
    "status": "active"
  }
}
```

## Verify the HMAC signature

> **The secret is reveal-once:** Copy the `whsec_…` value when the endpoint is created and store it in your secret manager. Onlo does not show it again. If it is lost or exposed, remove the endpoint and create a replacement.

Signing is enabled by default. Onlo computes lowercase hex HMAC-SHA256 over `<X-Onlo-Timestamp>.<raw request body>` with the endpoint secret, then sends `X-Onlo-Signature: v1=<hex>`. Verify the raw bytes before parsing JSON.

Also record `X-Onlo-Delivery` and reject timestamps older than your replay window. The example uses five minutes.

| Header | Meaning |
| --- | --- |
| `X-Onlo-Event` | Event type, identical to the body’s `type`. |
| `X-Onlo-Delivery` | Unique id for this delivery attempt. |
| `X-Onlo-Timestamp` | Unix epoch seconds used in the signature. Present when signing is enabled. |
| `X-Onlo-Signature` | `v1=` followed by the lowercase hex digest. Present when signing is enabled. |

### Node.js verification

```typescript
import crypto from 'node:crypto';

export function verifyOnloWebhook(rawBody: Buffer, headers: Headers, secret: string) {
  const timestamp = headers.get('x-onlo-timestamp');
  const supplied = headers.get('x-onlo-signature')?.replace(/^v1=/, '');
  if (!timestamp || !supplied || !/^\d+$/.test(timestamp)) return false;

  // Reject replayed deliveries before doing any business work.
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest('hex');
  const a = Buffer.from(supplied, 'hex');
  const b = Buffer.from(expected, 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

## Verify the complete webhook flow

### Expected result

Create a webhook endpoint with signing enabled, then create one API conversation using a new `external_id`.

**Success:** Your endpoint receives one `conversation.created` POST, signature verification succeeds, its `X-Onlo-Delivery` is unique, and the Onlo dashboard increments Deliveries with Healthy status.

**If you do not see this:**

- No request: confirm the endpoint is public HTTPS, selected for `conversation.created`, and still listed in the dashboard.
- Signature mismatch: verify against the exact raw bytes and include `<timestamp>.` before the body. Do not reserialize parsed JSON.
- Degraded or Failing: inspect Last error, return a 2xx within 8 seconds, and trigger a new event. A success returns the endpoint to Healthy.

## Next

- [Migrating from Intercom](https://onlo.ai/docs/developers/conversations-api/migrate-from-intercom): What carries over from your Intercom integration unchanged, the differences that need code changes, and how to move existing conversation history.

## Related pages

- [Conversations API](https://onlo.ai/docs/developers/conversations-api): Create a conversation for one of your users from your own backend, receive its durable Onlo id, set conversation attributes, and search that user’s conversations over REST.
- [Authentication and API keys](https://onlo.ai/docs/developers/conversations-api/authentication): Create the right key scope, send it correctly, rotate it without downtime, and read each authentication failure.
- [Conversations API reference](https://onlo.ai/docs/developers/conversations-api/reference): Every endpoint, request field, response field, limit, and error code for the Conversations REST API.
