What Zelo is
Zelo is an open-source LGPD compliance control plane: a REST API (and Spring Boot starter) that records consent decisions, orchestrates data-subject deletion requests, and keeps a tamper-evident, hash-chained audit trail of all of it.
The defining design choice: Zelo is a control plane, not a data store. It never holds your users' personal data. It stores only an opaque external_id (your own user id), the consent records, request state, and the audit chain. When a user must be erased, Zelo doesn't delete anything itself — it fires a signed webhook back to your app, and you erase the row in your own database. Zelo records the tamper-evident proof that it happened.
That makes integration small and the blast radius tiny: a leak of Zelo holds no names, emails, or CPFs.
The zero-PII contract
This is the one hard rule. Never send personal data to Zelo. Send only an opaque identifier and PII-free metadata.
external_id or metadata
The external_id must be opaque and non-PII — never an email, CPF, or name. Any source / metadata you attach to consent must be PII-free (e.g. "signup-form", {"ip":"203.0.113.7"}), because it is folded verbatim into the audit payload.
Get an API key
Every /v1 call is authenticated by a per-tenant API key sent in the Authorization header. There are two ways to get one:
Hosted (zelocompliance.com)
Use the managed service at https://api.zelocompliance.com — nothing to run. Onboarding is instant and email-verified: sign up, click the link in the verification email, and self-issue a key scoped to your account. Your data stays yours; the hosted plane holds zero end-user PII.
Self-host (open source, Apache-2.0)
Run the stack yourself with Docker Compose (Postgres + the control plane). The control plane seeds no key by default; mint per-tenant keys by signing up at the dashboard (/app) or via the operator admin API, or set ZELO_BOOTSTRAP_API_KEY to seed one. See the repository.
zk_live_8f1d6c2e…. Zelo stores only its SHA-256 hash — the raw value is shown to you exactly once, so store it in your secret manager. Treat it like a password.
Choose an external_id
Zelo keys everything off an opaque, stable external_id string. Pick an identifier that is:
- Opaque & non-PII — never an email, CPF, or name.
- Stable for the life of the user.
- Resolvable — your webhook handler must be able to map it back to the row to erase.
Recommended: add a dedicated external_id UUID column to your users table (non-enumerable, never reused). Quick start: a prefixed id like "app-user-" + user.id is fine to begin with — migrate to a UUID before you have meaningful volume. An auto-increment integer works but is enumerable, so prefer a UUID.
Quickstart — Spring Boot
If you run Spring Boot, the starter auto-configures a ZeloClient and an HMAC-verified webhook receiver. (Java 21, Spring Boot 3.x.)
-
Add the dependency (you also supply Spring Web yourself):
build.gradle
dependencies { implementation 'io.github.thgrcarvalho:zelo-spring-boot-starter:0.1.0' implementation 'org.springframework.boot:spring-boot-starter-web' // required by you }Resolves from Maven Central.
-
Configure it (
application.ymlorapplication.properties):application.ymlzelo: api-url: https://api.zelocompliance.com api-key: ${ZELO_API_KEY} # zk_live_... webhook-secret: ${ZELO_WEBHOOK_SECRET} # enables inbound /zelo/webhooks # webhook-path: /zelo/webhooks # default # webhook-tolerance-seconds: 300 # default (replay window) -
Declare a purpose, record consent, and handle deletion — the returned
Mapbecomes the fulfillment proof, sent back to Zelo automatically:PrivacyIntegration.javaimport io.github.thgrcarvalho.zelo.starter.*; @Component public class PrivacyIntegration { private final ZeloClient zelo; private final UserRepository users; // your own persistence public PrivacyIntegration(ZeloClient zelo, UserRepository users) { this.zelo = zelo; this.users = users; } // Declare the purpose once at boot (idempotent — safe every startup). @Bean ApplicationRunner declarePurposes() { return args -> zelo.definePurpose( "marketing-emails", "Send marketing and product-update emails", ZeloLegalBasis.CONSENT); } // Record a consent decision when the user opts in. public void onUserOptIn(String userId) { zelo.grantConsent(userId, "marketing-emails", "signup-form"); // later: if (zelo.isGranted(userId, "marketing-emails")) { ... } } // Handle the deletion webhook. HMAC + replay are verified by the // starter before this runs. The returned Map is the proof of erasure. @ZeloWebhook("dsr.delete.requested") // or just @ZeloWebhook (same default) public Map<String, Object> erase(ZeloDeletionRequest request) { int rows = users.deleteByExternalId(request.externalId()); return Map.of("deletedRows", rows, "requestId", request.requestId()); } }
That's the whole loop. The ZeloClient carries its own snake_case JSON mapper, so your app's Jackson config is never touched. See the API reference for every ZeloClient method's underlying endpoint.
Quickstart — REST
Any language with an HTTP client works. The whole API is JSON in snake_case, authenticated by the Authorization header. Here is the full loop with curl against the hosted plane.
# 1. Declare a purpose (idempotent on re-run)
curl -sS -X POST https://api.zelocompliance.com/v1/purposes \
-H "Authorization: Bearer zk_live_..." \
-H "Content-Type: application/json" \
-d '{"key":"marketing_emails","description":"Product emails","legal_basis":"CONSENT"}'
# 2. Record a consent decision (GRANT or WITHDRAW)
curl -sS -X POST https://api.zelocompliance.com/v1/consents \
-H "Authorization: Bearer zk_live_..." \
-H "Content-Type: application/json" \
-d '{"external_id":"user-42","purpose_key":"marketing_emails","action":"GRANT","source":"signup-form"}'
# 3. Check current consent for a subject
curl -sS "https://api.zelocompliance.com/v1/consents?subject=user-42" \
-H "Authorization: Bearer zk_live_..."
# 4. Open a deletion request (you'll receive a webhook, or poll the request)
curl -sS -X POST https://api.zelocompliance.com/v1/requests \
-H "Authorization: Bearer zk_live_..." \
-H "Content-Type: application/json" \
-d '{"external_id":"user-42","type":"DELETE"}'
# 5. After you erase the user, mark the request fulfilled with proof
curl -sS -X POST https://api.zelocompliance.com/v1/requests/{id}/fulfill \
-H "Authorization: Bearer zk_live_..." \
-H "Content-Type: application/json" \
-d '{"proof":{"deleted_records":17,"system":"crm"}}'
# 6. Verify the tamper-evident audit chain at any time
curl -sS https://api.zelocompliance.com/v1/audit/verify \
-H "Authorization: Bearer zk_live_..."
POST safely by sending a stable Idempotency-Key header — a repeat with the same key returns the original result instead of duplicating the effect.
Concept — Purposes
A purpose is a declared reason for processing personal data, tied to an LGPD legal basis (Art. 7). You declare purposes once (it's idempotent); consent is always recorded against a purpose by its key. Valid legal_basis values:
Concept — Consent
Consent is an append-only ledger per subject + purpose. You record GRANT or WITHDRAW actions; Zelo computes the current state and keeps the full history. Recording consent (or any other operation) for a never-seen external_id registers the subject implicitly — no separate step needed. A read returns both current[] (the latest state per purpose) and history[] (every action, oldest first).
Concept — Deletion requests
A data-subject request (DSR) tracks an erasure through its lifecycle. v1 supports DELETE. Each request carries an LGPD response deadline_at and moves through these statuses:
- RECEIVED — created; a signed webhook is queued.
- DISPATCHED — the deletion webhook was delivered to your app.
- FULFILLED — you confirmed erasure via
/fulfill(with optional proof). - OVERDUE — the deadline passed before fulfillment.
Deletion webhooks
When a deletion request is opened, Zelo delivers a signed dsr.delete.requested webhook to your app. The payload is PII-free — it carries the requestId, the externalId to erase, and a deadline.
Security model
- Signature — HMAC-SHA256 over the raw request body, in the
X-Zelo-Signatureheader, keyed by yourwebhook-secret. Verify it before trusting the body. - Replay defence — the signed body carries a
sentAt; reject anything outside your tolerance window (default 300s). Zelo re-sends with a fresh stamp, so transient clock skew self-heals. - Event type from the signed body — routing reads the
eventfield inside the signed payload, never an unsigned header.
With the Spring starter
All of the above is handled for you. Annotate a method with @ZeloWebhook("dsr.delete.requested") (see the Spring quickstart). The method receives a verified ZeloDeletionRequest; its return value is posted back as fulfillment proof automatically. If your handler throws, the receiver returns 5xx and Zelo retries — and because fulfillment is idempotent, a retry after a successful erase is safe.
Handling it yourself (any language)
- Read the raw request body and the
X-Zelo-Signatureheader. - Compute
HMAC_SHA256(raw_body, webhook_secret)and constant-time compare it to the header. Reject on mismatch. - Parse the body; reject if
sentAtis outside your tolerance window. - Erase the user identified by
externalIdin your database. - Call
POST /v1/requests/{requestId}/fulfillwith your proof. Respond200.
GET /v1/requests/{id}, erase, and call /fulfill yourself. The webhook is a convenience, not a requirement.
The audit chain
Every consent decision and request transition is appended to a hash-chained, per-tenant audit log: each entry's entry_hash covers the previous entry's hash, so any tampering with a past record breaks the chain from that point forward. GET /v1/audit/verify recomputes the chain end-to-end and tells you whether it's intact (and the first broken entry if not). Export entries with GET /v1/audit — keyset-paginated via after_id.
Authentication & errors
Authorization header
Send your key in the standard Authorization header. Both forms are accepted:
Authorization: zk_live_8f1d6c2e...
Authorization: Bearer zk_live_8f1d6c2e... # "Bearer " prefix optional
Each key scopes every query to its own tenant — you only ever see your own subjects, purposes, requests, and audit chain. A revoked key is rejected exactly like an unknown one.
Error body
All errors share one shape:
{
"status": 401,
"error": "Unauthorized",
"message": "Invalid API key",
"timestamp": "2026-06-17T12:00:00.123456Z"
}
| Status | When |
|---|---|
400 | Validation failure, malformed JSON, missing required query param, or a bad ISO-8601 timestamp. |
401 | Missing, invalid, or revoked API key. |
404 | Referenced resource (request, subject, purpose) doesn't exist or isn't yours. |
409 | Conflict — e.g. an illegal DSR transition, or two writes racing the same record (retry). |
429 | Rate limit, or a free-plan hard cap (see Plans & limits). Not retryable until usage or the window changes. |
500 | Unexpected server error (details are logged server-side only). |
Rate limits & idempotency
Write endpoints are limited to 100 requests/minute per (IP + path) and accept a stable Idempotency-Key header so retries don't double-apply. Reads are unthrottled.
Plans & limits
Hosted accounts start on the free plan: 500 subjects and 2,000 audit events per calendar month (UTC), and 3 active API keys. Self-hosted deployments have no plans unless you configure them.
Enforcement is deliberately soft, because compliance writes matter more than quotas:
| Usage | What happens |
|---|---|
| 80% / 100% of a limit | We email the account. Writes keep working. |
| Past 3× a limit | New subjects / consent grants get an explicit 429 with a clear message — never a silent drop. |
| Always | Deletion requests and consent withdrawals are never blocked — a data subject's rights outrank any quota. The 4th active key returns 409. |
Live usage, limits, and the PRO upgrade live in your dashboard (GET /account/usage behind your session). PRO is unmetered — same API, nothing to migrate.
API reference
Base URL (hosted): https://api.zelocompliance.com. All bodies are JSON in snake_case.
Purposes
Declare a processing purpose. Idempotent on the key.
| Field | Type | Notes | |
|---|---|---|---|
key | string | required | stable identifier, referenced later as purpose_key |
description | string | required | human description |
legal_basis | enum | required | one of the legal basis values |
Returns a PurposeResponse: { id, key, description, legal_basis, created_at }.
List every purpose declared for your key. Returns an array of PurposeResponse.
Consents
Record a consent action and get back the subject's full report.
| Field | Type | Notes | |
|---|---|---|---|
external_id | string | required | your subject id (opaque, non-PII) |
purpose_key | string | required | key of a declared purpose |
action | enum | required | GRANT or WITHDRAW |
source | string | optional | PII-free origin, e.g. signup-form |
metadata | object | optional | PII-free JSON, folded into the audit payload |
Returns a ConsentReportResponse with current[] and history[].
Get a subject's consent report. Required query param subject (the external_id); optional purpose narrows it to one purpose key.
Deletion requests
Open a deletion request. Body: external_id required; type optional (only DELETE is meaningful in v1). Returns a RequestResponse with status: "RECEIVED", a deadline_at, and seconds_until_deadline.
Fetch one request's current state. Unknown / not-yours id → 404.
Mark a request fulfilled after you've erased the user. Body is optional: { "proof": { ... } } — arbitrary JSON evidence, echoed back as fulfillment_proof. Fulfilling an already-fulfilled request → 409.
Subjects
Upsert a subject by external_id required. Optional — consent and request calls register the subject implicitly. Returns { id, external_id, created_at }.
Audit
Export audit entries, oldest first. Query params: from / to (ISO-8601 bounds), after_id (keyset cursor — pass the last id you saw), limit (default 200, clamped 1–1000). Each entry: { id, event_type, payload, occurred_at, prev_hash, entry_hash }.
Recompute and verify the chain. Returns { ok, entries_checked, first_broken_entry_id, detail }.
Admin (operator-only)
Provisioning lives under /admin/api-keys and is guarded by a separate master key, not a client key — it is for operators running a self-hosted (or the hosted) plane, not for app developers. On the hosted service it is not internet-exposed.
| Method | Path | Purpose |
|---|---|---|
POST | /admin/api-keys | mint a client key (raw value returned once) |
GET | /admin/api-keys | list keys (never exposes hashes/secrets) |
PATCH | /admin/api-keys/{id}/webhook | set a key's webhook URL + secret |
DELETE | /admin/api-keys/{id} | soft-revoke a key |
FAQ
Does Zelo store any personal data?
No. Only an opaque external_id, consent/request state, and the audit chain. PII never leaves your database.
Do I have to use the Spring starter?
No — the REST API is language-agnostic. The starter is a convenience for Spring Boot apps.
What if my deletion handler fails?
Don't call /fulfill. The request stays open and Zelo retries the webhook (or you retry on your next poll). Fulfillment is idempotent, so completing after a retry is safe.
Can I self-host?
Yes — Zelo is open source (Apache-2.0). Run it with Docker Compose; the hosted service at zelocompliance.com is the same control plane, managed for you.