DonutworkHelp Center
Api ReferenceDeveloper Tools
Donutwork documentation

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"}'
FieldRequiredDescription
event_idYesStable event ID in the source system, up to 128 UTF-8 bytes.
sourceYesName of the producing system, up to 80 UTF-8 bytes.
actionYescreate, update, or delete.
resource_typeYesResource type, up to 80 UTF-8 bytes.
resource_idYesResource ID in the source system, up to 200 UTF-8 bytes.
actor_idYesActor ID; use a stable identifier for an automated process, up to 200 UTF-8 bytes.
occurred_atYesISO 8601 date and time with timezone, for example 2026-09-30T12:00:00Z.
actor_typeNoActor type, up to 80 UTF-8 bytes.
executor_ipNoIPv4 or IPv6 address of the executor, asserted by the source system.
changed_fieldsNoField names only: at most 50 names, each up to 120 UTF-8 bytes.
correlation_idNoID 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

StatusMeaning
400Invalid JSON, field, action, date, or filter.
401 / 403Missing/invalid key or insufficient scope.
404Event not found in the authenticated company.
409source + event_id reused with different content.
413JSON 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

POST
/2026-02-01/audit-events.json
Required permissionexternal_audit:write

Path, query, and header parameters

No path, query, or additional header parameters.

Request Body

JSON
{
  "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_idstringRequired
See the field contract above.
sourcestringRequired
See the field contract above.
actionstringRequired
See the field contract above.
resource_typestringRequired
See the field contract above.
resource_idstringRequired
See the field contract above.
actor_idstringRequired
See the field contract above.
actor_typestring
See the field contract above.
executor_ipstring
Valid IPv4/IPv6 asserted by the source.
correlation_idstring
See the field contract above.
changed_fieldsarray
Field names only, at most 50 entries.
occurred_atstringRequired
ISO 8601 timestamp with timezone.

Responses

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

GET
/2026-02-01/audit-events.json
Required permissionexternal_audit:read

Path, query, and header parameters

sourcestring· query
Exact-match filter.
actionstring· query
Exact-match filter.
resource_typestring· query
Exact-match filter.
resource_idstring· query
Exact-match filter.
actor_idstring· query
Exact-match filter.
event_idstring· query
Exact-match filter.
correlation_idstring· query
Exact-match filter.
fromstring· query
UTC date or ISO 8601 timestamp with timezone.
tostring· query
UTC date or ISO 8601 timestamp with timezone.
pageinteger· query
One-based page, default 1.
sizeinteger· query
Page size 1–100, default 50.

Responses

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

GET
/2026-02-01/audit-events/{eventId}.json
Required permissionexternal_audit:read

Path, query, and header parameters

eventIdstring· pathRequired
Internal event ID returned by append or search.

Responses

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.

On this page