Docs

Recording & reading consent

POST /v1/consent, batch, idempotency, and reading state & history.

This is the endpoint your banner or backend calls the moment someone makes a choice: record it once, and Croombit vaults the exact statement version they saw and hands back a signed receipt you can point to as proof.

Record

POST /v1/consent
Authorization: Bearer sk_live_…
Idempotency-Key: 4b0f…

{
  "subject": "email:user@example.com",
  "purpose": "marketing_email",
  "state": "GRANTED",
  "channel": "banner",
  "locale": "en",
  "pageUrl": "https://acme.com/signup"
}

The subject can be an internal id, <type>:<value>, or an object { "identifiers": [{ "type", "value" }] } to create the subject if it does not exist. state can only be GRANTED, DENIED or WITHDRAWN.

201 response: { id, subjectId, subjectCreated, purpose, state, purposeVersion, source, collectedAt, expiresAt, receiptId }. Optional body fields: channel, locale, country, pageUrl, collectedAt, expiresAt, metadata (any JSON object of your own) and source (below).

Batch

POST /v1/consent/batch with { "records": [ … ] } (max 100). Issues one receipt per subject covering all of that subject’s records in the batch.

Who acted: source

Every record carries a source. It is the difference between “the person agreed” and “we assert this”, and readers of the vault can see it:

`source`MeaningWho can write it
API (default)The subject acted: the banner, the preference center, or your app on their behalf.Any key
IMPORTYou, the controller, assert it: a contact-list import, a staff entry. The subject did nothing.sk_ only
MIGRATIONCarried over from a previous system.sk_ only
CONSOLEWritten by the system itself — for example an expiry policy turning a grant into EXPIRED. You never send it.The platform

A pk_ key that sends IMPORT or MIGRATION gets 403 secret_key_required: a public key can only ever write what a subject did. The source comes back on the write response, on every history row, as recordedVia when you read the state, and in the consent.* webhooks (data.source). It is also signed into the receipt: collection.method is api, import or migration. In a batch, a subject’s receipt says import / migration only if none of that subject’s records in the batch is subject-side.

A purpose’s `legalBasis` (CONSENT, LEGITIMATE_INTEREST, CONTRACT, LEGAL_OBLIGATION, VITAL_INTEREST, PUBLIC_TASK) says why you may process. A record’s `state` says what was recorded. They are separate fields, and a purpose’s defaultState (NOT_GIVEN, GRANTED or DENIED: what someone with no record reads as) is a third setting of its own — it is not derived from the legal basis. A GRANTED written with source: "API" means the subject acted; a GRANTED written with source: "IMPORT" means you assert it.

To load contacts you already hold under a basis other than consent — say existing customers under a contract:

  1. Create a purpose whose legalBasis is that basis (CONTRACT), for example order_updates, and publish it.
  2. Write each contact with source: "IMPORT" and your own metadata — the import batch, the contract reference — so the basis can be evidenced later.
  3. Read them back with recordedVia (state) or source (history) to tell them apart from people who opted in themselves.
POST /v1/consent/batch
Authorization: Bearer sk_live_…

{ "records": [
  { "subject": "email:ana@example.com", "purpose": "order_updates", "state": "GRANTED",
    "source": "IMPORT", "channel": "crm_import",
    "metadata": { "importBatch": "2026-09-crm", "contractRef": "C-10482" } }
] }
Each contact still needs an identifier (email:, external_id:…) — see Subjects & identifiers. defaultState is not a substitute for records: it changes what a person with no record reads as, but leaves no per-person evidence.

Read the state

GET /v1/subjects/email:user@example.com/consent?purpose=marketing_email
→ { "subjectId": "…", "purposes": {
     "marketing_email": { "state": "GRANTED", "purposeVersion": 3,
       "needsReconsent": false, "latestPurposeVersion": 3,
       "legalBasis": "CONSENT", "recordedVia": "API" } } }

Published purposes with no record for the subject appear with their defaultState and source: "default". Every purpose carries its legalBasis; recordedVia (API, IMPORT, MIGRATION or CONSOLE) says how the latest record entered. needsReconsent is true if the stored version lags a later version published as RECONSENT_REQUIRED, or if the record has expired.

History

GET /v1/subjects/{ref}/consent/history?purpose= (sk_ only) returns up to 500 append-only rows, newest first. Each row has purposeKey, purposeVersion, state, source, channel, locale, pageUrl, collectedAt, expiresAt, your metadata, its proof (the hash of the exact statement it was given under) and the supersedesId chain. A top-level legalBasis map gives each purpose’s basis. IP addresses and user agents are not returned. The history can include EXPIRED rows written by the system (source: "CONSOLE"). To show it to the person it belongs to, see Guide: showing a user their own history.