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:
checkauthorizesPOST /v1/check.issueauthorizes the consent-request endpoints.attestauthorizes the manual-consent endpoints.relationshipauthorizes ending a customer relationship (art. 23).- The
checkorissuescope may readGET /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).
Cancelling a consent request
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-consentsrecords a company-attested consent. The body carriescustomerId,documentVersionId,effectiveDate,validUntil,items(catalog ids or{category, purpose}pairs), and anevidencewhosepdfSha256(a SHA-256 digest,0x+ 64 hex) is required; the base64pdfis optional, for escrow storage. The201response carriesconsentId,merkleRoot, aconsentRefsarray (one0x-hex per cell),assurance: "company_attested", andanchored(false at record time).POST /v1/manual-consents/claim-linksmints a single-use citizen claim link: body{ customerId }(required) and optionalreference;201response{ claimUrl, token, expiresAt }.POST /v1/manual-consents/{consentRef}/revokewithdraws the consent: optional{ reason? }body;200response{ consentRef, revoked: true, alreadyRevoked }. An unknown reference is404.POST /v1/manual-consents/{consentRef}/eraseperforms the erasure (crypto-shred): optional{ reason? }body;200response{ consentRef, erased: true, alreadyErased }. An unknown reference is404.
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
- The interactive
/v1reference for full schemas. - TypeScript SDK, PHP SDK, and CLI wrap all of this.