Skip to main content

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​

ParameterNotes
object_typeThe 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_codeNarrows to one record. Pair it with object_type
actor_codeWho performed the action
action_typeWhat happened. See below
start_date, end_dateRFC 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_at is when the change happened; recorded_at is when the entry was written. Filtering and ordering both use occurred_at. They differ by milliseconds in the normal case, and by more when an entry is written from a background worker.
  • actor_type says what kind of actor actor_code names: user a person, system the platform acting on its own (a scheduled job, an inbox poll), api a call made with a machine credential. An entry written without one defaults to user. If you are reconciling "who touched this", the system entries 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_at is set when the change happens. Overlap the window by a small margin anyway if your archive can deduplicate on code, which is unique per entry: it costs nothing and covers the recorded_at lag 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.