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

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
}

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