# PII payloads

Version 3 webhooks choose how much personal data each delivery carries: IDs only, masked, or full (`payload_detail`). The choice applies to every event type the webhook subscribes to.

Set the level in the dashboard under **What each delivery carries** (**Level of detail**), or with `POST /webhooks` and `PATCH /webhooks/{webhook_id}`. See [Set up in the dashboard](https://cademi.dev/webhooks/dashboard.md) and [API webhooks](https://cademi.dev/api/webhooks.md#payload-detail-level).

Payload versions 2 and 1 always send names and e-mails. They have no `payload_detail`. See [Payload version 2](https://cademi.dev/webhooks/v2.md) and [Payload version 1](https://cademi.dev/webhooks/v1.md).

## Levels

| Level | Dashboard label | `data` | `expanded` | Personal data |
|---|---|---|---|---|
| `ids` | IDs only, no personal data | Yes. People are identified by public ID | Not included | None stored by Cademí, except the `reason` noted below |
| `masked` | With personal data masked | Yes | Yes, with personal fields masked | Masked personal data |
| `full` | Full, with personal data | Yes | Yes, with values as stored | Full personal data |

At every level, the `reason` an administrator writes in `certificate.revoked` and `diamond_membership.stage_changed` is sent as written, in `data`.

Masked personal data is still personal data: handle it as such. A masked value may not match the `format` declared for its field. For example, a masked e-mail is not a valid address.

## What `expanded` carries

`expanded` holds the resources the event refers to, such as `user`, `product`, or `lesson`, in the same format the API returns them. It is built when the delivery is created, so every retry of that delivery sends the same objects.

Every key of the event type is always present. A key is `null` when the object is not available, for example because the resource was deleted. The event is still delivered. An event type with no expandable resource sends `expanded: {}`. Each event page in the [Webhooks v3 reference](https://cademi.dev/webhooks/reference.md) lists the keys.

These keys can carry personal data:

| Key | Personal fields |
|---|---|
| `user` | `name`, `email`, `document`, `phone`, `external_id` |
| `ticket` | `subject`, `user.name`, `user.email`, and the `anonymous` contact (`name`, `email`, `document`, `whatsapp`) |
| `comment` | `user.name`, `text` |
| `question` | `user.name`, `body` |
| `certificate` | `fields`: the document and address recorded at issue, and the values of custom fields |
| `term_acceptance` | `proof.ip`, `proof.user_agent` |

`product`, `lesson`, `enrollment`, `exam`, and `exam_attempt` use the listing format of the API and do not add those personal fields.

## Example: `user.created`

The three bodies are the same event. `data` is always `{ "id": "usr_42" }`: the event payload does not change with `payload_detail`. Name, e-mail, document, phone, and `external_id` are not in `data`. They appear only in `expanded.user` at `masked` and `full`.

### `ids`

```json
{
  "id": "evt_01J8Z3TESTE",
  "type": "user.created",
  "version": 1,
  "occurred_at": "2026-09-29T12:00:00Z",
  "data": {
    "id": "usr_42"
  },
  "delivery_id": "whd_01J8Z3TESTE",
  "attempt": 1
}
```

### `masked`

```json
{
  "id": "evt_01J8Z3TESTE",
  "type": "user.created",
  "version": 1,
  "occurred_at": "2026-09-29T12:00:00Z",
  "data": {
    "id": "usr_42"
  },
  "expanded": {
    "user": {
      "object": "user",
      "id": "usr_42",
      "external_id": "[redacted]",
      "name": "Maria S** L**",
      "email": "ma**@e**.com",
      "status": "active",
      "access": "granted",
      "created_at": "2026-09-29T12:00:00.000Z",
      "updated_at": "2026-09-29T12:00:00.000Z",
      "deleted": false,
      "tags": ["tag_42"],
      "revision": "2026-09-29 12:00:00",
      "document": "***.***.***-09",
      "phone": "(11) *****5678"
    }
  },
  "delivery_id": "whd_01J8Z3TESTE",
  "attempt": 1
}
```

### `full`

```json
{
  "id": "evt_01J8Z3TESTE",
  "type": "user.created",
  "version": 1,
  "occurred_at": "2026-09-29T12:00:00Z",
  "data": {
    "id": "usr_42"
  },
  "expanded": {
    "user": {
      "object": "user",
      "id": "usr_42",
      "external_id": "crm-1042",
      "name": "Maria Souza Lima",
      "email": "maria.souza@exemplo.com",
      "status": "active",
      "access": "granted",
      "created_at": "2026-09-29T12:00:00.000Z",
      "updated_at": "2026-09-29T12:00:00.000Z",
      "deleted": false,
      "tags": ["tag_42"],
      "revision": "2026-09-29 12:00:00",
      "document": "123.456.789-09",
      "phone": "+55 11 91234-5678"
    }
  },
  "delivery_id": "whd_01J8Z3TESTE",
  "attempt": 1
}
```

The dashboard **Delivery example** shows the body at the level you selected, with sample values.

## Masking rules

In `masked`, each personal field keeps its type and is masked by a fixed rule, so the same value is always masked the same way:

| Field | Masked as | Example |
|---|---|---|
| Name | The first word is kept; every other word becomes its first letter followed by `**` | `Renan C** P**` |
| Email | The first two characters of the user part (one, if it has two or fewer) and the first letter of the first domain label, each followed by `**`, then the other domain labels | `re**@g**.com` |
| Phone, 10 or more digits | The area code in parentheses, `*****`, and the last four digits. A leading `55` in a 12- or 13-digit number is dropped first | `(13) *****8923` |
| Phone, 4 to 9 digits | `*****` and the last four digits | |
| Phone, fewer than 4 digits | `****` | |
| Document | Only the last two digits are kept. An 11-digit document (CPF) becomes `***.***.***-` and those digits; any other length gets one `*` for each other digit | `***.***.***-09` |
| `external_id`, the IP address and user agent of a legal term acceptance, the address and custom field values recorded in a certificate, and free text written by users (comment text, question body, ticket subject) | Fully replaced | `[redacted]` |

`null` stays `null`, empty text stays empty, a name made only of spaces is sent as is, an e-mail without `@` is masked as a user part only, and a document without digits becomes empty.

## Permission for `full`

Setting the level to `full`, or changing the event types of a webhook whose level is `full`, requires `users.read_personal`. It also requires `tickets.read_personal` when a subscribed event type expands a `ticket` (the `ticket.*` events), and `legal_terms.read_proof` when one expands a `term_acceptance` (`term_acceptance.created`). Without a permission the change requires, the API returns `403 personal_data_permission_required` and the dashboard refuses the save.

The permissions that keep `full` in effect are those of whoever created the webhook: the API credentials or the dashboard administrator. Editing the webhook later does not change this. When the creator loses one of the permissions `full` requires, new deliveries are sent as `masked` until the permission is restored. `GET /webhooks/{webhook_id}` returns the level new deliveries use in `health.effective_payload_detail`. The dashboard shows that deliveries are sent masked until the permission is restored.

A test delivery uses that same effective level.

## Deliveries already queued

Each delivery keeps the `expanded` object it was built with, and sends it only while the webhook still accepts that level:

- If the level goes down, by an edit or by the fallback to `masked` described above, queued deliveries built at a higher level are sent without `expanded`.
- If the level goes up, queued deliveries keep what they were built with: a delivery built at `masked` stays masked, and a delivery built at `ids` has no `expanded`.

`GET /webhooks/{webhook_id}/deliveries` returns in `payload_detail` the level each delivery was built at (`ids` when it has no `expanded`).
