FR EN

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 (scope check): Consent decision for one cell.
  • POST /v1/check/batch (scope check): Decisions for many cells in one call (500-item cap).

Catalog and documents

  • GET /v1/catalog (scope check or issue): The active catalog, whole or narrowed to one document.
  • GET /v1/catalog/cells (scope retention): Every cell and the retention rule that governs it.
  • GET /v1/consent-documents (scope check or issue): The published consent documents.
  • GET /v1/consent-documents/{code} (scope check or issue): One published document, with its full disclosure.
  • GET /v1/consent-documents/versions/{documentVersionId}/pdf (scopes check, issue, attest or attest_verbal): The information document of one version, as a PDF.

Consent requests

  • POST /v1/consent-requests (scope issue): Issue a consent request.
  • GET /v1/consent-requests (scope issue): List requests, cursor-paged.
  • GET /v1/consent-requests/{id} (scope issue): One request by its requestId.
  • POST /v1/consent-requests/{id}/cancel (scope issue): Cancel a pending request.

Paper and telephone consents

  • POST /v1/manual-consents (scope attest): Record a consent signed on paper.
  • POST /v1/manual-consents/claim-links (scope attest): Mint a single-use claim link.
  • POST /v1/manual-consents/{consentRef}/revoke (scope attest or attest_verbal): Withdraw a company-recorded consent.
  • POST /v1/manual-consents/{consentRef}/erase (scope attest): Erase (crypto-shred) a company-recorded consent.
  • POST /v1/verbal-consents (scope attest_verbal): Record a consent given by telephone.
  • GET /v1/verbal-consents/{consentId} (scope attest_verbal or attest): The history of a verbal consent.
  • POST /v1/verbal-consents/{consentId}/paper (scope attest): Record the signed paper that confirms a verbal consent.
  • POST /v1/customers/{customerRef}/consent-sheets (scope attest): Produce the signature sheet of a version, with its claim link.

Customers, withdrawal and relationship

  • PUT /v1/customers/{customerRef} (scope registry): Record or amend a customer's registry identity.
  • GET /v1/customers/{customerRef} (scope registry): What the registry holds for a customer, as metadata.
  • POST /v1/customers/{customerRef}/consents/{consentRef}/withdrawal (scope withdraw): Record the withdrawal the person asked for.
  • POST /v1/customers/{customerRef}/relationship/end (scope relationship): End a customer relationship.
  • POST /v1/customers/{customerRef}/relationship/revert (scope relationship): Undo a mistaken end of relationship.

Retention

  • GET /v1/customers/{customerRef}/retention (scope registry): One customer's retention posture, with its holds.
  • POST /v1/customers/{customerRef}/retention/dispositions (scope registry): Declare what the host system did with the information.
  • POST /v1/customers/{customerRef}/retention/holds (scope registry): Place a retention hold.
  • POST /v1/customers/{customerRef}/retention/holds/{holdId}/release (scope registry): Release a retention hold.
  • GET /v1/retention/holds (scope holds): The feed of the holds in place.
  • GET /v1/retention/rules (scope retention): The retention rules the organization decided.
  • GET /v1/retention/rules/{ruleKey} (scope retention): One rule and the host's last reports.
  • POST /v1/retention/rules/{ruleKey}/purges (scope retention): Declare a purge.
  • POST /v1/retention/rules/{ruleKey}/sweeps (scope retention): Declare a pass that found nothing to purge.

Inventory

  • PUT /v1/inventory/categories (scope inventory): Declare a host system's record sets.
  • GET /v1/inventory/categories (scope inventory or retention): The declared sets and what your organization decided for each.
  • GET /v1/inventory/statements/{statementKey} (scopes inventory, retention or check): 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"
  }
}
  • code is 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 a 422 and on a 409 alike: for example effectiveDate on predates_withdrawal, validUntil on renewal_ends_before_current, evidence.pdfSha256 on invalid_pdf_hash, and items on a paper consent where a telephone consent, or its signed paper, names answers. A refusal about the record's state carries none.
  • message is 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).

  • 400 invalid_request: An unreadable body (invalid_json), or an unknown status filter on the request list.
  • 400 bad_request: A path or a query parameter that is not valid UTF-8, refused before any route runs.
  • 401 unauthorized: Missing, invalid, or revoked key.
  • 402 billing_inactive: The workspace has no active subscription. Every route except the withdrawal.
  • 403 forbidden: Valid key, missing scope.
  • 404 not_found: No such resource. An unknown, malformed or another organization's reference gets the same answer, byte for byte.
  • 409 conflict and route codes: The record's state forbids the act; reason or code says which.
  • 413 body_too_large: The body exceeds the size the route accepts.
  • 422 invalid_request and route codes: Invalid input; field names the field.
  • 429 rate_limited and caps: The rate window is exceeded (with Retry-After), or a daily cap is reached (without Retry-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, reason daily_cap).
  • Withdrawals recorded through /withdrawal: 50 (withdrawal_daily_cap, reason daily_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 original 201 byte 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 answers 409 already_minted and produces nothing.
  • POST /v1/retention/rules/{ruleKey}/purges and /sweeps: The key is required (422 idempotency_key_required) and bound to the API key that declares. Same key and same declaration: 200 with the stored declaration. Same key and another declaration: 422 idempotency_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/whoami once 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 401 or a 402, 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/check answer. A withdrawal denies on the very next call and reaches only a system that checks again. validUntil is 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 carries basis, and neither consentRef, nor assurance, 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's 0x reference, 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) or company_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, manual or verbal. Your system decides what each tier unlocks; for example, accepting verbal for one use and requiring manual for 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, including revoked, expired and relationship_ended. It moves as soon as a consent is renewed: read it on every check. null only for a legacy consent recorded with no end.
  • revokedAt: present only when status is revoked, 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 /withdrawal or /revoke it 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.
  • locale is fr (the default) or en. English is refused (422 english_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.

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.

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
}
  • items names 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.
  • effectiveDate is the date of the signature: a YYYY-MM-DD date or an RFC 3339 instant with an offset. An instant in the future, even by one second, is refused (effective_date_in_future).
  • evidence.pdfSha256 is 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.
  • sensitiveExpressAttested must be the JSON boolean true when 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.
  • versionAttested must be true when 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:

  • 409 stronger_consent_active: A passkey-signed consent is in force for one of these purposes; it is never replaced by a paper consent.
  • 409 renewal_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.
  • 409 predates_withdrawal: The signature predates a withdrawal recorded for the same purpose; it never reopens it (field: effectiveDate).
  • 409 relationship_ended: The relationship with this customer has ended: it takes no new consent.
  • 409 verbal_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.

  • 404 unknown_customer (code not_found): The organization holds no record of this customer (the same answer as the claim links). Nothing is written.
  • 409 relationship_ended (code conflict): The relationship has ended: no link is minted.
  • 409 already_minted (code and reason): This Idempotency-Key already produced a sheet; send a new key to print another.
  • 413 body_too_large (code and reason): The body exceeds 4,096 bytes.
  • 422 unknown_field (code and reason): A member the request does not define.
  • 422 invalid_field (code invalid_request): locale is neither fr nor en.
  • 422 invalid_document (code invalid_request): The version is not the published version of one of the organization's documents.
  • 422 notice_only (code no_consent_ask): No purpose of this version is a consent ask: there is nothing to sign.
  • 422 claim_link_already_live (code invalid_request): A link was minted at the same moment for the same customer.

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.

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

  • 404 unknown_consent: A missing, malformed or foreign reference, or one outside the key's reach: the same answer.
  • 409 superseded_by_paper: This verbal consent was confirmed by its signed paper: withdraw the consentRef of the paper consent that confirmed it (GET /v1/verbal-consents/{consentId} gives it).
  • 409 citizen_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.
  • 422 invalid_field: reason too long or invalid.

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.

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.
  • documentVersionId ties the consent to the exact version of the document read on the call. scriptVersion is 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.
  • obtainedAt is 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.
  • obtainedBy names the staff member who took the call; it is encrypted and kept out of the signed receipt.
  • respondent.consentedBy is self, self_with_assistant or representative. For a representative, representativeCapacity is required. For a minor under 14 (isMinor: true), the representative must be the person having parental authority or the tutor, named in respondent.name.
  • sensitiveExpressAttested: true is required when a "yes" covers a sensitive purpose resting on consent.
  • paperExpected says the organization expects the signed paper back. It is an attribute, not a tier: if the paper never returns, the consent stays verbal, 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.

  • 409 stronger_consent_active (code conflict): A stronger consent is in force for one of these purposes; leave them out. Nothing is recorded.
  • 409 all_covered (code conflict): Every ask of the document is already covered by a stronger consent.
  • 409 renewal_ends_before_current (code conflict): The new consent would end before the one it takes over.
  • 409 renewal_predates_current (code conflict): The renewal is dated before the signature of the paper it renews.
  • 409 predates_withdrawal (code conflict): The call is dated before a withdrawal recorded for the same purpose (at the same second, the withdrawal wins).
  • 409 relationship_ended (code conflict): The relationship with this customer has ended.
  • 429 daily_cap (code verbal_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).

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/check at once answers tier: "verbal" with the new validUntil, until the new signed paper comes back (POST .../paper), which raises it to manual with that same validUntil. A system requiring manual therefore sees verbal between the call and the paper;
  • its validUntil may 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.

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_person or other.
  • 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: true and assurance: "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.
  • gate is denied (nothing backs the decision any more), superseded (another consent of the same purpose, signed online after the declared requestedAt, still backs it and is untouched) or unchanged (a repeat).
  • alsoWithdrawn lists 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 declared requestedAt, 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.

  • 404 unknown_consent (code not_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.
  • 409 consent_lapsed (code conflict): 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.
  • 422 not_revocable (code invalid_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.
  • 422 invalid_channel, invalid_operator, requested_at_invalid, requested_at_in_future, requested_at_before_grant, invalid_reason (code invalid_request): An invalid field.
  • 429 daily_cap (code withdrawal_daily_cap): The cap of 50 withdrawals per rolling 24 hours is reached, with no Retry-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[].dueAt is each rule's arithmetic horizon; held: true says an active hold suspends it, and that this date is not a date to act on.
  • clock.dueAt is the earliest horizon of a rule no hold suspends. It is null with reason: no_declared_rule (no rule declared, and no period is invented), relationship_active or hold_active (every rule is held).
  • holds lists every active hold and the 20 most recently released (releasedTruncated when there are more). placedBy is api or organization; ground, hasProvision, reviewOn and hasReleaseReason are read back only on a hold the API placed. Never the text of a legal provision or of a release reason.
  • disposition is the standing declaration, with its agreelyIdentity.

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) or other_law.
  • provision: required for other_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 of GET /v1/retention/rules and GET /v1/catalog/cells (200 of each at most). Only a hold on all also keeps the registry identity after a destruction declaration.
  • startedOn (YYYY-MM-DD, today or earlier on the organization's calendar, today by default) and reviewOn (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 its status.
  • Pages of 500. Pass nextPageToken back unchanged as pageToken until a page answers cursor, the next sync's changedSince. 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 changedSince or pageToken is 422.

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