FR EN

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, optional undici fallback.

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.

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

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.

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 sends assurance: "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), and otherConsents: "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 422 other_consents_running, an AgreelyOtherConsentsRunningError whose otherConsentRefs lists those consents, nothing written: ask the person which, then retry. Those withdrawn at the same instant come back in alsoWithdrawn, those still in force in stillInForce.
  • Its own scope, never granted by default, and a daily cap (429 AgreelyDailyCapError, withdrawal_daily_cap, no Retry-After); your account owner is notified.
  • Refused: 404 unknown_consent, 409 consent_lapsed, 422 not_revocable, requested_at_in_future, requested_at_before_grant, not_running_for_purpose (an alsoWithdraw entry 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. legalBasis and basisNote are gone from the API: a value sent is ignored, and the answer no longer carries legalBasis or hasBasisNote. 0.6.1 neither sends nor types them. The purpose belongs to the processing, documented per catalog cell.
  • A merge, never a replace. created is 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) or identity_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 no rules. Purging on the stored snapshot is an explicit, bounded and audited exception (onOutage: "use-snapshot", maxSnapshotAge, a mandatory onDegrade). An auth, billing, validation or rate-limit failure throws instead of abstaining.
  • Holds suspend the rules for one customer. syncHolds() reads a snapshot, then syncHolds({ 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 (409 relationship_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, ageBracket and guardianName, and consentedOnBehalf in the answer;
  • on telephone consents: ageBracket;
  • on ending a relationship: an earlier endedAt;
  • the privacy-policy routes and the diffusion scope (API reference).

Next