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 ending a customer relationship (art. 23).
  • 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).

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

status is one of active (allow), none, revoked, expired, or erased. consentRef is present for every status except none. The body also carries assurance, citizen_signed (citizen-signed) or company_attested (manual consent), present for every status except none: it is the assurance tier of the backing consent. Send the raw human category and purpose; the server normalizes them. 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. 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 status none, and a cell with no active record 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" }

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