PHP SDK
agreely/sdk is the PHP port of the TypeScript reference.
Both SDKs assert the same shared golden vectors against the live API, so
neither drifts from the contract, and 0.6.2 exposes the same
surface under the same method names. One call gates a data use on a live,
authoritative check; there is no local mirror and no cache.
- One-call DX, typed result objects and typed errors, PSR-4 / PSR-12, phpstan max. Published under the MIT license.
- Fail-closed by default: an outage denies.
- PHP 8.2+ (also 8.3 / 8.4 / 8.5),
ext-curl+ext-json. Bring your own HTTP client or use the bundledCurlHttpClient.
This page documents 0.6.2, the version published on Packagist. The API members 0.6.2 does not send are listed in What 0.6.2 does not cover.
Install
composer require agreely/sdk
Construct
use Agreely\Sdk\Agreely;
$agreely = new Agreely(['apiKey' => getenv('AGREELY_API_KEY')]); // baseUrl optional
Every constructor option (an associative array; a missing required field throws
AgreelyConfigError):
| 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 |
maxDegradeWindow |
"24h" |
bound on the outage window, raisable (for example "48h") |
maxRetries |
0 |
extra retries of a 429 rate_limited, on reads and checks only |
respectRetryAfter |
true |
honor the server Retry-After |
httpClient |
CurlHttpClient |
any Agreely\Sdk\Http\HttpClient |
Resources are accessor methods: $agreely->consentDocuments(),
$agreely->consentRequests(), $agreely->manualConsents(),
$agreely->verbalConsents(), $agreely->withdrawals(),
$agreely->relationships(), $agreely->customers(), $agreely->catalog(),
$agreely->retention(), $agreely->inventory().
Honest divergences from the TS SDK
The PHP SDK has no onBreakGlass option and no breakGlass resource (fail-open is two-gate; the third gate, break-glass, is TS-only). It has no fetch option (it uses httpClient). Pagination is iterate() (a Generator), not listAll. Inputs are associative arrays checked at runtime. There is no AbortSignal. Both SDKs are MIT.
check() returns a bool
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 ($agreely->check('cust_8812', 'Phone number', 'Billing')) {
// ...you may use the phone number for billing
}
checkDetailed() returns the decision
$d = $agreely->checkDetailed('cust_8812', 'Phone number', 'Billing');
// $d->decision "allow" | "deny"
// $d->status one of the nine values below
// $d->consentRef "0x…" (null for "none" and "necessity")
// $d->checkedAt "2026-…Z"
// $d->assurance "citizen_signed" | "company_attested" | "company_documented" | null
// $d->tier "full" | "manual" | "verbal" | null (present exactly when assurance is)
// $d->basis the declared non-consent ground (ONLY on "necessity", else null)
// $d->validUntil the backing consent's end (null without a record)
// $d->revokedAt when it was withdrawn (ONLY on "revoked", else null)
// $d->degraded / $d->mode set only on a degraded allow
A deny is a normal 200; checkDetailed returns it, it does not throw.
validUntil is an upper bound, never a cache lease: a withdrawal can end the
consent before that date, and only a host that checks again sees it.
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 ($d->isNecessity()) 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.
An allow on a consent names its proof twice, assurance and tier, and your code
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 |
use Agreely\Sdk\Types\ConsentTier;
if ($d->isAllow() && ($d->isNecessity() || ConsentTier::atLeast($d->tier, ConsentTier::MANUAL))) {
// this use requires at least a signed paper
}
A line a document gives for information is recorded as an acknowledgement,
never a consent: the check answers it like no record, 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).
use Agreely\Sdk\Types\BatchCheckItem;
$decisions = $agreely->checkBatch([
new BatchCheckItem('cust_8812', 'Phone number', 'Billing'),
['customerRef' => 'cust_8813', 'category' => 'Email address', 'purpose' => 'Newsletter'],
]);
// list<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.
$grid = $agreely->checkFields(
['cust_8812', 'cust_8813'],
[ ['category' => 'Phone number', 'purpose' => 'Billing'],
['category' => 'Email address', 'purpose' => 'Newsletter'] ],
);
// CheckFieldsResult: $grid->decisions; $grid->isAllowed($customerRef, $category, $purpose): bool
if ($grid->isAllowed('cust_8812', 'Phone number', 'Billing')) {
// ...you may display this field for this customer
}
A batch holds at most 500 cells (Agreely::BATCH_CAP), refused client-side
before the call; checkFields 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. The returned object also
echoes the configured baseUrl, client-side.
$me = $agreely->identity();
// Identity: $me->scopes (list<string>), $me->baseUrl (string|null),
// $me->company (IdentityCompany|null: name, sector, statute, publicPolicyUrl)
if ($me->hasScope('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
scopes holds whatever the server sends, including a scope 0.6.2 does not name
(such as diffusion). The API also returns company.slug, which 0.6.2 does not
read.
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 (scope check or issue):
$docs = $agreely->consentDocuments()->list();
$doc = $agreely->consentDocuments()->get('conditions-marketing');
$doc->documentVersionId; // the version in force
$doc->disclosure->withdrawal->text('fr'); // the published text, verbatim
// The information document of one version, to print and hand to the person
// (scope check, issue, attest or attest_verbal):
$info = $agreely->consentDocuments()->getInformationPdf($doc->documentVersionId, ['locale' => 'fr']);
file_put_contents('/tmp/' . ($info->filename ?? 'information.pdf'), $info->pdf);
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. Fetching it records nothing: it is not evidence that
anyone was informed. A render gets a budget of at least 15 s ('timeout' => ms
overrides it).
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.
$r = $agreely->consentRequests()->create([
'customerId' => 'cust_8812',
'recipientEmail' => '[email protected]',
'documentCode' => 'conditions-marketing', // or: 'consentDocumentId' => '<documentVersionId>'
'validUntil' => '2031-01-01',
]);
// IssuedRequest: $r->requestId "0x…64hex"; $r->status "pending"; $r->deepLink; $r->emailDelivered; $r->items
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). Pass your own key to make a retry
replay the original 201:
$agreely->consentRequests()->create($input, ['idempotencyKey' => 'order-4471']);
Before issuing, hasPending('cust_8812', 'conditions-marketing') tells you whether
a request is already outstanding (the document code is optional). It reports a
pending request; it says nothing about consent.
$page = $agreely->consentRequests()->list([
'customerId' => 'cust_8812', // optional
'status' => 'pending', // optional
'limit' => 50, // default 50, max 100
'cursor' => $cursor, // a prior nextCursor
]);
// $page->items (list<ConsentRequestRecord>); $page->nextCursor (null when exhausted)
$one = $agreely->consentRequests()->get('0x…'); // the protocol requestId, NOT a uuid
// ConsentRequestRecord: requestId, status, validUntil, expiresAt, createdAt,
// settledAt (nullable), customerId, documentCode, items
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.
Walk every page, and wait for settlement
In PHP, full pagination is iterate() (a Generator), not listAll.
collect() gathers everything; waitForSettlement() polls get until the request
settles (asks_declined counts as settled).
foreach ($agreely->consentRequests()->iterate(['status' => 'pending']) as $rec) {
// one ConsentRequestRecord at a time
}
$all = $agreely->consentRequests()->collect(['status' => 'pending']);
// Poll until settled. Defaults: intervalMs 2000, timeoutMs 120000.
$settled = $agreely->consentRequests()->waitForSettlement('0x…', [
'intervalMs' => 2000,
'timeoutMs' => 120000,
]);
waitForSettlement throws AgreelyTimeoutError if the client budget elapses
(this is not a server outage). There is no AbortSignal in PHP.
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.
$c = $agreely->consentRequests()->cancel('0x…');
// CancelledRequest: $c->requestId; $c->status "revoked_before_action"; $c->cancelled true
// Already terminal -> $c->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.
$m = $agreely->manualConsents()->record([
'customerId' => 'cust_8812',
'documentVersionId' => $doc->documentVersionId,
'effectiveDate' => '2026-06-01',
'validUntil' => '2031-01-01',
'items' => [['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: $m->consentId; $m->merkleRoot; $m->consentRefs; $m->assurance "company_attested";
// $m->anchored (false at record time); $m->acknowledged; $m->asksDeclined
The input is closed: a member outside the ones above is refused before the call
(AgreelyConfigError). record is never auto-retried and carries an
Idempotency-Key: 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.
Refusals, by their stable ->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, predates_withdrawal,
renewal_ends_before_current. Nothing is recorded.
// A link for the person to claim the consent in their own space.
$link = $agreely->manualConsents()->createClaimLink(['customerId' => 'cust_8812']);
// $link->claimUrl; $link->token; $link->expiresAt
// Revoke or erase by consentRef.
$w = $agreely->manualConsents()->revoke('0x…', ['reason' => 'customer request']);
// $w->consentRef; $w->revoked; $w->alreadyRevoked; $w->gate "denied" | "superseded" | "unchanged"
$e = $agreely->manualConsents()->erase('0x…', ['reason' => 'erasure request']);
// $e->consentRef; $e->erased; $e->alreadyErased; $e->gate
// Keep in force a consent the person signed online (see the refusal below).
$k = $agreely->manualConsents()->revoke('0x…', ['otherConsents' => 'keep']);
// $k->gate "superseded"; $k->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 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
->stillInForce is true; without it (or with 'withdraw', which these routes
treat the same), the 409 stands. Both routes accept only reason and
otherConsents, and refuse any other value before the call.
Print a consent sheet
$sheet = $agreely->manualConsents()->createConsentSheet('cust_8812', [
'documentVersionId' => $doc->documentVersionId,
'locale' => 'fr',
]);
$printer->print($sheet->signatureSheet->bytes()); // the BLANK sheet, reference printed on it
$sheet->printedReference; // e.g. "ABCD-EFGH"
$mailer->send($person, $sheet->claim->claimUrl); // ANOTHER channel, never with the sheet
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; the
same Idempotency-Key answers 409 already_minted and mints nothing.
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.
$call = $agreely->verbalConsents()->record([
'customerId' => 'cust_8812',
'documentVersionId' => $doc->documentVersionId,
'answers' => [ // every ask put on the call, "yes" or "no"
['category' => 'Email address', 'purpose' => 'Newsletter', 'answer' => 'yes'],
['category' => 'Email address', 'purpose' => 'Surveys', 'answer' => 'no'],
],
'obtainedAt' => new DateTimeImmutable('now'), // 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,
]);
// The signed paper came back (scope attest): each "yes" moves into a new paper consent.
$rise = $agreely->verbalConsents()->confirmWithPaper($call->consentId, [
'signedAt' => new DateTimeImmutable('now'),
'answers' => [['category' => 'Email address', 'purpose' => 'Newsletter', 'answer' => 'yes']],
'evidence' => ['pdfSha256' => Agreely::hashPdfFile($path)],
]);
$history = $agreely->verbalConsents()->get($call->consentId);
$history->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.
$w = $agreely->withdrawals()->record('cust_8812', $consentRef, [
'channel' => 'phone', // phone | email | mail | in_person | other
'operator' => 'agent-042', // YOUR opaque staff id, never a name or an email
'requestedAt' => '2026-10-08T09:15:00-04:00', // optional: when they asked
'reason' => 'Asked on the phone', // optional, at most 1000 characters
]);
$w->gate; // "denied" | "superseded" | "unchanged": what /v1/check does now
$w->alsoWithdrawn; // the other consents of the purpose withdrawn at the same instant
$w->stillInForce; // the 0x refs of the other consents of the purpose still in force
$w->recordedOnBehalf; // always true
$w->assurance; // "company_documented", as the server sends it
- The answer says who speaks. 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,Withdrawals::SCOPE_MAX;[]declares nothing), and'otherConsents' => 'keep'withdraws this consent alone. Both together are refused before the call (AgreelyConfigError). Without a declaration the call throws anAgreelyOtherConsentsRunningError(422other_consents_running) whose->otherConsentRefslists those consents, nothing written: ask the person which, then retry with one declaration.
use Agreely\Sdk\Errors\AgreelyOtherConsentsRunningError;
try {
$agreely->withdrawals()->record('cust_8812', $consentRef, ['channel' => 'phone', 'operator' => 'agent-042']);
} catch (AgreelyOtherConsentsRunningError $e) {
// Ask the person which of $e->otherConsentRefs the withdrawal covers, then retry once:
$agreely->withdrawals()->record('cust_8812', $consentRef, [
'channel' => 'phone',
'operator' => 'agent-042',
'alsoWithdraw' => ['0x…'], // or 'otherConsents' => 'withdraw' (all of them), or 'keep' (this one only)
]);
}
- Its own scope, never granted by default, and a daily cap (429
AgreelyDailyCapError,withdrawal_daily_cap, noRetry-After). - 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.
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 AgreelyConfigError before the call). 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. 0.6.2 records
the end at the instant of the call; the API also accepts an earlier endedAt,
which 0.6.2 does not send.
$r = $agreely->relationships()->end(['customerRef' => 'cust_8812', 'reason' => 'Account closed, purposes fulfilled']);
// RelationshipEnded: $r->customerRef; $r->status "ended"; $r->endedAt; $r->endedBy "company" | "citizen_request"
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.
$u = $agreely->relationships()->revert(['customerRef' => 'cust_8812', 'reason' => 'End recorded by mistake']);
// RelationshipReverted: $u->customerRef; $u->status "active"; $u->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).
$record = $agreely->customers()->upsert('cust_8812', [
'displayName' => 'Marie Tremblay',
'email' => null, // null or "" CLEARS a field
'noticeLocale' => 'fr',
]); // a member you leave out is left as it stands
$record->created; // true when this call created the record (201)
$record->hasDisplayName; // metadata only: never the name or the email
$agreely->customers()->get('cust_8812'); // the same metadata, with the relationship's status
- The registry no longer holds a per-customer ground.
legalBasisandbasisNoteare gone from the API: a value sent is ignored, and the answer no longer carries them. 0.6.2 still accepts them, and readshasBasisNoteasfalseandlegalBasisasnull; do not rely on them. The purpose belongs to the processing, documented per catalog cell. - 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
$catalog = $agreely->catalog()->list();
// list<CatalogEntry>: id, category, purpose, description (nullable), legalBasis, sensitive
$form = $agreely->catalog()->forDocument('conditions-marketing'); // only the cells that document groups
$cells = $agreely->catalog()->listCells(); // each cell with its retentionRuleKey
$cells->gaps(); // cells with NO rule: abstain, never guess
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".
use Agreely\Sdk\Types\PurgeMethod;
$forRun = $agreely->retention()->rulesForPurge(['snapshot' => $stored]); // $stored may be null
if (!$forRun->mayPurge()) {
return; // Agreely unreachable: do NOT purge this run
}
$sync = $agreely->retention()->syncHolds(['changedSince' => $storedCursor]); // any error throws: purge nothing
foreach ($forRun->rules() as $rule) {
// your purge, skipping what a hold covers, then:
$agreely->retention()->declarePurge($rule->key, [
'ranAt' => new DateTimeImmutable('now'),
'recordsAffected' => 1_240, // at least 1; a pass that found nothing is declareSweep
'method' => PurgeMethod::DESTROYED, // or ::ANONYMIZED + anonymizationProcessKey
'coveredFrom' => '2024-01-01',
'coveredUntil' => '2024-06-30',
'hostSystem' => 'billing', // a STABLE name, never a pod name
'hostCategory' => 'invoices',
], ['idempotencyKey' => "nightly-{$runId}:{$rule->key}"]);
}
- A purge job fails closed by not purging.
rules()throws on an abstain rather than handing back an empty list. Purging on stored rules is an explicit, bounded and audited exception ('onOutage' => 'use-snapshot',maxSnapshotAge, a mandatoryonDegrade). Anything that is not an outage throws. - 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)
$declared = $agreely->inventory()->replaceCategories([
'hostSystem' => 'crm',
'categories' => [[
'key' => 'beneficiaires',
'label' => 'Bénéficiaires',
'fields' => [['key' => 'nom', 'label' => 'Nom complet']],
]],
]);
$declared->withdrawn; // how many sets this call withdrew
$sets = $agreely->inventory()->listCategories(['hostSystem' => 'crm']);
$resolved = $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.
$h1 = Agreely::hashPdf($bytes); // $bytes is a string (raw PDF bytes)
$h2 = Agreely::hashPdfFile('/path/to/consent.pdf');
Timeouts, retries and limits
The default timeout is an 800 ms total budget, sized for a caller in the same region as the API. Over the open internet a timeout is a spurious deny, so give an internet-facing caller room:
new Agreely(['apiKey' => $key, '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, and no other
429. No write is ever auto-retried. The limits are those of the
TypeScript SDK: 500 cells per batch
and 120 requests a minute per company, every key included.
Typed errors
Every failure is an Agreely\Sdk\Errors\AgreelyError subclass (with ->code,
->status, ->field, ->reason). A deny is never an error.
| Error | When |
|---|---|
AgreelyAuthError |
401 / 403 |
AgreelyValidationError |
400 / 422 (->field) |
AgreelyOtherConsentsRunningError |
422 other_consents_running on a withdrawal (a subclass of the validation error): ->otherConsentRefs lists the consents in force for the purpose. Never auto-retried |
AgreelyNotFoundError |
404 |
AgreelyConflictError |
409: code retry (->isRetryable(), retry with the same Idempotency-Key), code conflict (->isStateConflict(), ->reason says which), 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; never auto-retried |
AgreelyUnavailableError |
503 and any other 5xx, network, timeout (the only error subject to the degrade policy) |
AgreelyTimeoutError |
client-side poll budget elapsed in waitForSettlement (code: "timeout", not a server outage) |
AgreelyConfigError |
bad client config, or input refused before the call |
Branch on ->reason, never on the message. ErrorReason and ErrorCode name
the known values, and a reason added later is readable as a plain string.
use Agreely\Sdk\Errors\AgreelyConflictError;
use Agreely\Sdk\Errors\ErrorReason;
try {
$agreely->manualConsents()->revoke($consentRef);
} catch (AgreelyConflictError $e) {
if ($e->hasReason(ErrorReason::CITIZEN_CONSENT_AT_GATE)) {
$agreely->withdrawals()->record($customerRef, $consentRef, ['channel' => 'phone', 'operator' => 'agent-12']);
}
}
AgreelyAbortError does not exist in PHP (there is no AbortSignal).
Fail-closed, and the two-gate exception
When Agreely is unreachable, check() denies and checkDetailed() throws
AgreelyUnavailableError. A degraded allow comes back with $d->degraded true and
$d->mode set, and no assurance: it is a deliberate, audited exception to
the default deny. Opting a category into fail-open uses two gates:
$agreely = new Agreely([
'apiKey' => $key,
'degradeOnOutage' => [
'mode' => 'fail-open', // gate 1: the explicit word
'categories' => ['Browsing/usage'], // ONLY these may degrade
'maxOutageWindow' => '5m',
'onDegrade' => fn ($ctx) => $audit->log($ctx), // MANDATORY: without it the constructor throws
],
]);
// gate 2: the call must ALSO opt in. Without the config, this still denies.
$agreely->check('cust_8812', 'Browsing/usage', 'Analytics', ['onOutage' => 'allow']);
maxOutageWindow is bounded by maxDegradeWindow, 24h by default, which the
client option can raise; over that bound throws AgreelyConfigError.
A per-call ['onOutage' => 'allow'] not backed by a matching categories entry
has no effect (the check still denies; a one-time warning is logged via
error_log, silenced with AGREELY_SILENCE_WARNINGS).
Break-glass is omitted in PHP, by design
The TS SDK's third gate, an in-process, auto-expiring operator break-glass lever, is intentionally not in PHP. PHP requests are request-scoped with no long-lived in-process state, so a break-glass living in a single object would not survive across requests and would give a false sense of a fleet-wide override. PHP ships the parts that port cleanly: the fail-closed default, the two-gate fail-open, the maxDegradeWindow cap, and the ineffective-opt-in warning. A future version can back break-glass with a shared store.
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, preferably as JSON text
(ReceiptVerifier::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. Network access is
injected as httpGet / httpPost callables (not fetch).
$v = Agreely::verifyReceipt($bundleJson, ['rpcUrl' => 'https://base-rpc.publicnode.com']);
$v->overall; // "verified" | "partial" | "incomplete" | "failed" | "unavailable" | "malformed" | "withdrawn"
$v->withdrawal; // "active" | "withdrawn" | "not_checked"
$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), and explains why:
What 0.6.2 does not cover
The API accepts these members; 0.6.2 does not send them. To use them, call the route directly or act in Agreely:
- on paper consents: who consented (
capacity),isMinor,ageBracketandguardianName(the closed input refuses them before the call); - on telephone consents:
ageBracket; - on a relationship end: an earlier
endedAt; - the privacy policy routes and the
diffusionscope (API reference).
Next
- Handling outages and the TypeScript SDK (which adds break-glass).