> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://developer.jelliu.co/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://developer.jelliu.co/_mcp/server.

# Compliance

Jelliu places calls and sends messages on your behalf, so every outbound contact passes through one compliance gate before it happens. This page describes that gate, the controls you can configure through the API, and the records Jelliu keeps so you can show what happened and why.

Jelliu gives you controls and evidence; it does not decide which laws apply to your business. The defaults below are starting points taken from the platform's configuration, not legal advice.

## How it works

```mermaid
flowchart TD
    A[Outbound call, WhatsApp or email] --> B{Allowed contact hours?}
    B -->|outside, calls and WhatsApp only| X[Blocked]
    B -->|inside, or email| C{Blocked prefix?}
    C -->|yes| X
    C -->|no| D{On the suppression list?}
    D -->|yes| X
    D -->|no| E{Contact marked do-not-call?}
    E -->|yes| X
    E -->|no| F{Daily or total attempt limit reached?}
    F -->|yes| X
    F -->|no| G[Channel checks: opt-out, templates, caps]
    G --> H[Sent]
```

The gate reads your per-country configuration, chosen from the prefix of the contact's phone number. Numbers with no recognizable prefix (including email-only contacts) use the `DEFAULT` profile.

| Prefix | Country            | Prefix | Country        |
| ------ | ------------------ | ------ | -------------- |
| `+57`  | `CO` Colombia      | `+54`  | `AR` Argentina |
| `+52`  | `MX` Mexico        | `+56`  | `CL` Chile     |
| `+55`  | `BR` Brazil        | `+51`  | `PE` Peru      |
| `+1`   | `US` United States | other  | `DEFAULT`      |

## Endpoints

| Method   | Path                                                      | API key scope |
| -------- | --------------------------------------------------------- | ------------- |
| `POST`   | `/api/compliance/check-call`                              | `write`       |
| `GET`    | `/api/compliance/configs`                                 | `read`        |
| `GET`    | `/api/compliance/configs/{countryCode}`                   | `read`        |
| `GET`    | `/api/compliance/defaults/{countryCode}`                  | `read`        |
| `PUT`    | `/api/compliance/configs`                                 | `full`        |
| `DELETE` | `/api/compliance/configs/{countryCode}`                   | `full`        |
| `GET`    | `/api/compliance/suppressions`                            | `full`        |
| `POST`   | `/api/compliance/suppressions/import`                     | `full`        |
| `DELETE` | `/api/compliance/suppressions`                            | `full`        |
| `GET`    | `/api/compliance/consent/{contactId}`                     | `read`        |
| `GET`    | `/api/compliance/consent/check/{contactId}/{consentType}` | `read`        |
| `POST`   | `/api/compliance/consent`                                 | `full`        |
| `DELETE` | `/api/compliance/consent`                                 | `full`        |
| `GET`    | `/api/compliance/retention`                               | `read`        |
| `PUT`    | `/api/compliance/retention`                               | `full`        |
| `POST`   | `/api/compliance/retention/enforce`                       | `full`        |
| `POST`   | `/api/compliance/erase/{contactId}`                       | `full`        |
| `GET`    | `/api/audit`                                              | `full`        |
| `GET`    | `/api/audit/{id}`                                         | `full`        |
| `GET`    | `/api/audit/verify-chain`                                 | `full`        |
| `GET`    | `/api/legal/dpa-status`                                   | `read`        |
| `POST`   | `/api/legal/accept-dpa`                                   | `full`        |
| `DELETE` | `/api/legal/dpa`                                          | `full`        |
| `GET`    | `/api/settings/data-residency`                            | `read`        |

Routes marked `full` are restricted to workspace administrators or the owner, and an API key stands in for them only with the `full` scope. See [API keys](/platform/api-keys).

## Opt-out means total silence

When a person asks not to be contacted, Jelliu stops contacting them on that channel's identifier and **sends nothing further, not even a confirmation**. What protects the person is the suppression itself, not an acknowledgement.

| Where the request is made | How it is recognized                                                                                                                   | What Jelliu records                                                                                                           |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| WhatsApp                  | The whole message is an opt-out keyword, or a phrase such as "no me vuelvas a escribir". Voice notes are checked after transcription.  | Every contact with that number set to `dnc`, and the number added to the suppression list.                                    |
| Email                     | The first non-empty line is an opt-out keyword, or the first 500 characters contain a phrase such as `unsubscribe` or `darme de baja`. | Every contact with that address (compared case-insensitively) set to `dnc`.                                                   |
| Phone call                | The person asks during the call not to be called again.                                                                                | The contact set to `dnc`, and the number added to the suppression list, including on inbound calls and calls with no contact. |
| Instagram and Messenger   | Same keywords and phrases as WhatsApp.                                                                                                 | The contact suppressed.                                                                                                       |

The opt-out keywords are the same on every text channel: `stop`, `stopall`, `stop all`, `cancel`, `unsubscribe`, `quit`, `end`, `baja`, `cancelar`, `darme de baja`, `dar de baja`, `no molestar`. Keywords count only when they are the whole message, so "quiero cancelar mi cita" is answered normally.

After an opt-out:

* sends to the contact fail (for example `403 CONTACT_OPTED_OUT` on WhatsApp, `403 EMAIL_ERROR` on email, or `423` from the compliance gate);
* inbound messages from the person are not answered automatically;
* campaigns skip the contact.

See [WhatsApp](/channels/whatsapp#opt-out) and [Email](/channels/email#opt-out) for channel details.

## Suppression list

The suppression list holds phone numbers your workspace may not contact. It is keyed by number, not by contact, so it **survives deleting and re-importing a contact**, and the compliance gate checks it before every call and WhatsApp message regardless of any other setting. Jelliu's own opt-out paths write to it, and you can load lists into it.

### Import a registry or your own list

National do-not-call registries such as Colombia's Registro de Números Excluidos or the US National Do Not Call Registry do not offer a per-number API, so you download the list and import it.

**`numbers`** `string[]` — required

1 to 200,000 numbers. Spaces, dashes, dots and parentheses are removed; what remains must be E.164 (`+` and country code). Local-format numbers are rejected, not guessed.

---

**`source`** `string` — required

`registry:rne_co` (Colombia), `registry:dnc_us` (United States), `registry:repep_mx` (Mexico), `registry:nmp_br` (Brazil) or `customer_list`.

---

**`reason`** `string`

Up to 200 characters, stored on every new row, for example `RNE export 2026-09-01`.

---

**`cURL`**

```bash title="cURL"
curl -sS -X POST "https://api.jelliu.co/api/compliance/suppressions/import" \
  -H "Authorization: Bearer $JELLIU_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "source": "registry:rne_co",
    "reason": "RNE export 2026-09-01",
    "numbers": ["+57 300 123 4567", "+573109876543", "3001112233"]
  }'
```

**`Node.js`**

```javascript title="Node.js"
import { readFile } from 'node:fs/promises';

const numbers = (await readFile('rne-export.txt', 'utf8'))
  .split(/\r?\n/)
  .map((line) => line.trim())
  .filter(Boolean);

// Keep each request well under the 1 MB body limit.
const CHUNK = 20000;
for (let i = 0; i < numbers.length; i += CHUNK) {
  const res = await fetch('https://api.jelliu.co/api/compliance/suppressions/import', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.JELLIU_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      source: 'registry:rne_co',
      reason: 'RNE export 2026-09-01',
      numbers: numbers.slice(i, i + CHUNK),
    }),
  });
  const { data } = await res.json();
  console.log(`received=${data.received} rejected=${data.rejected}`, data.rejectedSample);
}
```

**`Python`**

```python title="Python"
import os
import requests

with open("rne-export.txt", encoding="utf-8") as f:
    numbers = [line.strip() for line in f if line.strip()]

CHUNK = 20000  # keep each request well under the 1 MB body limit
for i in range(0, len(numbers), CHUNK):
    res = requests.post(
        "https://api.jelliu.co/api/compliance/suppressions/import",
        headers={"Authorization": f"Bearer {os.environ['JELLIU_API_KEY']}"},
        json={
            "source": "registry:rne_co",
            "reason": "RNE export 2026-09-01",
            "numbers": numbers[i:i + CHUNK],
        },
        timeout=120,
    )
    res.raise_for_status()
    data = res.json()["data"]
    print(f"received={data['received']} rejected={data['rejected']}", data["rejectedSample"])
```

**`201 Created`**

```json title="201 Created"
{
  "data": {
    "suppressed": 2,
    "received": 2,
    "rejected": 1,
    "rejectedSample": ["3001112233"]
  }
}
```

| Field            | Description                                                       |
| ---------------- | ----------------------------------------------------------------- |
| `received`       | Distinct valid numbers in the request, after removing duplicates. |
| `suppressed`     | Numbers now on the list: new ones plus ones already there.        |
| `rejected`       | Entries that were not valid E.164 and were skipped.               |
| `rejectedSample` | Up to 10 of the rejected entries, to spot a format problem.       |

Imports are idempotent. A number already on the list keeps its original reason and date, so re-importing a monthly export never overwrites the record of when someone first opted out.

JSON request bodies are limited to 1 MB. A registry export with hundreds of thousands of numbers does not fit in one request: split it into chunks, as in the examples.

### Check the list and its freshness

```bash
curl -sS "https://api.jelliu.co/api/compliance/suppressions" \
  -H "Authorization: Bearer $JELLIU_API_KEY"
```

```json
{
  "data": {
    "total": 184233,
    "bySource": [
      { "source": "registry:rne_co", "count": 184100, "lastImportedAt": "2026-08-01T14:03:11.000Z" },
      { "source": "whatsapp", "count": 121, "lastImportedAt": "2026-09-12T19:40:02.000Z" },
      { "source": "customer_list", "count": 12, "lastImportedAt": "2026-07-20T10:00:00.000Z" }
    ],
    "stale": [
      { "source": "registry:rne_co", "ageDays": 44 }
    ]
  }
}
```

`stale` lists registry sources whose most recent import is older than 31 days, the maximum age the US National Do Not Call rule allows for a scrub. It is a report, not an enforcement: calls are not blocked because a registry is stale. Schedule a monthly re-import to keep it empty.

### Lift one suppression

Removing a number is done one number at a time and requires a written justification, which is stored in the audit log. There is no bulk removal.

```bash
curl -sS -X DELETE "https://api.jelliu.co/api/compliance/suppressions" \
  -H "Authorization: Bearer $JELLIU_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "phoneNumber": "+573001234567",
    "justification": "Renewed written consent received on 2026-09-14, ticket 4821"
  }'
```

`justification` must be 10 to 500 characters. The response is `{ "data": { "removed": true } }`, or `false` if the number was not on the list.

Lifting a suppression does not change contacts already marked `dnc`. Update those contacts separately if the person has genuinely consented again.

## Compliance configuration

Each workspace can store one configuration per country. Without one, the country defaults apply.

### Fields

| Field                       | Type            | Description                                                                         |
| --------------------------- | --------------- | ----------------------------------------------------------------------------------- |
| `country_code`              | string          | `CO`, `MX`, `BR`, `US`, `AR`, `CL` or `PE`.                                         |
| `allowed_call_hours`        | object          | `start` and `end` as `HH:MM`, and an IANA `timezone`.                               |
| `max_call_attempts_per_day` | integer         | 1 to 10. Calls to the contact today, counted in that timezone.                      |
| `max_call_attempts_total`   | integer         | 1 to 50. The contact's total call attempts.                                         |
| `require_dnc_check`         | boolean         | Also block any number that belongs to a contact marked do-not-call. Default `true`. |
| `blocked_prefixes`          | string\[]       | Number prefixes that must never be contacted.                                       |
| `require_recording_consent` | boolean         | Open calls with a recording notice.                                                 |
| `consent_script`            | string or null  | The notice to use, up to 2,000 characters.                                          |
| `require_ai_disclosure`     | boolean or null | Open calls by saying the caller is an AI. `null` inherits the country default.      |
| `require_opt_in`            | boolean         | Stored with the configuration.                                                      |
| `data_retention_days`       | integer         | 30 to 3,650. See [Data retention](#data-retention).                                 |

### Country defaults

| Country   | Allowed hours  | Timezone                             | Attempts per day / total | Retention | Recording notice |
| --------- | -------------- | ------------------------------------ | ------------------------ | --------- | ---------------- |
| `CO`      | 08:00 to 20:00 | `America/Bogota`                     | 3 / 10                   | 365 days  | Yes              |
| `MX`      | 08:00 to 21:00 | `America/Mexico_City`                | 3 / 10                   | 365 days  | Yes              |
| `BR`      | 08:00 to 20:00 | `America/Sao_Paulo`                  | 3 / 8                    | 365 days  | Yes              |
| `US`      | 08:00 to 21:00 | `America/New_York`                   | 3 / 15                   | 730 days  | Yes              |
| `AR`      | 08:00 to 20:00 | `America/Argentina/Buenos_Aires`     | 3 / 10                   | 365 days  | Yes              |
| `CL`      | 08:00 to 20:00 | `America/Santiago`                   | 3 / 10                   | 365 days  | Yes              |
| `PE`      | 08:00 to 20:00 | `America/Lima`                       | 3 / 10                   | 365 days  | Yes              |
| `DEFAULT` | 08:00 to 20:00 | `UTC`, evaluated as `America/Bogota` | 3 / 10                   | 365 days  | Yes              |

Every default checks the do-not-call list and has AI disclosure off. `GET /api/compliance/defaults/{countryCode}` returns the full defaults, including the country's `consent_script` and an `ai_disclosure_note` explaining what is known about AI-disclosure rules there.

### Read and change a configuration

`GET /api/compliance/configs` lists your stored configurations; `GET /api/compliance/configs/{countryCode}` returns one, or `{ "data": null }` when none is stored.

`PUT /api/compliance/configs` creates or replaces the configuration for `country_code`. `country_code`, `require_recording_consent`, `max_call_attempts_per_day`, `max_call_attempts_total`, `allowed_call_hours` and `data_retention_days` are required.

**`cURL`**

```bash title="cURL"
curl -sS -X PUT "https://api.jelliu.co/api/compliance/configs" \
  -H "Authorization: Bearer $JELLIU_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "country_code": "CO",
    "require_recording_consent": true,
    "consent_script": "Esta llamada será grabada con fines de calidad y cumplimiento según la Ley 1581 de Protección de Datos Personales. ¿Autoriza continuar?",
    "require_dnc_check": true,
    "max_call_attempts_per_day": 2,
    "max_call_attempts_total": 6,
    "allowed_call_hours": { "start": "09:00", "end": "19:00", "timezone": "America/Bogota" },
    "data_retention_days": 180,
    "blocked_prefixes": []
  }'
```

**`Node.js`**

```javascript title="Node.js"
const res = await fetch('https://api.jelliu.co/api/compliance/configs', {
  method: 'PUT',
  headers: {
    Authorization: `Bearer ${process.env.JELLIU_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    country_code: 'CO',
    require_recording_consent: true,
    require_dnc_check: true,
    max_call_attempts_per_day: 2,
    max_call_attempts_total: 6,
    allowed_call_hours: { start: '09:00', end: '19:00', timezone: 'America/Bogota' },
    data_retention_days: 180,
  }),
});
const { data: config } = await res.json();
console.log(config.country_code, config.allowed_call_hours);
```

**`Python`**

```python title="Python"
import os
import requests

res = requests.put(
    "https://api.jelliu.co/api/compliance/configs",
    headers={"Authorization": f"Bearer {os.environ['JELLIU_API_KEY']}"},
    json={
        "country_code": "CO",
        "require_recording_consent": True,
        "require_dnc_check": True,
        "max_call_attempts_per_day": 2,
        "max_call_attempts_total": 6,
        "allowed_call_hours": {"start": "09:00", "end": "19:00", "timezone": "America/Bogota"},
        "data_retention_days": 180,
    },
    timeout=30,
)
res.raise_for_status()
print(res.json()["data"])
```

`DELETE /api/compliance/configs/{countryCode}` removes a stored configuration (`204`), so the country defaults apply again. It returns `404` if none exists.

`data_retention_days` on a country configuration also moves your workspace's retention window: it becomes the **shortest** value across all your country configurations. Setting 30 days for one country shortens retention for all your data.

### Allowed contact hours

| Channel  | Allowed hours apply |
| -------- | ------------------- |
| Calls    | Yes                 |
| WhatsApp | Yes                 |
| Email    | No                  |

Hours are evaluated in the configuration's timezone, with two exceptions:

* if your workspace has a timezone set (`PATCH /api/settings` with `{ "timezone": "America/Bogota" }`), that timezone is used instead;
* if the resolved timezone is `UTC`, `America/Bogota` is used.

A window whose `end` is earlier than its `start` spans midnight. Campaigns can narrow the hours further with their own schedule; see [Campaigns](/resources/campaigns).

Attempt limits count **calls**. A contact who has reached today's call limit is also blocked on WhatsApp and email until the next day.

### Check before you contact

`POST /api/compliance/check-call` runs the gate without contacting anyone:

```bash
curl -sS -X POST "https://api.jelliu.co/api/compliance/check-call" \
  -H "Authorization: Bearer $JELLIU_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "phoneNumber": "+573001234567", "contactId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d" }'
```

```json
{
  "data": {
    "allowed": false,
    "reason": "Outside allowed call hours (08:00-20:00 America/Bogota)"
  }
}
```

`phoneNumber` must be E.164. `contactId` is optional; without it the attempt limits and the contact's own status are not checked. Possible reasons:

| `reason`                                                  |
| --------------------------------------------------------- |
| `Outside allowed call hours (08:00-20:00 America/Bogota)` |
| `Phone number matches blocked prefix: +1900`              |
| `Phone number is on the opt-out suppression list`         |
| `Contact is on the Do Not Call list`                      |
| `Phone number is on the Do Not Call list`                 |
| `Max daily call attempts reached (3/day)`                 |
| `Max total call attempts reached (10 total)`              |

## Recording notice and AI disclosure

**Recording notice.** When `require_recording_consent` is on for the callee's country (it is on in every default), the notice is added to the opening of the call, using `consent_script` or a built-in sentence in the call's language. If the agent's greeting already announces recording, it is not repeated. If the configuration cannot be read, the notice is included anyway.

**AI disclosure.** Off by default in every country, because no national rule in the supported countries requires it. It is decided in this order:

1. the agent's own `discloseAi` setting, when set (see [Agents](/resources/agents));
2. `require_ai_disclosure` on the country configuration, when set;
3. the country default, which is `false`.

When it applies, the call opens with a sentence such as `Hola, soy un asistente de inteligencia artificial de Clínica Norte.`

Some rules are state-level or foreign and cannot be derived from a phone prefix. For example, California requires AI-voice calls to say so at the start, and the EU AI Act requires telling people they are interacting with an AI. If your agents may reach people covered by such rules, turn disclosure on for those agents.

## Consent records

Consent records store evidence that a contact agreed to be contacted or to have data processed.

| Field                                    | Description                                      |
| ---------------------------------------- | ------------------------------------------------ |
| `consent_type`                           | `voice_call`, `email` or `data_processing`.      |
| `status`                                 | `granted`, `revoked` or `erased`.                |
| `source`                                 | Where consent was obtained, 1 to 255 characters. |
| `evidence`                               | Free text, up to 2,000 characters.               |
| `ip_address`                             | IP address of the request that recorded it.      |
| `granted_at`, `revoked_at`, `expires_at` | Timestamps.                                      |

Grant consent (returns `201` with `{ "data": { "success": true } }`). Granting again for the same type revokes the previous active record and creates a new one:

```bash
curl -sS -X POST "https://api.jelliu.co/api/compliance/consent" \
  -H "Authorization: Bearer $JELLIU_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "contactId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
    "consentType": "voice_call",
    "source": "web_form:landing-septiembre",
    "evidence": "Checkbox accepted on https://clinicanorte.example.com/contacto",
    "expiresAt": "2027-09-14T00:00:00Z"
  }'
```

* `DELETE /api/compliance/consent` with `{ "contactId", "consentType" }` revokes it.
* `GET /api/compliance/consent/{contactId}` returns the full history, newest first.
* `GET /api/compliance/consent/check/{contactId}/{consentType}` returns `{ "data": { "hasConsent": true } }` when a granted, unexpired record exists.

Workspaces configured to require consent before calling skip, in campaigns, any contact without an active `voice_call` consent.

## Data retention

Retention is one number of days for your workspace, from 1 to 3,650 (365 when never set). A daily sweep at 03:00 UTC applies two windows derived from it:

| Window  | Length                                                         | What happens                                                             |
| ------- | -------------------------------------------------------------- | ------------------------------------------------------------------------ |
| Content | The shorter of your retention and your plan's transcript limit | Call transcripts, message bodies and conversation summaries are blanked. |
| Rows    | Your retention                                                 | Call records are deleted from view and messages are deleted.             |

| Plan       | Transcript and content limit            |
| ---------- | --------------------------------------- |
| Starter    | 7 days                                  |
| Growth     | 30 days                                 |
| Business   | 90 days                                 |
| Enterprise | Unlimited (only your retention applies) |

### Read and set retention

```bash
curl -sS "https://api.jelliu.co/api/compliance/retention" \
  -H "Authorization: Bearer $JELLIU_API_KEY"
```

```json
{
  "data": {
    "retentionDays": 365,
    "contentRetentionDays": 30,
    "tier": "growth",
    "tierCapDays": 30,
    "minDays": 1,
    "maxDays": 3650
  }
}
```

`contentRetentionDays` and `tierCapDays` are `null` when the plan has no content limit. Set a new window with `PUT /api/compliance/retention` and `{ "data_retention_days": 180 }`; the response has the same shape. The value is also copied to every stored country configuration.

`POST /api/compliance/retention/enforce` runs the sweep for your workspace immediately and returns what it did:

```json
{
  "data": {
    "deletedCalls": 12,
    "redactedTranscripts": 48,
    "redactedMessages": 310,
    "deletedMessages": 95,
    "redactedConversations": 22
  }
}
```

Shortening retention destroys data on the next sweep, and enforcing it destroys data immediately. Neither can be undone.

## Right to erasure

`POST /api/compliance/erase/{contactId}` permanently erases a person's data from your workspace:

* the contact is anonymized (name and phone replaced with `[ERASED]`, email, WhatsApp number and metadata removed) and deleted;
* the contact's calls keep their metrics for analytics, but transcripts and summaries are stripped of personal data, and the conversations are deleted at the voice provider;
* the contact's messages are replaced with `[ERASED]`;
* reservations and appointments matching the person are anonymized;
* consent records are marked `erased`;
* personal data in matching audit log entries is redacted, and each redaction is signed so the audit chain still verifies.

```bash
curl -sS -X POST "https://api.jelliu.co/api/compliance/erase/a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d" \
  -H "Authorization: Bearer $JELLIU_API_KEY"
```

```json
{ "data": { "success": true, "message": "All contact data has been erased" } }
```

Erasure is irreversible and restricted to the workspace owner, or a `full` key. It is not available through the MCP server. Erasure does not add the person's number to the suppression list; if they also asked not to be contacted, import the number as `customer_list`.

## Audit log

Jelliu records who changed what in your workspace: creating, changing and deleting agents, campaigns, contacts imports, webhooks, integrations and connected apps, WhatsApp senders and templates, phone numbers, billing changes, identity and SSO settings, exports, compliance configuration, retention, consent, suppressions and erasure. Actions made with an API key are attributed to `apikey:` followed by the key ID. Sensitive values in `changes` are redacted before they are stored.

Entries are hash-chained: each row's hash covers its content and the previous row's hash, so any alteration is detectable.

The audit log is available to the workspace owner, or a `full` key, on every plan.

### Query entries

| Parameter      | Description                                                                  |
| -------------- | ---------------------------------------------------------------------------- |
| `resourceType` | For example `campaign`, `agent`, `outbound_webhook`, `contact_suppressions`. |
| `resourceId`   | UUID of the resource.                                                        |
| `userId`       | Who acted: a user ID, or `apikey:` plus a key ID.                            |
| `limit`        | 1 to 100, default 50.                                                        |
| `offset`       | Default 0.                                                                   |

**`cURL`**

```bash title="cURL"
curl -sS "https://api.jelliu.co/api/audit?resourceType=campaign&limit=20" \
  -H "Authorization: Bearer $JELLIU_API_KEY"
```

**`Node.js`**

```javascript title="Node.js"
const url = new URL('https://api.jelliu.co/api/audit');
url.searchParams.set('resourceType', 'campaign');
url.searchParams.set('limit', '20');

const res = await fetch(url, {
  headers: { Authorization: `Bearer ${process.env.JELLIU_API_KEY}` },
});
const { data: entries } = await res.json();
for (const e of entries) console.log(e.created_at, e.user_id, e.action, e.resource_id);
```

**`Python`**

```python title="Python"
import os
import requests

res = requests.get(
    "https://api.jelliu.co/api/audit",
    headers={"Authorization": f"Bearer {os.environ['JELLIU_API_KEY']}"},
    params={"resourceType": "campaign", "limit": 20},
    timeout=30,
)
res.raise_for_status()
for e in res.json()["data"]:
    print(e["created_at"], e["user_id"], e["action"], e["resource_id"])
```

```json
{
  "data": [
    {
      "id": "c0a8012e-4b1f-4d6a-9e3b-2f7c5a1d8e90",
      "tenant_id": "9f0fafb4-c3d1-488e-887f-ae2b8375d967",
      "user_id": "apikey:6d2b8e14-1a3c-4f5e-8d7b-0c9a2e4f6b13",
      "action": "activate",
      "resource_type": "campaign",
      "resource_id": "0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90",
      "ip_address": "203.0.113.24",
      "user_agent": "node",
      "created_at": "2026-09-14T15:42:07.318Z"
    }
  ]
}
```

`GET /api/audit/{id}` returns one entry with its full `changes`.

### Verify the chain

`GET /api/audit/verify-chain` recomputes the hash chain, optionally between `from` and `to` (ISO 8601). The response is **not** wrapped in `data`:

```json
{
  "valid": true,
  "totalChecked": 1842,
  "redactedRows": 3,
  "redactedRowIds": ["5f1e2d3c-4b5a-4968-8776-655443322110"],
  "anchoredMidChain": false
}
```

When `valid` is `false`, `firstInvalidRow` names the first bad row and `reason` is `hash_mismatch`, `link_broken` or `unattested_redaction`. `anchoredMidChain: true` is normal with a `from` date or after old rows were removed: the first row links to one outside the range, so that single link cannot be checked.

### Stream it to your SIEM

Subscribe a webhook to `audit.log_recorded` to receive each entry as it is written, and filter it by action prefix with `auditActions`. The event is not included in `"*"`: name it explicitly. See [Webhooks](/webhooks#event-catalog).

## Data processing agreement

Acceptance of the Data Processing Agreement is recorded per workspace, normally when the owner completes onboarding.

```bash
curl -sS "https://api.jelliu.co/api/legal/dpa-status" \
  -H "Authorization: Bearer $JELLIU_API_KEY"
```

```json
{
  "data": {
    "accepted": true,
    "acceptedAt": "2026-08-25T16:20:41.000Z",
    "acceptedBy": "user_2abcDEF",
    "acceptedVersion": "2026-04-17-v1",
    "currentVersion": "2026-04-17-v1"
  }
}
```

`POST /api/legal/accept-dpa` records acceptance of the current version (optionally `{ "version": "..." }`). `DELETE /api/legal/dpa` revokes it and requires the header `X-Confirm-Revoke: yes`. Both are owner-only. For a countersigned DPA, contact [support@jelliu.com](mailto:support@jelliu.com).

## Data residency

`GET /api/settings/data-residency` returns your workspace's residency commitment and the processing chain behind it:

| Field                            | Description                                                                                        |
| -------------------------------- | -------------------------------------------------------------------------------------------------- |
| `region`                         | `global` (no residency commitment) or `us`.                                                        |
| `label`, `meaning`               | What the commitment means, in Spanish.                                                             |
| `pinned`, `pinnedAt`, `pinnedBy` | Whether and when the region was committed.                                                         |
| `options`                        | Every region, whether it can be requested, and why not.                                            |
| `chain`                          | Each subprocessor, what it handles, its region today where verified, and whether it can be pinned. |
| `chainVerifiedOn`                | When the chain was last verified.                                                                  |

Jelliu's database and compute run in the United States. A `us` commitment is available; an EU region is not offered because there is no EU deployment. Committing to a region is a one-time decision made by the owner in the dashboard; API keys cannot make it.

## Encryption of stored credentials

Credentials you give Jelliu for connected systems, such as accounting, payment and social-media tokens, are encrypted at the application level before they are stored, with AES-256-GCM and a key derived per record. For transport security, tenant isolation and webhook signing, see [Security](/security).

## Errors

| Status | Code                                  | When                                                                                                                                                                                                           |
| ------ | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | `VALIDATION_FAILED`                   | Invalid body or parameters. For retention: `data_retention_days must be a whole number of days between 1 and 3650`. For DPA revocation without the header: `DPA revoke requires X-Confirm-Revoke: yes header`. |
| `403`  | `FORBIDDEN`                           | The key is not `full` on an admin or owner route: `This operation requires an API key with the 'full' scope`.                                                                                                  |
| `403`  | `FORBIDDEN`                           | Committing a data region with an API key: `This action requires a signed-in admin session — API keys cannot perform credential-management operations.`                                                         |
| `404`  | `NOT_FOUND`                           | `Compliance config not found for country CO`, or `Audit log entry not found`.                                                                                                                                  |
| `423`  | `COMPLIANCE_BLOCKED` or `EMAIL_ERROR` | A send or call was stopped by the compliance gate. The message is the gate's `reason`.                                                                                                                         |
| `429`  | `RATE_LIMIT_EXCEEDED`                 | Rate limit. On the audit log: `Too many audit log requests, please slow down`.                                                                                                                                 |

## Limits

| Limit                                            | Value                                                             |
| ------------------------------------------------ | ----------------------------------------------------------------- |
| `/api/compliance`, `/api/legal`, `/api/settings` | General per-workspace API limit. See [Rate limits](/rate-limits). |
| `/api/audit`                                     | 20 requests per minute, in addition to the general limit.         |
| Suppression import                               | 200,000 numbers per request, within a 1 MB JSON body.             |
| Consent evidence                                 | 2,000 characters.                                                 |
| Retention                                        | 1 to 3,650 days (30 to 3,650 on a country configuration).         |

## Related

#### [Webhooks](/webhooks)

Stream `audit.log_recorded` events to your systems.

#### [WhatsApp](/channels/whatsapp)

Opt-out keywords, templates and the 24-hour window.

#### [Email](/channels/email)

The reply gate and email opt-out.

#### [Security](/security)

Transport, authentication and tenant isolation.