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.
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"
Consent requests
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
- Handling outages walks the fail-closed vs fail-open decision in depth.
- PHP SDK and CLI for the same surface elsewhere.