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.

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
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 on a 429 only, idempotent calls
respectRetryAfter true honor the server Retry-After

Resources are read-only properties: agreely.consentRequests, agreely.manualConsents, agreely.relationships, agreely.catalog, 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.

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: "active" | "none" | "revoked" | "expired" | "erased",
//   consentRef?: "0x…",        // absent when status is "none"
//   checkedAt: "2026-…Z",
//   assurance?: "citizen_signed" | "company_attested",
//   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, rate-limit, and outage throw. The assurance field distinguishes a citizen-signed consent (citizen_signed) from a company-attested one (company_attested).

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?, checkedAt }  // consentRef/assurance absent for status "none"

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

Use case: gating a client-list display

Rendering a table of N customers x M fields is N x M uses of personal information: under Loi 25, each field you display is a use gated by a check (use-limitation, art. 12; accountability, art. 3.1). 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). The response is least-disclosure: scopes only.

const me = await agreely.identity();
// Identity: { scopes: ("check" | "issue" | "attest" | "relationship")[] }
if (me.scopes.includes("issue")) {
  // ...this key may issue consent requests
}

// The configured endpoint is also exposed client-side:
agreely.baseUrl; // "https://api.agreely.ca"

create emails the recipient; items are catalog ids or {category, purpose} objects.

const r = await agreely.consentRequests.create({
  customerId: "cust_8812",
  recipientEmail: "[email protected]",
  items: ["<catalogEntryId>", { category: "Email address", purpose: "Newsletter" }],
  validUntil: "2031-01-01",
});
// IssuedRequest: { requestId: "0x…64hex", status: "pending", deepLink, emailDelivered, items }

// List (cursor-paged) / get by protocol requestId.
const page = await agreely.consentRequests.list({ status: "pending", cursor });
// { items, nextCursor }, nextCursor is null when exhausted
const one = await agreely.consentRequests.get("0x…");  // the requestId, NOT a uuid
// ConsentRequestRecord: { requestId, status: "pending"|"approved"|"refused"|"expired"|"revoked_before_action",
//   validUntil, expiresAt, createdAt, settledAt (nullable), items }

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" });

Walk every page, and wait for settlement

listAll is an async generator (default maxPages 1000); collect gathers everything into an array; waitForSettlement polls get until the request settles.

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)

Manual consents

For consent collected offline (a hand-signed PDF). You record the evidence: the SHA-256 hash of the PDF, and optionally the PDF itself.

const m = await agreely.manualConsents.record({
  customerId: "cust_8812",
  documentVersionId: "doc_v3",
  effectiveDate: "2026-01-01",
  validUntil: "2031-01-01",
  items: [{ category: "Phone number", purpose: "Billing" }],
  evidence: {
    pdfSha256: Agreely.hashPdf(bytes), // "0x"+sha256, required
    pdf: base64Pdf,                    // optional opt-in upload
  },
});
// Note: manual consents do NOT honor an Idempotency-Key (only consentRequests.create does).
// ManualConsentResult: { consentId, merkleRoot, consentRefs[] (one per cell),
//   assurance: "company_attested", anchored: false } // anchored is false at record time

// A one-time link for the citizen to claim/confirm.
const link = await agreely.manualConsents.createClaimLink({ customerId: "cust_8812", reference: "order-4471" });
// { claimUrl, token, expiresAt }

// Revoke or erase by consentRef.
await agreely.manualConsents.revoke("0x…", { reason: "customer request" });
// { consentRef, revoked, alreadyRevoked }
await agreely.manualConsents.erase("0x…", { reason: "erasure request" });
// { consentRef, erased, alreadyErased }

Ending a customer relationship

Attest, from your own code, that a customer relationship is over (Law 25 art. 23). 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" }

Catalog

const catalog = await agreely.catalog.list();
// CatalogEntry[]: { id, category, purpose, description (nullable) }

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");

Rate-limit options

maxRetries (default 0) adds extra retries on a 429 only, and only for idempotent calls (GET and check); a mutating call never auto-retries a 429. respectRetryAfter (default true) honors the server Retry-After header.

Typed errors

Every failure is an AgreelyError subclass (with .code, .status, .field). A deny is never an error.

Error When
AgreelyAuthError 401 / 403
AgreelyValidationError 400 / 422 (.field names the input)
AgreelyNotFoundError 404
AgreelyRateLimitError 429 (.retryAfter seconds)
AgreelyUnavailableError 503 / network / timeout or abort (retryable; the only error subject to the degrade policy)
AgreelyTimeoutError client-side poll budget elapsed in waitForSettlement (code: "timeout", not a server outage)
AgreelyAbortError TS only: the caller's AbortSignal fired (code: "aborted")
AgreelyConfigError bad client config (thrown at init)
import { AgreelyRateLimitError } from "@agreely/sdk";

try {
  await agreely.check(id, cat, pur);
} catch (e) {
  if (e instanceof AgreelyRateLimitError) await sleep((e.retryAfter ?? 1) * 1000);
  else throw e;
}

Default timeout is a tight 800ms total budget.

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 always resolves: a tampered receipt yields a verdict, never an exception. It distinguishes a check that failed (fail) from a check that could not complete (unavailable), so a valid receipt during a DID outage never looks like a forgery.

const v = await Agreely.verifyReceipt(receipt, { rpcUrl: process.env.BASE_SEPOLIA_RPC });
v.overall; // "verified" | "partial" | "failed" | "unavailable"

The dedicated page details the four checks, the what it proves vs what it trusts table, and why a citizen receipt can never be verified offline:

Next