External audit events
Record and search create, update, and delete events from third-party systems.
External audit events
The Audit API stores a sequence of events sent by your systems. Each event records who changed which resource, when, and in which source system. Send metadata only: never include before/after values, credentials, or resource content.
Events are append only through the API and have no automatic expiration. Donutwork derives the company from the API key, not the request body. Use a server-side key with external_audit:write to send events and external_audit:read to search them. See authentication and integration fundamentals for key handling and common errors.
Use the versioned base URL https://api.hub.donutwork.com/2026-02-01. Version 2026-02-01 is compatible with the 2024-08-01 implementation.
Send an event
POST /audit-events.json requires external_audit:write and a JSON object no larger than 16 KiB. A new event returns 201 Created.
curl -X POST 'https://api.hub.donutwork.com/2026-02-01/audit-events.json' \
-H "Authorization: Bearer $DONUTWORK_API_KEY" \
-H 'Content-Type: application/json' \
--data '{"event_id":"crm-change-123","source":"crm","action":"update","resource_type":"contact","resource_id":"contact-42","actor_id":"user-7","actor_type":"user","executor_ip":"203.0.113.7","occurred_at":"2026-09-30T12:00:00Z","changed_fields":["status"],"correlation_id":"request-123"}'| Field | Required | Description |
|---|---|---|
event_id | Yes | Stable event ID in the source system, up to 128 UTF-8 bytes. |
source | Yes | Name of the producing system, up to 80 UTF-8 bytes. |
action | Yes | create, update, or delete. |
resource_type | Yes | Resource type, up to 80 UTF-8 bytes. |
resource_id | Yes | Resource ID in the source system, up to 200 UTF-8 bytes. |
actor_id | Yes | Actor ID; use a stable identifier for an automated process, up to 200 UTF-8 bytes. |
occurred_at | Yes | ISO 8601 date and time with timezone, for example 2026-09-30T12:00:00Z. |
actor_type | No | Actor type, up to 80 UTF-8 bytes. |
executor_ip | No | IPv4 or IPv6 address of the executor, asserted by the source system. |
changed_fields | No | Field names only: at most 50 names, each up to 120 UTF-8 bytes. |
correlation_id | No | ID linking events from one operation, up to 128 UTF-8 bytes. |
Do not include company_id: the server uses the company attached to the key. For executor_ip, use an address obtained from the connection or a trusted proxy: Donutwork validates its format but cannot verify that the asserted address belongs to the person who performed the action. Undocumented fields, including before and after, return 400.
The response contains the internal id, the submitted event_id, normalized metadata, and UTC occurred_at and received_at timestamps:
{
"id": "INTERNAL_EVENT_ID",
"event_id": "crm-change-123",
"source": "crm",
"action": "update",
"resource_type": "contact",
"resource_id": "contact-42",
"actor_id": "user-7",
"actor_type": "user",
"executor_ip": "203.0.113.7",
"correlation_id": "request-123",
"changed_fields": ["status"],
"occurred_at": "2026-09-30T12:00:00.000Z",
"received_at": "2026-09-30T12:00:01.000Z"
}Keep the same event_id for retries. source + event_id is unique within a company: replaying identical content returns the stored event with 200 OK; reusing the ID with different content returns 409 Conflict. If the network outcome is uncertain, retry the exact payload, including executor_ip. Responses for older events without this field contain executor_ip: null.
Search events
GET /audit-events.json requires external_audit:read. The response is ordered by descending occurred_at and includes elements, page, size, and total.
curl 'https://api.hub.donutwork.com/2026-02-01/audit-events.json?source=crm&resource_type=contact&resource_id=contact-42&size=50&page=1' \
-H "Authorization: Bearer $DONUTWORK_API_KEY"Text filters use exact matches: source, action, resource_type, resource_id, actor_id, event_id, and correlation_id. from and to constrain occurred_at and accept a UTC YYYY-MM-DD date or an ISO 8601 timestamp with timezone. to=2026-09-30 includes the whole UTC day. page starts at 1 and size ranges from 1 to 100; defaults are 1 and 50.
To read one event, use the internal id returned by POST or search:
curl 'https://api.hub.donutwork.com/2026-02-01/audit-events/INTERNAL_EVENT_ID.json' \
-H "Authorization: Bearer $DONUTWORK_API_KEY"A missing ID or an ID from another company returns 404. Events can also be searched in the backoffice under Developer Tools → External Audit.
Errors and limits
| Status | Meaning |
|---|---|
400 | Invalid JSON, field, action, date, or filter. |
401 / 403 | Missing/invalid key or insufficient scope. |
404 | Event not found in the authenticated company. |
409 | source + event_id reused with different content. |
413 | JSON body exceeds 16 KiB. |
received_at records when Donutwork received the event; occurred_at remains the time declared by the source system. The API does not edit or delete stored events.
Structured endpoint reference
The examples below use the same metadata contract and retry key described above.
Append audit event
external_audit:writePath, query, and header parameters
No path, query, or additional header parameters.
Request Body
{
"event_id": "crm-change-123",
"source": "crm",
"action": "update",
"resource_type": "contact",
"resource_id": "contact-42",
"actor_id": "user-7",
"actor_type": "user",
"executor_ip": "203.0.113.7",
"correlation_id": "request-123",
"changed_fields": [
"status"
],
"occurred_at": "2026-09-30T12:00:00.000Z"
}event_idRequiredsourceRequiredactionRequiredresource_typeRequiredresource_idRequiredactor_idRequiredactor_typeexecutor_ipcorrelation_idchanged_fieldsoccurred_atRequiredResponses
New event.
{
"id": "INTERNAL_EVENT_ID",
"event_id": "crm-change-123",
"source": "crm",
"action": "update",
"resource_type": "contact",
"resource_id": "contact-42",
"actor_id": "user-7",
"actor_type": "user",
"executor_ip": "203.0.113.7",
"correlation_id": "request-123",
"changed_fields": [
"status"
],
"occurred_at": "2026-09-30T12:00:00.000Z",
"received_at": "2026-09-30T12:00:01.000Z"
}Identical replay.
{
"id": "INTERNAL_EVENT_ID",
"event_id": "crm-change-123",
"source": "crm",
"action": "update",
"resource_type": "contact",
"resource_id": "contact-42",
"actor_id": "user-7",
"actor_type": "user",
"executor_ip": "203.0.113.7",
"correlation_id": "request-123",
"changed_fields": [
"status"
],
"occurred_at": "2026-09-30T12:00:00.000Z",
"received_at": "2026-09-30T12:00:01.000Z"
}Different payload for the same source event.
{
"error": "Event ID already used with different content"
}Request examples
curl --request POST 'https://api.hub.donutwork.com/2026-02-01/audit-events.json' \
--header 'Authorization: Bearer YOUR_API_KEY' \
--header 'Content-Type: application/json' \
--data '{
"event_id": "crm-change-123",
"source": "crm",
"action": "update",
"resource_type": "contact",
"resource_id": "contact-42",
"actor_id": "user-7",
"actor_type": "user",
"executor_ip": "203.0.113.7",
"correlation_id": "request-123",
"changed_fields": [
"status"
],
"occurred_at": "2026-09-30T12:00:00.000Z"
}'const response = await fetch('https://api.hub.donutwork.com/2026-02-01/audit-events.json', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.DONUTWORK_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"event_id": "crm-change-123",
"source": "crm",
"action": "update",
"resource_type": "contact",
"resource_id": "contact-42",
"actor_id": "user-7",
"actor_type": "user",
"executor_ip": "203.0.113.7",
"correlation_id": "request-123",
"changed_fields": [
"status"
],
"occurred_at": "2026-09-30T12:00:00.000Z"
}),
});
const requestId = response.headers.get('x-request-id');
if (!response.ok) {
const error = await response.text();
throw new Error(`${response.status} ${error} [${requestId}]`);
}
const contentType = response.headers.get('content-type') ?? '';
const data = response.status === 204 ? null
: contentType.includes('application/json') ? await response.json()
: await response.text();$request = curl_init('https://api.hub.donutwork.com/2026-02-01/audit-events.json');
curl_setopt_array($request, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DONUTWORK_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => '{
"event_id": "crm-change-123",
"source": "crm",
"action": "update",
"resource_type": "contact",
"resource_id": "contact-42",
"actor_id": "user-7",
"actor_type": "user",
"executor_ip": "203.0.113.7",
"correlation_id": "request-123",
"changed_fields": [
"status"
],
"occurred_at": "2026-09-30T12:00:00.000Z"
}',
]);
$response = curl_exec($request);
if ($response === false) {
$error = curl_error($request);
curl_close($request);
throw new RuntimeException($error);
}
$status = curl_getinfo($request, CURLINFO_RESPONSE_CODE);
$contentType = (string) curl_getinfo($request, CURLINFO_CONTENT_TYPE);
curl_close($request);
if ($status >= 400) { throw new RuntimeException($response); }
$data = $status === 204 ? null
: (str_contains($contentType, 'application/json')
? json_decode($response, true, flags: JSON_THROW_ON_ERROR) : $response);Replace uppercase placeholders and sample values with your own data and company resource IDs. Keep the API key on a trusted server. Reuse an idempotency key only for the same logical request and payload.
Search audit events
external_audit:readPath, query, and header parameters
sourceactionresource_typeresource_idactor_idevent_idcorrelation_idfromtopagesizeResponses
Matching events, descending occurred_at.
{
"elements": [
{
"id": "INTERNAL_EVENT_ID",
"event_id": "crm-change-123",
"source": "crm",
"action": "update",
"resource_type": "contact",
"resource_id": "contact-42",
"actor_id": "user-7",
"actor_type": "user",
"executor_ip": "203.0.113.7",
"correlation_id": "request-123",
"changed_fields": [
"status"
],
"occurred_at": "2026-09-30T12:00:00.000Z",
"received_at": "2026-09-30T12:00:01.000Z"
}
],
"page": 1,
"size": 50,
"total": 1
}Request examples
curl --request GET 'https://api.hub.donutwork.com/2026-02-01/audit-events.json' \
--header 'Authorization: Bearer YOUR_API_KEY'const response = await fetch('https://api.hub.donutwork.com/2026-02-01/audit-events.json', {
method: 'GET',
headers: {
Authorization: 'Bearer ' + process.env.DONUTWORK_API_KEY,
},
});
const requestId = response.headers.get('x-request-id');
if (!response.ok) {
const error = await response.text();
throw new Error(`${response.status} ${error} [${requestId}]`);
}
const contentType = response.headers.get('content-type') ?? '';
const data = response.status === 204 ? null
: contentType.includes('application/json') ? await response.json()
: await response.text();$request = curl_init('https://api.hub.donutwork.com/2026-02-01/audit-events.json');
curl_setopt_array($request, [
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DONUTWORK_API_KEY'),
],
]);
$response = curl_exec($request);
if ($response === false) {
$error = curl_error($request);
curl_close($request);
throw new RuntimeException($error);
}
$status = curl_getinfo($request, CURLINFO_RESPONSE_CODE);
$contentType = (string) curl_getinfo($request, CURLINFO_CONTENT_TYPE);
curl_close($request);
if ($status >= 400) { throw new RuntimeException($response); }
$data = $status === 204 ? null
: (str_contains($contentType, 'application/json')
? json_decode($response, true, flags: JSON_THROW_ON_ERROR) : $response);Replace uppercase placeholders and sample values with your own data and company resource IDs. Keep the API key on a trusted server. Reuse an idempotency key only for the same logical request and payload.
Read audit event
external_audit:readPath, query, and header parameters
eventIdRequiredResponses
Stored event.
{
"id": "INTERNAL_EVENT_ID",
"event_id": "crm-change-123",
"source": "crm",
"action": "update",
"resource_type": "contact",
"resource_id": "contact-42",
"actor_id": "user-7",
"actor_type": "user",
"executor_ip": "203.0.113.7",
"correlation_id": "request-123",
"changed_fields": [
"status"
],
"occurred_at": "2026-09-30T12:00:00.000Z",
"received_at": "2026-09-30T12:00:01.000Z"
}Missing event in the authenticated company.
{
"error": "Audit event not found"
}Request examples
curl --request GET 'https://api.hub.donutwork.com/2026-02-01/audit-events/EVENTID.json' \
--header 'Authorization: Bearer YOUR_API_KEY'const response = await fetch('https://api.hub.donutwork.com/2026-02-01/audit-events/EVENTID.json', {
method: 'GET',
headers: {
Authorization: 'Bearer ' + process.env.DONUTWORK_API_KEY,
},
});
const requestId = response.headers.get('x-request-id');
if (!response.ok) {
const error = await response.text();
throw new Error(`${response.status} ${error} [${requestId}]`);
}
const contentType = response.headers.get('content-type') ?? '';
const data = response.status === 204 ? null
: contentType.includes('application/json') ? await response.json()
: await response.text();$request = curl_init('https://api.hub.donutwork.com/2026-02-01/audit-events/EVENTID.json');
curl_setopt_array($request, [
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DONUTWORK_API_KEY'),
],
]);
$response = curl_exec($request);
if ($response === false) {
$error = curl_error($request);
curl_close($request);
throw new RuntimeException($error);
}
$status = curl_getinfo($request, CURLINFO_RESPONSE_CODE);
$contentType = (string) curl_getinfo($request, CURLINFO_CONTENT_TYPE);
curl_close($request);
if ($status >= 400) { throw new RuntimeException($response); }
$data = $status === 204 ? null
: (str_contains($contentType, 'application/json')
? json_decode($response, true, flags: JSON_THROW_ON_ERROR) : $response);Replace uppercase placeholders and sample values with your own data and company resource IDs. Keep the API key on a trusted server. Reuse an idempotency key only for the same logical request and payload.