TypeScript SDK
@agreely/sdk is the thin, fully typed Node client for the
/v1 API. One call gates a data use on a live,
authoritative consent check. There is no local mirror and no cache: every
check() is a fresh call, because caching an allow while a revoke lands is a
correctness failure.
- One-call DX, typed end to end (ESM + CJS, full
.d.ts). - Fail-closed by default: an outage denies.
- Node 18+ (global
fetch), with a lazy, optionalundicifallback.
This page documents 0.6.1, the version published on npm. The API members 0.6.1 does not send or type are listed in What 0.6.1 does not cover.
Install
npm install @agreely/sdk
Construct
import { Agreely } from "@agreely/sdk";
const agreely = new Agreely({ apiKey: process.env.AGREELY_API_KEY! });
Every constructor option:
| Option | Default | Role |
|---|---|---|
apiKey (required) |
(none) | a blank value throws AgreelyConfigError |
baseUrl |
https://api.agreely.ca |
API root |
timeout |
800 (ms) |
total per-call budget, retries included |
degradeOnOutage |
(off) | explicit fail-open opt-in |
onBreakGlass |
(none) | (event) => void break-glass audit sink |
maxDegradeWindow |
"24h" |
hard cap on break-glass ttl and the outage window |
fetch |
global fetch |
inject your own implementation |
maxRetries |
0 |
extra retries of a 429 rate_limited, on reads and checks only |
respectRetryAfter |
true |
honor the server Retry-After |
Resources are read-only properties: agreely.consentDocuments,
agreely.consentRequests, agreely.manualConsents, agreely.verbalConsents,
agreely.withdrawals, agreely.relationships, agreely.customers,
agreely.catalog, agreely.retention, agreely.inventory, agreely.breakGlass.
check() returns a boolean
allow is the only true. Send raw human labels; the SDK forwards them
verbatim and never normalizes the category or purpose. The server matches them
in French or English, with or without accents, ignoring case and spacing; an
ambiguous or undeclared label denies.
if (await agreely.check("cust_8812", "Phone number", "Billing")) {
// ...you may use the phone number for billing
}
checkDetailed() returns the decision
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 "necessity"
// checkedAt: "2026-…Z",
// assurance?: "citizen_signed" | "company_attested" | "company_documented",
// tier?: "full" | "manual" | "verbal", // 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"
// degraded?: boolean, mode?: string } // present only on a degraded allow
A deny is a normal 200: checkDetailed returns it, it does not throw. Only
auth, validation, billing, rate-limit and outage throw. validUntil and
revokedAt are absent, not null, when no record backs the answer. They date
the answer; they are not a time to live: never 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 of the tenant's act. Carries basis, no consentRef, no assurance, no tier |
none |
deny | no record on a consent-basis cell; also what an erased cell reads as |
revoked |
deny | the consent was withdrawn |
expired |
deny | the consent's period elapsed |
relationship_ended |
deny | the company documented that the relationship is over; the consent itself was never withdrawn |
requires_depersonalization |
deny | the declared ground exists only for depersonalized information, which a named customer cannot meet |
basis_not_in_regime |
deny | the declared ground does not exist in the tenant's act (usually after a sector change) |
erased |
deny | listed by the contract, not currently emitted: an erased cell reads none |
Treat a status you do not recognise as a deny: read d.decision. A
necessity allow is not a consent: it rests on a ground the company declared
on its catalog, with no signed proof behind it, and Agreely does not judge its
legal validity. Read company.sector from identity() before switching on
basis, whose vocabulary depends on the act.
An allow on a consent says how strong its 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 |
A line a document gives for information is recorded as an acknowledgement,
never a consent: the check answers it like no record (necessity or a refusal),
with no consentRef, assurance or tier.
Batch checks: checkBatch() and checkFields()
To evaluate many cells in one round-trip instead of N calls to check(). The
returned decisions are aligned to the submitted items. category / purpose
are sent raw (the server normalizes). On an outage these methods throw
AgreelyUnavailableError (no degrade here).
const decisions = await agreely.checkBatch([
{ customerRef: "cust_8812", category: "Phone number", purpose: "Billing" },
{ customerRef: "cust_8813", category: "Email address", purpose: "Newsletter" },
]);
// BatchDecision[]: { customerRef, category, purpose, decision, status,
// consentRef?, assurance?, tier?, basis?, validUntil?, revokedAt?, checkedAt }
// checkBatch([]) short-circuits to [] client-side, with no round-trip.
checkFields() is the ergonomic cartesian-product form: give a list of customers
and a list of fields, it builds every pair, calls checkBatch() once, and
returns a ready-to-use isAllowed gate.
const grid = await agreely.checkFields(
["cust_8812", "cust_8813"],
[ { category: "Phone number", purpose: "Billing" },
{ category: "Email address", purpose: "Newsletter" } ],
);
// CheckFieldsResult: { decisions, isAllowed(customerRef, category, purpose): boolean }
if (grid.isAllowed("cust_8812", "Phone number", "Billing")) {
// ...you may display this field for this customer
}
A batch holds at most 500 cells (BATCH_CAP), refused client-side before the
call. checkFields multiplies customers by fields, so it reaches the cap sooner
than it looks, and its error names the page size that fits.
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. checkFields collapses the whole grid into one round-trip and hands you an isAllowed(customerRef, category, purpose) gate to call per cell. Batching is an optimization of the same per-decision accountability (every cell is still decided and logged by the server), not a weakening.
Key identity
identity() calls GET /v1/whoami and returns the scopes the key actually
carries, server-verified (any scope reaches it), and the minimum needed to discover
the organization instead of hard-coding it: its name, its sector, the act that
governs it and the absolute address of its public privacy page (null when that
page would not resolve). The response stays least-disclosure: no company id, no
DID, no key name, nothing about plan, usage or billing.
const me = await agreely.identity();
// Identity: {
// scopes: Scope[], // "check" | "issue" | "attest" | "attest_verbal" | "relationship"
// // | "registry" | "retention" | "inventory" | "withdraw" | "holds"
// company: { name: string, slug: string, sector: "private" | "public",
// statute: "P-39.1" | "A-2.1", publicPolicyUrl: string | null }
// }
if (me.scopes.includes("issue")) {
// ...this key may issue consent requests
}
me.company.statute; // "P-39.1": the act whose vocabulary /v1/catalog uses
me.company.publicPolicyUrl; // link your public form to it instead of hard-coding it
// The configured endpoint is also exposed client-side:
agreely.baseUrl; // "https://api.agreely.ca"
company.slug is the organisation's public identifier, the one its public privacy
page's address carries. The API may also list an eleventh scope, diffusion, which
0.6.1 does not type but passes through.
Called once a minute from one scheduler, it also serves as a probe: on a 401 or a
402, purge every cache derived from Agreely and fail closed.
Consent documents
Paper consents, telephone consents and consent sheets take a documentVersionId.
Pin the document's stable code and resolve the version published today through
it, instead of copying an id 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, responsable, automatedDecision, profiling, integrity
// The information document of one version, to print and hand to the person
// (scope check, issue, attest or attest_verbal):
const info = await agreely.consentDocuments.getInformationPdf(doc.documentVersionId, { locale: "fr" });
await writeFile(info.filename ?? "information.pdf", info.pdf); // Uint8Array
The PDF is served by version id, never by code, and a superseded version is
still served (it says so). English is refused (english_text_missing) when a
section has no English text. The bytes are not frozen, so keep your own copy.
Fetching it records nothing: it is not evidence that anyone was informed. A render
gets a time budget of at least 15 s, overridable with { timeout }.
Consent requests
create emails the recipient. It names the published consent document the request
is issued under, by version id or by code (one, not both); the requested
categories and purposes derive from that document.
const r = await agreely.consentRequests.create({
customerId: "cust_8812",
recipientEmail: "[email protected]",
documentCode: "conditions-marketing", // or: consentDocumentId: "<documentVersionId>"
validUntil: "2031-01-01",
});
// IssuedRequest: { requestId: "0x…64hex", status: "pending", deepLink, emailDelivered, items, document }
validUntil (here and on paper and telephone consents) is a plain date, meaning
the end of that day in the organisation's timezone, or an RFC 3339 instant with an
offset. A relative phrase is refused, and so is an end more than 10 years after
the start (an Agreely product ceiling, not a statutory one).
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:
await agreely.consentRequests.create(input, { idempotencyKey: "order-4471" });
Before issuing, hasPending tells you whether a request is already outstanding for
the customer (optionally for one document), so you do not email twice. It reports a
pending request; it says nothing about consent.
if (!(await agreely.consentRequests.hasPending("cust_8812", "conditions-marketing"))) {
await agreely.consentRequests.create(input);
}
List, get, walk every page, and wait for settlement
const page = await agreely.consentRequests.list({
customerId: "cust_8812", // optional
status: "pending", // optional
limit: 50, // default 50, max 100
cursor, // a prior nextCursor
});
// { items, nextCursor }: newest first, nextCursor is null when exhausted
const one = await agreely.consentRequests.get("0x…"); // the requestId, NOT a uuid
// ConsentRequestRecord: { requestId, status, validUntil, expiresAt, createdAt,
// settledAt (nullable), customerId, documentCode, items, document }
status is pending, approved, asks_declined, refused, expired or
revoked_before_action. approved means the person accepted at least one
consent ask; a request whose every ask was declined reads asks_declined.
listAll is an async generator (default maxPages 1000); collect gathers
everything into an array; waitForSettlement polls get until the request
settles (asks_declined counts as settled).
for await (const rec of agreely.consentRequests.listAll({ status: "pending" })) {
// one ConsentRequestRecord at a time
}
const all = await agreely.consentRequests.collect({ status: "pending" });
// Poll until settled. Defaults: intervalMs 2000, timeoutMs 120000.
const settled = await agreely.consentRequests.waitForSettlement("0x…", {
intervalMs: 2000,
timeoutMs: 120000,
signal: controller.signal, // TS only: an AbortSignal
});
waitForSettlement throws AgreelyTimeoutError if the client budget elapses
(this is not a server outage), and AgreelyAbortError if the supplied
AbortSignal fires.
Cancel a pending request
cancel cancels a pending request by its protocol requestId (the "revoke
before action" path). It is idempotent: an already-terminal request is not an
error and returns cancelled: false. An unknown requestId throws
AgreelyNotFoundError.
const c = await agreely.consentRequests.cancel("0x…");
// CancelledRequest: { requestId, status: "revoked_before_action", cancelled: true }
// Already terminal -> { requestId, status, cancelled: false } (no error)
Paper consents (scope attest)
For consent collected on paper (a hand-signed PDF). You record the evidence:
the SHA-256 hash of the signed PDF, and optionally the PDF itself. 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 and come back in acknowledged.
const m = await agreely.manualConsents.record({
customerId: "cust_8812",
documentVersionId: doc.documentVersionId,
effectiveDate: "2026-06-01",
validUntil: "2031-01-01",
items: ["<catalogEntryId>", { category: "Email address", purpose: "Newsletter" }],
evidence: {
pdfSha256: Agreely.hashPdf(bytes), // "0x"+sha256 of the SIGNED scan, required
pdf: base64Pdf, // optional opt-in upload
},
sensitiveExpressAttested: true, // when a ticked purpose is sensitive and rests on consent
versionAttested: true, // when the paper was signed before this version's publication
});
// ManualConsentResult: { consentId, merkleRoot, consentRefs[], assurance: "company_attested",
// anchored: false, acknowledged: [{ category, purpose, consentRef }], asksDeclined }
record is never auto-retried. It carries an Idempotency-Key like
consentRequests.create: a retry with the same key and the same body replays the
original answer and records nothing new; the same key with a different body is a
new request. A key is 1 to 255 printable ASCII characters.
Refusals, by their stable e.reason: 422 notice_only, pdf_hash_mismatch,
valid_until_too_far, effective_date_in_future, sensitive_express_required,
version_attestation_required; 409 stronger_consent_active,
relationship_ended, verbal_awaits_paper. Nothing is recorded.
// A link for the person to claim the consent in their own space.
const link = await agreely.manualConsents.createClaimLink({ customerId: "cust_8812" });
// { claimUrl, token, expiresAt }
// Revoke or erase by consentRef.
const w = await agreely.manualConsents.revoke("0x…", { reason: "customer request" });
// { consentRef, revoked: true, alreadyRevoked, gate: "denied" | "superseded" | "unchanged" }
const e = await agreely.manualConsents.erase("0x…", { reason: "erasure request" });
// { consentRef, erased: true, alreadyErased, gate }
// Keep in force a consent the person signed online (see the refusal below).
const k = await agreely.manualConsents.revoke("0x…", { otherConsents: "keep" });
// { ..., gate: "superseded", stillInForce: true }
gate is what /v1/check now does for that purpose: denied (this consent backed
it), superseded (another consent backs it, and the check may still allow), or
unchanged (an idempotent repeat). Read gate, not revoked, before telling
anyone the use stopped. A revoke or erase is refused with 409
citizen_consent_at_gate when a consent the person signed online holds the
purpose: these routes never end it, record their withdrawal with
withdrawals.record instead. otherConsents: "keep" ends this record and leaves
that consent in force, and the answer carries stillInForce: true; without it (or
with "withdraw", which these routes treat the same), the 409 stands. Both routes
accept only reason and otherConsents.
Print a consent sheet
createConsentSheet returns the signature sheet of one published version for
one customer, with a new claim link and the printed reference that protects it:
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)
Never send the claim link in the same envelope or message as the sheet: delivered together, the printed reference protects nothing. And never record the blank sheet's hash as evidence: the evidence is the signed paper, scanned when it comes back. The reference and the token are returned once and stored nowhere; a new sheet retires the previous claim.
Telephone consents (scope attest_verbal)
A consent the person gave by telephone, documented by your organisation. There
is no document, so the proof is your word: every result says tier: "verbal" and
assurance: "company_documented". The attest_verbal scope is never granted by
default.
const v = await agreely.verbalConsents.record({
customerId: "cust_8812",
documentVersionId: doc.documentVersionId,
answers: [ // every ask put on the call, answered explicitly
{ category: "Email address", purpose: "Newsletter", answer: "yes" },
{ category: "Email address", purpose: "Surveys", answer: "no" },
],
obtainedAt: callEndedAt, // at most 7 days old, never in the future
obtainedBy: "Julie, customer service",
scriptVersion: "script-2026.09",
respondent: { consentedBy: "self" }, // or a representative and its capacity
validUntil: "2027-09-29",
paperExpected: true,
});
// { consentId, merkleRoot, consentRefs, tier: "verbal", assurance: "company_documented",
// anchored: false, paperExpected, acknowledged, asksDeclined }
// The signed paper came back (scope attest): each "yes" moves into a new paper consent.
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) },
});
// What was said, what the paper said, what is in force:
const h = await agreely.verbalConsents.get(v.consentId);
// h.state: "awaiting_paper" | "verbal_only" | "paper_received"
For a minor under 14, pass isMinor: true and a representative in the capacity
titulaire_autorite_parentale or tuteur_mineur. A daily limit applies (429
AgreelyVerbalDailyCapError, never auto-retried). Withdraw with
manualConsents.revoke; once the paper came back, withdraw the paper consent's
reference instead.
Withdrawals recorded on the person's behalf (scope withdraw)
When a person asks you, by any channel, to withdraw a consent, record it here. It reaches any consent you hold for them, including one they signed online, and the check denies at once. It works while your billing has lapsed: honouring a withdrawal is a legal duty, not a paid service.
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 they asked
reason: "Asked on the phone", // optional, at most 1000 characters
otherConsents: "keep", // see below: "withdraw" | "keep", or alsoWithdraw: ["0x…"]
});
// { consentRef, withdrawn: true, alreadyWithdrawn, recordedOnBehalf: true,
// assurance: "company_documented", gate: "denied" | "superseded" | "unchanged",
// alsoWithdrawn: ["0x…"], stillInForce: ["0x…"] }
- The answer says who speaks.
recordedOnBehalf: true, and the server sendsassurance: "company_documented": Agreely documents that your organisation says it received and actioned the request, never that the person signed a revocation. - Another consent of the same purpose. When another consent of the purpose is
in force for this customer (two people may each hold one), the call says what
happens to it:
otherConsents: "withdraw"withdraws all of them,alsoWithdraw: ["0x…"]names the ones it covers (at most 50,WITHDRAWAL_SCOPE_MAX;[]declares nothing), andotherConsents: "keep"withdraws this consent alone. Never both together: the SDK refuses before any call (AgreelyConfigError), as it does any unknown member. Without a declaration the answer is 422other_consents_running, anAgreelyOtherConsentsRunningErrorwhoseotherConsentRefslists those consents, nothing written: ask the person which, then retry. Those withdrawn at the same instant come back inalsoWithdrawn, those still in force instillInForce. - Its own scope, never granted by default, and a daily cap (429
AgreelyDailyCapError,withdrawal_daily_cap, noRetry-After); your account owner is notified. - Refused: 404
unknown_consent, 409consent_lapsed, 422not_revocable,requested_at_in_future,requested_at_before_grant,not_running_for_purpose(analsoWithdrawentry that is not another consent in force for the purpose). Accepted after the relationship ended. Never auto-retried; a retry with the same key and body replays the first answer.
Ending a customer relationship
Document, from your own code, that a customer relationship is over (P-39.1 s. 23; A-2.1 s. 73).
Scope relationship. The customerRef is your own reference (the same one used by
check), scoped to the key's company. reason is required (a blank reason
throws an AgreelyError). The call is idempotent: re-ending an already-ended
relationship returns the original endedAt and endedBy origin. It is a lifecycle
overlay: it never revokes or erases any consent.
const r = await agreely.relationships.end({ customerRef: "cust_8812", reason: "Account closed, purposes fulfilled" });
// RelationshipEnded: { customerRef, status: "ended", endedAt, endedBy: "company" | "citizen_request" }
0.6.1 records the end at the instant of the call; the API also accepts an earlier
endedAt, which 0.6.1 does not send.
An end recorded by mistake can be undone, within the 30-day correction window
(an Agreely product rule, not a period set by law) and as long as nothing was destroyed (P-39.1 s. 11; A-2.1 s. 72). reason is
required. An ineligible relationship throws AgreelyNotFoundError with nothing
written: a returning person is a new relationship needing a fresh consent request.
const u = await agreely.relationships.revert({ customerRef: "cust_8812", reason: "End recorded by mistake" });
// RelationshipReverted: { customerRef, status: "active", reverted: true }
The customer registry (scope registry)
The identity your own system holds for one of its customers, 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
noticeLocale: "fr",
}); // a member you leave out is left as it stands
// { customerRef, registered, source, hasDisplayName, hasEmail, noticeLocale,
// createdAt, updatedAt, relationship, created }
const meta = await agreely.customers.get("cust_8812"); // metadata only
- The registry no longer holds a per-customer ground.
legalBasisandbasisNoteare gone from the API: a value sent is ignored, and the answer no longer carrieslegalBasisorhasBasisNote. 0.6.1 neither sends nor types them. The purpose belongs to the processing, documented per catalog cell. - A merge, never a replace.
createdis true when the call created the record (HTTP 201), false when it merged (200). - The read is metadata:
hasEmail: true, never the address. - 409 carries its stable string in
code:identity_erased(a destruction removed the identity; a clear still works) oridentity_held(a hold keeps it as is).
Catalog
const catalog = await agreely.catalog.list();
// CatalogEntry[]: { id, category, purpose, description (nullable), legalBasis, sensitive }
const form = await agreely.catalog.forDocument("conditions-marketing");
// { regime, document: { code, documentVersionId }, catalog }: only the cells that document groups
const { cells } = await agreely.catalog.listCells(); // each cell with its retentionRuleKey
The catalog says what was declared, never whether this person consented: only a check does.
Retention (scopes retention, holds, registry)
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".
// 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);
const held = await agreely.retention.syncHolds(); // every active hold; any error throws: do not purge
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 });
}
}
- A purge job fails closed by not purging. An outage returns
source: "abstain", which has norules. Purging on the stored snapshot is an explicit, bounded and audited exception (onOutage: "use-snapshot",maxSnapshotAge, a mandatoryonDegrade). An auth, billing, validation or rate-limit failure throws instead of abstaining. - Holds suspend the rules for one customer.
syncHolds()reads a snapshot, thensyncHolds({ changedSince })the changes since your cursor; treat any error on that feed as "do not purge". - One customer (scope
registry):getCustomerRetention,placeHold,releaseHold,listHolds,declareDisposition. A disposition needs the relationship to have ended first (409relationship_active). - The sweep floor (one per rule and host system every 15 minutes) throws
AgreelySweepTooFrequentError, never auto-retried. Host-system names are rationed (10 per organisation over 30 days).
Inventory (scope inventory)
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' key
replaceCategories sends the complete list for that system: every set it
declared before and that you omit is withdrawn. Fields carry names, never
values. Up to 200 sets per declaration and 60 fields per set.
Hash a PDF
hashPdf and hashPdfFile are static and touch no network. They produce the
exact form evidence.pdfSha256 expects: "0x" + the SHA-256 hex.
const h1 = Agreely.hashPdf(bytes); // bytes is a Uint8Array
const h2 = await Agreely.hashPdfFile("/path/to/consent.pdf");
Timeouts, retries and limits
The default timeout is a tight 800 ms total budget, sized for a caller in the same region as the API. Over the open internet, a cold TLS handshake can exceed it on a healthy server, and because the SDK fails closed, a timeout is a spurious deny. Give an internet-facing caller room:
new Agreely({ apiKey, timeout: 8000, maxRetries: 1 });
Only reads and the check are retried on a transient outage (up to 2 attempts,
inside the budget). maxRetries also retries a 429 rate_limited after its
Retry-After, and no other 429. No write is ever auto-retried. The two calls
that render a PDF (getInformationPdf, createConsentSheet) get at least 15 s.
| Limit | Value | Past it |
|---|---|---|
| batch | 500 cells per checkBatch / checkFields |
AgreelyConfigError before the call |
| rate | 120 requests a minute per company, every key included | 429 AgreelyRateLimitError with Retry-After |
| withdrawals | 50 per company per 24 hours through the API | 429 AgreelyDailyCapError |
| holds | 1,000 placed, 20 released that the API did not place, per 24 hours | 429 AgreelyDailyCapError |
| inventory | 200 sets per declaration, 1,000 new set keys per 30 days | AgreelyConfigError; 422 new_set_limit |
customerRef |
200 characters | AgreelyConfigError before the call |
These are deployment defaults: design against them, they are not a contract.
Typed errors
Every failure is an AgreelyError subclass (with .code, .status, .field,
.reason). A deny is never an error. Every method that returns a promise
rejects, it never throws synchronously, and the options of a write are closed: a
misspelt idempotencykey is refused, never ignored.
| Error | When |
|---|---|
AgreelyAuthError |
401 / 403 |
AgreelyValidationError |
400 / 422 (.field names the input) |
AgreelyOtherConsentsRunningError |
422 other_consents_running on a withdrawal (an AgreelyValidationError): .otherConsentRefs lists the consents in force for the purpose |
AgreelyNotFoundError |
404 |
AgreelyConflictError |
409: code: "conflict" (the record's state refuses it), code: "retry" (retry with the same Idempotency-Key), or a named state |
AgreelyBillingInactiveError |
402 (code: "billing_inactive"): the subscription lapsed. Not an outage: check() returns false, checkDetailed() throws it |
AgreelyRateLimitError |
429 (.retryAfter seconds) |
AgreelyDailyCapError |
429 on a daily cap (withdrawal_daily_cap, hold_budget_exhausted, hold_release_cap_reached); never auto-retried |
AgreelyVerbalDailyCapError |
429 verbal_daily_cap; never auto-retried |
AgreelySweepTooFrequentError |
429 on the sweep floor (.retryAfterSeconds); never auto-retried |
AgreelyUnavailableError |
503 / network / timeout (the only error subject to the degrade policy) |
AgreelyTimeoutError |
client-side poll budget elapsed in waitForSettlement (not a server outage) |
AgreelyAbortError |
the caller's AbortSignal fired |
AgreelyConfigError |
bad client config or invalid input, before any request |
Branch on e.reason, never on the message. The paper, telephone, claim-link,
consent-sheet and withdrawal routes carry a stable reason; the registry, holds,
dispositions, inventory and document routes carry their stable string in code.
AgreelyErrorReason stays open-ended: handle a reason you do not know by its HTTP
status.
import { AgreelyConflictError } from "@agreely/sdk";
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;
}
Fail-closed, and the three-gate exception
When Agreely is unreachable (503 / timeout / network), check() denies and
checkDetailed() throws AgreelyUnavailableError. A real 200 deny is
never affected.
Opting a category into fail-open is possible, but only through three
independent gates so it can never happen by accident. A degraded allow is
returned as { decision: "allow", status: "active", degraded: true, mode: … }
with no assurance field: it is a deliberate, audited exception to the
default deny.
Gate 1: config allow-list (with a mandatory audit sink)
const agreely = new Agreely({
apiKey,
degradeOnOutage: {
mode: "fail-open", // the explicit word
categories: ["Browsing/usage"], // ONLY these may ever degrade
maxOutageWindow: "5m", // refuse to degrade past this
onDegrade: (ctx) => audit.log(ctx) // MANDATORY: without it the constructor throws
},
});
Gate 2: the per-call opt-in
// Effective ONLY because the category is allow-listed above. Without the
// config, this per-call opt-in still denies.
await agreely.check("cust_8812", "Browsing/usage", "Analytics", { onOutage: "allow" });
Gate 3: break-glass (the operator lever)
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. It is a TypeScript-only
capability: the request-scoped PHP SDK stops at two gates.
No allow-cache, and bounded windows
There is no cache of allow decisions, by design. Every degraded allow emits an evidence record via onDegrade, and every break-glass-authorized allow emits an onBreakGlass event. The client's maxDegradeWindow (default 24h) caps both a break-glass ttl and the outage window; a value over the cap throws AgreelyConfigError rather than opening an unbounded fail-open window.
A per-call { onOutage: "allow" } that is not backed by a matching
degradeOnOutage.categories entry has no effect: the check still denies (and the
SDK logs a one-time dev warning, silenced with AGREELY_SILENCE_WARNINGS).
Verify a ConsentReceipt
Agreely.verifyReceipt is static, needs no API key, and returns a verdict
on every input, never an exception. It takes the verification bundle a passkey
consent issues, or a bare receipt (isVerificationBundle tells one from the
other), and separates a check that failed (fail) from a check that could not
complete (unavailable), so a valid receipt during an outage never looks like a
forgery.
import { Agreely, isVerificationBundle } from "@agreely/sdk";
isVerificationBundle(bundle); // true for a verification bundle, false for a bare receipt
const v = await Agreely.verifyReceipt(bundle, { rpcUrl: "https://base-rpc.publicnode.com" });
v.overall; // "verified" | "partial" | "incomplete" | "failed" | "unavailable" | "malformed" | "withdrawn"
v.withdrawal; // "active" | "withdrawn" | "not_checked"
const inForce = v.overall === "verified" && v.withdrawal === "active";
A paper receipt (company_attested) and a passkey consent verified from its bundle
can reach verified, meaning the checks listed held, not a legal proof; a bare
passkey receipt reads incomplete, and a telephone receipt (company_documented)
stops at partial. With an rpcUrl, the SDK also reads the on-chain anchors and
the withdrawal: withdrawn means the chain records the withdrawal of an authentic
consent, no longer in force. The keys come from the DID documents the verifier
reads: a resolver that serves a document without the key makes an authentic
receipt read failed. A failed alone is therefore not evidence of forgery: read
the check that failed and notes, or verify the receipt at
app.agreely.ca/verify with its verification
bundle. The dedicated page details the checks, the options (timeoutMs,
rpcDeadlineMs, registryAddress), the what it proves vs what it presumes
table, and why:
What 0.6.1 does not cover
The API accepts these members; 0.6.1 neither sends nor types them. To use them, call the route directly or act in Agreely:
- on paper consents: who consented (
capacity),isMinor,ageBracketandguardianName, andconsentedOnBehalfin the answer; - on telephone consents:
ageBracket; - on ending a relationship: an earlier
endedAt; - the privacy-policy routes and the
diffusionscope (API reference).
Next
- Handling outages walks the fail-closed vs fail-open decision in depth.
- PHP SDK and CLI for the same surface elsewhere.