Integration guide

Integrate Zelo

Wire consent, deletion-request orchestration, and a tamper-evident audit trail into your app — without sending Zelo a single byte of personal data. Two ways in: a language-agnostic REST API, or a drop-in Spring Boot starter.

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.

Your database
Holds the PII
nameemailCPFdate of birthhealth data
external_id only
Zelo control plane
Holds zero PII
external_idconsent staterequest stateaudit chain
Never put PII in the 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.

A key looks like 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.)

  1. 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.

  2. Configure it (application.yml or application.properties):
    application.yml
    zelo:
      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)
  3. Declare a purpose, record consent, and handle deletion — the returned Map becomes the fulfillment proof, sent back to Zelo automatically:
    PrivacyIntegration.java
    import 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.

end-to-end with curl
# 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_..."
Idempotency Retry any 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:

CONSENTLEGAL_OBLIGATIONPUBLIC_POLICYRESEARCHCONTRACTREGULAR_EXERCISE_OF_RIGHTSVITAL_INTERESTSHEALTH_PROTECTIONLEGITIMATE_INTERESTCREDIT_PROTECTION

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:

RECEIVEDDISPATCHEDFULFILLEDOVERDUE
  • 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-Signature header, keyed by your webhook-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 event field 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)

  1. Read the raw request body and the X-Zelo-Signature header.
  2. Compute HMAC_SHA256(raw_body, webhook_secret) and constant-time compare it to the header. Reject on mismatch.
  3. Parse the body; reject if sentAt is outside your tolerance window.
  4. Erase the user identified by externalId in your database.
  5. Call POST /v1/requests/{requestId}/fulfill with your proof. Respond 200.
You can also drive it without inbound webhooks Don't want to expose an endpoint? Open the request, then poll 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"
}
StatusWhen
400Validation failure, malformed JSON, missing required query param, or a bad ISO-8601 timestamp.
401Missing, invalid, or revoked API key.
404Referenced resource (request, subject, purpose) doesn't exist or isn't yours.
409Conflict — e.g. an illegal DSR transition, or two writes racing the same record (retry).
429Rate limit, or a free-plan hard cap (see Plans & limits). Not retryable until usage or the window changes.
500Unexpected 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:

UsageWhat happens
80% / 100% of a limitWe email the account. Writes keep working.
Past 3× a limitNew subjects / consent grants get an explicit 429 with a clear message — never a silent drop.
AlwaysDeletion 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

POST/v1/purposes201 Created

Declare a processing purpose. Idempotent on the key.

FieldTypeNotes
keystringrequiredstable identifier, referenced later as purpose_key
descriptionstringrequiredhuman description
legal_basisenumrequiredone of the legal basis values

Returns a PurposeResponse: { id, key, description, legal_basis, created_at }.

GET/v1/purposes200 OK

List every purpose declared for your key. Returns an array of PurposeResponse.

Consents

POST/v1/consents200 OK

Record a consent action and get back the subject's full report.

FieldTypeNotes
external_idstringrequiredyour subject id (opaque, non-PII)
purpose_keystringrequiredkey of a declared purpose
actionenumrequiredGRANT or WITHDRAW
sourcestringoptionalPII-free origin, e.g. signup-form
metadataobjectoptionalPII-free JSON, folded into the audit payload

Returns a ConsentReportResponse with current[] and history[].

GET/v1/consents?subject={external_id}200 OK

Get a subject's consent report. Required query param subject (the external_id); optional purpose narrows it to one purpose key.

Deletion requests

POST/v1/requests201 Created

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.

GET/v1/requests/{id}200 OK

Fetch one request's current state. Unknown / not-yours id → 404.

POST/v1/requests/{id}/fulfill200 OK

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

POST/v1/subjects200 OK

Upsert a subject by external_id required. Optional — consent and request calls register the subject implicitly. Returns { id, external_id, created_at }.

Audit

GET/v1/audit200 OK

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 }.

GET/v1/audit/verify200 OK

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.

MethodPathPurpose
POST/admin/api-keysmint a client key (raw value returned once)
GET/admin/api-keyslist keys (never exposes hashes/secrets)
PATCH/admin/api-keys/{id}/webhookset 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.

Ready to integrate?

Get a key for the hosted control plane, or self-host the open-source stack. Either way, ship LGPD consent + erasure with a tamper-evident trail — and zero end-user PII in Zelo.

Get your API key View on GitHub