FR EN

API reference

The Agreely /v1 API is the backend the TypeScript, PHP, and CLI clients wrap. It is small, stateless, and server-to-server. This page is the orientation; the interactive /v1 reference has the full request/response schemas, rendered from the OpenAPI spec.

Authentication

Every call is a Bearer API key:

Authorization: Bearer agr_live_...

The key alone identifies the tenant. There is no session and no CSRF. Keys carry scopes:

  • check authorizes POST /v1/check.
  • issue authorizes the consent-request endpoints.
  • attest authorizes the manual-consent endpoints.
  • relationship authorizes both customer-relationship lifecycle actions (ending it, art. 23; undoing a mistaken end, art. 11 and 28).
  • The check or issue scope may read GET /v1/catalog.
  • Any valid key (any scope) may call GET /v1/whoami.

A missing or invalid key is 401; a valid key lacking the required scope is 403.

Endpoints

Method Path Scope Purpose
GET /v1/whoami any The scopes the presented key carries (least disclosure).
POST /v1/check check Synchronous consent decision for one cell.
POST /v1/check/batch check Synchronous decisions for many cells in one call (500-item cap).
GET /v1/catalog check or issue The company's active (category, purpose) catalog.
POST /v1/consent-requests issue Issue a consent request.
GET /v1/consent-requests issue List requests, newest first, cursor-paged.
GET /v1/consent-requests/{id} issue One request by its protocol requestId.
POST /v1/consent-requests/{id}/cancel issue Cancel a pending request (idempotent).
POST /v1/manual-consents attest Record an offline, company-attested consent.
POST /v1/manual-consents/claim-links attest Mint a single-use citizen claim link.
POST /v1/manual-consents/{consentRef}/revoke attest Revoke a manual consent.
POST /v1/manual-consents/{consentRef}/erase attest Erase (crypto-shred) a manual consent.
POST /v1/customers/{customerRef}/relationship/end relationship End a customer relationship (art. 23, idempotent).
POST /v1/customers/{customerRef}/relationship/revert relationship Undo a mistaken end of relationship (a correction, art. 11 and 28).

GET /v1/consent-requests lists only the key's own company requests, newest first. Four query parameters, all optional:

Parameter Role
status Filter on a request status. An unknown status is the API's only 400.
customerId Your own customer ref, the one supplied at issuance. Narrows the list to the requests issued for that subject, within your own requests. A ref that is not yours matches nothing.
cursor The requestId to page after (keyset pagination, never offset).
limit Page size, clamped to [1, 100], defaulting to 50. An out-of-range value is clamped rather than rejected; a non-numeric value falls back to the default.

Key identity (whoami)

GET /v1/whoami confirms, server-side, the scopes the presented key actually carries. Any valid key reaches it (any scope). The response is deliberately minimal: only the key's scopes, nothing else. No company id, no key name, no counters.

{ "scopes": ["check", "issue"] }

A missing or invalid key is 401. Like the other endpoints, it is rate-limited (429).

POST /v1/consent-requests/{id}/cancel cancels a pending request (the company-side "revoke before action" path): it flips to the terminal revoked_before_action state, so the citizen deep link no longer leads to an approval. Scope: issue. The request is identified by its protocol requestId and is company-scoped from the key (never a body value).

The call is idempotent: an already-terminal request (approved, refused, expired, or revoked_before_action) is not an error; it returns its current status with cancelled: false. Only the pending-to-cancelled transition returns cancelled: true.

{ "requestId": "0x...", "status": "revoked_before_action", "cancelled": true }

An unknown, foreign, or malformed requestId is 404 (never a cross-tenant write).

The check: deny is a 200

A consent decision, allow or deny, is always a successful 200. Deny is not an error.

{ "decision": "deny", "status": "revoked", "consentRef": "0x...", "assurance": "citizen_signed", "checkedAt": "2026-06-26T15:04:05Z" }

Always branch on decision (allow or deny). The status tells you why, and the vocabulary has eight values:

status decision What it means
active allow An active enforcement record covers the cell.
necessity allow No per-subject record, but the active catalog cell declares a non-consent lawful basis (contract, necessary_for_service, security_fraud, legal_obligation, professional_contact). The response then carries basis and neither consentRef nor assurance: there is no signed proof. The call creates nothing.
none deny No record on a consent-basis cell.
revoked deny The consent was withdrawn.
expired deny The consent's lifespan elapsed (art. 14 al. 3).
erased deny The cell was erased (crypto-shredded).
relationship_ended deny The company attested the relationship is over (art. 23). This is a prospective, relationship-level stop; the per-cell consent stays truthfully active (it was never withdrawn).
sensitive_requires_consent deny No record, and the cell is declared sensitive by the company: it requires express consent and never opens on a bare necessity basis (art. 12 al. 1 in fine and art. 13).

consentRef and assurance are present for every status backed by a record, and absent for none and for necessity. assurance is citizen_signed (citizen-signed) or company_attested (manual consent): the assurance tier of the backing consent.

Agreely records the declared basis; it does not validate it

A necessity allow rests on the lawful basis your company declared on the catalog cell. Agreely records that declaration and enforces it consistently; it does not certify that it is legally sound. Note: professional_contact (art. 1 al. 5) is a scope carve-out, not a necessity ground under art. 12 or art. 9; it appears here because it also produces an allow without consent.

Send the raw human category and purpose; the server resolves them. Resolution is case- and whitespace-insensitive; a French label resolves with or without accents, and an English label resolves with or without accents only if the company has disclosed an English label for that cell. An ambiguous or undisclosed label matches nothing and denies (none). Do not normalize client-side.

The check reads only the status of a single indexed enforcement record and never touches the chain or the indexer, so a revoke denies on the very next call. It is fail-closed: there is no allow without a positive record or a declared basis. An Agreely outage should be treated as a deny too; that is what the SDKs do.

The batch check

POST /v1/check/batch evaluates many cells in one round-trip instead of N calls to /v1/check. Same scope (check), same decision engine, same fail-closed rule, and the same per-decision access log (Loi 25 G3): one batched, per-decision-granular write. It is scoped to the api-key's company. category / purpose are sent raw; the server normalizes them.

Two body shapes are accepted. The flat form:

{ "items": [ { "customerRef": "cust_8812", "category": "Phone number", "purpose": "Billing" } ] }

And the grouped form (expanded server-side into a customer x fields product):

{ "checks": [ { "customerRef": "cust_8812", "fields": [ { "category": "Phone number", "purpose": "Billing" }, { "category": "Email address", "purpose": "Newsletter" } ] } ] }

Batch uses customerRef, the single call uses customerId

A compatibility detail worth knowing: POST /v1/check names the subject field customerId, while POST /v1/check/batch names it customerRef. Both mean your own customer reference, resolved within the api-key's company. The SDKs hide this difference.

The response is a decisions array aligned to the submitted cells. Each decision carries the same fields as a single /v1/check, plus the echoed-back customerRef, category, and purpose:

{ "decisions": [
  { "customerRef": "cust_8812", "category": "Phone number", "purpose": "Billing",
    "decision": "allow", "status": "active", "consentRef": "0x...",
    "assurance": "citizen_signed", "checkedAt": "2026-06-26T15:04:05Z" }
] }

As with the single call, consentRef and assurance are absent for statuses none and necessity, the status vocabulary is identical (including necessity with its basis, relationship_ended, and sensitive_requires_consent), and a cell with no record and no declared basis returns deny / none (fail-closed). An empty batch returns 422, and a batch of more than 500 cells returns 422 (invalid_request: "Batch exceeds the 500-item cap. Split into smaller batches."); split it into smaller batches.

Use case: gating a client-list display

Rendering a table of N customers x M fields is N x M uses of personal information. Under Loi 25, each field you display is a use gated by a check (use-limitation, art. 12; accountability, art. 3.1). The batch collapses the whole grid into one round-trip while keeping a decision and an access-log entry per cell. Batching is an optimization of the same per-decision accountability, never a weakening: every cell is still decided and logged individually.

Manual consents (attest)

The attest scope covers consents collected offline and attested by the company. Four endpoints:

  • POST /v1/manual-consents records a company-attested consent. The body carries customerId, documentVersionId, effectiveDate, validUntil, items (catalog ids or {category, purpose} pairs), and an evidence whose pdfSha256 (a SHA-256 digest, 0x + 64 hex) is required; the base64 pdf is optional, for escrow storage. The 201 response carries consentId, merkleRoot, a consentRefs array (one 0x-hex per cell), assurance: "company_attested", and anchored (false at record time).
  • POST /v1/manual-consents/claim-links mints a single-use citizen claim link: body { customerId } (required) and optional reference; 201 response { claimUrl, token, expiresAt }.
  • POST /v1/manual-consents/{consentRef}/revoke withdraws the consent: optional { reason? } body; 200 response { consentRef, revoked: true, alreadyRevoked }. An unknown reference is 404.
  • POST /v1/manual-consents/{consentRef}/erase performs the erasure (crypto-shred): optional { reason? } body; 200 response { consentRef, erased: true, alreadyErased }. An unknown reference is 404.

A manual consent and a citizen-signed consent allow identically; only the assurance tier differs (company_attested versus citizen_signed). Manual consents do not implement idempotency.

Ending a customer relationship (relationship)

POST /v1/customers/{customerRef}/relationship/end attests, from your own code (e.g. an offboarding flow), that a customer relationship is over, in the sense of Law 25 art. 23 ("les fins sont accomplies"). Scope: relationship. This is the /v1 exposure of the company-UI action: it performs the same terminal lifecycle write, so the required justification, the endedBy origin, the active/ending to ended transition, and any downstream obligation behave identically. It is a pure lifecycle overlay: it never revokes, erases, or hides any per-cell consent.

The customer is identified by your own customerRef (the same one used by /v1/check), resolved within the key's company. An unknown or foreign reference is a clean 404 with nothing written. A missing or blank reason is 422 (never a silent end). The call is idempotent: re-ending an already-ended relationship is a success; the original endedAt and endedBy origin stand. The endpoint never accepts or returns a citizen DID.

{ "customerRef": "cust_8812", "status": "ended", "endedAt": "2026-06-26T15:04:05Z", "endedBy": "company" }

Undoing a mistaken end

POST /v1/customers/{customerRef}/relationship/revert undoes a premature or mistaken company-attested end. It is a correction of an inaccurate record (art. 11 and 28), never a resurrection of dead consent. Scope: relationship. It is the /v1 twin of the undo offered in the company UI, behind exactly the same eligibility gate.

The undo is allowed only if all of these hold: the end was company-attested, nothing was destroyed (no consent withdrawn, no erasure executed), and the call lands within the 30-day correction window. An ineligible relationship (a citizen-driven end, past the window, or after any destruction) is a clean 404 with nothing written: a returning person is a new relationship needing a fresh consent request (art. 14). A missing or blank reason is 422: the correction must carry its justification.

On success the relationship returns to active and the still-active cells, which were never withdrawn, allow again on their own: no re-consent to request, nothing lapsed.

{ "customerRef": "cust_8812", "status": "active", "reverted": true }

The error envelope

Every error (never a deny) shares one shape:

{ "error": { "code": "invalid_request", "message": "…", "field": "category" } }
HTTP code When
401 unauthorized Missing, invalid, or revoked key.
402 billing_inactive The workspace has no active subscription (its trial ended unpaid, or billing lapsed). Every /v1 endpoint returns this until the account owner picks a plan in the app.
403 forbidden Valid key, missing scope.
422 invalid_request Malformed input (field names the offender). The only 400 is the unknown status filter on the request list.
404 not_found No such resource.
429 rate_limited Per-company window exceeded (see Retry-After).

The unavailable code is reserved in the enum, but no endpoint emits it yet.

Rate limits

Limits are per-company, in a fixed window (120 requests per minute by default). Every /v1 endpoint is limited. A 429 carries the rate_limited code and a Retry-After header (integer seconds until the window resets). There is no other rate-limit header. The SDKs surface this as a typed error with retryAfter.

Idempotency

Only POST /v1/consent-requests honors an Idempotency-Key header (255 characters at most). A retry with the same key for the same company replays the original 201 byte-for-byte and creates nothing new, so there is no double-issue and no double-email. The SDKs attach a unique key per call automatically; pass your own to make a retry safe. The manual-consent endpoints do not implement idempotency.

Idempotency-Key: order-4471

Next