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

# Reviewing a denied transaction

> Read a Tracer DENY or REVIEW decision to find the rule or the limit behind it, retrieve the stored record later, and verify its audit event during an audit.

export const GAuditTrail = ({children}) => <Tooltip headline="Audit trail" tip="A chronological, immutable record of every action and transaction in the system — essential for regulatory compliance and dispute resolution." cta="See glossary" href="/en/glossary">
    {children}
  </Tooltip>;

A validation came back `DENY` or `REVIEW` and someone is asking why. This guide takes you from the decision Tracer returned to a named rule or a named spending limit. From there it reaches the stored record and the <GAuditTrail>audit trail</GAuditTrail> entry you can hand an auditor months later.

**What changes in your operation:** the answer to "why was this blocked?" stops being a log search. Each decision arrives with the identifiers of what produced it. The same record answers by id years later, and its audit event can be checked against the hash chain.

<Tip>
  **Who is this guide for?** Developers integrating the validation call, support and disputes teams answering customer questions, and compliance officers preparing evidence. Steps 1 to 4 need only the response you already have; steps 5 to 8 use the query endpoints.
</Tip>

## Before you start

***

* [ ] Tracer running and reachable, with an API key — see [Getting started](./getting-started.mdx)
* [ ] A `DENY` or `REVIEW` response to work from, or the `validationId` of one
* [ ] Familiarity with what rules and limits do — see the [Rules engine](./rule-engine.mdx) and [Spending limits](./spending-limits.mdx)

All calls below send the API key as `X-API-Key`.

***

## Step 1: Read the decision Tracer returned

***

`POST /v1/validations` answers with the full decision. A new request answers `201`; repeating a `requestId` answers `200` with the decision Tracer already recorded for that key.

```bash theme={null}
TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)

curl -X POST http://localhost:4020/v1/validations \
  -H "X-API-Key: your-secure-api-key" \
  -H "Content-Type: application/json" \
  -d '{
    "requestId": "7c3f1b8e-5a2d-4c6b-9e1f-0a4d8b2c6e30",
    "transactionType": "CARD",
    "subType": "purchase",
    "amount": "1500.00",
    "currency": "BRL",
    "transactionTimestamp": "'"$TS"'",
    "account": {
      "accountId": "550e8400-e29b-41d4-a716-446655440100",
      "type": "checking",
      "status": "active"
    },
    "merchant": {
      "merchantId": "550e8400-e29b-41d4-a716-446655440103",
      "category": "5411",
      "country": "BR",
      "name": "Acme Store"
    },
    "metadata": {
      "channel": "mobile"
    }
  }'
```

A denial produced by a rule looks like this:

```json theme={null}
{
  "validationId": "8f14e45f-ea3b-4c6e-9a1c-2b7d5e0a91c4",
  "requestId": "7c3f1b8e-5a2d-4c6b-9e1f-0a4d8b2c6e30",
  "decision": "DENY",
  "matchedRuleIds": [
    "3c8a5b21-9f4d-4e17-8b6a-1d2e3f405162"
  ],
  "evaluatedRuleIds": [
    "3c8a5b21-9f4d-4e17-8b6a-1d2e3f405162",
    "a6b7c8d9-0e1f-4a2b-9c3d-4e5f60718293"
  ],
  "reason": "Rule matched with DENY action",
  "totalRulesLoaded": 2,
  "truncated": false,
  "limitUsageDetails": [],
  "processingTimeMs": 9.4,
  "evaluatedAt": "2026-07-31T14:05:09.481Z"
}
```

| Field                              | What it tells you                                                                         |
| ---------------------------------- | ----------------------------------------------------------------------------------------- |
| `validationId`                     | The key to this decision. Everything later in this guide starts from it                   |
| `decision`                         | `ALLOW`, `DENY`, or `REVIEW`                                                              |
| `reason`                           | What produced the decision — see [Step 2](#step-2-tell-a-rule-denial-from-a-limit-denial) |
| `matchedRuleIds`                   | Every rule that matched this transaction, whatever action the rule carries                |
| `evaluatedRuleIds`                 | The rules Tracer evaluated for this transaction                                           |
| `limitUsageDetails`                | One entry per spending limit Tracer checked                                               |
| `totalRulesLoaded` / `truncated`   | How many rules were loaded, and whether `MAX_RULES_PER_REQUEST` cut the set               |
| `processingTimeMs` / `evaluatedAt` | How long the evaluation took, and when it ran                                             |

For the full request and response schema, see [Validate a transaction](/en/reference/tracer/validate-transaction).

<Note>
  Store the `validationId` next to your own transaction record. It is the key [Step 5](#step-5-retrieve-the-record-later) takes, and it is also the `resourceId` of the audit event in [Step 7](#step-7-pull-the-audit-event-behind-the-decision).
</Note>

***

## Step 2: Tell a rule denial from a limit denial

***

Read `reason` first. It names what produced the decision, and it tells you which of the next two steps to take.

| `reason`                        | What happened                                                       | Go to                                                                                       |
| ------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| `Rule matched with DENY action` | A rule whose action is `DENY` matched                               | [Step 3](#step-3-name-the-rule)                                                             |
| `limit_exceeded`                | The amount would push a spending limit past its ceiling             | [Step 4](#step-4-name-the-spending-limit)                                                   |
| `No matching rules found`       | No rule matched, so Tracer applied `DEFAULT_DECISION_WHEN_NO_MATCH` | Review that setting on the [environment variables](./tracer-environment-variables.mdx) page |

`reason` carries the same text on `REVIEW` decisions — `Rule matched with REVIEW action` — and Step 3 reads a review the same way it reads a denial.

<Note>
  On a rule denial, `limitUsageDetails` comes back empty because Tracer stops before the limit check — not because no limit applies to the account.
</Note>

***

## Step 3: Name the rule

***

`matchedRuleIds` lists every rule that matched, whatever action each one carries — a `DENY` rule and an `ALLOW` rule can both appear on the same denial. Retrieve each one and read its `action` to find the rule behind the decision:

```http theme={null}
GET /v1/rules/3c8a5b21-9f4d-4e17-8b6a-1d2e3f405162
X-API-Key: {api_key}
```

The rule carries the `name`, `description`, `expression`, `action`, and `scopes` a colleague needs to see why it fired — see [Retrieve a rule](/en/reference/tracer/retrieve-rule).

Compare `matchedRuleIds` with `evaluatedRuleIds` when the question is the opposite one — "why did my rule *not* fire?". A rule absent from `evaluatedRuleIds` was not evaluated for this transaction, so start with its status and its scope rather than its expression.

<Tip>
  A rule deleted since the decision no longer answers on `GET /v1/rules/{id}`. Its history, including who deleted it, stays in the audit trail — see [Audit and compliance](./audit-compliance.mdx).
</Tip>

***

## Step 4: Name the spending limit

***

On a `limit_exceeded` denial, `limitUsageDetails` holds one entry per limit Tracer checked, and the entries marked `"exceeded": true` are the ones the amount would push past:

```json theme={null}
{
  "validationId": "8f14e45f-ea3b-4c6e-9a1c-2b7d5e0a91c4",
  "requestId": "7c3f1b8e-5a2d-4c6b-9e1f-0a4d8b2c6e30",
  "decision": "DENY",
  "matchedRuleIds": [],
  "evaluatedRuleIds": [
    "a6b7c8d9-0e1f-4a2b-9c3d-4e5f60718293"
  ],
  "reason": "limit_exceeded",
  "totalRulesLoaded": 2,
  "truncated": false,
  "limitUsageDetails": [
    {
      "limitId": "b2d4f6a8-1c3e-4507-9b8d-6f0a2c4e6810",
      "limitAmount": "50000",
      "scope": "(segment:2f1c9b7e-40a5-4d18-9c6b-3e7f5a0d1b24,transactionType:CARD)",
      "period": "DAILY",
      "currentUsage": "51500",
      "attemptedAmount": "1500",
      "exceeded": true
    },
    {
      "limitId": "d4f6a8b2-3e5c-4709-8b6d-0a2c4e681012",
      "limitAmount": "400000",
      "scope": "(account:550e8400-e29b-41d4-a716-446655440100)",
      "period": "MONTHLY",
      "currentUsage": "128400",
      "attemptedAmount": "1500",
      "exceeded": false
    }
  ],
  "processingTimeMs": 14.2,
  "evaluatedAt": "2026-07-31T14:05:09.481Z"
}
```

Read the exceeded entry as the arithmetic of the denial: `attemptedAmount` against `limitAmount`, with `currentUsage` reporting what that limit's current period and matched scope would hold if this transaction were allowed. In the entry above, a `1500` purchase would take a `50000` daily cap to `51500`.

A `PER_TRANSACTION` limit keeps no count, so its entry reports `currentUsage` as `0` and `attemptedAmount` against `limitAmount` is the whole comparison.

`GET /v1/limits/{limitId}` gives the limit's current name and configuration — see [Retrieve a limit](/en/reference/tracer/retrieve-limit). The decision record keeps the ceiling, period, and scope as they were when the decision was made, so a limit changed since then does not change what the record says.

For how each period counts and how consumption accumulates, see [Spending limits](./spending-limits.mdx).

***

## Step 5: Retrieve the record later

***

Every decision is stored under its `validationId`:

```http theme={null}
GET /v1/validations/8f14e45f-ea3b-4c6e-9a1c-2b7d5e0a91c4
X-API-Key: {api_key}
```

The record answers with the transaction context Tracer evaluated — `transactionType`, `amount`, `currency`, `transactionTimestamp`, `account`, and the optional `segment`, `portfolio`, `merchant`, and `metadata` — plus the same `decision`, `reason`, `matchedRuleIds`, `evaluatedRuleIds`, and `limitUsageDetails` the original response carried, and a `createdAt`. See [Retrieve a validation](/en/reference/tracer/retrieve-validation).

This is the record to read out in a dispute: it is the input and the outcome in one document, and it does not change when rules or limits change afterwards.

***

## Step 6: Find records when you do not have the id

***

`GET /v1/validations` lists stored decisions, newest first, with cursor pagination:

```http theme={null}
GET /v1/validations?decision=DENY&account_id=550e8400-e29b-41d4-a716-446655440100&start_date=2026-07-01T00:00:00Z&end_date=2026-07-31T23:59:59Z
X-API-Key: {api_key}
```

<Warning>
  **A query with no dates covers the last 90 days, not the full retention period.** Tracer applies that default window only when both `start_date` and `end_date` are absent. Send either one — or both — to reach an older range.
</Warning>

Two filters answer the questions this guide exists for:

* `matched_rule_id={ruleId}` — every stored decision this rule matched
* `exceeded_limit_id={limitId}` — every stored decision this limit stopped

Each result is a summary: `validationId`, `decision`, `reason`, `amount`, `currency`, `transactionType`, `accountId`, `matchedRuleIds`, `exceededLimitIds`, `processingTimeMs`, and `createdAt`. Take the `validationId` of the one you want and retrieve it with Step 5 for the full record. See [List validations](/en/reference/tracer/list-validations) for every filter and the pagination fields.

***

## Step 7: Pull the audit event behind the decision

***

The audit trail records the decision under the `validationId` as the event's `resourceId`:

```http theme={null}
GET /v1/audit-events?resource_type=transaction&resource_id=8f14e45f-ea3b-4c6e-9a1c-2b7d5e0a91c4
X-API-Key: {api_key}
```

```json theme={null}
{
  "auditEvents": [
    {
      "hash": "a3f1e2b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2",
      "previousHash": "b4e2f3a5c6d7e8f9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3",
      "eventId": "1d0a6f52-7b3c-4e8d-9a01-5c2b6e7f8091",
      "eventType": "TRANSACTION_VALIDATED",
      "createdAt": "2026-07-31T14:05:09.492Z",
      "action": "VALIDATE",
      "result": "DENY",
      "resourceId": "8f14e45f-ea3b-4c6e-9a1c-2b7d5e0a91c4",
      "resourceType": "transaction",
      "actor": {
        "actorType": "api_key",
        "id": "tracer-default",
        "name": "",
        "ipAddress": "203.0.113.42"
      },
      "context": {
        "request": {
          "requestId": "7c3f1b8e-5a2d-4c6b-9e1f-0a4d8b2c6e30",
          "transactionType": "CARD",
          "amount": "1500",
          "currency": "BRL"
        },
        "response": {
          "decision": "DENY",
          "reason": "Rule matched with DENY action",
          "matchedRuleIds": [
            "3c8a5b21-9f4d-4e17-8b6a-1d2e3f405162"
          ],
          "evaluatedRuleIds": [
            "3c8a5b21-9f4d-4e17-8b6a-1d2e3f405162",
            "a6b7c8d9-0e1f-4a2b-9c3d-4e5f60718293"
          ],
          "totalRulesLoaded": 2,
          "truncated": false,
          "limitUsageDetails": [],
          "processingTimeMs": 9.4
        }
      }
    }
  ],
  "hasMore": false
}
```

What the audit event adds to the validation record:

| Field              | Why it matters in a review                                                                                                                    |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `actor`            | Who made the call — the identity behind the request, and the client IP                                                                        |
| `result`           | The decision, indexed for filtering, so `result=DENY` scopes a query                                                                          |
| `context.request`  | The request snapshot recorded with the decision                                                                                               |
| `context.response` | The decision, reason, and rule identifiers as they were returned                                                                              |
| `eventId`          | The key for [Step 8](#step-8-verify-the-audit-event-in-an-audit) and for [Retrieve an audit event](/en/reference/tracer/retrieve-audit-event) |

<Warning>
  The 90-day default window applies here too: `GET /v1/audit-events` with neither `start_date` nor `end_date` covers the last 90 days. Send the range you want when the decision is older than that.
</Warning>

The same endpoint carries the lifecycle of rules and limits — who created, activated, or deleted them. See [List audit events](/en/reference/tracer/list-audit-events) for the filters, and [Audit and compliance](./audit-compliance.mdx) for the event types and retention periods.

***

## Step 8: Verify the audit event in an audit

***

Pass the `eventId` to the verification endpoint:

```http theme={null}
GET /v1/audit-events/1d0a6f52-7b3c-4e8d-9a01-5c2b6e7f8091/verify
X-API-Key: {api_key}
```

```json theme={null}
{
  "isValid": true,
  "totalChecked": 12345,
  "message": "Hash chain integrity verified successfully"
}
```

`isValid: true` establishes that every record from the first one through the one you named still matches the hash stored with it. Each also links to the hash of the record before it, so within that span no record was removed, re-ordered, or re-dated. `totalChecked` reports how many records the check covered.

On a failed check, `isValid` is `false`, `message` reports tampering, and `firstInvalidId` carries an internal sequence number for the diverging record. That number is not an audit-event id, so it is not a value to pass to `GET /v1/audit-events/{id}`.

See [Verify an audit event](/en/reference/tracer/verify-audit-event).

<Note>
  Hand over the two together: the retrieved event from Step 7 is the content of the decision, and the verification result is the evidence that the chain holding it is intact. The verification call reports on the chain — it does not return the record.
</Note>

***

## Common pitfalls

***

<Warning>
  **What usually goes wrong in a review:**

  * **"My query for last year came back empty."** A query with no `start_date` and no `end_date` covers the last 90 days. Send the range you want.
  * **"`matchedRuleIds` has three entries and only one denied."** The array holds every rule that matched, whatever action it carries. Retrieve each rule and read its `action` (Step 3).
  * **"`limitUsageDetails` is empty on a denial."** The decision came from a rule, not a limit. Read `reason` (Step 2).
  * **"`currentUsage` is higher than what the customer actually spent."** It is the projected figure, with the attempted amount already added. On an exceeded limit the counter was not incremented, so the stored consumption does not include this transaction.
  * **"The rule that fired no longer exists."** Deleted rules stop answering on `GET /v1/rules/{id}`. Query their lifecycle through `GET /v1/audit-events?resource_type=rule&resource_id={ruleId}`.
</Warning>

### Error codes

| Code            | Status | What to change                                                                              |
| --------------- | ------ | ------------------------------------------------------------------------------------------- |
| `0065`          | 400    | The id in the path is not a UUID                                                            |
| `0432`          | 404    | No stored validation has that `validationId`                                                |
| `0381`          | 404    | No audit event has that `eventId`                                                           |
| `0077`          | 400    | A date is not RFC3339 with a timezone — send `2026-07-01T00:00:00Z`, not `2026-07-01`       |
| `0083`          | 400    | `end_date` falls before `start_date`                                                        |
| `0431`          | 400    | A filter on `GET /v1/validations` carries a value the endpoint does not accept              |
| `0080` / `0331` | 400    | `limit` is above 1000, or is not positive                                                   |
| `0334`          | 400    | `cursor` was sent together with `sort_by` or `sort_order` — the cursor already carries them |

The complete list is in the [Tracer error list](/en/reference/tracer/tracer-error-list).

***

## Quick reference

***

| Step                     | Method | Endpoint                       |
| ------------------------ | ------ | ------------------------------ |
| Validate a transaction   | POST   | `/v1/validations`              |
| Find stored decisions    | GET    | `/v1/validations`              |
| Retrieve one decision    | GET    | `/v1/validations/{id}`         |
| Name a rule              | GET    | `/v1/rules/{id}`               |
| Name a limit             | GET    | `/v1/limits/{id}`              |
| Find audit events        | GET    | `/v1/audit-events`             |
| Retrieve one audit event | GET    | `/v1/audit-events/{id}`        |
| Verify the hash chain    | GET    | `/v1/audit-events/{id}/verify` |
