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. None is ticked in advance: every scope is a deliberate choice by the person creating the key, and a scope added later is never granted by default.

  • check: The check (/v1/check, /v1/check/batch), reading the catalog and the documents, one retention statement.
  • issue: Consent requests, reading the catalog and the documents.
  • attest: Paper consents, signature sheets, claim links, the signed paper of a verbal consent, withdrawing and erasing a company-recorded consent.
  • attest_verbal: Consents given by telephone and their history.
  • relationship: Ending a customer relationship and undoing a mistaken end.
  • registry: A customer's registry identity, retention posture, disposition declarations and retention holds.
  • retention: Retention rules, catalog cells, declared purges and passes.
  • inventory: Declaring a host system's record sets.
  • withdraw: The withdrawal recorded on the person's behalf, on any consent they gave.
  • holds: The feed of the retention holds in place.
  • diffusion: Declaring how a published policy was made known.

Any valid key, whatever its scope, may call GET /v1/whoami and GET /v1/policies/current.

A missing, invalid or revoked key is 401. A valid key lacking the required scope is 403 forbidden. A workspace with no active subscription gets 402 on every route except the withdrawal route (see the recorded withdrawal). A workspace suspended by Agreely's operator gets 403 workspace_suspended (code and reason) on every route except the withdrawal. A workspace closed for its departure from Agreely gets 403 forbidden with the reason workspace_sealed on every route, reads included (the withdrawal stays open until the destruction), then the reason workspace_shredding once its destruction has begun, withdrawal included.

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.

Privacy policy

  • GET /v1/policies/current (any key): The privacy policy in force, for the organization or for a product.
  • POST /v1/policies/{policyId}/diffusions (scope diffusion): Declare how a published policy was made known.

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, inventory and diffusion route) read only the body: a field placed in the query string is ignored there, so it never lands in a proxy's logs. /v1/check, /v1/check/batch and POST /v1/consent-requests go further: a field they expect, sent in the query string and absent from the body, is 422 with the reason fields_in_query. Only the documented filters of the GET routes (status, customerId, cursor, limit, changedSince, pageToken, locale, documentCode, hostSystem, type, product) 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; or a closed workspace (reasons workspace_sealed and workspace_shredding).
  • 403 workspace_suspended: The workspace is suspended. Every route except the withdrawal.
  • 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.
  • 409 retry: A concurrent request held a lock this one needed, and nothing was written. Send the same request again (with the same Idempotency-Key) after the Retry-After delay.
  • 410 gone (reason replay_erased): The consent request an Idempotency-Key created has since been erased; send a new key.
  • 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).
  • 503 unavailable: The rate-limit count could not be taken in time; nothing was done. Send the same request again after the Retry-After delay.

An internal error answers 500 with the code internal_error.

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).
  • Privacy-policy diffusions declared through the API: 50 (diffusion_daily_cap, reason daily_cap).

These caps apply to every organisation and cannot be set for a single customer. Three of them (verbal consents, withdrawals, releases) are set by the platform's configuration: changing one changes it for every customer at once. The other two (holds placed, diffusions) are fixed. 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, no space, otherwise 422 invalid_idempotency_key) makes a retry safe:

Idempotency-Key: order-4471
  • POST /v1/consent-requests: A retry with the same key and the same body replays the original 201 byte for byte: no double issue, no double email. The same key with another body is 422 idempotency_key_reused.
  • 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 with the same body answers 409 already_minted and produces nothing. The same key with another body is 422 idempotency_key_reused.
  • POST /v1/policies/{policyId}/diffusions: The key is required (422 idempotency_key_required). Same key and same body: the original 201 is replayed. Same key and another body: 422 idempotency_key_reused.
  • 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",
    "slug": "acme",
    "statute": "P-39.1",
    "publicPolicyUrl": "https://app.agreely.ca/p/acme/privacy"
  }
}

slug is the organization's public identifier, the one already in the address of its public pages. 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 internal 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. 12, first paragraph; A-2.1 s. 65.1, first paragraph: use is confined to the purposes of collection). 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 their 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
    }
  ],
  "products": [
    {
      "productKey": "shop",
      "name": "Online shop"
    }
  ]
}

legalBasis is the ground the company declares for the cell; consent means it requires a consent. products lists the organization's active products; their productKey is the one GET /v1/policies/current expects. 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"
  }
}

Refusals (422 invalid_request, with field): customerId, recipientEmail or validUntil missing or invalid, neither or both of consentDocumentId and documentCode, a code with no published document. A validUntil that is not in the future or lies more than 10 years ahead (an Agreely product rule) is refused without field. A document that cannot be issued yet carries a reason: document_placeholder (the document still holds sample text) or document_refusal_consequences_missing (it does not state the consequences of a refusal). An organization whose domain is not verified cannot issue a request.

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, revoked_before_action or erased. 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 their receipt of the lines given for information: no consent was obtained, and such a request never reads as approved. erased is an approved request whose consent has since been fully erased.

{
  "requests": [
    {
      "requestId": "0x4b08…1234",
      "status": "pending",
      "validUntil": "2027-10-09T03:59:59.000000Z",
      "expiresAt": "2026-11-08T14:05:12.000000Z",
      "createdAt": "2026-10-09T14:05:12.000000Z",
      "settledAt": null,
      "customerId": "cust_8812",
      "documentCode": "intake-form",
      "items": [
        {
          "category": "Photo",
          "purpose": "Publication"
        }
      ],
      "document": {
        "code": "intake-form",
        "name": "Intake form",
        "version": "3"
      }
    }
  ],
  "nextCursor": null
}

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.
  • isMinor (JSON boolean) or ageBracket (under_14 or 14_to_17) declare a minor. For a minor under 14, consent is given by the holder of parental authority or the tutor: capacity.consentedBy is representative and capacity.representativeCapacity is titulaire_autorite_parentale or tuteur_mineur (otherwise minor_needs_guardian). guardianName (1 to 128 characters) may name that representative, for a minor under 14 only.
  • capacity (optional) describes who consented: consentedBy (self, self_with_assistant or representative), and for a representative or an assisted person representativeCapacity (tutelle, mandat_protection_homologue, representation_temporaire, curatelle, other, undeclared, or the two values for a minor), verificationOutcome (verified, inconclusive, mismatch or unverified, required in both cases), verificationMethod (representation_register, assistants_register, document_shown or other), verifiedOn, identifierSeen, identifierKind (measure_number or court_file), reviewOn, assistanceRequested, assistanceProvided, assistanceProvidedOn and askedPersonFirst. An element of items may carry its own consentedBy. Agreely records the declaration and the key that sent it; it verifies neither the measure nor the representative's identity.
  • Any other member, in the body, in evidence or in an element of items, is 422 unknown_field.

201 response:

{
  "consentId": "6f96…64ff",
  "merkleRoot": "0x…",
  "consentRefs": [
    "0x…",
    "0x…"
  ],
  "assurance": "company_attested",
  "anchored": false,
  "acknowledged": [
    {
      "category": "Address",
      "purpose": "Delivery",
      "consentRef": "0x…"
    }
  ],
  "asksDeclined": false,
  "consentedOnBehalf": false
}

anchored is false at record time; the anchor follows asynchronously. asksDeclined is true when no ask was accepted. consentedOnBehalf is true when the consent was given on the person's behalf (a representative, a minor): the claim link still live for this customer has then been retired, and POST /v1/manual-consents/claim-links refuses to mint a new one (409 consented_on_behalf).

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, document_refusal_consequences_missing, 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, capacity_method_minor_refused, capacity_identifier_invalid, capacity_date_invalid, capacity_item_conflict, minor_needs_guardian, age_bracket_required, age_bracket_adult_measure, age_bracket_not_recorded, age_bracket_conflict, no_verified_domain, and unknown_field (code and reason).

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 closure_in_progress (code conflict): The person has asked the organization to close their account: no link is minted.
  • 409 already_minted (code and reason): This Idempotency-Key already produced a sheet; send a new key to print another.
  • 422 idempotency_key_reused (code and reason): This Idempotency-Key was already used with another body.
  • 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 their offline consents to their 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, consented_on_behalf (an unbound paper consent was given by a representative or concerns a minor), claim_unavailable (the record cannot be bound to any citizen account by a link) or closure_in_progress (the person has asked to close their account); 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) and otherConsents ("keep" or "withdraw", see below). 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).

This route never ends a consent the person signed online with their passkey. When such a consent is in force for the purpose, the request is refused (citizen_consent_at_gate) unless you declare "otherConsents": "keep": only this record (and its chain) ends, the consent signed online stays in force, and the response adds "stillInForce": true. "withdraw" is the same as leaving the field out.

  • 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 their passkey, and that consent is in force. To record the withdrawal they asked for, use the withdrawal route, which withdraws both at the same instant; to end only this record, send "otherConsents": "keep". Nothing is written.
  • 422 invalid_field: reason too long or invalid, or otherConsents other than "keep" or "withdraw" (or sent in the query string).

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 and otherConsents, as for the withdrawal. 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, or send "otherConsents": "keep"). Erasure also consults the retention holds:

  • 409 hold_active: An active hold covers this consent's information. Nothing is erased or withdrawn. Release the hold, with a reason, once the law no longer requires keeping that information, then erase.
  • 409 customer_unresolved: This consent's customer and purpose cannot be read, so no hold can be checked. Nothing is erased; contact Agreely support with the consent reference.

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 or ageBracket: "under_14"), the representative must be the person having parental authority or the tutor, named in respondent.name. ageBracket: "14_to_17" declares a minor aged 14 or over for whom that representative answered; respondent.name is then refused.
  • 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, age_bracket_required, age_bracket_adult_measure, age_bracket_not_recorded, age_bracket_conflict, 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 they asked it for through any channel, on any consent it holds for them: on paper, by telephone, or signed online with their 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 their 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 -, starting with a letter or a digit). 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.
  • otherConsents ("withdraw" or "keep") or alsoWithdraw (a list of at most 50 consentRefs): the scope of the withdrawal when another consent of the same purpose is in force for this customer. Two people may each hold one, so Agreely does not guess: without either, the request is refused (other_consents_running) with the list of those consents. "withdraw" withdraws every other consent in force given at or before the declared requestedAt, "keep" withdraws this one only, and alsoWithdraw names the ones the person's request also covers. The two are never sent together.

200 response:

{
  "consentRef": "0x9f2c…0d2e",
  "withdrawn": true,
  "alreadyWithdrawn": false,
  "recordedOnBehalf": true,
  "assurance": "company_documented",
  "gate": "denied",
  "alsoWithdrawn": [
    "0x…"
  ],
  "stillInForce": []
}
  • recordedOnBehalf: true and assurance: "company_documented" 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, which the act did not reach, 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, and alsoWithdrawn also carries the ones otherConsents or alsoWithdraw reached.
  • stillInForce lists the other consents of the purpose still in force after the act, which it did not reach.

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 other_consents_running (code invalid_request, field otherConsents): Another consent of the purpose is in force and the scope was not declared. The answer lists their references in error.otherConsentRefs. Nothing is written.
  • 422 not_running_for_purpose (field alsoWithdraw): A reference in alsoWithdraw is not another consent in force for the same customer and purpose.
  • 422 invalid_channel, invalid_operator, requested_at_invalid, requested_at_in_future, requested_at_before_grant, invalid_reason, invalid_field (otherConsents and alsoWithdraw sent together, or malformed) (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. 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]",
  "noticeLocale": "en"
}

The registry records no ground per customer: the purpose and the ground belong to the processing, declared cell by cell in the catalog. legalBasis and basisNote are no longer members of this route: a body that still sends them (an older SDK or CLI) is accepted and they are ignored. 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, a noticeLocale other than fr or en, a field that is neither a string nor null); 409 identity_erased (a destruction or anonymization declaration removed the identity: a name, an email 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 three 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, "noticeLocale": "en",
  "createdAt": "2026-10-01T14:05:12.000000Z", "updatedAt": "2026-10-08T13:15:00.000000Z",
  "relationship": { "status": "active", "endedAt": null, "endedRecordedAt": null } }

hasEmail: true, never the address. registered is false for a reference known only through its consent history. source says how the identity first came in: manual, import, api or ceremony. 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 (kept up to 2,000 characters) and an optional endedAt, a past RFC 3339 instant with an offset for a system that declares the end late (absent, the end is dated by the call). The call is idempotent: the original endedAt and endedBy origin stand.

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

endedRecordedAt is the instant Agreely recorded the end; it is later than endedAt only for an end declared in the past.

Refusals: 404 (an unknown or foreign reference, nothing written); 422 invalid_request (reason missing or blank, the message cites the organization's act; or an unreadable endedAt), ended_at_in_future, ended_at_too_old (more than 60 months in the past, an Agreely product rule) or ended_at_before_relationship (earlier than the first record Agreely holds for this customer, its latest request, its latest recorded consent or its latest claim).

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:00.000000Z",
    "endedRecordedAt": "2026-03-31T20:00:00.000000Z"
  },
  "clock": {
    "trigger": "purpose_achieved",
    "recipe": "For an unnamed purpose-achieved event only: dueAt = the tenant-local date of endedAt plus duration in its entered unit",
    "dueAt": null,
    "reason": "hold_active",
    "rules": [
      {
        "ruleId": "b7e0…",
        "label": "Dossiers clients",
        "labelEn": "Customer files",
        "periodMonths": 24,
        "duration": {
          "value": 24,
          "unit": "months"
        },
        "eventLabel": null,
        "eventLabelEn": null,
        "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 lists the rules triggered by the purposes being achieved. duration gives the duration in its entered unit (days, months or years); periodMonths repeats it only for a duration in months, and is null otherwise.
  • 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. A rule whose starting event the organization named (eventLabel) has no dueAt: Agreely does not know the date of that event.
  • 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": null,
  "hasBasisNote": false,
  "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 and the notice language Agreely's registry holds for this customer (and the ground note an older record still carried). The reference, 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.

basis and hasBasisNote read back the ground and note frozen on a declaration made while the registry still recorded a ground per customer; they are null and false on any more recent declaration. 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 their 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",
      "fiscalYearEnd": null,
      "eventLabel": null,
      "eventLabelEn": null,
      "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. duration.unit is days, months or years. trigger is collection, purpose_achieved, last_activity, decision or fiscal_year_end; for the latter, fiscalYearEnd gives the organization's fiscal year end (MM-DD), null for any other trigger. eventLabel and eventLabelEn are the name the organization gives the starting event, or null. 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: 409 host_retired (the organization recorded in Agreely that this hostSystem was retired: its inventory declarations are refused until that retirement is lifted, but its purges and passes are still accepted); 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",
  "eventLabel": null,
  "eventLabelEn": null,
  "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.

The privacy policy (policies, diffusion)

Read the policy in force

GET /v1/policies/current?type=privacy · any valid key · optional filter product

The privacy policy in force, so your system cites and links the version people can read today rather than a copy it keeps. type is required and is privacy (422 otherwise). product is the productKey of an active product (see products in GET /v1/catalog); without it, the answer covers the organization's policy.

{
  "policyId": "9d41…",
  "version": "3",
  "effectiveDate": "2026-09-01",
  "publishedAt": "2026-08-28T15:00:00.000000Z",
  "declarationHash": "0x…",
  "publicUrl": "https://app.agreely.ca/p/acme/privacy",
  "title": {
    "fr": "Politique de confidentialité",
    "en": "Privacy policy"
  },
  "crossBorder": false
}
  • declarationHash is the digest recorded when the version was sealed, read back as is and never recomputed; null for a version published before sealing.
  • crossBorder says whether the transfer register declares, today, a communication outside Quebec for this scope. It is read live: the published text is frozen and may predate a change to the register.
  • publicUrl is the policy's public address (followed by the product key for a product), null when the organization has no public address.

Refusals: 404 not_found (no active product carries this productKey, or no privacy policy is in force for this scope); 422 invalid_request (type missing or other than privacy).

Declare how a policy was made known

POST /v1/policies/{policyId}/diffusions · scope diffusion · Idempotency-Key required

Records how the organization made a published version of its policy known: the /v1 twin of the policy's "Record a dissemination" form. The diffusion register is append-only: a declaration cannot be withdrawn, hence the required idempotency key. The declaration is attributed to the API key, never to a person.

{
  "channel": "own_email",
  "note": "Email sent to the active customer list on September 2.",
  "declaredReach": 1240
}
  • channel (required): site_notice, in_service, own_email, sms, postal, point_of_service, public_notice or other.
  • note (required): 10 to 2,000 characters describing how the policy was made known.
  • declaredReach (optional): the number of people reached, as declared (an integer from 1 to 1,000,000,000), or null. Agreely records it; it does not verify it.

201 response (replayed as is for the same key and the same body):

{
  "diffusionId": "51c2…",
  "policyId": "9d41…",
  "firstPublication": false,
  "channel": "own_email",
  "declaredReach": 1240
}

firstPublication is derived by Agreely, as in the form: true when the version is the first policy of this type the organization published, false when the diffusion announces a change. It is never read from the body.

Refusals: 404 not_found (an unknown, malformed or foreign id); 409 policy_not_in_force (code and reason: the version is a draft or no longer published); 422 idempotency_key_required, idempotency_key_reused, unknown_field (any other member) or invalid_request (an invalid channel, note or declaredReach, reasons missing_field and invalid_field); 429 diffusion_daily_cap (reason daily_cap, no Retry-After) beyond 50 diffusions declared through the API per rolling 24 hours.

Next