The thin, typed TypeScript/Node client for the Agreely /v1 consent API. One
call to gate data use on a live, authoritative consent check. No database, no ref
tables, no local mirror - every check() is a fresh, synchronous call to
Agreely (caching an allow while a revoke lands is a correctness failure).
- One-call DX.
if (await agreely.check(id, category, purpose)) { ... } - Typed end to end. Strict types, typed errors, ESM + CJS, full
.d.ts. - Fail-closed by default. On an outage
check()denies - unless you opt in, explicitly and per-category, to a scoped, audited fail-open. - Node 18+ (global
fetch), with a lazyundicifallback. Minimal deps.
npm install @agreely/sdkimport { Agreely } from "@agreely/sdk";
const agreely = new Agreely({ apiKey: process.env.AGREELY_API_KEY! });
// Boolean gate - ALLOW is the only true. Send RAW human labels; Agreely
// normalizes server-side (never normalize them yourself).
if (await agreely.check("cust_8812", "Phone number", "Billing")) {
// ...you may use the phone number for billing
}const d = await agreely.checkDetailed("cust_8812", "Phone number", "Billing");
// { decision: "allow" | "deny",
// status: Status, // the nine values below
// consentRef?: "0x…", // absent for "none" and for "necessity"
// assurance?: "citizen_signed" | "company_attested" | "company_documented",
// tier?: "full" | "manual" | "verbal", // the same proof, present exactly when assurance is
// basis?: Basis, // ONLY on "necessity"
// validUntil?: "2027-…Z", // the end of the consent behind the answer
// revokedAt?: "2026-…Z", // ONLY on "revoked": when the withdrawal took effect
// checkedAt: "2026-…Z" }A consent deny is a normal 200 - checkDetailed returns it, it does not
throw. Errors (auth, validation, rate-limit, outage) throw typed errors.
validUntil and revokedAt are absent, not null, when there is no record
behind the answer (none, necessity, the named refusals) and on an
acknowledged information line. checkBatch decisions carry them the same way. They
date the answer; they never replace the next check: do not cache an allow until
its validUntil, a withdrawal can land at any moment.
Two statuses allow, seven deny:
| status | decision | what it means |
|---|---|---|
active |
allow | a live consent record backs the cell |
necessity |
allow | no consent record; the catalog cell declares a non-consent ground the tenant's act carries. Carries basis, no consentRef, no assurance, no tier. Creates nothing. |
none |
deny | no record on a consent-basis cell. Also what an erased cell reads as. |
revoked |
deny | the consent was withdrawn (art. 14) |
expired |
deny | the consent lifespan elapsed (art. 14 al. 3) |
relationship_ended |
deny | the company attested the purposes are accomplished (art. 23). A relationship-level stop: the per-cell consent stays truthfully active, it was never withdrawn. |
requires_depersonalization |
deny | the cell declares a ground the act carries only for depersonalized information (A-2.1 art. 65.1 al. 2 (4°)), which cannot be met for a named customer. Not "no consent": do not answer it by collecting one. |
basis_not_in_regime |
deny | the cell declares a ground the tenant's act does not carry at all (usually left over from a sector change). The tenant re-declares the cell. |
erased |
deny | listed by openapi.yaml, not currently emitted. Erasure crypto-shreds the record, so an erased cell reads back as none. |
sensitive_requires_consent is gone (removed in 0.4.0; the API stopped
emitting it on 2026-09-28). A cell declared sensitive now answers by its basis like
any other: on consent it denies none (the consent must be express), on a
non-consent ground it allows necessity with its basis.
A necessity allow is not a consent. It rests on a basis the company
declared on its catalog, there is no signed proof behind it, and Agreely does not
certify its legal validity. The basis vocabulary is sector-keyed: a private
enterprise (P-39.1) gets contract, necessary_for_service, security_fraud,
legal_obligation or professional_contact; a public body (A-2.1) gets
attributions, programme, entente_collecte, compatible_use,
manifest_benefit, law_application or public_character. Read
company.sector from identity() before switching on it. Never present a
necessity allow to a person or an auditor as "consented":
if (d.status === "necessity") {
// allowed, but on d.basis, NOT on consent
}Treat any status you do not recognise as a deny: read d.decision, which is
only ever allow or deny.
An allow on a consent tells you how strong the proof is, and your system decides what each tier may unlock:
tier |
assurance |
the consent |
|---|---|---|
full |
citizen_signed |
the person signed it with a passkey |
manual |
company_attested |
the organisation recorded a hand-signed paper |
verbal |
company_documented |
the person consented by telephone; the organisation documented it, with no document |
const d = await agreely.checkDetailed("cust_8812", "Email address", "Newsletter");
const acceptable = d.decision === "allow" && (d.tier === "full" || d.tier === "manual");Treat an assurance or tier you do not recognise as not acceptable.
Information lines are not consents. A line the document gives for
information is recorded as the person's acknowledgement. /v1/check answers it
exactly like no record (necessity with its basis, or the usual refusal), with no
consentRef and no assurance/tier. Once withdrawn it answers deny revoked
with a consentRef but still no assurance/tier.
Manual and verbal consents and consent sheets take a documentVersionId. Pin the
document's stable code and resolve the version published today through it,
instead of copying a uuid out of a browser (scope check or issue):
const documents = await agreely.consentDocuments.list();
// [{ code, documentVersionId, version, name, nameEn, effectiveDate, publishedAt, items }]
const doc = await agreely.consentDocuments.get("conditions-marketing");
// ...plus disclosure { purpose, means, categories, retention, withdrawal, recipients,
// crossBorder, rights } as { fr, en }, responsable, automatedDecision, profiling, integrity
// The INFORMATION DOCUMENT of one version, to print and hand to the person
// (scopes check, issue, attest or attest_verbal):
const info = await agreely.consentDocuments.getInformationPdf(doc.documentVersionId, { locale: "fr" });
await writeFile(info.filename ?? "information.pdf", info.pdf); // Uint8ArrayThe PDF is served by version id, never by code: the version a consent is
recorded against is the one whose text the person must be given, and a version
superseded since is still served (it says so). English is refused (422, code
english_text_missing) when a section has no English text, rather than printed
without it. The bytes are not frozen, so keep your own copy, keyed on
(documentVersionId, locale). Fetching it records nothing: it is not evidence
that anyone was informed. A render takes longer than a check, so these calls get
a time budget of at least 15 s (DOCUMENT_TIMEOUT_MS), overridable with
{ timeout }.
const r = await agreely.consentRequests.create({
customerId: "cust_8812",
recipientEmail: "person@example.com",
// REQUIRED: the published consent document (the Law 25 s. 8 disclosure) the
// request is issued under. Pass its version id OR its code (one, not both);
// the requested (category, purpose) items derive from the document.
consentDocumentId: "<documentVersionId>", // or: documentCode: "conditions-marketing"
validUntil: "2031-01-01",
});
// { requestId: "0x…64hex", status: "pending", deepLink, emailDelivered, items, document }validUntil (here, on manual and on verbal consents) is either a plain date,
meaning the end of that calendar day in the organisation's timezone, or an RFC
3339 instant with an offset. Relative phrases ("+1 year") are refused, and so is
an end more than 10 years after the start (an Agreely product ceiling, not a
statutory one: the acts limit a consent to the time its purposes need).
create is never auto-retried (it emails). The SDK attaches a unique
Idempotency-Key per call; pass your own to make a retry replay the original
instead of issuing twice:
await agreely.consentRequests.create(input, { idempotencyKey: "order-4471" });When you gathered consent out of band (a signed paper or PDF), record it under
your company's attestation. The result carries assurance: "company_attested"
(the live citizen flow yields "citizen_signed"). Send only the PDF hash by
default; upload the bytes only if you opt in.
items names the consent asks ticked on the sheet, and may be empty when
every ask was answered "no". The lines the document gives for information are
added by the server as acknowledgements (never consents) and come back in
acknowledged; one you send in items is ignored.
const recorded = await agreely.manualConsents.record({
customerId: "cust_8812",
documentVersionId: "<docVersionId>",
effectiveDate: "2026-06-01",
validUntil: "2031-01-01",
items: ["<catalogEntryId>", { category: "Email address", purpose: "Newsletter" }],
evidence: { pdfSha256: Agreely.hashPdf(bytes) }, // pdf?: "<base64>" to opt into uploading the bytes
sensitiveExpressAttested: true, // when a ticked purpose is sensitive and rests on consent
versionAttested: true, // when the paper was signed before this version's publication
});
// { consentId, merkleRoot, consentRefs: ["0x…"], assurance: "company_attested", anchored: false,
// acknowledged: [{ category, purpose, consentRef }], asksDeclined: false }
// Hand the subject a link to self-claim the attestation:
const link = await agreely.manualConsents.createClaimLink({ customerId: "cust_8812" });
// { claimUrl, token, expiresAt }
const w = await agreely.manualConsents.revoke("0x…", { reason: "withdrawn" });
// { consentRef, revoked: true, alreadyRevoked, gate: "denied" | "superseded" | "unchanged" }
const e = await agreely.manualConsents.erase("0x…");
// { consentRef, erased: true, alreadyErased, gate }sensitiveExpressAttested and versionAttested are the organisation's own
attestations, sent only when you pass them and only as booleans (a string
"true" is refused, never read as a yes). Absent is false.
What gets refused, and how (compare error.reason, never the message):
- 422 (
AgreelyValidationError): a document that asks no consent at all (notice_only); an uploaded file that is empty or not a PDF; an upload that does not hash topdfSha256(pdf_hash_mismatch); an end past the 10-year ceiling (valid_until_too_far); aneffectiveDatein the future by any amount (effective_date_in_future); a sensitive consent purpose withoutsensitiveExpressAttested(sensitive_express_required); a paper signed before the version's publication withoutversionAttested(version_attestation_required). - 409 (
AgreelyConflictError,code: "conflict"): a purpose already held by a stronger consent (stronger_consent_active), an ended relationship (relationship_ended), or a verbal consent for the same customer and document still awaiting its paper (verbal_awaits_paper). Nothing is recorded. revokeanderase: 409citizen_consent_at_gatewhen a consent the person signed with her passkey holds the purpose. These routes never end one: record her withdrawal withwithdrawals.record(below). 409superseded_by_paperfor a verbal ref whose paper came back. 404unknown_consent.createClaimLink: 404unknown_customerfor a customer the company holds no record of (nothing written), 409relationship_endedonce the relationship has ended.
revoke returns gate, what /v1/check does now for that purpose: denied
(this consent backed it), superseded (a later consent had already taken over, and
the gate may still allow), or unchanged (an idempotent repeat). Read gate, not
revoked, before telling anyone the use stopped.
Like consentRequests.create, record is never auto-retried; it attaches a
unique Idempotency-Key per call, and a retry with the same key and the same body
replays the original 201 and records nothing new. On the manual and verbal
endpoints the server binds the key to the endpoint and the request body: the
same key with a different body is a new request, and a key spent elsewhere never
replays another endpoint's answer. An override must be 1 to 255 printable ASCII
characters; anything else is refused client-side.
The signature sheet of one published version for one customer, printed by your system, minted together with a NEW claim the person may redeem later. The sheet carries a printed reference, the claim link's second factor:
const sheet = await agreely.manualConsents.createConsentSheet("cust_8812", {
documentVersionId: doc.documentVersionId,
locale: "fr",
});
const pdf = Buffer.from(sheet.signatureSheet.pdf, "base64"); // print this
// sheet.printedReference: "ABCD-EFGH" (on the sheet)
// sheet.claim: { claimUrl, token, expiresAt } (send it SEPARATELY, see below)Two rules, and the SDK cannot enforce either for you:
- Never send the claim link in the same envelope as the sheet, nor in the same email, text message or piece of paper. The printed reference protects a link that travels on its own; delivered together, it protects nothing.
- Never send the blank sheet's hash as
evidence.pdfSha256. The evidence of the consent is the signed paper: scan it when it comes back, and record it withmanualConsents.recordandAgreely.hashPdfof the signed scan.
The reference and the claim token are returned once and stored nowhere. A
retry with the same Idempotency-Key is a 409 already_minted that mints nothing;
a new sheet retires the claim of the previous one, so its printed reference stops
working. Refusals: 404 unknown_customer, 422 invalid_document (not one of your
published versions), 422 code no_consent_ask with reason notice_only (the
version asks nothing, so there is nothing to sign), 409 relationship_ended.
A consent the person gave by telephone, documented by your organisation. It is
the weakest of the three tiers: there is no document, so the proof is your word.
Every result says tier: "verbal" and assurance: "company_documented", and
/v1/check returns the same tier. Scope attest_verbal, a separate grant that is
never given by default.
const v = await agreely.verbalConsents.record({
customerId: "cust_8812",
documentVersionId: "<docVersionId>",
// Every ask put on the call, answered explicitly. Never a line given for information.
answers: [
{ category: "Email address", purpose: "Newsletter", answer: "yes" },
{ category: "Email address", purpose: "Surveys", answer: "no" },
],
obtainedAt: callEndedAt, // the call: a Date or RFC 3339 with an offset, at most 7 days old
obtainedBy: "Julie, customer service",
scriptVersion: "script-2026.09",
respondent: { consentedBy: "self" }, // or a representative, see below
validUntil: "2027-09-29",
paperExpected: true,
});
// { consentId, merkleRoot, consentRefs, tier: "verbal", assurance: "company_documented",
// anchored: false, paperExpected: true, acknowledged: [...], asksDeclined: false }
// The signed paper came back (scope `attest`): each "yes" moves into a NEW manual
// consent and the gate re-points at it; each "no" is a withdrawal dated at signedAt.
const p = await agreely.verbalConsents.confirmWithPaper(v.consentId, {
signedAt: "2026-10-02T09:00:00-04:00",
answers: [{ category: "Email address", purpose: "Newsletter", answer: "yes" }],
evidence: { pdfSha256: Agreely.hashPdf(bytes) },
});
// { verbalConsentId, manualConsentId, merkleRoot, tier: "manual", confirmed: [...], withdrawn: [...] }
// What was said, what the paper said, what is in force (scope attest_verbal or attest):
const h = await agreely.verbalConsents.get(v.consentId);
// h.state: "awaiting_paper" | "verbal_only" | "paper_received"
// h.purposes[].answer: "yes" | "no" | "informed"- A minor under 14: pass
isMinor: trueand a representative in the capacitytitulaire_autorite_parentaleortuteur_mineur, named inrespondent.name. The adult measures (tutelle,curatelle,mandat_protection_homologue,representation_temporaire,other,undeclared) are for adults only. - Sensitive purposes: when a "yes" purpose is declared sensitive and rests on
consent, pass
sensitiveExpressAttested: true. - No instant in the future, by any amount.
obtainedAtandsignedAtahead of Agreely's clock are refused (obtained_at_in_future,signed_at_in_future), never clamped. Keep your clock synchronised, and send the instant the call ended rather than a timestamp taken ahead of the request. - Renewal by telephone. A paper consent ending within 30 days can be renewed
by a verbal one:
recordas usual, with avalidUntilno earlier than the paper's, thenconfirmWithPaperwhen the new paper comes back. Outside the window the purpose is refused asstronger_consent_activeorall_covered; a renewal ending earlier isrenewal_ends_before_current; a call before the paper's signature isrenewal_predates_current; before a withdrawal,predates_withdrawal. A withdrawal through any reference of the chain withdraws the running consent too. - Refused: 422 for a notice-only document or an answer on a line given for
information (
informed_line_answered); 409 (code: "conflict") when a purpose is already covered or held by a stronger consent, when the relationship has ended (relationship_ended), or for a second paper (already_confirmed); 429AgreelyVerbalDailyCapErrorat your organisation's daily limit (never auto-retried). - Withdraw with
manualConsents.revoke(a key holding onlyattest_verbalreaches verbal refs only). Once the paper came back, withdraw the confirming manualconsentRefinstead (seeverbalConsents.get). Erase needsattest. recordandconfirmWithPaperare never auto-retried and carry anIdempotency-KeylikemanualConsents.record.
When a person asks you, by any channel, to withdraw a consent, record it here.
It reaches any consent ask you hold for her, including one she signed online
with her passkey, and /v1/check denies at once:
const w = await agreely.withdrawals.record("cust_8812", "0x…consentRef", {
channel: "phone", // phone | email | mail | in_person | other
operator: "agent-042", // YOUR opaque staff id, never an email address
requestedAt: "2026-10-08T14:05:00-04:00", // optional: when she asked
reason: "Asked on the phone", // optional, at most 1000 characters
});
// { consentRef, withdrawn: true, alreadyWithdrawn, recordedOnBehalf: true,
// assurance: "company_attested", gate: "denied" | "superseded" | "unchanged",
// alsoWithdrawn: ["0x…"] }- Honest by construction. Every answer says
recordedOnBehalf: trueandassurance: "company_attested": Agreely records that your organisation says it received and actioned the request, never that she signed a revocation. - A withdrawal attaches to the purpose. The other running consents of the same
purpose (a renewal and the paper it renewed, a verbal consent and its paper) are
withdrawn at the same instant and listed in
alsoWithdrawn. Readgatebefore telling anyone the use stopped. - Its own scope, never granted by default: a key holding it can switch off every
consent of your company one call at a time. A daily cap applies (429
AgreelyDailyCapError, codewithdrawal_daily_cap, reasondaily_cap, noRetry-After); your account owner is notified. Record further ones in Agreely. - It works while your billing has lapsed: honouring a withdrawal is a legal duty, not a paid service. It is the only route that does.
- Refused: 404
unknown_consent(unknown, another tenant's, or another customer's reference: one answer), 409consent_lapsed(it ended on its own date; another consent may be in force, check the purpose), 422not_revocable(the cell was never a consent ask),requested_at_in_future,requested_at_before_grant. Accepted after the relationship ended. - Never auto-retried; an Idempotency-Key is attached per call, and a retry with the same key and body replays the first answer.
Attest that a customer relationship is over (Law 25 art. 23, "les fins sont
accomplies") from your own offboarding flow, and undo a mistaken end within the
correction window (art. 11 / art. 28). Both require a reason and fail closed
client-side on a blank one. Scope: relationship.
const ended = await agreely.relationships.end({
customerRef: "cust_8812", // your OWN ref (the check ref), never a DID
reason: "account closed; purposes accomplished",
});
// { customerRef, status: "ended", endedAt, endedBy: "company" | "citizen_request" }
// Undo a premature/mistaken end (a correction, NOT a resurrection of dead consent):
const restored = await agreely.relationships.revert({
customerRef: "cust_8812",
reason: "offboarded the wrong account",
});
// { customerRef, status: "active", reverted: true }Ending is a pure lifecycle overlay: it never revokes, erases, or hides any per-cell consent. A non-undo-eligible revert (citizen-driven end, past the window, or after any destruction) is a clean 404 with nothing written.
The identity your own system holds for one of its customers, recorded where the
accountability record lives. One customerRef at a time: there is no list
endpoint, by construction.
const r = await agreely.customers.upsert("cust_8812", {
displayName: "Marie Tremblay",
email: null, // null or "" CLEARS a value
legalBasis: "contract", // a non-consent ground of YOUR act
noticeLocale: "fr",
}); // a member you leave out is left as it stands
// { customerRef, registered, source, hasDisplayName, hasEmail, hasBasisNote,
// legalBasis, noticeLocale, createdAt, updatedAt, relationship, created }
const meta = await agreely.customers.get("cust_8812"); // metadata only- A merge, never a replace. A full replace would let a sync that only knows
addresses wipe a name curated in Agreely.
createdis true when the call created the record (HTTP 201), false when it merged (200). - The read is metadata.
hasEmail: true, never the address: Agreely is not a place to read your customers back from. - 409 carries its stable string in
codehere:identity_erased(a standing destruction removed the identity; a value would undo that, a clear still works) oridentity_held(a hold keeps it as is, a clear included).legalBasis: "consent", or a ground of the other act, is a 422 naminglegalBasis.
const page = await agreely.consentRequests.list({
customerId: "cust_8812", // filter to one subject ref (optional)
status: "pending", // pending | approved | asks_declined | refused | expired | revoked_before_action (optional)
limit: 50, // page size, default 50, max 100 (optional)
cursor, // a prior nextCursor (optional)
});
// { items, nextCursor } - metadata only, newest first; nextCursor is null when exhausted.
// "approved" means the person accepted AT LEAST ONE consent ask. A request whose every
// ask was declined reads "asks_declined" (no consent obtained) and is never listed
// under "approved". waitForSettlement treats both as settled.
const one = await agreely.consentRequests.get("0x…"); // the protocol requestId, NOT a uuid
const catalog = await agreely.catalog.list(); // discovery for issuance
const form = await agreely.catalog.forDocument("conditions-marketing");
// { regime, document: { code, documentVersionId }, catalog }: only the active cells that
// document groups, so an intake screen asks about nothing the document does not coverDedup before issuing. hasPending answers "is a consent request already
outstanding for this customer?" so you do not re-issue (and re-email):
if (!(await agreely.consentRequests.hasPending("cust_8812", "conditions-marketing"))) {
await agreely.consentRequests.create({
customerId: "cust_8812",
recipientEmail: "person@example.com",
documentCode: "conditions-marketing",
validUntil: "2031-01-01",
});
}The documentCode argument is optional; omit it to match any pending request for
the customer. This is a metadata convenience over the list endpoint, not a
compliance decision: it reports whether a pending request exists, it does not
assert consent was given. Each record now carries customerId and documentCode.
Agreely decides the retention rules and records what your systems declare.
Your system reads the rules, runs its own purges, and declares them. Agreely
observes nothing in your systems and verifies none of it: every write answers
status: "declared", never "verified".
// A nightly purge job. Its key carries 'retention' AND 'holds'.
const run = await agreely.retention.rulesForPurge({ snapshot: await loadSnapshot() });
if (run.source === "abstain") return; // Agreely unreachable: do NOT purge this run
await saveSnapshot(run.snapshot);
// The holds in place: every reference you must skip. Any error throws: do not purge.
const held = await agreely.retention.syncHolds(); // a snapshot of every active hold
for (const rule of run.rules) {
const purged = await purgeExpired(rule, held.holds); // your code: skip what a hold covers
const idempotencyKey = `${jobRunId}:${rule.key}`; // durable, never random per attempt
if (purged.count > 0) {
await agreely.retention.declarePurge(rule.key, {
ranAt: new Date(),
recordsAffected: purged.count, // at least 1
method: "destroyed", // or "anonymized" + anonymizationProcessKey
coveredFrom: "2025-01-01",
coveredUntil: "2025-01-31",
hostSystem: "billing", // a STABLE name, never a pod name
hostCategory: "invoices",
}, { idempotencyKey });
} else {
await agreely.retention.declareSweep(rule.key, { sweptAt: new Date(), hostSystem: "billing" }, { idempotencyKey });
}
}
const { cells } = await agreely.catalog.listCells(); // each cell with its retentionRuleKeyWhat the SDK makes impossible rather than merely documents:
- The
Idempotency-Keyis a header. It is the required third argument ofdeclarePurge/declareSweep; leaving it out, or puttingidempotencyKeyin the body, does not compile, and the body is built from the named fields only. methodis"destroyed"or"anonymized". There is no third disposition (aggregation is a technique), and a third value, or the rule's own"destroy", does not compile."anonymized"withoutanonymizationProcessKeydoes not compile either.recordsAffectedis at least 1. A pass that found nothing is adeclareSweep, refused client-side as a purge of zero.- The sweep floor (one per rule and
hostSystemevery 15 minutes) throwsAgreelySweepTooFrequentError, a typed subclass ofAgreelyRateLimitErrorcarryingretryAfterSeconds, and is never auto-retried. Do not loop on it. AsweptAtolder than 24 hours is refused before the wire call. - A 409 is
AgreelyConflictError: retry with the same idempotency key.
What it can only tell you:
- The replay is bound to the API key. After a key rotation, retrying a declaration whose response was lost, with the NEW key, records a second declaration. Settle pending declarations before you revoke the old key.
- Host-system names are rationed: 10 per organisation (and 20 host categories
per rule) over a rolling 30 days. A pod name, a container id or a hostname as
hostSystemburns a slot per deploy. The SDK refuses the obvious shapes (a hexadecimal suffix, a run of five digits) but cannot catchworker-3.
A hold suspends the rules it covers for one customer: the information under it must be kept until the hold is released. Holds come from the rights register in Agreely (an access or rectification request), from people in Agreely, and from your own systems.
The feed (scope holds) is the list a purge job reads before purging:
references plus scope, never the ground.
// First run: a SNAPSHOT of every active hold, which replaces your whole active set.
let sync = await agreely.retention.syncHolds();
await saveActiveHolds(sync.holds, sync.cursor);
// Later runs: the DELTA since the stored cursor (each hold with its status).
sync = await agreely.retention.syncHolds({ changedSince: await loadCursor() });
await upsertHoldsById(sync.holds); // delivery is at least once: upsert by id
await saveCursor(sync.cursor);syncHolds follows the pages (500 holds each) for you; holdPages iterates them
and listHolds({ changedSince, pageToken }) reads one. Only the last page carries
cursor. Treat any error on the feed (401, 403, 402, a 5xx, a timeout) as
"do not purge": syncHolds and holdPages throw rather than end on a partial feed
(with holdPages, collect the pages and purge only once the loop completes). A hold
whose status is missing or is anything but "released" is read as "active".
One customer (scope registry):
const r = await agreely.retention.getCustomerRetention("cust_8812");
// { relationship, clock: { trigger, recipe, dueAt, reason, rules[{ ..., held }] },
// disposition, holds, releasedTruncated }
const hold = await agreely.retention.placeHold("cust_8812", {
ground: "other_law", // or "rights_request" (no provision)
provision: "Tax Administration Act, s. 35", // required for other_law, recorded as written
scope: "all", // or { rules: [ruleKey], cells: [cellId] }
startedOn: "2026-10-08", // optional, defaults to today
reviewOn: "2027-10-08", // optional reminder; nothing releases on it
}, { idempotencyKey: `hold:${caseId}` });
await agreely.retention.releaseHold("cust_8812", hold.id, { reason: "Recourse period over" });
// { ..., status: "released", agreelyIdentity: "erased" | "none_held" | "retained_hold" | null }
await agreely.retention.declareDisposition("cust_8812", { disposition: "destroyed" });
// legal_hold requires a reason naming the law, and only it takes retentionUntilclock.dueAtis the earliest horizon of a rule no hold suspends, or null withclock.reason:no_declared_rule(never a defaulted period),relationship_active, orhold_active. A rule withheld: truekeeps its arithmeticdueAt, which is not a date to act on.- A disposition needs the relationship to have ended first (409
relationship_active): it never implies an end. One declared while a hold covers the customer is recorded with ahold_activewarning, never refused. releaseHoldlifts any active hold, including one the organisation placed (placedBy: "organization"). Your account owner is notified, and the API releases a capped number of those a day (429AgreelyDailyCapError,hold_release_cap_reached). Placing holds has its own daily budget (hold_budget_exhausted). When the release lifts the last hold on all the information and a destruction or anonymization stands, it removes the name, email, basis note and notice language from Agreely's record, irreversibly (agreelyIdentity: "erased").- On these routes the stable string is in
code(already_released,too_many_holds,relationship_active,unknown_field), not inreason.
rulesForPurge fails the other way from check. For a consent check, failing
closed means denying. For a purge job it means not purging, because the two
ways stored rules can be wrong are not symmetrical:
- a rule was shortened and you missed it: you keep data somewhat too long. A minor lapse, corrected on the next run.
- a rule was lengthened (a legal hold, an investigation) and you missed it: you destroy what you were required to keep, and that does not repair.
So an outage returns source: "abstain" by default, and the abstain branch has no
rules to purge on (narrow on source or it does not compile). Purging on the
stored snapshot is the explicit exception, bounded and audited like fail-open:
const run = await agreely.retention.rulesForPurge({
snapshot,
onOutage: "use-snapshot",
maxSnapshotAge: "26h", // capped by the client's maxDegradeWindow (default 24h)
onDegrade: (ctx) => audit.log(ctx) // mandatory
});An auth, billing, validation or rate-limit failure is not an outage: it throws rather than abstaining quietly.
await agreely.inventory.replaceCategories({
hostSystem: "crm",
categories: [{ key: "benevoles", label: "Bénévoles", fields: [{ key: "nom", label: "Nom" }] }],
});
const sets = await agreely.inventory.listCategories({ hostSystem: "crm" });
const statement = await agreely.inventory.getStatement(statementKey); // also readable with a 'check' keyreplaceCategories sends the complete list for that system: every set it
declared before and that you omit is withdrawn. It is not an append. An empty
list is refused. Fields carry names, never values. A set whose
decision.state is not "decided" has no duration: abstain, never invent one.
Up to 200 sets per declaration (INVENTORY_MAX_SETS) and 60 fields per set; a set
label takes 120 characters and a field label 200. Set keys are stable names: at
most 1,000 keys your company never declared before may appear in any 30 days (422
new_set_limit), so generating keys per call locks you out, while re-sending the
same list always passes.
Every failure is an AgreelyError subclass - a deny is not an error. Every
method that returns a promise rejects, it never throws synchronously: invalid
input rejects with AgreelyConfigError before any request. The options object of a
write is closed: a misspelt idempotencykey is refused, never ignored in favour of a
random key.
| Error | When |
|---|---|
AgreelyAuthError |
401 unauthorized / 403 forbidden |
AgreelyValidationError |
400 / 422 (.field names the input) |
AgreelyNotFoundError |
404 |
AgreelyBillingInactiveError |
402 - the company's Agreely subscription lapsed |
AgreelyRateLimitError |
429 (.retryAfter seconds). code: "rate_limited" is the per-minute window, the only 429 ever auto-retried; any other code keeps its own and is never retried |
AgreelyDailyCapError |
429 on a per-company daily cap: withdrawal_daily_cap, hold_budget_exhausted, hold_release_cap_reached, or any 429 whose reason is daily_cap (a rate-limit subclass; never auto-retried) |
AgreelyVerbalDailyCapError |
429 verbal_daily_cap (a daily-cap subclass; never auto-retried) |
AgreelyConflictError |
409: code: "conflict" (the record's state refuses it, do not retry as is), code: "retry" (retry with the same Idempotency-Key), or a named state (already_minted, identity_held, identity_erased, relationship_active, already_released) |
AgreelyUnavailableError |
503 / network / timeout |
AgreelyConfigError |
bad client config (thrown at init) |
Every refusal of the manual, verbal, claim-link, consent-sheet and withdrawal
routes carries a stable error.reason, exposed as e.reason (typed
AgreelyErrorReason), beside the HTTP-level e.code and the input e.field.
The English message may be reworded at any time:
try {
await agreely.manualConsents.revoke(ref);
} catch (e) {
if (e instanceof AgreelyConflictError && e.reason === "citizen_consent_at_gate") {
await agreely.withdrawals.record(customerRef, ref, { channel: "email", operator: "agent-042" });
} else throw e;
}AgreelyErrorReason lists the reasons this version knows and stays open-ended, so
a reason added later still compiles: handle an unknown one by its HTTP status. The
customer registry, holds, dispositions, inventory and document routes carry their
stable string in code instead, and leave reason undefined.
import { AgreelyRateLimitError } from "@agreely/sdk";
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
try {
await agreely.check(id, cat, pur);
} catch (e) {
if (e instanceof AgreelyRateLimitError) await sleep((e.retryAfter ?? 1) * 1000);
else throw e;
}A 402 AgreelyBillingInactiveError means the company's Agreely subscription
lapsed (trial ended unpaid, past_due, or canceled) - not an outage. check()
fail-closes to false (a lapsed biller never gets an accidental allow), while
checkDetailed() throws it so you can surface it distinctly. It is actionable
(the company must pay to restore service), so treat it apart from "Agreely is down".
import { AgreelyBillingInactiveError } from "@agreely/sdk";
try {
await agreely.checkDetailed(id, cat, pur);
} catch (e) {
if (e instanceof AgreelyBillingInactiveError) {
// Gate is closed AND the company must fix its billing. Surface, don't retry.
} else throw e;
}Low default timeout (800ms total budget). Only idempotent reads and the
check are retried on a transient outage (network / 503): up to 2 attempts,
jittered, inside the budget. With maxRetries, a read also retries a 429
rate_limited after its Retry-After, and no other 429. No write is ever
auto-retried (consentRequests.create emails; the others record). The two calls that render a PDF
(consentDocuments.getInformationPdf, manualConsents.createConsentSheet) get at
least 15 s, overridable per call with { timeout }: a cut-off sheet would lose a
reference the server returns only once.
new Agreely({ apiKey, timeout: 1200 }); // ms, including retries800ms is sized for a call inside the same datacentre or region as the API. It is not a safe budget over the open internet: a cold TLS handshake plus a cross-region round trip can exceed it on a perfectly healthy server.
That matters because this SDK fails closed. A timeout is treated as an outage,
so check() returns false and checkDetailed() throws. An under-set timeout
does not give you a slow answer, it gives you a spurious deny, and the person
is shown less of their own data than they consented to.
// Internet-facing caller: give it room.
new Agreely({ apiKey, timeout: 8000, maxRetries: 1 });The default is deliberately left low rather than raised for everyone: this is a synchronous gate on a request path, and an unattended multi-second default would turn an Agreely outage into a multi-second hang on every one of your pages. Choose the budget your deployment can actually pay.
| limit | value | what happens past it |
|---|---|---|
| batch size | 500 cells per checkBatch / checkFields |
the SDK throws AgreelyConfigError before the wire call (exported as BATCH_CAP). Server-side it is a 422 that decides nothing. |
| rate | 120 requests/minute per company | 429 with Retry-After, surfaced as AgreelyRateLimitError (exported as RATE_LIMIT_PER_MINUTE) |
| withdrawals | 50 per company per 24 hours through the API (a deployment default) | 429 AgreelyDailyCapError (withdrawal_daily_cap), no Retry-After; the owner is notified |
| holds | 1,000 placed, and 20 released that the API did not place, per company per 24 hours (deployment defaults) | 429 AgreelyDailyCapError (hold_budget_exhausted, hold_release_cap_reached) |
| inventory | 200 sets per declaration, 1,000 new set keys per 30 days | AgreelyConfigError before the call over 200; 422 new_set_limit |
| customerRef | 200 characters (CUSTOMER_REF_MAX) |
AgreelyConfigError before the call |
The rate limit is per company, not per api key, so every key you issue shares
one allowance. One checkBatch of 500 cells costs one request, which is the
whole point of batching.
checkFields builds a refs x fields product, so it hits the cap sooner than it
looks: 100 rows x 6 fields is 600 cells. It refuses client-side and tells you the
safe page size:
checkFields: 100 customerRefs x 6 fields = 600 cells, over the server cap of 500.
Page the customerRefs (at most 83 per call with 6 fields) or check fewer fields at a time.
The rate limit is a deployment default (API_RATE_LIMIT_PER_MINUTE); a
self-hosted or negotiated deployment may differ. Treat it as the number to design
against, not a contract.
- Probe with
identity(), about once a minute, from ONE scheduler, not on every request: every call counts against the company's 120 a minute. - On a 401 or a 402, purge whatever you cached from Agreely and fail closed. A revoked key or a lapsed subscription means none of it may be relied on.
- Never cache
/v1/check. Every gate decision is a fresh call: an allow cached while a withdrawal lands is a use the person refused.validUntildates an answer, it is not a time to live. - Never decide consent from a cached catalog. The catalog and the documents say what was declared and asked, never whether this person consented. Only a check does.
When Agreely is unreachable (503 / timeout / network), check() denies
(returns false); checkDetailed() throws AgreelyUnavailableError. A
real 200 deny is never affected by any of this.
You can opt specific categories into fail-open, but only explicitly, scoped, and audited - three independent gates:
const agreely = new Agreely({
apiKey,
degradeOnOutage: {
mode: "fail-open", // the explicit word
categories: ["Browsing/usage"], // ONLY these may ever degrade (gate 1)
maxOutageWindow: "5m", // refuse to degrade past this
onDegrade: (ctx) => audit.log(ctx) // MANDATORY - absent, the constructor throws
},
});
// gate 2: the call must ALSO opt in. Effective only because the category is
// allow-listed above. Without the config, a per-call opt-in still denies.
await agreely.check("cust_8812", "Browsing/usage", "Analytics", { onOutage: "allow" });Every degraded allow emits an evidence record via onDegrade:
{ customerId, category, purpose, mode, reason?, breakGlass, error, at }.
agreely.breakGlass.engage({ reason: "incident-4471", ttl: "30m", scope: ["Browsing/usage"] });
// ...degraded checks in scope now allow, tagged breakGlass:true, until ttl expires
agreely.breakGlass.disengage();Break-glass auto-expires, requires a reason (engaging without one throws), and
is independent of the config allow-list. Engage / disengage / expiry are audited
via the onBreakGlass callback.
Every break-glass-authorized allow also emits a per-decision onBreakGlass
event { action: "authorized", customerId, category, purpose, reason, error, at }
- for every such allow, even with no
degradeOnOutageconfig - so the evidence trail shows every access permitted inside a break-glass window, not just the engage/expire bookends.
Both a break-glass ttl and degradeOnOutage.maxOutageWindow are capped at
24h by default. A value over the cap (e.g. ttl: "9999h") throws
AgreelyConfigError rather than opening an effectively-unbounded fail-open
window. Raise (or lower) the cap per client:
new Agreely({ apiKey, maxDegradeWindow: "12h" }); // default "24h"Heads-up: a per-call
{ onOutage: "allow" }that is not backed by a matchingdegradeOnOutage.categoriesentry has no effect - the check still denies. The SDK logs a one-time dev warning when this happens; silence it with theAGREELY_SILENCE_WARNINGSenv var.
- Never normalize category/purpose before sending - the server does it.
- Labels are bilingual and accent-tolerant. The
categoryandpurposepassed tocheck()may be sent in French OR English, with or without accents, and are matched case- and whitespace-insensitively. English resolves only when the company actually disclosed an English label for that cell. If a label is ambiguous or undeclared the check fails closed (deny /none), so pass the label as declared in the catalog when you can. - The public identifier everywhere is the protocol
requestId(0x+ 64 hex), never an internal uuid. - Scopes:
checkauthorizescheck;issueauthorizes the consent-request endpoints;checkorissuereads the catalog and the consent documents;attestauthorizes manual consents, consent sheets, claim links, the paper of a verbal consent, revoke and erasure;attest_verbalauthorizes recording a verbal consent, reading its history and revoking verbal cells;relationshipauthorizes the relationship end/revert;registrythe customer registry, the per-customer retention clock, dispositions and holds;retentionthe rules, purges and passes;inventorythe host inventory;withdrawwithdrawals recorded on a person's behalf;holdsthe holds feed.attest_verbal,withdrawandholdsare never granted by default. - Receipt verification.
Agreely.verifyReceiptrecognises three receipt types:company_attested(paper;overall: "verified"when sound),citizen(at mostpartialoffline) andcompany_documented, a verbalVerbalConsentReceipt: its company signature is verified, butoverallis at mostpartial, because the organisation's word is all there is.
MIT-licensed and built to be provable, not just trusted:
- No telemetry, no analytics, no phone-home. No posthog/sentry/mixpanel/GA, no hidden fetch to an Agreely-controlled server, no data collection. Every network call is in the source.
- Only the endpoints you configure. The client contacts your configured
Agreely API base URL (default
https://api.agreely.ca). The opt-in receipt verifier additionally contacts a chain RPC you pass in (on-chain anchor) and an IPFS gateway (defaultgateway.lighthouse.storage, overridable) for the opt-in disclosure-copy check; itsdid:webresolver fetches the issuer host named in the receipt over HTTPS (inject your ownresolverfor untrusted receipts). Resolving a citizen DID calls the Agreely CITIZEN tier (defaulthttps://my.agreely.ca/did/{did}), andresolveCompanyDidcalls the Agreely WEB tier (defaulthttps://app.agreely.ca/c/{slug}/did.json); both are overridable, and injecting your ownresolverremoves them entirely. - Minimal deps, no install scripts. Only an optional
undicifallback. - Audit surface.
src/transport.tsandsrc/verify/receipt.tsare the only files that open a socket.
Agreely records and structures consent; it does not certify that your organization is compliant.
On PHP instead of Node? The same client, same contract, same golden vectors:
agreely/sdkon Packagist: https://packagist.org/packages/agreely/sdk- Source: https://github.com/agreely-protocol/sdk-php
Both SDKs assert the same shared golden vectors so neither drifts from the
/v1 contract.
- Product and API: https://agreely.ca
- Organization: https://github.com/agreely-protocol