Audit Logs External API — Authentication & Usage Guide (Beta)
Overview
The Audit Logs External API lets partners and integrations programmatically pull a bot's audit log records from your own systems. You authenticate once with OAuth client credentials, exchange them for a short-lived JWT access token, then use that token to incrementally fetch audit logs using a cursor-based pagination model.
All requests use HTTPS and JSON. The API is read-only and idempotent — designed for incremental polling so you only fetch records that have been created or updated since your last successful call.
Getting Started
Follow these four steps to get up and running:
- Request an OAuth app from the Leena AI team. You will receive a
client_id,client_secret,username, andpassword. Leena AI will share these with you. - Generate an access token by calling
POST https://<region-code>-acl.leena.ai/api/v1.0/oauth/tokenwith your credentials. The issued token must include theaudit-logs:readscope. - Fetch the first page of audit logs by calling
GET https://<audit-logs-host>/external/v1/audit-logs?updatedAt=<ISO-timestamp>with your token. - Page through results using the returned
nextCursoruntilhasMoreisfalse, then persist the highestupdatedAtseen as your new watermark for the next poll.
Region Codes and Endpoints
The OAuth token endpoint and the Audit Logs API endpoint live on different hosts. Use the row matching your bot's region.
| Region | Region Code | Auth URL | Audit Logs URL |
|---|---|---|---|
| us-east-1 | us-east-1 | https://us-east-1-acl.leena.ai | https://us-east-1-auditlogs.leena.ai |
| ap-south-1 | – | https://acl.leena.ai | https://auditlogs.leena.ai |
NoteFor
ap-south-1(the primary region), the host has no region-code prefix. For all other regions, the host is prefixed with the region code as shown above.
Authentication
All endpoints require a Bearer JWT token in the Authorization header. Tokens are obtained via the platform's OAuth token API.
Step 1 — Register an OAuth App
Request an OAuth app from the Leena AI team. You will receive:
| Credential | Description |
|---|---|
client_id | Unique identifier for your OAuth app |
client_secret | Secret key for your OAuth app |
username | System username for the API execution |
password | Password for the system user for the API execution |
The OAuth app must be provisioned with the audit-logs:read scope. Confirm this with the Leena AI team when requesting the app.
Step 2 — Generate an Access Token
Exchange your credentials for a JWT access token.
POST https://<region-code>-acl.leena.ai/api/v1.0/oauth/token
Headers
| Header | Value |
|---|---|
Authorization | Basic auth, with client_id as username and client_secret as password |
Content-Type | application/json |
Request Body
{
"username": "<username-received-from-leena>",
"password": "<password-received-from-leena>",
"grant_type": "password"
}Example Request
curl -X POST "https://<region-code>-acl.leena.ai/api/v1.0/oauth/token" \
-H "Content-Type: application/json" \
-H "Authorization: Basic <Base64-encoded-string-of-clientId:clientSecret>" \
-d '{
"username": "<username-received-from-leena>",
"password": "<password-received-from-leena>",
"grant_type": "password"
}'Response
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "Bearer",
"refresh_token": "<Refresh-token>",
"expires_in": 3600
}Step 3 — Use the Token
Include the token in the Authorization header for all API calls:
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Endpoint
Get Audit Logs
Returns a page of audit log records for your bot, sorted ascending by (updatedAt, _id). Use cursor-based pagination to drain the result set.
GET https://<audit-logs-host>/external/v1/audit-logs
Replace <audit-logs-host> with the value from the Region Codes and Endpoints table for your bot's region.
Headers
| Header | Value |
|---|---|
Authorization | Bearer <access_token> |
Query Parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
updatedAt | ISO-8601 string | Yes (when no cursor) | — | Lower bound for the first page. Records returned have updatedAt > value. |
cursor | string | No | — | Opaque base64 cursor from a prior response. When supplied, overrides updatedAt and continues from the previous page. |
limit | integer | No | 100 | Maximum records per page. Must be between 1 and 1000. |
NoteIf both
updatedAtandcursorare supplied,cursorwins.
Pagination
Records are returned sorted ascending by (updatedAt, _id). The cursor encodes the (updatedAt, _id) of the last returned record so subsequent pages skip past ties at identical timestamps without gaps or duplicates.
Flow:
- First call: pass
updatedAt(e.g. epoch or last successful poll watermark). - Read
nextCursorandhasMorefrom the response. - Next call: pass
nextCursorback ascursor(theupdatedAtparameter is ignored whencursoris set). - Repeat until
hasMoreisfalse(nextCursorwill also benull).
Cursor wire format (do not parse — treat as opaque):
base64({ "updatedAt": "<ISO-8601 datetime>", "_id": "<24-char hex ObjectId>" })
Invalid cursors return 400 Bad Request with message "Invalid cursor".
Rate Limiting
Rate limits are enforced per OAuth client (keyed by the JWT id claim).
| Property | Value |
|---|---|
| Window | 60 seconds |
| Limit | 60 requests / minute (default) |
| Breach response | 429 — "Rate limit exceeded. Please retry after some time." |
Two different clients hitting on behalf of the same bot get independent buckets.
Response — 200 OK
| Field | Type | Description |
|---|---|---|
data | ExternalAuditLog[] | Page of audit log records, sorted ascending by (updatedAt, _id). |
nextCursor | string or null | Pass back as cursor for the next page. null when no more records. |
hasMore | boolean | true if a subsequent call will return at least one record. |
Example — First Page Request
curl --location \
'https://<audit-logs-host>/external/v1/audit-logs?updatedAt=2021-05-11T00%3A00%3A00.000Z&limit=1' \
--header 'Authorization: Bearer <access_token>'Example — Response
{
"data": [
{
"_id": "6a0edb13c17f8e6581707a63",
"auditLogId": "DMS-202302-0000001",
"botId": "68628c943ec15f64a4e9abd9",
"componentId": "dms",
"activity": "upload-file",
"priority": "critical",
"status": "fail",
"message": "Error upload the document",
"generatedAt": "2023-02-14T06:57:25.329Z",
"updatedAt": "2023-03-17T03:28:58.742Z",
"actor": {
"userId": "62971c9eaa0bc914188f62d6",
"userType": "dashboard"
},
"targetUser": {
"userId": "101234578",
"userType": "bot",
"source": "integrations"
},
"resourceId": "63086ae9dd99ed5cb559a325",
"ip": "1.1.1.1",
"metadata": []
}
],
"nextCursor": "eyJ1cGRhdGVkQXQiOiIyMDIzLTAzLTE3VDAzOjI4OjU4Ljc0MloiLCJfaWQiOiI2YTBlZGIxM2MxN2Y4ZTY1ODE3MDdhNjMifQ==",
"hasMore": true
}Example — Next Page (Cursor)
curl --location \
'https://<audit-logs-host>/external/v1/audit-logs?cursor=eyJ1cGRhdGVkQXQiOiIyMDIzLTAzLTE3VDAzOjI4OjU4Ljc0MloiLCJfaWQiOiI2YTBlZGIxM2MxN2Y4ZTY1ODE3MDdhNjMifQ%3D%3D&limit=1' \
--header 'Authorization: Bearer <access_token>'
NoteThe
cursorvalue must be URL-encoded — the trailing==becomes%3D%3D.
ExternalAuditLog Schema
Only the fields below are exposed; internal fields on the underlying record (isDeleted, notifications, expireAt, ingestedAt, flaggerUser, etc.) are stripped before the response leaves the service.
| Field | Type | Required | Description |
|---|---|---|---|
_id | string (24-char hex) | Yes | Mongo ObjectId of the record. |
auditLogId | string | Yes | Human-readable ID, e.g. DMS-202302-0000001. |
botId | string | Yes | Owning bot — matches the JWT's botId. |
componentId | string | Yes | Component that produced the log (dms, case-management, onboarding, …). |
activity | string | Yes | Activity slug (upload-file, create-ticket, …). |
priority | string | Yes | One of critical, high, normal, low. |
status | string | Yes | One of success, in-progress, fail. |
message | string | No | Free-form human-readable message. |
generatedAt | ISO-8601 datetime | Yes | When the audited event occurred. |
updatedAt | ISO-8601 datetime | Yes | Server-side last-modified — drives pagination. |
actor | Actor | Yes | Who initiated the action. See Actor below. |
targetUser | Actor | No | Who the action was performed on (if applicable). |
submoduleId | string | No | Submodule scope inside componentId. |
resourceId | string | No | ID of the resource acted on. |
ip | string | No | Source IP of the actor. |
useragent | string | No | User-agent string of the actor. |
metadata | Metadata array | No | Arbitrary label/value key-value pairs. See Metadata below. |
redirectionLink | string | No | Deep link to the record in the product UI. |
Actor
| Field | Type | Required | Description |
|---|---|---|---|
userId | string | Yes | Actor identifier. |
userType | string | Yes | One of dashboard, bot, none. |
source | string | No | Origin (e.g. integrations). |
email | string | No | Actor email. |
phone | string | No | Actor phone. |
employeeId | string | No | HRIS employee ID. |
Metadata
| Field | Type | Description |
|---|---|---|
label | string | Field label. |
value | string | Stringified value. |
Error Responses
| Status | When | Body |
|---|---|---|
400 Bad Request | cursor cannot be decoded | { "statusCode": 400, "message": "Invalid cursor" } |
400 Bad Request | updatedAt missing while no cursor supplied, or fails ISO-8601 validation | DTO validation error |
400 Bad Request | limit < 1 or > 1000 | DTO validation error |
401 Unauthorized | Missing or non-Bearer Authorization header | { "statusCode": 401, "message": "Missing or invalid Authorization header" } |
401 Unauthorized | Token fails signature verification or is malformed | { "statusCode": 401, "message": "Invalid token" } |
401 Unauthorized | Token expired | { "statusCode": 401, "message": "Token expired" } |
401 Unauthorized | Token does not include the audit-logs:read scope | { "statusCode": 401, "message": "Insufficient OAuth scope" } |
401 Unauthorized | Token has no botId | { "statusCode": 401, "message": "Token is missing botId" } |
429 Too Many Requests | Per-client rate limit exceeded | { "statusCode": 429, "message": "Rate limit exceeded. Please retry after some time." } |
Recommended Polling Pattern
A typical integration follows this flow:
- Store the last successful
updatedAtseen (initialize to epoch on the first run). - On each poll, call with
updatedAt = <stored value>— orcursor = <last nextCursor>if you are mid-page from the previous poll. - Drain pages until
hasMoreisfalse. - Persist the highest
updatedAtfromdataas the new watermark for the next poll. - Respect
429responses — back off and retry after the rate-limit window resets.
Usage Pattern
1. POST https://<region-code>-acl.leena.ai/api/v1.0/oauth/token
→ get access_token
2. GET https://<audit-logs-host>/external/v1/audit-logs?updatedAt=<watermark>
→ read data[], nextCursor, hasMore
3. While hasMore is true:
GET https://<audit-logs-host>/external/v1/audit-logs?cursor=<nextCursor>
→ keep draining pages
4. Persist max(data[].updatedAt) as the new watermark.Updated about 2 hours ago
