# Redaction and stored data

> Which parameters are masked, which keys you declare per API, and which bodies are stored as they came.

Source: https://boring-observability.dev/requizon/docs/redaction
Section: Configuration — Requizon documentation (version 0.3)
Updated: 2026-09-20

---

A recorder for outbound calls sits exactly where your credentials travel: API keys in query strings, passwords in login bodies, tokens in headers. Requizon redacts by parameter name, which means anything it can parse into names it can clean, and anything it cannot it leaves alone. This page lists what a row holds and which rules reach it.

## What a row holds

| Column | Stored |
| --- | --- |
| `api_name`, `host` | As described in [Naming APIs](https://boring-observability.dev/requizon/docs/naming-apis) |
| `method`, `status_code`, `duration_ms` | As they were |
| `path` | Normalised, without the query string. See [Paths](https://boring-observability.dev/requizon/docs/paths) |
| `failure_type`, `failure_message` | For failures only. See [Failure detection](https://boring-observability.dev/requizon/docs/failure-detection) |
| `query_params` | The query string, redacted as below |
| `request_params` | The request body, redacted as below |
| `request_headers`, `response_headers` | By the rules on [Headers](https://boring-observability.dev/requizon/docs/headers), with sensitive names masked |
| `response_body` | For failures only, [redacted where it parses](#response-bodies) |

Headers are recorded under rules of their own, which [Headers](https://boring-observability.dev/requizon/docs/headers) covers in full. Out of the box nothing from the request is stored and the response headers of calls recorded as a failure are, with `Authorization`, `Cookie` and the rest of the sensitive names kept as `***`.

## Query strings and request bodies

The query string and the body are recorded separately, whatever the method. The query goes into `query_params` and the body into `request_params`, so a `POST /v1/charges?expand[]=customer` keeps its `expand`, a `HEAD` or `OPTIONS` keeps the only parameters it has, and a `GET` or `DELETE` that sends a JSON body (search APIs often do) keeps the body. A name that appears in both is kept in both, since which of the two it came from is usually what you opened the row to find out.

The Details panel shows them as **Query** and **Body**. Both go through the same redaction, per-API rules and callback included. The query string is parsed into named parameters; a JSON body (a `Content-Type` containing `json`) and a form body (`application/x-www-form-urlencoded`) are parsed the same way, and anything else is [unparsed](#unparsed).

A query string has no length limit of its own, so `recording.query_max_bytes` (4 KB) caps what is stored. The column holds JSON and JSON cannot be cut part of the way through, so whole parameters that do not fit are dropped and counted under `_truncated`, together with any values `parse_str()` never reached because they sat past PHP's `max_input_vars`:

```json
{"page": "2", "sort": "name", "_truncated": "1 parameter omitted"}
```

Bodies are read under the limits they always were: `recording.max_body_read_bytes` (256 KB) is never read past, only seekable streams are read at all, and a request with no body costs nothing to check.

### Bodies Requizon cannot parse

Redaction works by parameter name, so a body that cannot be parsed into names is a body whose credentials Requizon cannot find: XML, SOAP, `text/plain`, `multipart/form-data` uploads, and anything with no usable `Content-Type`. By default such a body is recorded by shape only:

```json
{ "_unparsed": "application/xml, 1482 bytes" }
```

If you know your unparsed bodies carry nothing sensitive, store them. They are kept verbatim, cut to `recording.request_body_max_bytes` (4 KB):

```php
'recording' => [
    // ...
    'store_unparsed_bodies' => true,   // stored as {"_raw": "<?xml ..."}
],
```

Two kinds of body are never read at all. A body larger than `recording.max_body_read_bytes` (256 KB) is recorded by shape from its declared size, which keeps a file upload from being loaded into memory on every request. A body that cannot be rewound after reading is skipped, and the row has no parameters.

## Redaction by parameter name

Before storage, every parameter's name is lowercased and checked against two lists. A match replaces the value with `***`. Nested arrays are walked, so `credentials.api_secret` is caught as well. The same two lists decide which header names are masked and which values in a failed response body are.

```php
'recording' => [
    // ...

    // Redacted when the name contains one of these: user_password, api_secret_key, auth_token
    'redact_patterns' => ['pass', 'secret', 'token', 'apikey', 'api_key', 'auth'],

    // Redacted on an exact name match, for APIs that abbreviate: ?l=user&p=password
    'redact_exact' => ['p', 'l', 'pwd'],
],
```

```json
{
    "username": "rest@example",
    "password": "***",
    "periodFrom": "12.10.2026",
    "credentials": { "api_secret": "***", "region": "eu" }
}
```

Add the names your own APIs use. When you replace either list in your config, include the defaults you still want: the list you write replaces the default one.

> **Substrings over-redact**
>
> Matching on part of a name is deliberately generous. `pass` also catches `passenger_count` and `auth` catches `author`, and those values are stored as `***`. If a parameter you need to read is being hidden, rename the pattern to something more specific (`password` instead of `pass`) rather than removing it.

### Keys that belong to one API

The names worth hiding are usually one provider's rather than everybody's. Declare them on that API's `apis` entry and they are added to the global lists for that API alone:

```php
'apis' => [
    // Patterns left bare, with the keys beside them
    'nausys' => ['ws.nausys.com', '*.nausys.com', 'redact' => ['l']],

    'stripe' => [
        'hosts' => ['api.stripe.com', 'files.stripe.com'],
        'redact' => ['card_number', 'cvc'],   // substrings, like redact_patterns
    ],

    'sedna' => [
        'hosts' => ['sedna.example.com'],
        'redact_exact' => ['l', 'p'],         // whole names, like redact_exact
    ],
],
```

Both [entry shapes](https://boring-observability.dev/requizon/docs/naming-apis#entry-shapes) can sit in the same list and both can carry the keys. `redact` matches by substring and `redact_exact` by the whole name, the same split as the global lists, and that split is what makes a one-letter key usable: Sedna authenticates with `?l=user&p=pass`, and `l` as a global substring rule would take every parameter with an `l` in its name.

Rules are looked up by the **resolved** API name, so they also cover a name minted by [`Requizon::resolveApiUsing()`](https://boring-observability.dev/requizon/docs/naming-apis#resolver). An entry that carries rules and no `hosts` matches no host of its own; it exists to hold the rules for such a name.

### Redacting with a callback

For a rule no key name can express, such as a value that looks like a card number, a field that is only sensitive on one endpoint, or a nested structure to drop whole, register a callback in a service provider:

```php
use BoringO11y\Requizon\Requizon;
use Psr\Http\Message\RequestInterface;

Requizon::redactUsing(function (array $params, string $api, RequestInterface $request): array {
    if ($api === 'stripe') {
        unset($params['source']);
    }

    return $params;
});
```

It receives what the name rules have already redacted, so it adds to the redaction rather than replacing it. It runs for request parameters and for the parsed body of a failed response, so switch on `$api` rather than assuming a direction. Anything it throws takes the row down with it, which is the safe direction for a failure in redaction.

## The response body of a failed call

The response body of a failed call is stored whole, because it is the thing you opened the row to read. It is redacted by the rules above when it parses as JSON or as a form, and stored as it came when it does not: XML, SOAP, or a body past `recording.response_body_max_bytes`, which is truncated and therefore no longer parses. A parsed body is only re-encoded when something was actually redacted, so a body with nothing to hide keeps the formatting it arrived with.

What that means in practice:

- An API that echoes your request back in its error response has those credentials caught, as long as the response is JSON or a form.
- A SOAP fault or an XML error carrying a password is stored with the password in it, in `requizon_http_requests`.
- A failed call to an API that returns personal data stores that personal data: redaction matches names that look like credentials, and a customer's address is not one of them.
- A message returned by your [failure detector](https://boring-observability.dev/requizon/docs/failure-detection) is stored as-is.

These are the settings that bound it:

- **`retention.detail_days`** decides how long those bodies exist. The default is 14 days; a few days is often enough to debug with. The hourly rollup holds no bodies and is unaffected.
- **`recording.response_body_max_bytes`** caps how much of each body is kept (64 KB).
- **`connection`** can put Requizon's tables on a separate database with its own access controls and backup policy.
- **`ignore_hosts`** keeps an API whose traffic must not be stored anywhere out of the table entirely.

> **Treat requizon_http_requests as sensitive**
>
> Give it the same care as your application logs: restrict who can query it, think about what your backups copy, and keep the `viewRequizon` gate as narrow as the table deserves. The dashboard shows these bodies to anyone the gate lets in.

## Secrets in URLs

The query string is never part of the stored path, and a path segment longer than 40 characters is stored as `:id`, which covers most signed tokens. A short secret placed directly in the path, such as `/hooks/abc123/send`, is stored as written. Use a [path resolver](https://boring-observability.dev/requizon/docs/paths#resolver) to mask it for that host.


## Common questions

### Can Requizon store API keys or passwords?

Parameters are redacted by name, in the query string and in the body alike, so one called api_key or password is stored as ***. Declare a provider's own key names on its apis entry, or register Requizon::redactUsing() for a rule no name can express. What can still hold a credential: an XML or plain-text request body if you enable store_unparsed_bodies, and a failed response body that does not parse as JSON or a form, which is stored as it came. Keep detail_days short and treat requizon_http_requests as sensitive.

### How do I redact a parameter for one API without redacting it everywhere?

Put the key names on that API's entry in the apis map: redact matches by substring, redact_exact by the whole name, and both are added to the global lists for that API alone. It is how a provider that authenticates with ?l=user&p=pass gets l redacted without every other API losing every parameter with an l in it.
