> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tesouro.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Expense history

> Learn how to review the history of changes made to an expense.

<Info>
  This guide covers the history of expenses. Tesouro also provides [document history for accounts
  receivable](/finops/guides/accounts-receivable/document-history) and [payable
  history](/finops/guides/accounts-payable/payable-history).
</Info>

## Overview

Tesouro keeps a history of every change made to an [expense](/finops/guides/expense-management/index), from the moment the transaction is created to its final approval state. The history records what changed, who changed it, and when.

To read the history of an expense, call [`GET /transactions/{transaction_id}/expense_history`](/finops/reference/openapi/transactions/get-expense-history):

```sh lines theme={null}
curl -X GET 'https://api.sandbox.tesouro.com/finops/v1/transactions/9d1c2b8e-4a7f-4f5e-9d29-0f1b7c3a5e64/expense_history' \
     -H 'X-Finops-Version: 2025-06-23' \
     -H 'X-Organization-Id: ORGANIZATION_ID' \
     -H 'Authorization: Bearer ACCESS_TOKEN'
```

Each entry carries a `timestamp`, an `event_type`, an `event_data` payload whose shape follows the event type, and the fields that identify the actor:

```json expandable lines theme={null}
{
  "data": [
    {
      "id": "59c70641-213c-41fc-9105-5be84f8cfbbc",
      "transaction_id": "9d1c2b8e-4a7f-4f5e-9d29-0f1b7c3a5e64",
      "event_type": "status_changed",
      "event_data": {
        "old_status": "new",
        "new_status": "approve_in_progress"
      },
      "entity_user_id": "c8192600-d792-4f2d-aaf8-cdd123563619",
      "entity_user_first_name": "Mary",
      "entity_user_last_name": "O'Brien",
      "entity_user_role_id": "3f0b1a52-9c47-4f2f-8f1e-2b6d4c8a7e91",
      "entity_user_role_name": "Approver",
      "recorded_by": "user",
      "timestamp": "2026-03-04T09:12:44.118204+00:00"
    }
  ],
  "prev_pagination_token": null,
  "next_pagination_token": "100"
}
```

The history of an expense stays readable after the expense is deleted. Deleting an expense does not delete its history.

### Filtering and paging

| Parameter    | Description                                                                        |
| ------------ | ---------------------------------------------------------------------------------- |
| `event_type` | Return only entries of one [event type](#event-types). Omit to return all of them. |
| `order`      | Sort by `timestamp`, either `desc` (newest first, the default) or `asc`.           |
| `limit`      | Maximum entries to return. Between 1 and 500. Defaults to 100.                     |
| `offset`     | Entries to skip. Defaults to 0.                                                    |

<Warning>
  This endpoint pages by `offset`, not by token. `next_pagination_token` and `prev_pagination_token`
  hold the offset to read next or previous, as a string, and `null` when there is no such page. Send
  that value back in the `offset` query parameter. There is no `pagination_token` parameter on this
  endpoint.
</Warning>

## Roles and permissions

To read an expense's history using an [organization user token](/finops/guides/organizations/users#get-organization-user-token), the user must have a role with the `transaction.read` [permission](/finops/api/concepts/permissions).

If the permission type is `allowed_for_own` (rather than `allowed`), the user can read the history of their own expenses and of the expenses owned by the people who report to them. The returned history still includes the changes that other users made to those expenses.

If a [partner-level token](/finops/guides/authentication/client-credentials) is used, no special permissions are needed.

## Who made the change

Every entry identifies the actor behind the event:

| Field                    | Description                                                                                                  |
| ------------------------ | ------------------------------------------------------------------------------------------------------------ |
| `recorded_by`            | `user` if the event is attributed to an organization user, `system` if it is not.                            |
| `entity_user_id`         | ID of the organization user who made the change, or `null` in `system` entries.                              |
| `entity_user_first_name` | First name of that organization user.                                                                        |
| `entity_user_last_name`  | Last name of that organization user.                                                                         |
| `entity_user_role_id`    | ID of the [role](/finops/api/concepts/permissions) that the organization user held at the time of the event. |
| `entity_user_role_name`  | Name of that role.                                                                                           |

The name and role values are captured when the entry is written and are never recalculated, so an entry shows the name and role that the user held at the time of the event, not the ones they hold today. All five are `null` in `system` entries, and the name and role fields are also `null` on older entries where they were not captured. Use `recorded_by` to tell those two cases apart.

## Event types

<Info>
  `event_type` is an open vocabulary and it grows over time. Treat it as a string, and let your
  integration ignore a type it does not recognize rather than fail on it.
</Info>

| Event type                                          | Description                                                    |
| --------------------------------------------------- | -------------------------------------------------------------- |
| [`expense_created`](#expense_created)               | The expense was created.                                       |
| [`expense_updated`](#expense_updated)               | One or more fields of the expense changed.                     |
| [`submitted`](#submitted)                           | The expense was submitted for approval.                        |
| [`approval_requested`](#approval_requested)         | An approval policy matched and approval was requested.         |
| [`approval_action`](#approval_action)               | An approver approved or rejected the expense.                  |
| [`auto_approved`](#auto_approved)                   | A policy approved the expense with no human action.            |
| [`approvals_bypassed`](#approvals_bypassed)         | A force approval cancelled the pending approvals.              |
| [`status_changed`](#status_changed)                 | The expense status changed, with no more specific type to use. |
| [`reopened`](#reopened)                             | A decided expense was reopened.                                |
| [`canceled`](#canceled)                             | The expense was canceled.                                      |
| [`deleted`](#deleted)                               | The expense was deleted.                                       |
| [`receipt_matched`](#receipt_matched)               | A receipt was linked to the expense.                           |
| [`receipt_unlinked`](#receipt_unlinked)             | A receipt was unlinked from the expense.                       |
| [`auto_categorized`](#auto_categorized)             | Enrichment set the ledger account or the description.          |
| [`payment_status_changed`](#payment_status_changed) | The payment behind the expense changed status.                 |

The expense status values are `new`, `approve_in_progress`, `approved`, `rejected`, and `canceled`.

### `expense_created`

| Field            | Description                                                                                                    |
| ---------------- | -------------------------------------------------------------------------------------------------------------- |
| `expense_status` | The expense status the entry was created with.                                                                 |
| `created_via`    | `feed` when a partner service created it with no organization user, `manual` otherwise.                        |
| `entity_user_id` | The owner assigned at creation, or `null`.                                                                     |
| `location_id`    | Location defaulted from the owner's profile, or `null`.                                                        |
| `department_id`  | Department defaulted from the owner's profile, or `null`.                                                      |
| `source_of_data` | Per-field source tracking as it stood at creation. Maps a field name to `user`, `generated`, or `automatched`. |

```json lines theme={null}
{
  "event_type": "expense_created",
  "event_data": {
    "expense_status": "new",
    "created_via": "feed",
    "entity_user_id": "c8192600-d792-4f2d-aaf8-cdd123563619",
    "location_id": null,
    "department_id": null,
    "source_of_data": { "receipt_id": "automatched" }
  }
}
```

### `expense_updated`

`event_data.fields` maps each changed field name to the change made to it. An entry with no tracked field changed carries an empty map.

| Field       | Description                                                                     |
| ----------- | ------------------------------------------------------------------------------- |
| `old`       | The value before the change.                                                    |
| `new`       | The value after the change.                                                     |
| `old_label` | A human-readable label for `old`, when the field holds an ID. Otherwise `null`. |
| `new_label` | A human-readable label for `new`, when the field holds an ID. Otherwise `null`. |
| `redacted`  | `true` when the values are withheld.                                            |

```json lines theme={null}
{
  "event_type": "expense_updated",
  "event_data": {
    "fields": {
      "description": { "old": "Taxi", "new": "Taxi to airport" },
      "ledger_account_id": {
        "old": "5e2b8f11-6d3a-4a91-9f0c-77c2a1b4de83",
        "new": "b4f7d0a9-2c15-4e6b-8a33-1d9e5c07fb24",
        "old_label": "Travel",
        "new_label": "Travel: air"
      }
    }
  }
}
```

### `submitted`

| Field                       | Description                                                |
| --------------------------- | ---------------------------------------------------------- |
| `old_status`                | The expense status before submission.                      |
| `new_status`                | The expense status after submission.                       |
| `bypass_receipt_validation` | Whether the submission bypassed receipt validation.        |
| `no_receipt_reason`         | Reason given when bypassing receipt validation, or `null`. |

```json lines theme={null}
{
  "event_type": "submitted",
  "event_data": {
    "old_status": "new",
    "new_status": "approve_in_progress",
    "bypass_receipt_validation": true,
    "no_receipt_reason": "Receipt lost"
  }
}
```

### `approval_requested`

| Field                     | Description                                    |
| ------------------------- | ---------------------------------------------- |
| `approval_policy_id`      | The policy that matched.                       |
| `approval_policy_name`    | Policy name as it read at the time, or `null`. |
| `approval_type`           | The policy's approval type, or `null`.         |
| `required_approval_count` | Approvals the policy requires, or `null`.      |
| `approver_ids`            | Designated approvers, if the policy names any. |

```json lines theme={null}
{
  "event_type": "approval_requested",
  "event_data": {
    "approval_policy_id": "a1d94f7c-3b62-4e80-9c1a-5f8e2d7b6034",
    "approval_policy_name": "Over 500 USD",
    "approval_type": "any",
    "required_approval_count": 2,
    "approver_ids": ["c8192600-d792-4f2d-aaf8-cdd123563619"]
  }
}
```

### `approval_action`

An approver acted on the expense. See [Record an approval action](#record-an-approval-action) for how these entries are written.

| Field                        | Description                              |
| ---------------------------- | ---------------------------------------- |
| `action`                     | `approved` or `rejected`.                |
| `approval_source`            | `policy` for a policy-driven action.     |
| `acted_at`                   | When the action was performed.           |
| `reject_reason`              | Reason for rejection, when there is one. |
| `approval_request_id`        | The approval request behind the action.  |
| `approval_policy_id`         | The policy behind the action.            |
| `approval_policy_name`       | Policy name as it read at the time.      |
| `approval_policy_process_id` | The policy process behind the action.    |

```json lines theme={null}
{
  "event_type": "approval_action",
  "event_data": {
    "action": "approved",
    "approval_source": "policy",
    "acted_at": "2026-03-04T09:20:11+00:00",
    "approval_policy_id": "a1d94f7c-3b62-4e80-9c1a-5f8e2d7b6034",
    "approval_policy_name": "Over 500 USD"
  }
}
```

### `auto_approved`

| Field                  | Description                                    |
| ---------------------- | ---------------------------------------------- |
| `acted_at`             | When the auto-approval happened.               |
| `approval_policy_id`   | The policy that auto-approved, or `null`.      |
| `approval_policy_name` | Policy name as it read at the time, or `null`. |

```json lines theme={null}
{
  "event_type": "auto_approved",
  "event_data": {
    "acted_at": "2026-03-04T09:12:45+00:00",
    "approval_policy_id": "a1d94f7c-3b62-4e80-9c1a-5f8e2d7b6034",
    "approval_policy_name": "Under 50 USD"
  }
}
```

### `approvals_bypassed`

An administrator force approved the expense, which cancelled the approvals that were still pending.

| Field                   | Description                                                                          |
| ----------------------- | ------------------------------------------------------------------------------------ |
| `bypassed_approver_ids` | The approvers whose pending approval was cancelled.                                  |
| `bypassed_approvers`    | The same approvers with the names they had at the time. Same order as the IDs above. |
| `event_id`              | The producer's identifier for the force approval.                                    |

`bypassed_approver_ids` is the audit fact and is always present. `bypassed_approvers` is a display convenience and is empty when the names could not be resolved.

```json lines theme={null}
{
  "event_type": "approvals_bypassed",
  "event_data": {
    "bypassed_approver_ids": ["c8192600-d792-4f2d-aaf8-cdd123563619"],
    "bypassed_approvers": [
      {
        "id": "c8192600-d792-4f2d-aaf8-cdd123563619",
        "first_name": "Mary",
        "last_name": "O'Brien"
      }
    ],
    "event_id": "0f6a4c31-b2d8-4e57-9a10-83bf6c2d5471"
  }
}
```

### `status_changed`

The fallback for a status transition that has no more specific event type.

| Field                        | Description                                                           |
| ---------------------------- | --------------------------------------------------------------------- |
| `old_status`                 | The expense status before the transition. `null` on migrated entries. |
| `new_status`                 | The expense status after the transition.                              |
| `approval_source`            | `policy` when an approval policy drove the change, otherwise `force`. |
| `reject_reason`              | Reason for rejection, when there is one.                              |
| `approval_request_id`        | The approval request behind the change.                               |
| `approval_policy_id`         | The policy behind the change.                                         |
| `approval_policy_process_id` | The policy process behind the change.                                 |
| `approval_policy_name`       | Policy name as it read at the time.                                   |
| `migrated_action`            | Legacy action value carried over when the history table was migrated. |

```json lines theme={null}
{
  "event_type": "status_changed",
  "event_data": {
    "old_status": "approve_in_progress",
    "new_status": "approved",
    "approval_source": "force"
  }
}
```

### `reopened`

A decided expense was reopened. The payload is a plain status transition and reads with the same fields as [`status_changed`](#status_changed).

```json lines theme={null}
{
  "event_type": "reopened",
  "event_data": {
    "old_status": "rejected",
    "new_status": "new"
  }
}
```

### `canceled`

The expense was canceled. The payload is a plain status transition and reads with the same fields as [`status_changed`](#status_changed).

```json lines theme={null}
{
  "event_type": "canceled",
  "event_data": {
    "old_status": "new",
    "new_status": "canceled"
  }
}
```

### `deleted`

The expense was deleted. Its history stays readable.

| Field        | Description                                  |
| ------------ | -------------------------------------------- |
| `old_status` | The expense status at the point of deletion. |

```json lines theme={null}
{
  "event_type": "deleted",
  "event_data": {
    "old_status": "approved"
  }
}
```

### `receipt_matched`

| Field           | Description                                                             |
| --------------- | ----------------------------------------------------------------------- |
| `receipt_id`    | The receipt linked to the expense.                                      |
| `link_source`   | `user` for a link made in the UI, `automatched` for an automatic match. |
| `document_id`   | Receipt document ID, captured for display.                              |
| `merchant_name` | Merchant name, captured for display.                                    |

```json lines theme={null}
{
  "event_type": "receipt_matched",
  "event_data": {
    "receipt_id": "7c4e9a02-51d6-4b83-9e27-6a0f3b8d1c45",
    "link_source": "automatched",
    "document_id": "RCPT-000241",
    "merchant_name": "The Smith Restaurant"
  }
}
```

### `receipt_unlinked`

| Field        | Description                    |
| ------------ | ------------------------------ |
| `receipt_id` | The receipt that was unlinked. |

```json lines theme={null}
{
  "event_type": "receipt_unlinked",
  "event_data": {
    "receipt_id": "7c4e9a02-51d6-4b83-9e27-6a0f3b8d1c45"
  }
}
```

### `auto_categorized`

| Field               | Description                                        |
| ------------------- | -------------------------------------------------- |
| `source`            | How the values were set. Always `generated` today. |
| `ledger_account_id` | Ledger account the enrichment set, or `null`.      |
| `description`       | Memo the enrichment generated, or `null`.          |

```json lines theme={null}
{
  "event_type": "auto_categorized",
  "event_data": {
    "source": "generated",
    "ledger_account_id": "b4f7d0a9-2c15-4e6b-8a33-1d9e5c07fb24",
    "description": "Coffee with client"
  }
}
```

### `payment_status_changed`

The card payment behind the expense changed status. This is the `payment_processing_status` vocabulary, not the expense one: `created`, `processing`, `succeeded`, `declined`, `refunded`, and `failed`.

| Field        | Description                                      |
| ------------ | ------------------------------------------------ |
| `old_status` | Payment status before the transition, or `null`. |
| `new_status` | Payment status after the transition.             |

```json lines theme={null}
{
  "event_type": "payment_status_changed",
  "event_data": {
    "old_status": "processing",
    "new_status": "succeeded"
  }
}
```

## Record an approval action

Approval services record their decisions by calling [`POST /transactions/{transaction_id}/expense_history`](/finops/reference/openapi/transactions/record-approval-action). The call appends one [`approval_action`](#approval_action) entry.

The user must have a role with the `transaction.approve` permission.

`action` and `acted_at` are required. Everything else is optional, and a field you omit is left out of the stored payload rather than stored as `null`.

```sh expandable lines theme={null}
curl -X POST 'https://api.sandbox.tesouro.com/finops/v1/transactions/9d1c2b8e-4a7f-4f5e-9d29-0f1b7c3a5e64/expense_history' \
     -H 'X-Finops-Version: 2025-06-23' \
     -H 'X-Organization-Id: ORGANIZATION_ID' \
     -H 'Authorization: Bearer ACCESS_TOKEN' \
     -H 'Content-Type: application/json' \
     -d '{
       "action": "approved",
       "acted_at": "2026-03-04T09:20:11Z",
       "entity_user_id": "c8192600-d792-4f2d-aaf8-cdd123563619",
       "approval_source": "policy",
       "approval_policy_id": "a1d94f7c-3b62-4e80-9c1a-5f8e2d7b6034",
       "approval_policy_name": "Over 500 USD"
     }'
```

The successful `201` response confirms the entry was recorded:

```json lines theme={null}
{
  "status": "created"
}
```

Two details are worth knowing. The entry's `timestamp` is the `acted_at` you send, not the moment of the call, so a decision recorded late still lands in the right place in the timeline. And a repeated call for the same policy auto-approval is a no-op that still answers `201`, so a retry does not create a second entry.

## See also

* [Expense lifecycle](/finops/guides/expense-management/lifecycle)
* [Document history for receivables](/finops/guides/accounts-receivable/document-history)
* [Payable history](/finops/guides/accounts-payable/payable-history)
