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 and API webhooks.
Payload versions 2 and 1 always send names and e-mails. They have no payload_detail. See Payload version 2 and Payload version 1.
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 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
{
"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
{
"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
{
"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": "[email protected]",
"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** |
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
maskeddescribed above, queued deliveries built at a higher level are sent withoutexpanded. - If the level goes up, queued deliveries keep what they were built with: a delivery built at
maskedstays masked, and a delivery built atidshas noexpanded.
GET /webhooks/{webhook_id}/deliveries returns in payload_detail the level each delivery was built at (ids when it has no expanded).