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. 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.
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 |
degradeOnOutage |
(off) | explicit fail-open opt-in |
maxDegradeWindow |
"24h" |
hard cap on the outage window |
maxRetries |
0 |
extra retries on a 429 only, idempotent calls |
respectRetryAfter |
true |
honor the server Retry-After |
httpClient |
CurlHttpClient |
any Agreely\Sdk\Http\HttpClient |
Resources are accessor methods: $agreely->consentRequests(),
$agreely->manualConsents(), $agreely->relationships(), $agreely->catalog().
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. The TS SDK ships UNLICENSED; this PHP SDK is 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.
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 "active" | "none" | "revoked" | "expired" | "erased"
// $d->consentRef "0x…" (null when status is "none")
// $d->checkedAt "2026-…Z"
// $d->assurance "citizen_signed" | "company_attested" | null
// $d->degraded / $d->mode present only on a degraded allow
A deny is a normal 200; checkDetailed returns it, it does not throw.
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>: $d->customerRef; $d->category; $d->purpose; $d->decision;
// $d->status; $d->consentRef (null for "none"); $d->assurance (null for "none"); $d->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
}
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 wire response is
least-disclosure (scopes only); the returned object also echoes the configured
baseUrl, client-side.
$me = $agreely->identity();
// Identity: $me->scopes (list<string>), $me->baseUrl (string|null)
if (in_array('issue', $me->scopes, true)) {
// ...this key may issue consent requests
}
Consent requests
create emails the recipient; items are catalog ids or
['category' => …, 'purpose' => …] arrays.
$r = $agreely->consentRequests()->create([
'customerId' => 'cust_8812',
'recipientEmail' => '[email protected]',
'items' => ['<catalogEntryId>', ['category' => 'Email address', 'purpose' => 'Newsletter']],
'validUntil' => '2031-01-01',
]);
// IssuedRequest: $r->requestId "0x…64hex"; $r->status "pending"; $r->deepLink; $r->emailDelivered; $r->items
$page = $agreely->consentRequests()->list(['status' => 'pending', 'cursor' => $cursor]);
// $page->items (list<ConsentRequestRecord>); $page->nextCursor (null when exhausted)
$one = $agreely->consentRequests()->get('0x…'); // the protocol 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). Pass your own key to make a retry
replay the original 201:
$agreely->consentRequests()->create($input, ['idempotencyKey' => 'order-4471']);
Walk every page, and wait for settlement
In PHP, full pagination is iterate() (a Generator), not listAll.
collect() gathers everything; waitForSettlement() polls get.
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)
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.
$m = $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: $m->consentId; $m->merkleRoot; $m->consentRefs (one per cell);
// $m->assurance "company_attested"; $m->anchored (false at record time)
// A one-time link for the citizen to claim/confirm. NOTE the name createClaimLink.
$link = $agreely->manualConsents()->createClaimLink(['customerId' => 'cust_8812', 'reference' => 'order-4471']);
// $link->claimUrl; $link->token; $link->expiresAt
// Revoke or erase by consentRef.
$agreely->manualConsents()->revoke('0x…', ['reason' => 'customer request']);
// { consentRef, revoked, alreadyRevoked }
$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 AgreelyValidationError). 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.
$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"
Catalog
$catalog = $agreely->catalog()->list();
// 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.
$h1 = Agreely::hashPdf($bytes); // $bytes is a string (raw PDF bytes)
$h2 = 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 Agreely\Sdk\Errors\AgreelyError subclass (with ->code,
->status, ->field). A deny is never an error.
| Error | When |
|---|---|
AgreelyAuthError |
401 / 403 |
AgreelyValidationError |
400 / 422 (->field) |
AgreelyNotFoundError |
404 |
AgreelyRateLimitError |
429 (->retryAfter seconds) |
AgreelyUnavailableError |
503 / network / timeout (retryable; 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 (thrown at init) |
use Agreely\Sdk\Errors\AgreelyRateLimitError;
try {
$agreely->check($id, $cat, $pur);
} catch (AgreelyRateLimitError $e) {
sleep($e->retryAfter ?? 1);
}
Default timeout is an 800ms total budget. 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 is returned as
['decision' => 'allow', 'status' => 'active', 'degraded' => true, 'mode' => …]
with no assurance field: 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']);
maxDegradeWindow is capped at 24h; over the cap 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 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. Network seams are injected as httpGet / httpPost callables (not
fetch).
$v = Agreely::verifyReceipt($receipt, ['rpcUrl' => getenv('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 and the TypeScript SDK (which adds break-glass).