FR EN

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 bundled CurlHttpClient.

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.

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

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.

$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 an AgreelyOtherConsentsRunningError (422 other_consents_running) whose ->otherConsentRefs lists 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, no Retry-After).
  • 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.

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. legalBasis and basisNote are gone from the API: a value sent is ignored, and the answer no longer carries them. 0.6.2 still accepts them, and reads hasBasisNote as false and legalBasis as null; 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) or identity_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 mandatory onDegrade). 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 (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)

$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, ageBracket and guardianName (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 diffusion scope (API reference).

Next