Pull audit logs
SpendOne records every state change in an append-only log: who did what, when, on which object, with the before and after values. This is the feed for a SIEM, a compliance archive, or a warehouse table someone queries at period close.
The log is read-only over the API. There is no write endpoint and no delete endpoint, by design.
The three endpoints
GET /api/v1/audit-logs # the whole log, filtered
GET /api/v1/audit-logs/{code} # one entry
GET /api/v1/audit-logs/object/{object_type}/{object_code} # one object's history
Everything shares the standard list contract from API Basics: limit (default 50, capped at 100) and offset, with pagination.total describing the whole filtered set.
Filters
| Parameter | Notes |
|---|---|
object_type | The kind of record. purchase_case, purchase_request, purchase_order, subscription, approval, invoice, booking, credit_card, product, supplier, user, config, accountant_task, personal_access_token, inbound_email, inbound_attachment, mail_forward_suppression, environment, import_job |
object_code | Narrows to one record. Pair it with object_type |
actor_code | Who performed the action |
action_type | What happened. See below |
start_date, end_date | RFC 3339. Both filter on occurred_at |
action_type shares one vocabulary across every object type: the generic CREATED, UPDATED, DELETED, STATUS_CHANGED, CONFIG_CHANGED, ACCESS_DENIED; the workflow APPROVED, REJECTED, ASSIGNED, REASSIGNED, DELEGATED, UNDELEGATED, SUBMITTED, COMMENTED, MATCHED, UNMATCHED, LINKED, UNLINKED, ATTACHMENT_ADDED, ATTACHMENT_REMOVED, EXTRACTED, CORRECTED, VERIFIED, RESTORED, EXPORTED, EXPORT_FAILED, IMPORTED, MAIL_FORWARDED; and the card lifecycle CARD_ISSUED, CARD_REPLACED, CARD_DEACTIVATED, CARD_REACTIVATED, CARD_REVEALED, LIMIT_CHANGED.
The vocabulary is additive like the rest of the API, so treat an unrecognized value as unknown rather than as an error.
What an entry carries
{
"code": "AL-01JD7Z8F3K",
"object_type": "invoice",
"object_code": "INV-1042",
"action_type": "APPROVED",
"actor_type": "user",
"actor_code": "U-1000",
"actor_display_name": "Ben Cole",
"old_values": {"workflow_status": "in_review"},
"new_values": {"workflow_status": "approved"},
"changed_fields": ["workflow_status"],
"metadata": {},
"ip_address": "203.0.113.7",
"user_agent": "Mozilla/5.0 …",
"request_id": "01JD7Z8F3K2M5N8QRT9V4WX6YZ",
"occurred_at": "2026-09-07T14:03:22.481Z",
"recorded_at": "2026-09-07T14:03:22.512Z"
}
Two fields are worth separating before you build on them:
occurred_atis when the change happened;recorded_atis when the entry was written. Filtering and ordering both useoccurred_at. They differ by milliseconds in the normal case, and by more when an entry is written from a background worker.actor_typesays what kind of actoractor_codenames:usera person,systemthe platform acting on its own (a scheduled job, an inbox poll),apia call made with a machine credential. An entry written without one defaults touser. If you are reconciling "who touched this", thesystementries are the ones that have no human behind them.
Pulling incrementally
The list is ordered newest first by occurred_at. For a repeating export, window on occurred_at rather than paging deeper and deeper:
curl -G https://api.spendone.tech/api/v1/audit-logs \
-H "Authorization: Bearer $TOKEN" \
--data-urlencode "start_date=2026-09-07T00:00:00Z" \
--data-urlencode "end_date=2026-09-08T00:00:00Z" \
--data-urlencode "limit=100" \
--data-urlencode "offset=0"
Then page offset through pagination.total for that window, and move the window forward. Two properties make this safe:
- The log is append-only. An entry never changes after it is written, so a window you have already pulled cannot become stale.
- A closed window cannot gain rows retroactively, because
occurred_atis set when the change happens. Overlap the window by a small margin anyway if your archive can deduplicate oncode, which is unique per entry: it costs nothing and covers therecorded_atlag on a worker-written entry.
Back off on 429, honoring Retry-After when it is present. A nightly full-day pull at limit=100 is well inside normal use; a tight loop over the whole history is not.
One object's history
For a support question or an approval dispute, the per-object endpoint is faster than filtering the whole log:
curl https://api.spendone.tech/api/v1/audit-logs/object/invoice/INV-1042 \
-H "Authorization: Bearer $TOKEN"
For a purchase case specifically, GET /purchase-requests/{code}/audit-log.pdf renders the whole trail as a signed-off PDF, which is what auditors usually want rather than JSON.
Permissions
Reading the audit log requires ACCOUNTANT or ADMIN. It is not part of the STANDARD role, and no grantable permission adds it: the role is the gate. Scope your integration's service account accordingly, and treat the log as the sensitive artifact it is.