API reference
The Agreely /v1 API is the backend the TypeScript,
PHP, and CLI clients wrap. It is stateless and
server-to-server. This page describes every endpoint: its scope, its body, its
response and its refusals. The interactive /v1 reference has the same
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. Each key carries one or more scopes, chosen when it is created in the app:
check(ticked by default): The check (/v1/check,/v1/check/batch), reading the catalog and the documents, one retention statement.issue(ticked by default): Consent requests, reading the catalog and the documents.attest(ticked by default): Paper consents, signature sheets, claim links, the signed paper of a verbal consent, withdrawing and erasing a company-recorded consent.attest_verbal(never ticked by default): Consents given by telephone and their history.relationship(ticked by default): Ending a customer relationship and undoing a mistaken end.registry(ticked by default): A customer's registry identity, retention posture, disposition declarations and retention holds.retention(ticked by default): Retention rules, catalog cells, declared purges and passes.inventory(ticked by default): Declaring a host system's record sets.withdraw(never ticked by default): The withdrawal recorded on the person's behalf, on any consent she gave.holds(never ticked by default): The feed of the retention holds in place.
Each of the three scopes never ticked by default must be a deliberate choice by the
person creating the key. Any valid key, whatever its scope, may call
GET /v1/whoami.
A missing, invalid or revoked key is 401. A valid key lacking the required scope
is 403. A workspace with no active subscription gets 402 on every route
except the withdrawal route (see
the recorded withdrawal).
Endpoints
Identity and check
GET /v1/whoami(any key): The key's scopes and the organization it names.POST /v1/check(scopecheck): Consent decision for one cell.POST /v1/check/batch(scopecheck): Decisions for many cells in one call (500-item cap).
Catalog and documents
GET /v1/catalog(scopecheckorissue): The active catalog, whole or narrowed to one document.GET /v1/catalog/cells(scoperetention): Every cell and the retention rule that governs it.GET /v1/consent-documents(scopecheckorissue): The published consent documents.GET /v1/consent-documents/{code}(scopecheckorissue): One published document, with its full disclosure.GET /v1/consent-documents/versions/{documentVersionId}/pdf(scopescheck,issue,attestorattest_verbal): The information document of one version, as a PDF.
Consent requests
POST /v1/consent-requests(scopeissue): Issue a consent request.GET /v1/consent-requests(scopeissue): List requests, cursor-paged.GET /v1/consent-requests/{id}(scopeissue): One request by itsrequestId.POST /v1/consent-requests/{id}/cancel(scopeissue): Cancel a pending request.
Paper and telephone consents
POST /v1/manual-consents(scopeattest): Record a consent signed on paper.POST /v1/manual-consents/claim-links(scopeattest): Mint a single-use claim link.POST /v1/manual-consents/{consentRef}/revoke(scopeattestorattest_verbal): Withdraw a company-recorded consent.POST /v1/manual-consents/{consentRef}/erase(scopeattest): Erase (crypto-shred) a company-recorded consent.POST /v1/verbal-consents(scopeattest_verbal): Record a consent given by telephone.GET /v1/verbal-consents/{consentId}(scopeattest_verbalorattest): The history of a verbal consent.POST /v1/verbal-consents/{consentId}/paper(scopeattest): Record the signed paper that confirms a verbal consent.POST /v1/customers/{customerRef}/consent-sheets(scopeattest): Produce the signature sheet of a version, with its claim link.
Customers, withdrawal and relationship
PUT /v1/customers/{customerRef}(scoperegistry): Record or amend a customer's registry identity.GET /v1/customers/{customerRef}(scoperegistry): What the registry holds for a customer, as metadata.POST /v1/customers/{customerRef}/consents/{consentRef}/withdrawal(scopewithdraw): Record the withdrawal the person asked for.POST /v1/customers/{customerRef}/relationship/end(scoperelationship): End a customer relationship.POST /v1/customers/{customerRef}/relationship/revert(scoperelationship): Undo a mistaken end of relationship.
Retention
GET /v1/customers/{customerRef}/retention(scoperegistry): One customer's retention posture, with its holds.POST /v1/customers/{customerRef}/retention/dispositions(scoperegistry): Declare what the host system did with the information.POST /v1/customers/{customerRef}/retention/holds(scoperegistry): Place a retention hold.POST /v1/customers/{customerRef}/retention/holds/{holdId}/release(scoperegistry): Release a retention hold.GET /v1/retention/holds(scopeholds): The feed of the holds in place.GET /v1/retention/rules(scoperetention): The retention rules the organization decided.GET /v1/retention/rules/{ruleKey}(scoperetention): One rule and the host's last reports.POST /v1/retention/rules/{ruleKey}/purges(scoperetention): Declare a purge.POST /v1/retention/rules/{ruleKey}/sweeps(scoperetention): Declare a pass that found nothing to purge.
Inventory
PUT /v1/inventory/categories(scopeinventory): Declare a host system's record sets.GET /v1/inventory/categories(scopeinventoryorretention): The declared sets and what your organization decided for each.GET /v1/inventory/statements/{statementKey}(scopesinventory,retentionorcheck): One frozen retention statement.
The customer is always named by your own reference: customerId in the body
of /v1/check, of the consents and of the claim links, customerRef in the path
of the /v1/customers/... routes and in the batch. Both carry the same value,
resolved within the key's company. A reference may contain a slash (STORE/0042):
in a path, percent-encode it (/v1/customers/STORE%2F0042). A reference of . or
.. is refused by every route that creates one (422, reason
invalid_customer_id): no path can carry it. No route accepts or returns a citizen
DID.
Shared conventions
The request body
Send every field in a JSON body (Content-Type: application/json). The routes that
write (consent requests, paper and telephone consents, claim links, signature
sheets, withdrawals and every /v1/customers/..., retention and inventory route)
read only the body: a field placed in the query string is ignored there, so it
never lands in a proxy's logs. Only the documented filters of the GET routes
(status, cursor, changedSince, pageToken, locale, documentCode,
hostSystem) go in the query string. A body that is not a JSON object in valid
UTF-8 is 400 with the reason invalid_json.
The error envelope
Every error (never a consent deny, which is a 200) shares one shape:
{
"error": {
"code": "conflict",
"message": "…",
"reason": "predates_withdrawal",
"field": "effectiveDate"
}
}
codeis the class of the error, tied to the HTTP status.reason, when present, is the stable machine reason of the refusal. Every refusal of the paper-consent, verbal-consent, claim-link, signature-sheet and withdrawal routes carries one.field, when present, names the offending field, in the route's own vocabulary. It accompanies every refusal about a single input, on a422and on a409alike: for exampleeffectiveDateonpredates_withdrawal,validUntilonrenewal_ends_before_current,evidence.pdfSha256oninvalid_pdf_hash, anditemson a paper consent where a telephone consent, or its signed paper, namesanswers. A refusal about the record's state carries none.messageis an English sentence meant for a human. It may be reworded at any time.
Compare reason, never message
Branch your code on code and reason. The text of message is not a contract: a test on that text breaks the day it is reworded. A new reason may be added; treat an unknown reason by its code. The SDKs expose reason and field on their error type.
The retention, hold, registry and inventory routes name their refusal directly in
code (for example already_released, identity_held, new_set_limit). Four
reasons are shared by the routes that carry one: missing_field and
invalid_field (422, with field), invalid_idempotency_key (422) and
invalid_json (400).
400invalid_request: An unreadable body (invalid_json), or an unknownstatusfilter on the request list.400bad_request: A path or a query parameter that is not valid UTF-8, refused before any route runs.401unauthorized: Missing, invalid, or revoked key.402billing_inactive: The workspace has no active subscription. Every route except the withdrawal.403forbidden: Valid key, missing scope.404not_found: No such resource. An unknown, malformed or another organization's reference gets the same answer, byte for byte.409conflictand route codes: The record's state forbids the act;reasonorcodesays which.413body_too_large: The body exceeds the size the route accepts.422invalid_requestand route codes: Invalid input;fieldnames the field.429rate_limitedand caps: The rate window is exceeded (withRetry-After), or a daily cap is reached (withoutRetry-After).
An internal error answers 500 with the code internal_error. The unavailable
code is reserved, but no route emits it yet.
Rate limits
Limits are per company, in a fixed window (120 requests per minute by default).
Every /v1 route is limited. A 429 rate_limited carries a Retry-After header
(integer seconds). A few actions also have a per-company daily cap, over a
rolling 24 hours, which answers 429 without Retry-After: it is not a queue
to wait out, it is a tripwire.
- Verbal consents recorded: 200 (
verbal_daily_cap, reasondaily_cap). - Withdrawals recorded through
/withdrawal: 50 (withdrawal_daily_cap, reasondaily_cap). - Holds placed by the API: 1,000 (
hold_budget_exhausted). - Releases of holds the API did not place: 20 (
hold_release_cap_reached).
Agreely's operator may adjust these caps for a customer whose volume calls for it.
A second pass under the same rule, from the same host system, within 15 minutes is
429 sweep_too_frequent, with Retry-After.
Idempotency
An Idempotency-Key header (1 to 255 printable ASCII characters, otherwise 422
invalid_idempotency_key) makes a retry safe:
Idempotency-Key: order-4471
POST /v1/consent-requests: A retry with the same key replays the original201byte for byte: no double issue, no double email.POST /v1/manual-consents,POST /v1/verbal-consents,POST /v1/verbal-consents/{consentId}/paper,POST .../withdrawal,POST .../retention/holds,POST .../holds/{holdId}/release: The key is bound to the route and to the body. Same key and same body: the first answer is replayed and nothing is written. Same key with another body: a new request. Concurrent requests carrying the same key are processed one at a time.POST .../consent-sheets: The key is a latch, not a replay: the sheet and its link are never kept, so a retry answers409already_mintedand produces nothing.POST /v1/retention/rules/{ruleKey}/purgesand/sweeps: The key is required (422idempotency_key_required) and bound to the API key that declares. Same key and same declaration:200with the stored declaration. Same key and another declaration:422idempotency_key_reused.
The SDKs attach a unique key per call; pass your own so that a retry after a dropped connection is recognized.
Revoked keys and caching
A revoked key answers 401 from the very next call. For your system to notice
quickly without multiplying calls:
- Call
GET /v1/whoamionce a minute, from one scheduler, not from every process. It is the cheapest signal that a key was revoked or that billing lapsed. - On a
401or a402, purge every cache derived from Agreely and fail closed until it is restored. One exception: keep recording withdrawals, which the withdrawal route accepts even during a billing lapse. - Never cache a
/v1/checkanswer. A withdrawal denies on the very next call and reaches only a system that checks again.validUntilis an upper bound, never a cache lease. - Never decide consent from a cached catalog. The catalog and the documents are for building a form; only the check authorizes a use.
Key identity (whoami)
GET /v1/whoami · any valid key
Confirms, server-side, the scopes the presented key actually carries, and the minimum needed to discover the organization rather than hard-code it: its name, the act that governs it and the absolute address of its public privacy page.
{
"scopes": ["check", "issue"],
"company": {
"name": "Acme Inc.",
"sector": "private",
"statute": "P-39.1",
"publicPolicyUrl": "https://app.agreely.ca/p/acme/privacy"
}
}
sector is private or public, statute is P-39.1 or A-2.1.
publicPolicyUrl is null when that page would not resolve (no published policy
and no designated person in charge). The response deliberately carries no
company id, no DID, no key name or prefix, and nothing about the organization's
size, plan, usage or billing: an API key is a bearer credential, and everything it
obtains leaks with it.
The check: deny is a 200
POST /v1/check · scope check
{
"customerId": "cust_8812",
"category": "Phone number",
"purpose": "Billing"
}
A consent decision, allow or deny, is always a 200. Deny is not an error.
{
"decision": "allow",
"status": "active",
"consentRef": "0x3103…c354",
"assurance": "company_documented",
"tier": "verbal",
"validUntil": "2027-10-09T03:59:59Z",
"checkedAt": "2026-10-09T14:05:12Z"
}
{
"decision": "deny",
"status": "revoked",
"consentRef": "0x0fc2…337c",
"assurance": "company_attested",
"tier": "manual",
"validUntil": "2027-10-09T03:59:59Z",
"revokedAt": "2026-10-08T20:08:52Z",
"checkedAt": "2026-10-09T14:05:12Z"
}
{
"decision": "allow",
"status": "necessity",
"basis": "necessary_for_service",
"checkedAt": "2026-10-09T14:05:12Z"
}
Always branch on decision (allow or deny). The status tells you why:
active(allow): A consent in force covers the cell.necessity(allow): No consent on record, but the active catalog cell declares a ground other than consent that the organization's act allows. The response then carriesbasis, and neitherconsentRef, norassurance, nor dates: there is no signed proof. The call creates nothing.none(deny): No consent on a consent-basis cell, or no active cell matches the labels.revoked(deny): The consent was withdrawn.expired(deny): The consent's lifespan elapsed (P-39.1 s. 14, third paragraph; A-2.1 s. 53.1, third paragraph).relationship_ended(deny): The company attested the relationship is over (P-39.1 s. 23; A-2.1 s. 73). This is a prospective stop on the use; its record stays at the active status (it was never withdrawn).requires_depersonalization(deny): The cell declares a ground the organization's act allows only for depersonalized information. The check is asked about a named customer reference, so the condition cannot be met here. It is not "no consent": do not answer it by collecting a consent.basis_not_in_regime(deny): The cell declares a ground the organization's act does not know, typically a cell left in the other sector's vocabulary. The fix is one catalog re-declaration.
Two older values deserve a note. sensitive_requires_consent is no longer
emitted since September 28, 2026: a cell declared sensitive answers by its basis
like any other (on consent it denies none until there is a consent, which must
be express; on a ground other than consent it allows necessity). And erased
stays in the spec's enum, but no answer carries it: the erasure destroys the record
the check reads, and the cell then answers as if there had never been one (none
on a consent-basis cell).
The fields of an answer backed by a consent:
consentRef: the cell's0xreference, used to withdraw and to erase.assurance: how strong the proof is,citizen_signed(signed by the person with her passkey),company_attested(signed on paper, attested by the company) orcompany_documented(given by telephone, documented by the company). A client that only knows the first two values must treat an unknown value as not acceptable.tier: the same tier under its storage name,full,manualorverbal. Your system decides what each tier unlocks; for example, acceptingverbalfor one use and requiringmanualfor another is your choice of evidence, not a legal threshold.validUntil: the consent's end (an ISO 8601 UTC instant), present on every status backed by a consent, includingrevoked,expiredandrelationship_ended. It moves as soon as a consent is renewed: read it on every check.nullonly for a legacy consent recorded with no end.revokedAt: present only whenstatusisrevoked, the instant from which Agreely treats the consent as withdrawn. For a box left blank on a signed paper it is the signature date; for a withdrawal recorded through/withdrawalor/revokeit is the moment it was recorded, not necessarily when the person expressed it.
consentRef, assurance, tier, validUntil and revokedAt are absent (not
null) when no consent is involved: none, necessity,
requires_depersonalization and basis_not_in_regime. A line a document gives for
information, which the person only acknowledged, is not a consent: the check
answers it by the catalog's current basis, as if there were no record.
The values of basis depend on the sector. A private enterprise (P-39.1)
receives contract, necessary_for_service, security_fraud, legal_obligation or
professional_contact. A public body (A-2.1) receives attributions,
programme, entente_collecte, compatible_use, manifest_benefit,
law_application or public_character. Read regime from GET /v1/catalog before
branching on basis.
Agreely records the declared basis; it does not validate it
A necessity allow rests on the lawful basis your organization declared on the catalog cell. Agreely records that declaration and enforces it consistently; it takes no position on whether it is legally sound. Note: professional_contact is a carve-out, not a necessity ground: for information relating to the performance of duties within an enterprise, Divisions II and III do not apply (P-39.1 s. 1, fifth paragraph), so the rules on collection, use and communication do not reach it. It appears here because it also produces an allow without consent. public_character plays the same role for a public body (A-2.1 s. 55, first paragraph). The necessity grounds are those of P-39.1 (ss. 9 and 12) or of A-2.1 (ss. 64 and 65.1).
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 withdrawal denies on the very next call. It is fail-closed: there is no allow without a positive consent or a declared basis. An Agreely outage should be treated as a deny too; that is what the SDKs do.
Refusals: 401, 402, 403, 422 invalid_request (customerId, category or
purpose missing, with field; or one of them sent as a list, an object or a
boolean, with the reason invalid_field), 429.
The batch check
POST /v1/check/batch · scope check
Evaluates many cells in one round-trip, with the same decision engine, the same fail-closed rule and the same per-decision access log. 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"
}
]
}
]
}
The response is a decisions array aligned to the submitted cells. Each
decision carries the same fields as a single /v1/check, including tier,
validUntil and revokedAt, plus the echoed customerRef, category and
purpose:
{
"decisions": [
{
"customerRef": "cust_8812",
"category": "Phone number",
"purpose": "Billing",
"decision": "allow",
"status": "active",
"consentRef": "0x9a2b…7081",
"assurance": "citizen_signed",
"tier": "full",
"validUntil": "2031-01-01T04:59:59Z",
"checkedAt": "2026-10-09T14:05:12Z"
}
]
}
An empty batch or one of more than 500 cells is 422 (invalid_request:
"Batch exceeds the 500-item cap. Split into smaller batches."): split it into
smaller batches. A reference or a label sent as a list, an object or a boolean is
422 with the reason invalid_field. The batch names the subject customerRef,
the single call customerId; the SDKs hide this difference.
Use case: gating a client-list display
Rendering a table of N customers x M fields is N x M uses of personal information. The Act limits use to the purposes of collection (P-39.1 s. 12; A-2.1 s. 65.1) and access to staff who need the information for their duties (P-39.1 s. 20; A-2.1 s. 62). Agreely gates each displayed field with a check and logs the decision, to document that limitation. 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.
The catalog and the documents
Read the catalog
GET /v1/catalog · scope check or issue · optional filter documentCode
The company's active (category, purpose) catalog, read-only. Pass
documentCode to narrow the response to one published document's cells: the
response then adds document, whose documentVersionId is the one the paper and
telephone consents require.
{
"regime": {
"sector": "private",
"statute": "P-39.1"
},
"document": {
"code": "intake-form",
"documentVersionId": "e4d41dc3-c8a5-44b1-96c5-be740c45cc6d"
},
"catalog": [
{
"id": "5b1e…",
"category": "Photo",
"purpose": "Publication",
"description": null,
"legalBasis": "consent",
"sensitive": false
}
]
}
legalBasis is the ground the company declares for the cell; consent means it
requires a consent. An unknown or foreign documentCode gets the same 404.
Read the cells and their rules
GET /v1/catalog/cells · scope retention
Every cell, archived ones included (with their status), each with its English
labels (categoryEn, purposeEn) and retentionRuleKey, the retention rule that
governs it. A null retentionRuleKey is a gap in the register, surfaced and never
filled: the host system abstains.
List the published documents
GET /v1/consent-documents · scope check or issue
Every published document, with its cells:
{
"documents": [
{
"code": "intake-form",
"documentVersionId": "e4d41dc3-c8a5-44b1-96c5-be740c45cc6d",
"version": "3",
"name": "Formulaire d'accueil",
"nameEn": "Intake form",
"effectiveDate": "2026-09-01",
"publishedAt": "2026-08-28T15:00:00Z",
"items": [
{
"id": "5b1e…",
"category": "Photo",
"categoryEn": "Photo",
"purpose": "Publication",
"purposeEn": "Publication",
"sensitive": false
}
]
}
]
}
Resolve a document by its code, stable across versions; the documentVersionId
names one version and changes with every publication.
Read one published document
GET /v1/consent-documents/{code} · scope check or issue
The published document and its full disclosure in French and English
(disclosure, responsable, automatedDecision, profiling, integrity), so your
form shows the published text rather than a paraphrase. Neither language falls back
to the other. An unknown or foreign code gets the same 404.
Download a version's information document
GET /v1/consent-documents/versions/{documentVersionId}/pdf?locale=en · scope
check, issue, attest or attest_verbal
The information document of one version, as a PDF (application/pdf), to print
and hand to the person, with the signature sheet or on its own. It names itself an
information document, reproduces that version's text and its person in charge, and
states that it contains no consent request.
- The version is named by its id, never by its code: the person must receive the text of the version the consent is recorded against. A version superseded since is still served and says it was replaced.
localeisfr(the default) oren. English is refused (422english_text_missing) when a section has no English text, rather than printing an incomplete document.- A draft, unknown or foreign version, or a malformed id, gets the same
404. - The response is
Cache-Control: no-store. Downloading the PDF records nothing: it is not evidence that anyone was informed.
Consent requests (issue)
Issue a request
POST /v1/consent-requests · scope issue · honors Idempotency-Key
{
"customerId": "cust_8812",
"recipientEmail": "[email protected]",
"validUntil": "2027-10-09",
"documentCode": "intake-form"
}
Exactly one of consentDocumentId (the published version id) or documentCode
names the document the request is issued under. The requested cells derive from
the document; an items field is refused (422). The offer is signed server-side
and the email is sent once the request is recorded. 201 response:
{
"requestId": "0x4b08…1234",
"status": "pending",
"deepLink": "https://…",
"emailDelivered": true,
"items": [
{
"category": "Photo",
"purpose": "Publication"
}
],
"document": {
"code": "intake-form",
"name": "Intake form",
"version": "3"
}
}
List requests
GET /v1/consent-requests · scope issue
Lists only the key's own company requests, newest first, with nextCursor
(null at the end). Four parameters, all optional:
| Parameter | Role |
|---|---|
status |
pending, approved, asks_declined, refused, expired or revoked_before_action. An unknown status is 400. |
customerId |
Your own customer reference. A reference 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. Out of range, it is clamped; non-numeric, it falls back to the default. |
asks_declined is a request the person answered by declining every consent
ask, confirming only her receipt of the lines given for information: no consent was
obtained, and such a request never reads as approved.
Read one request
GET /v1/consent-requests/{id} · scope issue
One request by its requestId, with its effective status (a pending request past
its deadline reads expired). Its instants, validUntil, expiresAt, createdAt
and settledAt (null until the request settles), are RFC 3339 in UTC, as in the
list. An unknown or foreign id is 404.
Cancel a request
POST /v1/consent-requests/{id}/cancel · scope issue
Cancels a pending request: it flips to revoked_before_action, and the citizen
link no longer leads to an approval. The call is idempotent: an
already-terminal request returns its current status with cancelled: false.
{
"requestId": "0x4b08…1234",
"status": "revoked_before_action",
"cancelled": true
}
An unknown, foreign, or malformed requestId is 404 (never a cross-tenant write).
Paper consents (attest)
A consent signed by hand on a sheet, then recorded by the organization. Its
response and its check always say assurance: "company_attested" and
tier: "manual": Agreely records that the organization attests it holds a signed
sheet whose digest matches; it does not prove who signed it. The
Record an offline consent guide walks through the whole
path.
Record a paper consent
POST /v1/manual-consents · scope attest · honors Idempotency-Key
{
"customerId": "cust_8812",
"documentVersionId": "e4d41dc3-c8a5-44b1-96c5-be740c45cc6d",
"effectiveDate": "2026-10-08",
"validUntil": "2027-10-31",
"items": [ { "category": "Photo", "purpose": "Publication" } ],
"evidence": { "pdfSha256": "0x9f2c…0d2e" },
"sensitiveExpressAttested": false,
"versionAttested": false
}
itemsnames the consent asks ticked on the sheet ({category, purpose}pairs or catalog ids). It may be empty when every ask got a "no". The server adds every line the document gives for information itself, as an acknowledgement and never as a consent.effectiveDateis the date of the signature: aYYYY-MM-DDdate or an RFC 3339 instant with an offset. An instant in the future, even by one second, is refused (effective_date_in_future).evidence.pdfSha256is the SHA-256 digest (0x+ 64 hex) of the signed sheet, computed on your side.evidence.pdf(base64) is optional, for escrow; it must match the digest.sensitiveExpressAttestedmust be the JSON booleantruewhen a ticked purpose is declared sensitive and rests on consent: the organization attests the consent was given expressly. Agreely records this attestation and the key that sent it; it does not verify it.versionAttestedmust betruewhen the sheet was signed before the chosen version was published: the organization attests the signed sheet asked consent for the same purposes, with the same information.
201 response:
{
"consentId": "6f96…64ff",
"merkleRoot": "0x…",
"consentRefs": [
"0x…",
"0x…"
],
"assurance": "company_attested",
"anchored": false,
"acknowledged": [
{
"category": "Address",
"purpose": "Delivery",
"consentRef": "0x…"
}
],
"asksDeclined": false
}
anchored is false at record time; the anchor follows asynchronously.
asksDeclined is true when no ask was accepted.
A new signed sheet over a paper or verbal consent still in force for the same purpose is accepted, and the check moves to the new one; the earlier one stays recorded as it was. The state refusals:
409stronger_consent_active: A passkey-signed consent is in force for one of these purposes; it is never replaced by a paper consent.409renewal_ends_before_current: The new sheet would end before the consent in force it takes over. Agreely treats a shorter consent as a withdrawal: record a withdrawal instead.409predates_withdrawal: The signature predates a withdrawal recorded for the same purpose; it never reopens it (field:effectiveDate).409relationship_ended: The relationship with this customer has ended: it takes no new consent.409verbal_awaits_paper: A verbal consent of this document is awaiting its signed paper for this customer: record this sheet as its confirmation (/v1/verbal-consents/{consentId}/paper).
Input refusals (422): missing_field, invalid_field, invalid_customer_id,
invalid_document, document_placeholder, notice_only (the document asks no
consent), no_items, invalid_items, invalid_pdf_hash, pdf_hash_mismatch,
pdf_not_pdf, pdf_empty, invalid_effective_date, effective_date_in_future,
invalid_valid_until, valid_until_in_past, valid_until_too_far (beyond 10
years, an Agreely product rule), version_attestation_required,
sensitive_express_required, capacity_declaration_required,
capacity_verification_invalid, no_verified_domain.
Produce a signature sheet
POST /v1/customers/{customerRef}/consent-sheets · scope attest
The signature sheet of a published version, for a customer the organization knows, as a PDF to print, with the claim link the person may use later. It is the same sheet as the app's paper flow: one Yes/No pair per consent ask, the information purposes with no box, the signature, and a printed reference below the signature. It never names the person.
{
"documentVersionId": "e4d41dc3-c8a5-44b1-96c5-be740c45cc6d",
"locale": "en"
}
201 response, Cache-Control: no-store:
{
"signatureSheet": {
"documentVersionId": "e4d41dc3-…",
"locale": "en",
"contentType": "application/pdf",
"filename": "agreely-formulaire-consentement-….pdf",
"pdf": "<base64>"
},
"printedReference": "ABCD-EFGH",
"claim": {
"claimUrl": "https://…",
"token": "…",
"expiresAt": "2026-12-08T14:05:12Z"
}
}
Printing is minting. The printed reference is the second factor of the claim
link minted in the same call, and only its digest is kept: it and the token are
returned once, in this response. A new sheet retires any link still live for
this customer, so the reference on a previous sheet stops working. A retry with the
same Idempotency-Key answers 409 already_minted and produces nothing.
Two rules to follow with a signature sheet
Never send the claim link in the same envelope as the sheet, nor in the same message. The printed reference only defends a link that travels separately: put on one paper, or in one mailing, the two factors become one. The sheet's PDF carries the reference, never the link.
Never send the blank sheet's digest as evidence.pdfSha256. The evidence is the signed sheet, scanned when it comes back; its digest is what POST /v1/manual-consents asks for. That is why this response deliberately carries no digest.
404unknown_customer(codenot_found): The organization holds no record of this customer (the same answer as the claim links). Nothing is written.409relationship_ended(codeconflict): The relationship has ended: no link is minted.409already_minted(code and reason): ThisIdempotency-Keyalready produced a sheet; send a new key to print another.413body_too_large(code and reason): The body exceeds 4,096 bytes.422unknown_field(code and reason): A member the request does not define.422invalid_field(codeinvalid_request):localeis neitherfrnoren.422invalid_document(codeinvalid_request): The version is not the published version of one of the organization's documents.422notice_only(codeno_consent_ask): No purpose of this version is a consent ask: there is nothing to sign.422claim_link_already_live(codeinvalid_request): A link was minted at the same moment for the same customer.
Mint a claim link
POST /v1/manual-consents/claim-links · scope attest
Mints a single-use link the person uses to bind her offline consents to her citizen
decentralized identifier. Body
{ "customerId": "cust_8812", "reference": "order-4471" } (reference optional);
201 response { "claimUrl", "token", "expiresAt" }. Minting a link retires any
link still live for this customer.
Refusals: 404 unknown_customer (no record of this customer, nothing written),
409 relationship_ended, 422 missing_field (no customerId) or
claim_link_already_live.
Withdraw a company-recorded consent
POST /v1/manual-consents/{consentRef}/revoke · scope attest or attest_verbal
The withdrawal of a consent the organization recorded, on paper or by telephone. A
key holding only attest_verbal reaches verbal consents only. Optional body
{ "reason": "…" } (1,000 characters at most, no control characters). 200
response:
{
"consentRef": "0x0fc2…337c",
"revoked": true,
"alreadyRevoked": false,
"gate": "denied"
}
gate says what /v1/check now answers for this purpose: denied (this consent
backed the decision, which now denies), superseded (a more recent consent for the
same purpose already backed the decision and is untouched) or unchanged (an
idempotent repeat). A withdrawal attaches to the purpose, never to one record:
it carries, at the same instant, to every other consent of the same purpose still
running for that person and linked to this one (a renewal and the paper it renews,
a verbal consent and the paper that confirmed it).
404unknown_consent: A missing, malformed or foreign reference, or one outside the key's reach: the same answer.409superseded_by_paper: This verbal consent was confirmed by its signed paper: withdraw theconsentRefof the paper consent that confirmed it (GET /v1/verbal-consents/{consentId}gives it).409citizen_consent_at_gate: The person signed the same purpose online, with her passkey, after this consent: this route never ends a consent the person signed. Record her withdrawal through the withdrawal route, which withdraws both at the same instant. Nothing is written.422invalid_field:reasontoo long or invalid.
Erase a company-recorded consent
POST /v1/manual-consents/{consentRef}/erase · scope attest only
Performs the erasure (crypto-shred) of a company-recorded cell: the cell's salt,
the enforcement record, the receipt and any escrowed PDF are destroyed; the
commitment is kept in the Merkle tree. An erasure cannot be undone, hence the
attest scope alone. Optional body { "reason": "…" }. 200 response:
{
"consentRef": "0x0fc2…337c",
"erased": true,
"alreadyErased": false,
"gate": "denied"
}
Erasing the paper cell that confirmed a verbal consent also erases the verbal
cell. Erasing a consent still in force withdraws (without erasing) the other
consents of the same purpose still running, which keep their evidence. The
refusals are those of the withdrawal: 404 unknown_consent, 409
superseded_by_paper and 409 citizen_consent_at_gate (record the person's
withdrawal first, then erase). Erasure does not consult retention holds: make sure
no access or rectification request is pending.
Telephone consents (attest_verbal)
A consent given by telephone and documented by the organization. It is the
weakest of the three tiers (full > manual > verbal): there is no document, the
proof is the organization's word. The response and the check name it
tier: "verbal" and assurance: "company_documented", never "signed". The
attest_verbal scope is separate and never ticked by default.
Record a verbal consent
POST /v1/verbal-consents · scope attest_verbal · honors Idempotency-Key
{
"customerId": "client-7",
"documentVersionId": "e4d41dc3-c8a5-44b1-96c5-be740c45cc6d",
"answers": [
{ "category": "Photo", "purpose": "Publication", "answer": "yes" },
{ "category": "Address", "purpose": "Meal delivery", "answer": "no" }
],
"obtainedAt": "2026-10-08T14:05:00-04:00",
"obtainedBy": "Luc Gagnon",
"scriptVersion": "intake-v3",
"respondent": { "consentedBy": "self" },
"validUntil": "2027-10-31",
"paperExpected": true
}
- Every ask put on the call gets an explicit answer,
"yes"or"no". Only the "yes" answers become consents; each "no" is kept in the signed receipt. An answer on a line given for information is refused: the server records those lines as acknowledged from the script. documentVersionIdties the consent to the exact version of the document read on the call.scriptVersionis your organization's own label for the script it read (letters, digits,.,_,-, 64 characters at most). The script to read is available in the Agreely web app, on the customer's Verbal consent page.obtainedAtis the instant of the call (the consent date), RFC 3339 with an offset, at most 7 days old and never in the future: a call dated one second ahead of Agreely's clock is refused (obtained_at_in_future). The moment it was recorded is kept beside it.obtainedBynames the staff member who took the call; it is encrypted and kept out of the signed receipt.respondent.consentedByisself,self_with_assistantorrepresentative. For a representative,representativeCapacityis required. For a minor under 14 (isMinor: true), the representative must be the person having parental authority or the tutor, named inrespondent.name.sensitiveExpressAttested: trueis required when a "yes" covers a sensitive purpose resting on consent.paperExpectedsays the organization expects the signed paper back. It is an attribute, not a tier: if the paper never returns, the consent staysverbal, and Agreely does not lapse it for that.
201 response:
{
"consentId": "0a4f…",
"merkleRoot": "0x…",
"consentRefs": [
"0x…"
],
"tier": "verbal",
"assurance": "company_documented",
"anchored": false,
"paperExpected": true,
"acknowledged": [],
"asksDeclined": false
}
A purpose already covered by a stronger consent in force (on paper or by passkey) is never replaced by a verbal consent, except in the renewal window described below.
409stronger_consent_active(codeconflict): A stronger consent is in force for one of these purposes; leave them out. Nothing is recorded.409all_covered(codeconflict): Every ask of the document is already covered by a stronger consent.409renewal_ends_before_current(codeconflict): The new consent would end before the one it takes over.409renewal_predates_current(codeconflict): The renewal is dated before the signature of the paper it renews.409predates_withdrawal(codeconflict): The call is dated before a withdrawal recorded for the same purpose (at the same second, the withdrawal wins).409relationship_ended(codeconflict): The relationship with this customer has ended.429daily_cap(codeverbal_daily_cap): The daily cap on verbal consents is reached.
Input refusals (422): missing_field, invalid_answers, duplicate_answer,
informed_line_answered, nothing_consented, invalid_obtained_at,
obtained_at_in_future, invalid_obtained_by, invalid_script_version,
invalid_respondent, minor_needs_guardian, invalid_valid_until,
valid_until_too_far, sensitive_express_required, and the reasons shared with
paper (invalid_customer_id, invalid_document, document_placeholder,
notice_only, no_items, invalid_items, no_verified_domain).
Renew a paper consent by telephone
A consent signed on paper whose end falls within 30 calendar days (on the
organization's calendar) may be renewed by telephone. Those 30 days are an Agreely
product rule, not a period set by law. The request is an ordinary
POST /v1/verbal-consents; a "yes" for that purpose is accepted:
- it is a new consent, which renews the paper without ever rewriting it;
/v1/checkat once answerstier: "verbal"with the newvalidUntil, until the new signed paper comes back (POST .../paper), which raises it tomanualwith that samevalidUntil. A system requiringmanualtherefore seesverbalbetween the call and the paper;- its
validUntilmay not be earlier than the paper's (renewal_ends_before_current), and the call may not be dated before the paper's signature (renewal_predates_current); - a "no" at renewal withdraws nothing: the paper stays in force until its own end;
- a withdrawal made through any reference in the chain also withdraws the running consent: withdrawing the renewal withdraws the paper, and the other way around.
Outside the window, the request is refused as usual (stronger_consent_active or
all_covered). A withdrawn or lapsed paper blocks nothing. A passkey-signed consent
is never renewed by telephone, even in its last days: renew it with a new online
request.
Record the signed paper of a verbal consent
POST /v1/verbal-consents/{consentId}/paper · scope attest · honors
Idempotency-Key
The signed paper came back. It takes attest (not attest_verbal), because the
paper produces a company-attested consent.
{
"signedAt": "2026-10-20T10:30:00-04:00",
"answers": [
{
"category": "Photo",
"purpose": "Publication",
"answer": "yes"
}
],
"evidence": {
"pdfSha256": "0x…"
}
}
The verbal consent is never rewritten: each purpose ticked on the paper moves into a
new manual consent, and the check follows it. The two dates stay distinct: the
consent dates from the call, the signature is its evidence date. A purpose left
without a "yes" on the paper is recorded as a withdrawal dated at the signature. The
paper must answer exactly the purposes still consented by telephone; a purpose
first consented on paper is a new consent (POST /v1/manual-consents). 201
response:
{
"verbalConsentId": "0a4f…",
"manualConsentId": "7c21…",
"merkleRoot": "0x…",
"tier": "manual",
"confirmed": [
{
"category": "Photo",
"purpose": "Publication",
"verbalConsentRef": "0x…",
"consentRef": "0x…"
}
],
"withdrawn": []
}
Refusals: 404 not_found; 409 already_confirmed (one paper per verbal
consent), lapsed (the verbal consent reached its end) or nothing_to_confirm;
422 invalid_signed_at, signed_at_in_future, paper_answer_missing,
paper_purpose_not_consented_by_phone, as well as invalid_pdf_hash and
pdf_hash_mismatch.
Read a verbal consent's history
GET /v1/verbal-consents/{consentId} · scope attest_verbal or attest
What was said on the telephone, what the paper said, and what is in force today.
The verbal consent keeps tier: "verbal" forever; state is awaiting_paper,
verbal_only or paper_received, and each entry of purposes gives the answer
(yes, no or informed), its status and, when there is one, the paper
consentRef that confirms it. A missing, foreign, malformed or non-verbal id gets
the same 404 (reason not_found).
The withdrawal recorded on the person's behalf (withdraw)
POST /v1/customers/{customerRef}/consents/{consentRef}/withdrawal · scope
withdraw · honors Idempotency-Key
The organization records, on the person's behalf, the withdrawal she asked it
for through any channel, on any consent it holds for her: on paper, by
telephone, or signed online with her passkey. It is the /v1 twin of the customer
page's "Record a withdrawal" action: /v1/check denies at once, the end of the
consent is anchored, and the act is recorded as the organization's attestation,
never as the person's signature. The request may come from the person or from her
representative (for example the person having parental authority or the tutor of a
minor under 14).
{
"channel": "phone",
"operator": "staff-0042",
"requestedAt": "2026-10-08T09:15:00-04:00",
"reason": "Call of October 8: no longer wants the newsletter."
}
channel(required):phone,email,mail,in_personorother.operator(required): the opaque identifier of your staff member who received the request (1 to 64 letters, digits,.,_,:or-). Never a name, never an email address. It is recorded inside the encrypted attestation and never echoed back.requestedAt(optional): when the person asked, as declared, RFC 3339 with an offset, not before the consent withdrawn and not in the future. It is recorded beside the withdrawal without moving the instant Agreely closes the check. Recommendation: stop the use as soon as the person asks, and record the withdrawal without delay.reason(optional): 1,000 characters at most, encrypted, destroyed on erasure, never on chain, never echoed back.
200 response:
{
"consentRef": "0x9f2c…0d2e",
"withdrawn": true,
"alreadyWithdrawn": false,
"recordedOnBehalf": true,
"assurance": "company_attested",
"gate": "denied",
"alsoWithdrawn": [
"0x…"
]
}
recordedOnBehalf: trueandassurance: "company_attested"always: Agreely records that the organization says it received and actioned a request; it did not witness it. The answer is the same whatever the consent's tier, so it never tells whether the person holds a citizen account.gateisdenied(nothing backs the decision any more),superseded(another consent of the same purpose, signed online after the declaredrequestedAt, still backs it and is untouched) orunchanged(a repeat).alsoWithdrawnlists the other consents of the same purpose withdrawn at the same instant. A withdrawal attaches to the purpose: withdrawing through any reference in the chain (a renewal and the paper it renews, a verbal consent and the paper that confirmed it) withdraws the running consent. When the person signed the same purpose online at or before the declaredrequestedAt, that consent is withdrawn too.
The withdrawal is accepted after the relationship ended (the right to withdraw
outlives the relationship) and during a billing lapse: it is the only /v1
route that never answers 402, because honoring a withdrawal is a duty, not a paid
service. A repeat answers 200 with alreadyWithdrawn: true, gate: "unchanged"
and alsoWithdrawn: [], and writes nothing.
404unknown_consent(codenot_found): A missing, malformed or foreign consent reference, an unknown customer reference, or a consent of another customer of the same organization: the same answer, byte for byte.409consent_lapsed(codeconflict): The consent reached its own end: there is nothing left to withdraw. It never means nothing is in force for the purpose; re-read/v1/check.422not_revocable(codeinvalid_request): The cell was never a consent ask (it rests on another ground). The message cites the provision of the act that governs your organization.422invalid_channel,invalid_operator,requested_at_invalid,requested_at_in_future,requested_at_before_grant,invalid_reason(codeinvalid_request): An invalid field.429daily_cap(codewithdrawal_daily_cap): The cap of 50 withdrawals per rolling 24 hours is reached, with noRetry-After. Record further withdrawals from the customer page in Agreely.
A repeat is never refused by the cap, and the
/v1/manual-consents/{consentRef}/revoke route does not use up this budget. On the
first refusal of the day, the workspace owner gets one email (counts and key names
only), and the owner receives a daily summary, per key, of what the organization's
keys recorded.
The customer registry (registry)
The registry scope offers no list route: every route is addressed by one
customer reference you already know. No bulk read is therefore possible, by
construction.
Record a customer's identity
PUT /v1/customers/{customerRef} · scope registry
Records or amends the identity the registry holds for a customer held without
consent, on a documented ground. It is a merge, never a replace: an absent field
stays as it is, a field sent as null or "" is cleared, a field with a value is
written.
{
"displayName": "Léa Tremblay",
"email": "[email protected]",
"legalBasis": "contract",
"basisNote": "Service contract 4471",
"noticeLocale": "en"
}
legalBasis must belong to the organization's sector vocabulary, and may not be
consent (a customer held on consent belongs to the consent flow). 201 when the
identity did not exist, 200 when it was merged; the body is the one of the GET
below.
Refusals: 422 invalid_request with field (a 1 to 200 character reference,
displayName of 200 characters at most, a valid email, basisNote of 2,000
characters at most, a legalBasis outside the vocabulary, a noticeLocale other
than fr or en); 409 identity_erased (a destruction or anonymization
declaration removed the identity: a name, an email, a note or a language are
refused, but a field may still be cleared); 409 identity_held (a hold on all the
information keeps the identity: no change to those four fields, clearing included).
Read what the registry holds
GET /v1/customers/{customerRef} · scope registry
What Agreely holds for that reference, as metadata, never in plain text:
{ "customerRef": "cust_8812", "registered": true, "source": "api",
"hasDisplayName": true, "hasEmail": true, "hasBasisNote": true,
"legalBasis": "contract", "noticeLocale": "en",
"createdAt": "2026-10-01T14:05:12Z", "updatedAt": "2026-10-08T13:15:00Z",
"relationship": { "status": "active", "endedAt": null } }
hasEmail: true, never the address. Agreely is not a place to read your customers
back from: it is where the accountability record lives. A reference the
organization never used is 404.
The customer relationship (relationship)
End a relationship
POST /v1/customers/{customerRef}/relationship/end · scope relationship
Attests, from your own code (e.g. an offboarding flow), that a customer
relationship is over, within the meaning of P-39.1 s. 23: the purposes 'are
achieved' (A-2.1 s. 73 for a public body: the purposes 'have been achieved').
It is the same write as the UI action: it never revokes, erases, or hides any
per-cell consent. Body { "reason": "…" } required (2,000 characters at most). The
call is idempotent: the original endedAt and endedBy origin stand.
{
"customerRef": "cust_8812",
"status": "ended",
"endedAt": "2026-06-26T15:04:05Z",
"endedBy": "company"
}
Refusals: 404 (an unknown or foreign reference, nothing written), 422 (reason
missing or blank; the message cites the organization's act).
Undo a mistaken end
POST /v1/customers/{customerRef}/relationship/revert · scope relationship
Undoes a premature or mistaken company-attested end. It is a correction of an
inaccurate record (P-39.1 s. 11; A-2.1 s. 72), never a resurrection of dead
consent. Allowed only if the end was company-attested, nothing was destroyed
(no consent withdrawn, no erasure) and the call lands within 30 days (an Agreely
product rule, not a period set by law). Otherwise: a clean 404, nothing written; a
returning person is a new relationship: the earlier consent was valid only for
as long as its purposes required (P-39.1 s. 14, third paragraph; A-2.1 s. 53.1,
third paragraph).
Body { "reason": "…" } required.
{ "customerRef": "cust_8812", "status": "active", "reverted": true }
Retention (registry, retention, holds)
Your organization decides, Agreely records and reminds, the host system
executes and reports. Nothing
here verifies what the host system does: every write records a declaration
("status": "declared"), never a verification.
Read a customer's retention posture
GET /v1/customers/{customerRef}/retention · scope registry
{
"customerRef": "cust_8812",
"relationship": {
"status": "ended",
"endedAt": "2026-03-31T20:00:00Z"
},
"clock": {
"trigger": "purpose_achieved",
"recipe": "dueAt = the tenant-local calendar date of endedAt, plus periodMonths calendar months",
"dueAt": null,
"reason": "hold_active",
"rules": [
{
"ruleId": "b7e0…",
"label": "Dossiers clients",
"labelEn": "Customer files",
"periodMonths": 24,
"action": "destroy",
"dueAt": "2028-03-31",
"held": true
}
]
},
"disposition": null,
"holds": [
{
"id": "3c9d…",
"status": "active",
"scope": "all",
"placedBy": "api",
"startedOn": "2026-10-08",
"placedAt": "2026-10-08T13:00:00Z",
"releasedAt": null,
"ground": "rights_request",
"hasProvision": false,
"reviewOn": null,
"hasReleaseReason": false
}
],
"releasedTruncated": false
}
clock.rules[].dueAtis each rule's arithmetic horizon;held: truesays an active hold suspends it, and that this date is not a date to act on.clock.dueAtis the earliest horizon of a rule no hold suspends. It isnullwithreason:no_declared_rule(no rule declared, and no period is invented),relationship_activeorhold_active(every rule is held).holdslists every active hold and the 20 most recently released (releasedTruncatedwhen there are more).placedByisapiororganization;ground,hasProvision,reviewOnandhasReleaseReasonare read back only on a hold the API placed. Never the text of a legal provision or of a release reason.dispositionis the standing declaration, with itsagreelyIdentity.
An unknown, foreign or malformed reference gets the same 404.
Declare what was done with the information
POST /v1/customers/{customerRef}/retention/dispositions · scope registry
The host system declares destroyed, anonymized or legal_hold for a
customer whose relationship has ended.
{ "disposition": "destroyed", "scheduleRef": "CC-2026-04" }
reason is required for legal_hold (it names the law, not the person), and
retentionUntil (YYYY-MM-DD) is allowed only with legal_hold. The ledger is
append-only: a correction is a new append that supersedes the standing declaration.
201 response (appended) or 200 (an identical declaration already standing,
replayed with appended: false):
{
"customerRef": "cust_8812",
"recorded": true,
"status": "declared",
"appended": true,
"id": "d1f0…",
"disposition": "destroyed",
"retentionUntil": null,
"hasReason": false,
"endedAtSnapshot": "2026-03-31T20:00:00Z",
"scheduleRef": "CC-2026-04",
"basis": "contract",
"hasBasisNote": true,
"declaredBy": "api:nightly-purge",
"declaredAt": "2026-10-09T03:00:00Z",
"agreelyIdentity": "erased",
"warnings": []
}
A destruction declaration removes the identity Agreely holds
A destroyed or anonymized declaration that is appended (not replayed) removes, in the same transaction, the name, the email, the basis note and the notice language Agreely's registry holds for this customer. The reference, the declared ground, the declarations, the consent evidence and the rights register remain. Nothing is removed on a legal_hold, nor while a hold on all the customer's information is in place (releasing that hold then removes them). Existing backups are not altered.
agreelyIdentity says what the declaration did: erased (the identity was
removed), none_held (nothing was held), retained_hold (a hold keeps it) or
retained (a legal_hold); null on a declaration older than this field. A
declaration made while a hold is active is recorded, never refused, with a
{ "code": "hold_active", "holdIds": […] } warning in warnings.
Refusals: 404; 409 relationship_active (the relationship has not ended: end
it first, or place a hold to keep information while it continues); 422
invalid_request.
Place a retention hold
POST /v1/customers/{customerRef}/retention/holds · scope registry · honors
Idempotency-Key
Records that a customer's information must be kept despite the retention rules, on a live or an ended relationship.
{
"ground": "other_law",
"provision": "A retention duty set by another law, as cited by the organization",
"scope": {
"rules": [
"b7e0…"
],
"cells": []
},
"startedOn": "2026-10-08",
"reviewOn": "2027-04-08"
}
ground(required):rights_request(the information is the subject of an access or rectification request: P-39.1 s. 36; A-2.1 s. 102.1) orother_law.provision: required forother_law, refused otherwise. The provision is recorded as written, never verified (2,000 characters at most). What the law reserves differs by regime: P-39.1 (s. 23) reserves a retention period set by a law; A-2.1 (s. 73) reserves only the Archives Act and the Professional Code.scope:"all"(the default), or{ "rules": [...], "cells": [...] }with the keys ofGET /v1/retention/rulesandGET /v1/catalog/cells(200 of each at most). Only a hold onallalso keeps the registry identity after a destruction declaration.startedOn(YYYY-MM-DD, today or earlier on the organization's calendar, today by default) andreviewOn(a reminder, never an automatic release).
Nothing releases a hold by itself: a release is an explicit act, with a reason.
201 response with the hold (customerRef, recorded: true, replayed: false),
or 200 when the same Idempotency-Key and the same body replay the first answer.
A host system never links a rights request: the rights register places its own
linked holds.
Refusals: 404 (an unknown, foreign, malformed or over-long reference); 422
invalid_request (an unknown, foreign or malformed rule or cell key, an invalid
field), unknown_field (any undefined field, rightsRequestId included) or
too_many_holds (50 active holds per customer); 429 hold_budget_exhausted
(1,000 holds placed by the API per 24 hours).
Release a retention hold
POST /v1/customers/{customerRef}/retention/holds/{holdId}/release · scope
registry · honors Idempotency-Key
Lifts any active hold of the organization on this customer, including those the
rights register or a member of the organization placed. Body { "reason": "…" }
required (2,000 characters at most, encrypted, never echoed); any other field is
422 unknown_field. The release is recorded and attributed to the key.
{
"customerRef": "cust_8812",
"recorded": true,
"id": "3c9d…",
"status": "released",
"scope": "all",
"placedBy": "organization",
"startedOn": "2026-10-01",
"placedAt": "2026-10-01T15:00:00Z",
"releasedAt": "2026-10-09T14:05:12Z",
"agreelyIdentity": null
}
Releasing a hold the rights register placed is recorded and never refused; any duty to keep the information concerned until the person has exhausted her recourses remains the organization's, and Agreely does not verify it. Every release of a hold your system did not place is emailed to the organization's owner, and capped at 20 per rolling 24 hours; releasing the holds your system placed is not capped.
When this is the last active hold on all the information and a destruction or
anonymization declaration stands, the release removes the registry identity in the
same transaction: agreelyIdentity is then erased. Otherwise retained_hold,
none_held or null (no such declaration).
Refusals: 404 (an unknown, foreign, malformed or another customer's hold id);
409 already_released; 422; 429 hold_release_cap_reached.
Read the holds feed
GET /v1/retention/holds · scope holds · filters changedSince and pageToken
What a purge job reads before it purges: the references to spare and the scope
of each hold, never the ground, the origin, the legal provision or any date
other than changedAt. The holds scope is granted separately and never ticked by
default: a purge job's key deliberately carries retention and holds.
{
"holds": [
{
"id": "3c9d…",
"customerRef": "cust_8812",
"status": "active",
"scope": "all",
"changedAt": "2026-10-08T13:00:00Z"
}
],
"nextPageToken": null,
"cursor": "2026-10-09T13:58:00Z"
}
- Without
changedSince: every active hold, a snapshot that replaces your system's whole active set. - With
changedSince: every hold placed or released since, with itsstatus. - Pages of 500. Pass
nextPageTokenback unchanged aspageTokenuntil a page answerscursor, the next sync'schangedSince. The token is opaque but not signed: send it back unmodified. - The cursor trails the read, so a hold still committing is sent again rather than
missed: delivery is at least once, upsert by
id. - A malformed or future
changedSinceorpageTokenis422.
When in doubt, purge nothing
Treat a 401, a 403 or a 5xx error on this feed as "purge nothing". A purge made without knowing the holds in place cannot be undone.
Read the retention rules
GET /v1/retention/rules · scope retention · optional filter changedSince
Every rule of the company, archived ones included, with its decided duration:
{
"cursor": "2026-10-09T13:55:00Z",
"rules": [
{
"key": "b7e0…",
"label": "Dossiers clients",
"labelEn": "Customer files",
"duration": {
"value": 24,
"unit": "months"
},
"trigger": "purpose_achieved",
"action": "destroy",
"anonymizationProcessKey": null,
"status": "active",
"archivedAt": null,
"updatedAt": "2026-09-01T12:00:00Z"
}
],
"ruleKeys": [
"b7e0…"
]
}
Pass the previous cursor as changedSince to receive only the rules changed
since; ruleKeys always carries the complete key set, so a deleted rule shows as a
key that disappeared. trigger is collection, purpose_achieved,
last_activity or decision; action is destroy or anonymize.
GET /v1/retention/rules/{ruleKey} returns one rule and lastReports, the latest
purge and pass instants declared by the host, each beside when Agreely received
it. They are neither an overdue flag nor a next due date. An unknown, foreign or
malformed key gets the same 404.
Declare a purge
POST /v1/retention/rules/{ruleKey}/purges · scope retention · Idempotency-Key
required
{
"ranAt": "2026-10-09T03:00:00-04:00",
"recordsAffected": 42,
"method": "destroyed",
"coveredFrom": "2024-01-01",
"coveredUntil": "2024-09-30",
"hostSystem": "crm",
"hostCategory": "customer-files"
}
method is destroyed or anonymized (anonymizationProcessKey required with
anonymized, refused with destroyed). hostSystem and hostCategory are short
identifiers (lowercase letters, digits, ., _, -) that name a system or a
kind of records, never a record. references, optional, lists at most 1,000
identifiers of your own records (no more than recordsAffected); Agreely never keeps
their text, only a keyed lookup, an encrypted salt and a salted commitment. 201
response with the declaration (status: "declared", referencesRecorded,
warnings), or 200 for a replay. A method that differs from the rule's action is
recorded as declared and flagged in warnings (method_differs_from_rule). A pass
that found nothing is a pass, not a purge of zero records.
Refusals: 404; 409 retry (a concurrent retry could not be settled: retry with
the same key); 422 idempotency_key_required, idempotency_key_reused,
unknown_field, invalid_method, anonymization_process_required,
invalid_anonymization_process, invalid_records_affected, ran_at_in_future,
invalid_covered_period, invalid_host_token, host_token_limit.
Declare a pass with no purge
POST /v1/retention/rules/{ruleKey}/sweeps · scope retention · Idempotency-Key
required
The heartbeat: the host system declares it ran a pass under this rule and found
nothing to purge. Without it, a host that stopped purging and a host with nothing to
purge look the same. Body { "sweptAt": "…", "hostSystem": "crm" }; sweptAt must
fall within the last 24 hours. 201 response with declaredNothingDue: true: what
the host said, never a fact Agreely established. Refusals: 404; 422
swept_at_in_future and the purge's input refusals; 429 sweep_too_frequent.
The inventory (inventory)
Declare a system's record sets
PUT /v1/inventory/categories · scope inventory
A host system declares the complete list of its record sets and the names of their fields. Never a value: you declare that a set has a "Date of birth" field, you never send a date of birth.
{
"hostSystem": "crm",
"categories": [
{
"key": "customer-files",
"label": "Dossiers clients",
"labelEn": "Customer files",
"fields": [
{
"key": "birth-date",
"label": "Date de naissance",
"labelEn": "Date of birth"
}
]
}
]
}
A set no longer declared is withdrawn (it stays visible, marked withdrawn). An
empty list is refused rather than read as "withdraw everything". 200 response:
{ "hostSystem", "withdrawn", "categories" }, each set with what your organization
decided for it (decision.state: decided, no_cell or no_rule) and its
retentionStatement.
Limits: 200 sets per declaration, 60 fields per set, 120 characters per set label,
200 per field label, a 256 KiB body. At most 1,000 new set keys per rolling 30
days (422 new_set_limit; a key declared before is always accepted), and 10 new
host systems per 30 days (422 host_system_limit). A label shaped like a value (an
email address, a run of five digits, a date) is refused.
Refusals: 413 body_too_large; 422 invalid_request, unknown_field,
value_shaped_label, invalid_key, duplicate_key, limit_exceeded,
empty_declaration, new_set_limit, host_system_limit. A refusal names a
path (categories[3].fields[0].label), never what was sent there.
What these filters cannot do
"Jean-Paul's registrations" is a well-formed label: no filter tells a person's name from a category's. Labels and keys are shown only on your own organization's surfaces, where the person in charge reads them in the register; they never reach another company, nor an anchor on chain.
Read the inventory
GET /v1/inventory/categories · scope inventory or retention · optional filter
hostSystem
The declared sets, each with the cell and the rule Agreely linked it to, and its retention statement.
Read a frozen retention statement
GET /v1/inventory/statements/{statementKey} · scope inventory, retention or
check
The statement a host system stamped on a record at collection. It is frozen: a record collected under "3 months" keeps a key that resolves to "3 months" after the rule becomes 2.
{
"categoryKey": "customer-files",
"hostSystem": "crm",
"current": true,
"supersededAt": null,
"key": "8a7b…",
"revision": 2,
"frozenAt": "2026-09-01T12:00:00Z",
"decided": true,
"duration": {
"value": 24,
"unit": "months"
},
"trigger": "purpose_achieved",
"action": "destroy",
"text": {
"fr": "…",
"en": "…"
}
}
A set with no decided duration has a decided: false statement, with duration and
text set to null: the host system abstains, it never invents a number. An
unknown key is 404.
Next
- The interactive
/v1reference for full schemas. - Record an offline consent: paper, telephone, renewal and the signed paper, step by step.
- TypeScript SDK, PHP SDK, and CLI wrap all of this.