Search by

allus / company-data

allme-sdk

PHP SDK for the allus company-data API: typed, plaintext, slug-keyed conclusions with transparent decryption.

0.1.4 2026-10-15 17:02 UTC

README

The PHP SDK for the allus company-data API. Point it at a JSON config file and it hands back typed, plaintext, your-slug-keyed conclusions: for each connected person, a map of your request-field slug → plaintext value (plus whether the value is live and when it last changed).

The SDK hides everything else — the OAuth token, the field catalog, the id plumbing, the hybrid decryption, binary fetching, the changes-queue mechanics, JSON-vs-XML. The platform is zero-knowledge: the API only ever holds ciphertext, so all decryption happens inside the SDK with your service private key. The person's own field choices are never exposed — you only ever see the request slots you configured.

This SDK is one of six language ports that share an identical API surface. This manual is the PHP view of it.

Example: one runnable website demonstrating this SDK lives in examples/ — one command (composer start) and a browser. It serves all three scenario families as sections of a single portal: identity (Sign in with allme, OIDC login, 2FA by allme), company-data (connections read, request fields, the change feed, webhooks, documents), and flow (contract flows driven end-to-end). See its README.

Contents: TL;DR — fetch new updates · Quickstart · Every call · The typed value model · The changes pump · Webhooks · Rate limits · Errors · How it's wired

Deeper reference pages live in docs/: config · model · pump · webhooks · errors.

TL;DR — fetch new updates

composer require allus/company-data

Point a config.json at your service keys:

{
  "api_url": "https://api.allme.fyi",
  "client_id": "svc_xxx",
  "client_secret": "xxx",
  "service_private_key": "/path/to/service.pem",
  "key_passphrase": "xxx",
  "cache_dir": "./allus-cache"
}

Drain everything new, handled one update at a time:

<?php
require 'vendor/autoload.php';

use Allus\CompanyData\Client;
use Allus\CompanyData\Model\Change;

$client = Client::fromConfig('config.json');

$client->processChanges(function (Change $change): void {
    // one update at a time: event, person, slug, value, live, at
    printf("%s %s %s %s %s %s\n",
        $change->event, $change->personId, $change->slug ?? '—',
        is_scalar($change->value) ? (string) $change->value : '…',
        $change->live ? 'live' : 'snapshot',
        $change->at?->format('c') ?? '—',
    );
});   // returns when the feed is empty

processChanges pulls every pending change, decrypts it, and hands them to your callback ONE BY ONE, acking each only after your code returns. Crash mid-batch? The next run replays exactly what wasn't acked — nothing is lost, and the API keeps no backlog of its own. Run it on a schedule (cron / systemd timer); there is no daemon/follow mode by design. Connections, binary values, and webhooks are documented below.

Quickstart

Requires PHP ≥ 8.1, with ext-openssl and ext-json (both standard).

composer require allus/company-data
# or, working from this repo:  composer install     # from the repo root
php -r 'require "vendor/autoload.php"; echo Allus\CompanyData\Client::class, PHP_EOL;'

The package is PSR-4 autoloaded (namespace Allus\CompanyData\ → src/), so require 'vendor/autoload.php' and you're done — no manual includes.

1. Write a config file

A single JSON file holds everything. Any field can be overridden by an ALLUS_* env var, so secrets needn't live in the file. No SDK method ever takes a key, passphrase, or secret as an argument — they all come from here.

allus.json:

{
  "api_url": "https://api.allme.fyi",
  "client_id": "svc_1a2b3c…",
  "client_secret": "…",
  "service_private_key": "./service-CRM.pem",
  "key_passphrase": "…",

  "account_private_key": "./account.pem",
  "account_passphrase": "…",

  "webhooks": {
    "wh_abc123": "hmac_secret_for_that_webhook"
  },

  "cache_dir": "./allus-cache",
  "format": "json"
}
Field Required Meaning
api_url yes API base, e.g. https://api.allme.fyi.
client_id / client_secret yes The registered client_credentials credentials for one service.
service_private_key yes Path to the OpenSSL-encrypted PKCS#8 PEM you downloaded from the portal.
key_passphrase yes Decrypts that PEM in memory at startup.
account_private_key / account_passphrase only for encrypt_payload webhooks The company account key, used to unwrap an encrypted webhook envelope.
webhooks / webhook_secret webhook auth — HMAC (default) Per-webhook HMAC secrets keyed by webhook id (matched via the X-Allus-Webhook-Id header). A single-webhook service can use a flat "webhook_secret": "…" instead of the map.
webhook_bearer_token webhook auth — bearer Verify Authorization: Bearer <token> deliveries.
webhook_basic webhook auth — basic {"username","password"} — verify HTTP Basic deliveries.
webhook_header webhook auth — header {"name","value"} — verify a custom-header delivery.
webhook_auth_none webhook auth — none true — explicit opt-out; verifyWebhook always passes (use only behind your own gateway). Configure at most one webhook auth method (two+ → ConfigError).
cache_dir no (default ./allus-cache) Durable local buffer for the changes pump. Must be writable + durable.
format no (default json) Wire format json or xml. Invisible in the output.

Env overrides use the ALLUS_ prefix of the field name, e.g. ALLUS_CLIENT_SECRET, ALLUS_KEY_PASSPHRASE, ALLUS_ACCOUNT_PASSPHRASE, ALLUS_WEBHOOK_SECRET. A missing/invalid config (or an unreadable PEM / wrong passphrase) throws ConfigError at construction — fail fast.

2. First call — list a connection's values

<?php
require 'vendor/autoload.php';

use Allus\CompanyData\Client;

$client = Client::fromConfig('allus.json');

// Iterate every connected person (lazy, auto-paged Generator).
foreach ($client->connections() as $conn) {
    echo $conn->displayName, ' ', $conn->personId, PHP_EOL;
    foreach ($conn->values as $slug => $val) {
        printf("  %s = %s  (live=%s, updated=%s)\n",
            $slug,
            is_scalar($val->value) ? (string) $val->value : json_encode($val->value),
            $val->live ? 'true' : 'false',
            $val->updatedAt?->format('c') ?? '—',
        );
    }
    break; // just the first one for the demo
}

Or fetch one connection by id:

$conn  = $client->connection('019xxxxxxxxxxxxxxxxxxxxxxxxx');
$email = $conn->values['work_email']->value;   // "alice@acme.com"  (a string)

$client = Client::fromEnv(); builds the same client entirely from ALLUS_* env vars (no file).

Every call

Client is the only object you construct. Build it from config, then:

Client::fromConfig(string $path, ?HttpClient $http = null, ?Logger $logger = null, ?callable $sleep = null): Client
Client::fromEnv(?HttpClient $http = null, ?Logger $logger = null, ?callable $sleep = null): Client

The optional args are advanced: $http (an injected HttpClient), $logger (a Allus\CompanyData\Pump\Logger), $sleep (a callable(float): void, for tests).

requestFields()

requestFields(): array  // list<RequestField>

Your request-field definitions — fetched once from GET /api/company-data/request-fields and cached for the life of the client (it types every value). Returns your request config, never the person's fields.

  • Params: none.
  • Returns: list<RequestField> — each RequestField has slug, label, type, oneTime, mandatory, raw. mandatory is true when the field is mandatory-to-provide or mandatory-to-stay-connected.
  • Throws: AuthError, ApiError, RateLimitError.
foreach ($client->requestFields() as $f) {
    $flag = $f->mandatory ? 'mandatory' : 'optional';
    printf("%-20s %-10s %s%s\n", $f->slug, $f->type, $flag, $f->oneTime ? ' (one-time)' : '');
}

connections(limit)

connections(int $limit = 100): \Generator   // Generator<Connection>

A lazy generator that pages GET /api/company-data/connections, newest connection first, following the list's cursor — each page's next marker is sent back as after until a page answers none — and yields one typed Connection at a time (bounded memory for a large book). A page is fetched only once the previous one has been consumed; a page costs the platform the same at any depth, and the list carries no total. Each $conn->values[$slug] is already decrypted (or a lazy binary handle).

  • Params: $limit — page size (default 100, max 500).
  • Returns: \Generator<int, Connection>.
  • Throws: AuthError, ApiError, RateLimitError (after the iterator's bounded internal backoff — see Rate limits).
  • A value the service key cannot open never ends the listing. It is returned in its own place with value null and unreadable true (see Value), and every other value and connection is returned as usual. When every value of every connection reads unreadable, check the configured service_private_key.

Heavily rate-limited. Use for the initial full sync + occasional reconciliation only — never as a poll substitute for the changes feed. The generator paces itself within the limit (backs off on Retry-After).

// Initial full sync, streaming so a 100k-connection book never lands in memory.
foreach ($client->connections(limit: 200) as $conn) {
    upsertLocalRecord($conn);
}

connection(id)

connection(string $id): Connection

Fetch one connection by its connection id (GET /api/company-data/connections/{id}).

  • Params: $id — the connection id (Connection->id).
  • Returns: one Connection. Note: this endpoint returns {connection_id, user_id, values} and no display_name/connected_at, so those identity fields are null here (the list endpoint carries them).
  • Throws: AuthError, ApiError (404 if unknown), RateLimitError. A value the service key cannot open is returned marked unreadable, never raised.
$conn  = $client->connection($connId);
$phone = $conn->values['mobile'] ?? null;
if ($phone !== null) {
    echo $phone->value, ' ', $phone->live ? 'live' : 'snapshot', PHP_EOL;
}

deleteConnection(connectionId)

deleteConnection(string $connectionId): void

End one of this service's connections (DELETE /api/company-data/connections/{id}). It leaves exactly the state the customer's own disconnect leaves: the link, the answers and copies held through it and the pair's messages on this service are deleted; the company connection, documents, signatures and flow runs stay. The customer is told by the platform, and connection_deleted reaches your change feed and webhooks. A customer whose account was deleted can still be disconnected. Reconnecting stays possible.

  • Returns: nothing.
  • Throws: AuthError, ApiError, RateLimitError; refusals are 404 company_data.connection_not_found (an id that is not a connection of this service) and 409 company_connections.active_contract (the customer holds an active agreement or subscription on this service).
$client->deleteConnection($connId);

logs(limit)

logs(int $limit = 50): \Generator   // Generator<LogEntry>

The service's activity log, newest first (GET /api/company-data/logs) — ops events only (email / purge / webhook), never person field data. A lazy generator following the list's cursor (next sent back as after) until a page answers none.

  • Params: $limit — page size (default 50, max 200).
  • Returns: \Generator<int, LogEntry> — each LogEntry has type, message, metadata, at, raw.
  • Throws: AuthError, ApiError, RateLimitError.
foreach ($client->logs(limit: 20) as $entry) {
    echo $entry->at?->format('c'), ' ', $entry->type, ' ', $entry->message, PHP_EOL;
}

processChanges(handler, ...$options)

processChanges(
    callable $handler,                 // callable(Change): void
    int $batchSize = 100,              // clamped to ≤ 500
    int $maxRetries = 3,
    string $onError = 'deadletter',    // 'deadletter' | 'halt'
    ?callable $backoff = null,         // callable(int $attempt): float (seconds)
): void

The crash-safe changes pump: drains the feed through $handler one Change at a time, durably buffering each batch before delivery, with per-item ack and retry → dead-letter → continue. Runs until the feed is empty, then returns — there is no follow/daemon mode (you schedule re-runs yourself). Delivery is at-least-once, so your handler must be idempotent (dedup on Change->id). See The changes pump for the full model.

  • Params: $handler — your callback; called with one Change. A normal return is an ack; a thrown exception triggers retry.
  • Options: $batchSize (clamped to ≤ 500, default 100), $maxRetries (default 3), $onError ('deadletter' — default — or 'halt'), $backoff (callable(int): float, attempt → seconds).
  • Returns: void (when the feed is empty + the buffer is drained).
  • Throws: AuthError, ApiError, RateLimitError (during a drain); InvalidArgumentException (bad $onError); whatever the handler throws if $onError='halt' and retries are exhausted.
$client->processChanges(function (\Allus\CompanyData\Model\Change $change): void {
    if (alreadyProcessed($change->id)) {   // idempotency — dedup on the stable id
        return;
    }
    match ($change->event) {
        'field_updated'                       => store($change->personId, $change->slug, $change->value),
        'field_deleted', 'connection_deleted' => remove($change->personId, $change->slug),
        default                               => null,
    };
    markProcessed($change->id);
});                                          // returns when the feed is empty

$logger is not a processChanges option in this SDK — pass it once to the Client constructor (Client::fromConfig('allus.json', logger: $myLogger)).

Advanced changes primitives

drainBatch(int $max = 100): array                      // list<Change> — raw, UNBUFFERED (you own durability)
deadLetters(): array                                   // list<array> — the local dead-letter store
retryDeadLetters(callable $handler, ...$options): int  // re-drive dead-lettered events; returns count re-driven
  • drainBatch($max) — fetches one batch (clamped ≤ 500) and returns the decrypted Changes directly. It does not persist anything, so a crash loses what the API already deleted. Prefer processChanges for safe consumption.
  • deadLetters() — each entry is the stored (ciphertext) event plus a flattened error and attempts (and the event's id).
  • retryDeadLetters($handler, ...) — same $maxRetries / $onError / $backoff options as processChanges; on success a record is removed, on repeated failure it stays dead-lettered (or re-throws under 'halt'). Dead letters are never re-fetched from the API — the local store is their only home.
foreach ($client->deadLetters() as $dl) {
    printf("stuck: %s %s after %d attempts\n", $dl['id'], $dl['error'], $dl['attempts']);
}

$n = $client->retryDeadLetters($handler);   // after you've fixed the bug
echo "re-drove {$n} dead letters", PHP_EOL;

Key rotation — key_rotated and the public-key cache

Every client caches the RSA public keys it fetches: a person's key is immutable — until they rotate it. A person learns of a rotation from a silent push; your service gets no pushes, so the key_rotated change is your only signal. Without it a long-running worker keeps encrypting to the rotated-away key for its whole lifetime, and the person can never read those values.

On the pump this is automatic — the cached key is dropped as the change passes through, before your handler sees it. Over a webhook it is not: the signature verifier is static and has no client instance, so it cannot reach the cache. Call the invalidator yourself — noting that the two clients key their caches differently: the service client by share_code, the customer client by the person's user id. Passing a share code to the customer client removes nothing and leaves you encrypting to the old key. Both identifiers ride every change, alongside public_key_sha256 — the fingerprint of the person's new key.

if ($change->event === 'key_rotated') {
    $client->invalidatePublicKey($change->shareCode);     // service Client — keyed by SHARE CODE
    $customer->invalidatePublicKey($change->personId);    // CustomerClient — keyed by PERSON USER ID
    // $change->publicKeySha256 = fingerprint of the NEW key, if you want to verify the refetch
}

This is eventual, not fail-closed — nothing rejects a document encrypted to a stale key, so a window remains between the rotation and your next drain. Drain often if that window matters.

service_key_rotated — the same thing, the other way round

The customer client also caches the service's public key, the one you encrypt your consent answers and documents to, keyed "companyCode/serviceCode". When that company replaces its service keypair, the service_key_rotated change on your account feed is your only signal — you receive no pushes. Same shape, same guarantees, same automatic handling on the pump:

if ($change->event === 'service_key_rotated') {
    // Automatic on the pump. Over a webhook, from the raw event body:
    $customer->invalidateServiceKey($body['company_share_code'], $body['service_share_code']);
    // $body['service_public_key_sha256'] = fingerprint of the service's NEW key
}

Also eventual, not fail-closed. Note the identifiers are share codes, not the ids used by invalidatePublicKey — the two caches are keyed differently and the wrong call removes nothing.

Webhook helpers (on the client)

The webhook receiver helpers are also exposed as Client methods (they delegate to Allus\CompanyData\Webhooks\Webhooks, fully config-driven — no key/secret arguments):

$client->verifyWebhook(string $rawBody, array $headers): bool
$client->parseWebhook(string $rawBody, array $headers):  Change
$client->handleWebhook(string $rawBody, array $headers): Change   // verify + parse
  • verifyWebhook — recomputes HMAC-SHA256($rawBody, secret) and constant-time-compares it (hash_equals) to X-Allus-Signature. Returns true/false; never throws for a bad signature.
  • parseWebhook — body → a typed Change. Does not verify. Handles JSON, XML, and the encrypt_payload account-key envelope. Throws WebhookError on a malformed/unparseable body.
  • handleWebhook — verify then parse; throws WebhookError on a bad/unknown signature, otherwise returns the Change. The typical one-liner inside a route.

The same three are available as static functions on Allus\CompanyData\Webhooks\Webhooks, which take the Config and the decrypt/type closures explicitly — but inside an app you'll almost always use the client methods. See Webhooks.

Company documents

The service can also publish documents — contracts, statements, terms, or any structured/binary payload — either broadcast to everyone connected or addressed to one person. The encryption rule is simple and automatic:

  • A per-person document is ALWAYS end-to-end encrypted to that recipient's public key — for any value of is_private. The SDK fetches the recipient key (from the share_code, or resolved from the connection_id / person_user_id) and encrypts before sending. As always, no method takes a key or secret argument — keys come from your config.
  • A broadcast document (no target) is plaintext. It cannot be locked, so is_private=true without a per-person target throws ConfigError.
  • is_private is device-display-only (it tells the recipient's app to show a lock / decrypt-on-load instead of rendering inline) — it does not change the value shape or whether encryption happens. Per-person ⇒ encrypted, broadcast ⇒ plaintext, regardless of is_private.

payload_kind is either 'json' (a structured value) or 'file' (raw bytes, optionally with a MIME type; for 'file' the metadata row is created first, then the bytes are uploaded — encrypted for a per-person target, raw for a broadcast).

createDocument(array $opts)

createDocument(array $opts): Document
use Allus\CompanyData\Client;

$client = Client::fromConfig('allus.json');

// A BROADCAST plaintext JSON document — visible to everyone connected, no target.
$terms = $client->createDocument([
    'name'         => 'Terms of Service v3',
    'payload_kind' => 'json',
    'json_value'   => ['version' => 3, 'effective' => '2026-07-01', 'url' => 'https://acme.example/tos'],
    // no connection_id / person_user_id  → broadcast → plaintext
    // is_private MUST stay false here (a broadcast can't be locked)
]);
echo $terms->id, ' ', $terms->status, PHP_EOL;

// A PER-PERSON document — automatically end-to-end encrypted to the recipient.
// Address it with ONE of: connection_id, person_user_id, or share_code.
$contract = $client->createDocument([
    'name'          => 'Service Agreement',
    'payload_kind'  => 'json',
    'json_value'    => ['plan' => 'pro', 'monthly' => 4900, 'currency' => 'EUR'],
    'connection_id' => $someConnectionId,   // or 'person_user_id' => …, or 'share_code' => 'AB12CD'
    'is_private'    => true,                 // device-display-only; encryption happens regardless
    'status'        => 'offering',
    'metadata'      => ['ref' => 'AGR-2026-118'],
]);

// Read a per-person JSON document back — decryption happens transparently with
// the SDK's own service key (only for the per-person, encrypted shape):
$plain = $client->document($contract->id)->json();   // ['plan' => 'pro', …]

// A file document (raw bytes). Per-person → encrypted; broadcast → plaintext.
// plain_sha256 (SHA-256 of the raw PDF bytes) is computed from file_bytes via
// Crypto::computePlainSha256() when not given explicitly — required by the
// server for a signable file document (requires_signature/requires_acceptance),
// optional for any other, ignored for payload_kind='json'.
$signed = $client->createDocument([
    'name'               => 'Signed PDF',
    'payload_kind'       => 'file',
    'file_bytes'         => file_get_contents('/tmp/agreement.pdf'),
    'file_mime'          => 'application/pdf',
    'person_user_id'     => $personUserId,        // per-person → bytes encrypted on upload
    'requires_signature' => true,
    // 'plain_sha256' => Crypto::computePlainSha256($fileBytes), // optional — computed otherwise
]);
  • Options (associative array): name (required), payload_kind ('json'|'file', required), is_private (default false), kind (default 'document'), description, status, metadata, plain_sha256 ('file' only), and one target — connection_id, person_user_id, or share_code (omit all three for a broadcast). For 'json': json_value. For 'file': file_bytes (+ optional file_mime).
  • Returns: the created Document.
  • Throws: ConfigError (missing name, bad payload_kind, is_private=true with no target, or a missing json_value/file_bytes); AuthError, ApiError, RateLimitError.

The document seal. A Document also carries plainSha256 (SHA-256 of the unencrypted PDF bytes, null on a json document and on a file document with no stored plaintext hash) and sealedAt (null until a seal actually succeeds). Completing every required signature/acceptance on a signable document is not the same as sealing it: when the last one is recorded the platform attempts, on that same request, to append a Signatures page and sign the whole PDF with a platform certificate, replacing every party's copy with the sealed one — but the attempt can fail (no PDF bytes on the completing act, a byte mismatch, the sealing service unavailable, or a custodian-completed ward act) without affecting the signatures or the document's completed status. It is then simply left unsealed, and any party can seal it afterwards from their own device or the owning company's portal (no SDK call triggers a seal). Each Document::$signatures entry additionally carries plain_sha256, signer_first_name, signer_last_name and signer_name_verified beside its existing action/method/content_sha256/ip/user_agent/created_at keys.

listDocuments(...) / document($id)

listDocuments(?string $personUserId = null, ?string $status = null, int $limit = 100): \Generator  // of Document
document(string $documentId): Document
documentFile(string $documentId): string   // #491: the file BYTES
foreach ($client->listDocuments(status: 'active', limit: 50) as $doc) {
    echo $doc->id, ' ', $doc->name, ' [', $doc->status, ']', PHP_EOL;
}

$doc = $client->document($documentId);
$value = $doc->payloadKind === 'json' ? $doc->json() : $doc->value;   // json() decrypts per-person docs
  • listDocuments yields every document of the service, newest first, filtered optionally by personUserId and/or status. It is a lazy generator over GET /api/company-data/documents's cursor: limit is the page size (1–500), each page's next marker is sent back as after, and the next page is fetched only once this one is consumed.
  • document($id) fetches one. Call ->json() on a 'json' document to get the plaintext (it transparently decrypts a per-person, encrypted document; a broadcast doc is already plaintext).
  • documentFile($id) downloads a 'file' document's BYTES — the metadata methods don't include them. A broadcast (plaintext) document's bytes are returned as-is; a per-person / private document is encrypted to the recipient's key (not your service key), so documentFile fails clearly with documents.recipient_encrypted rather than a doomed decrypt. For a generated flow document's own copy use flowRunDocument($runId, $outputKey) below (that copy IS service-key-encrypted).

Contract flows & identity (#491)

flowRunAnswers(FlowRun|string $run): FlowRunAnswers   // a completed run's DECRYPTED answers + the slugs that would not open
flowRunDocument(string $runId, string $outputKey): string   // the company's own copy of one generated output document (plaintext bytes)
identity(): array                             // #491 gap 3 — this client's {company_user_id, service_id}
  • flowRunAnswers($run) returns a completed run's answers as a FlowRunAnswers (accepts a fetched FlowRun or a run id): ->answers is the decrypted {slug => plaintext} map, ->unreadable the list of slugs whose answer the service key could not open (empty when every answer opened). An unreadable answer is left out of ->answers and never fails the call. It is the public accessor for a finished run's answers, which processFlowRun returns untouched.
  • A document leaf can produce several named output documents (e.g. "Contract" and "Addendum"). generateFlowDocument($run) returns ['documents' => [...], 'status' => ...] — one [output_key, party_key, document_id, position] per produced (output document, participant); position is the step's 1-based place in the run's ONE signing line (one signer at a time, across every output), null for a party an output's signer list does not name. A repeat answers the same set.
  • A FlowRun's participants are FlowRunParticipant objects (partyKey, personUserId, connectionId, documents); documents is that participant's own copy of each output document — FlowRunParticipantDocument (outputKey, name, documentId, documentStatus, requiresSignature, requiresAcceptance, position, action, actedAt), ordered by line position.
  • flowRunDocument($runId, $outputKey) downloads the company's own service-key-encrypted copy of one output document and returns the plaintext file bytes — the honest completion step (fill → complete → flowRunAnswers → flowRunDocument per output). A 404 is flows.run_not_found for an unknown run, or flows.no_document when that output was not produced or the company is not a bound party.
$run = $client->flowRun($runId);
foreach ($run->participants as $p) {
    if ($p->partyKey !== $run->companyPartyKey()) {
        continue;
    }
    foreach ($p->documents as $doc) {
        $pdf = $client->flowRunDocument($run->id, $doc->outputKey);
    }
}
  • triggerFlowRun reads the flow's latest published version first (publishedFlow($flowId) — its version, definition and the service's request-field types) and sends it as flow_version. When that version's text elements show the customer's shared values ({{party.field}} tags), it opens those values from the connection with the service key and seals them per recipient — ONE wrapper of the non-private values, and each private value on its own — to the company (the service key) and to the customer, and sends them as tag_values; the connection's valuesPrivate decides which values are private (a slug it does not name is private). A newer publish in between (flows.version_changed) is re-read and retried once; a customer key that changed (flows.tag_values_stale) is re-read and retried once; a stale SERVICE key raises a ConfigError — rebuild the client with the service's current private key. The run carries ownerTagValues (the owning company's profile values the text names, plaintext) and tagValues (the company's sealed set).
  • identity() returns this client's {company_user_id, service_id} from GET /api/company-data/whoami, so a triggerFlowRun binding's company party can bind to company_user_id (the person party's user_id comes from the connection).

Participant PDF sources. A leaf output rule's PDF is a company template (asset_key), a flow field's answer (source_field → source key field:<slug>, a pdf_document field), or what the customer bound to a party shared on its connection for one of the service's pdf_document request fields (source_connection: {party, request_slug} → conn:<party>:<request_slug>). A rule whose source the run does not hold does not match, and the next rule is tried.

triggerFlowRun(string $flowId, string $connectionId, array $bindings, array $sourceFiles = []): FlowRun
stageRunFile(string $flowId, string $sourceUserId, array|string $sealedValue): string   // POST /flows/{flowId}/run-files → file
uploadAnswerFile(string $runId, string $slug, string $forUserId, array|string $sealedValue): string   // POST /flow-runs/{runId}/answer-files → file
flowRunSourceFile(string $runId, string $sourceKey): array|string               // GET /flow-runs/{runId}/source-files/{sourceKey} → the sealed wrapper
  • Connection sources are copied at run start. For each answered conn: source a rule of the flow's latest published version names, seal the source's envelope JSON once per distinct bound user (your own copy to the service key, a person's to their public key), stage each with stageRunFile($flowId, $sourceUserId, $sealed) — $sourceUserId the customer bound to the source's party, whose shared PDF the copy is; the copy is stored in that customer's home region — and pass [['source_key' => …, 'for_user_id' => …, 'file' => …], …] as triggerFlowRun's $sourceFiles. A start whose list is not exactly that set, or whose copy was staged for another customer than the one bound to its source's party, is refused with ApiError flows.source_files_invalid; its $details['missing'] lists [source_key, for_user_id, source_user_id] entries and $details['unexpected'] the files that were not wanted — nothing is written. Staging a copy for a customer that is not connected to the service is refused flows.source_user_invalid. The copy is a snapshot: a later change on the connection does not reach the run.
  • FlowRun->sourceFiles is [source_key => file] — your own copies of the run's connection sources (empty when none). flowRunSourceFile($runId, $sourceKey) returns one as stored (the key is URL-encoded; it opens with the service key).
  • A binary field on your own turn is answered by uploading one sealed copy per bound party with uploadAnswerFile($runId, $slug, $forUserId, $sealed) and submitting {"_enc_file": file} as each party's answer value. A file answer in FlowRun->answers is that plaintext reference (never a wrapper); the SDK reads it as an answered value.
  • Generation uploads the held source PDFs. generateFlowDocument($run) — and so processFlowRun at a document leaf — first computes the leaf's HELD sources (a source_field whose answer copy of yours is a file, a source_connection in sourceFiles), fetches your own copy of each (slots/{slug}/file resp. source-files/{key}), decrypts it with the service key, seals it under the same one-time key as values and uploads it to …/generate/inputs, then generates with inputs: [[source_key, input], …] (empty when nothing is held). flows.generate_inputs_mismatch refuses inputs that are not exactly the held set; flows.source_pdf_invalid refuses a source that is not a usable PDF, and the run stays generating.

The party that answers a run's last step generates the contract — the customer role included. When your company is a CUSTOMER of another company's service and its answer completes a document-mode leaf, the run parks at generating until you generate:

$customer->generateFlowDocument(string $connectionId, FlowRun $run): mixed   // POST /api/company-connections/{connectionId}/flow-runs/{runId}/generate

Pass the run as re-read after your leaf submit. The answer map comes from your OWN copy of the run's answers, decrypted with the account key — every party's answers are sealed to every bound party, so that copy holds the whole run and no service key is involved. Returns [documents, status] — one [output_key, party_key, document_id, position] per produced (output document, participant); a repeat answers the same set. Throws ConfigError when the run's current step is not bound to your company. Every participant PDF source the leaf holds for your company is uploaded first, exactly as above: your own copy (your answer file, or your copy of a connection source) through …/answer-files/{file}, decrypted with the account key, sealed under the call's one-time key and posted to …/generate/inputs.

Example: a runnable website that drives a contract flow end-to-end through these calls — trigger, type-checked step filling, a person turn on the phone, then the decrypted answers + downloaded document — is in the flow section of examples/: one command (composer start) and a browser. See its README.

Plugin fields on a flow step

A flow step can hold a plugin element (kind: 'plugin'): blocks (search_select, text, number, date) answered by picks and typed values, inputs the company wired to earlier flow keys, and outputs the plugin computes. When such a step is the company's turn, the service client talks to the plugin through the platform's forwarder:

pluginPass(string $runId): PluginPass
pluginOptions(string $runId, string $slug, string $block, string $query = '', array $picks = [], array $values = [], array $draft = []): PluginOptions
pluginOutputs(string $runId, string $slug, array $picks = [], array $values = [], array $draft = []): PluginOutputs|PluginPicksInvalid
checkFlowValue(FlowRun $run, string $slug, mixed $value, array $draft = []): void   // throws the bound ValidationError
  • pluginPass — POST /api/company-data/flow-runs/{runId}/plugin-pass → PluginPass { pass, forwarderUrl, plugins: [[id, publicKey]], specs: [slug => spec] }. Issued only while the run awaits your party on that step; it lives ten minutes. The two calls below fetch one themselves.
  • pluginOptions — the options of one block: $query is the search text ('' lists everything), $picks the ids picked so far by block key, $values the typed block values so far → PluginOptions { options: [[id, label]], more } (more: the list was cut — narrow the query).
  • pluginOutputs — the outputs for $picks and $values → PluginOutputs { outputs }, or PluginPicksInvalid (->picksInvalid === true) when the picks no longer fit the inputs or each other: clear them and pick again. Call it again whenever an input changes, before you submit.
  • $draft is the current step's not-yet-submitted answers (slug => plaintext). The plugin's inputs are read from ONE live answer map: the run's answers you can read, overlaid with $draft for the current step's slugs, plugin answers expanded, constants computed. Each input is converted to its declared type (number → a JSON number, date → YYYY-MM-DD, boolean → a JSON boolean, text → a string).
  • Another party's private value is never sent to a plugin. A source is private when its slug is in the run's privateSlugs, when it is a constant that reads a private source, or when it is a current-step draft value whose field's default reads a private source (whatever the draft value is). A plugin answer's outputs are never private, whatever inputs produced them. A run read with no privateSlugs list treats every other party's value as private. A REQUIRED input that is unwired, unanswered, private or not convertible throws PluginInputUnavailable (->input, ->source, ->reason: unwired | unanswered | other_party_private | not_convertible); an OPTIONAL one is left out of the call.
  • The call itself: the request is sealed to the plugin's public key with the platform wrapper and carries the public half of a fresh RSA-2048 reply key made for that call; it is posted to {forwarderUrl}/call over a plain CurlTransport that carries no allme credential and uses the URL exactly as the pass names it; the reply is opened with the reply key. A 409 plugin.key_changed reseals once with the key it returns; a 401 or 403 fetches a new pass once. Any other refusal is an ApiError carrying the forwarder's status and key (plugin.not_responding, plugin.busy, plugin.rate_limited, plugin.unavailable, …); a plugin that publishes no key reads plugin.not_responding.

The answer you then submit for the plugin slug is the plugin answer's JSON string:

$answer = json_encode([
    'plugin' => 'Flex', 'type' => 'cao',
    'blocks' => [['key' => 'cao', 'kind' => 'search_select', 'label' => 'CAO', 'id' => 'hrc', 'value' => 'Horeca Fictief']],
    'outputs' => [['key' => 'min_wage', 'type' => 'number', 'label' => 'Minimum wage', 'value' => 9.5]],
]);
$client->submitFlowAnswers($run, ['age' => '19', 'cao' => $answer, 'wage' => '14.20']);

Blocks go in declared order with their labels from the spec's snapshot; an answer without an outputs array is unfinished and reads as unanswered.

Defaults, minimums and maximums. A flow field may carry default, min and max, each an expression in the constants language (plugin outputs included, e.g. max(cao.min_wage, cao.agreement_wage)). submitFlowAnswers computes min/max over the same live answer map (your $fill as the draft) and refuses a value outside them with a ValidationError naming the bound (->bound: 'min' | 'max', ->boundValue) before anything is encrypted; a bound that computes to null is no bound. checkFlowValue($run, $slug, $value, $draft) applies the same check on its own, before you submit. A value derived from another party's private source — a field whose default reads one — is submitted marked source_private (a run with no privateSlugs list counts every other party's value as private). Routing evaluates every edge over the answers with plugin answers expanded, plus the computed constants.

FlowRun->privateSlugs lists the slugs whose answers came from a private source — metadata, never a value; null when the API did not send it (unknown).

The customer role. CustomerClient has the same three with a leading $connectionId, over POST /api/company-connections/{connectionId}/flow-runs/{runId}/plugin-pass, reading its inputs from its own copies of the run's answers (decrypted with the account key):

$customer->pluginPass($connectionId, $runId);
$customer->pluginOptions($connectionId, $runId, $slug, $block, $query, $picks, $values, $draft);
$customer->pluginOutputs($connectionId, $runId, $slug, $picks, $values, $draft);
$customer->checkFlowValue($run, $slug, $value, $draft);   // throws the same bound ValidationError

Call checkFlowValue before encryptFlowAnswer seals a value. $customer->submitFlowAnswers reads the run once and marks every answer derived from another party's private source (the same rule) source_private: true before it is sent.

The wire shape of each answers[].values[].value is the sealed wrapper serialized as a JSON string. submitFlowAnswers and $customer->submitFlowAnswers send it that way, whether values[].value is the array encryptFlowAnswer returns or a string; encryptFlowAnswer itself still returns the array.

The evaluator helpers (static, pure, on Allus\CompanyData\FlowCondition): expandPluginAnswers($answers, $pluginSlugs) — a new map where each finished plugin answer becomes its summary (the blocks' values joined by ' / ') plus slug.<block>, slug.<block>.id (a search_select pick's id) and slug.<output>, and an unfinished one is removed; pluginAnswerSummary($plaintext); pluginAnswerView($plaintext) → ['blocks' => [[label, value]], 'outputs' => [[label, type, value]]] or null; resolvedConstants($constants, $answers, $referenceDate, $pluginSlugs = null) expands first when given the slugs. The math ops include variadic max and min, which skip what is not a number.

updateDocumentStatus / updateDocumentMetadata / deleteDocument

updateDocumentStatus(string $documentId, string $status): Document
updateDocumentMetadata(string $documentId, ?array $metadata = null, ?string $name = null, ?string $description = null): Document
deleteDocument(string $documentId): void
$client->updateDocumentStatus($documentId, 'ready_to_sign');   // offering | ready_to_sign | active | active_but_ending | ended
$client->updateDocumentMetadata($documentId, name: 'Service Agreement (rev B)', metadata: ['ref' => 'AGR-2026-118b']);
$client->deleteDocument($documentId);                          // also removes the on-disk file
  • updateDocumentStatus moves a document through its lifecycle (offering → ready_to_sign → active → active_but_ending → ended).
  • updateDocumentMetadata updates name, description, and/or metadata — pass at least one (else ConfigError).
  • deleteDocument deletes the document and its stored file.
  • A contract-flow-generated document can also read waiting — a run-participant copy whose signer has not been reached yet in the run's signing line. It is read-only: updateDocumentStatus throws with error_key: 'documents.run_managed' (409) if you try to write status on a run-participant document while it is waiting, ready_to_sign or offering — that status moves only through flow generation, the run's own advance, sign/accept, or a run cancel/decline. A run-participant document's runSignatures property carries the WHOLE run's signing line — one entry per (output document, participant), in line order, each {output_key, name, party_key, document_id, position, status, action, acted_at}; every document of the run carries the same summary.

Reacting to a status change in the pump

When a document's lifecycle status changes, the feed/webhook emits a document_status_changed Change carrying documentId + the new status (and the usual personId / shareCode / at). A signature also carries signerFirstName, signerLastName and signerNameVerified, and a cancellation its note; the signer names and the note arrive encrypted to your service key and the SDK decrypts them. When the platform seals a file document (never a JSON contract) the feed/webhook emits a separate document_sealed Change carrying documentId, sealedAt and plainSha256 — the same seal state the document read carries, so you never need a follow-up document($id) call just to learn a document sealed. A webhook receives document_sealed only when it subscribes to it (or has an empty event filter, which receives every event). Handle them alongside your field events:

$client->processChanges(function (\Allus\CompanyData\Model\Change $change): void {
    if (alreadyProcessed($change->id)) {
        return;
    }
    match ($change->event) {
        'field_updated'           => store($change->personId, $change->slug, $change->value),
        'document_status_changed' => onDocumentStatus($change->documentId, $change->status, $change->personId),
        'document_sealed'         => onDocumentSealed($change->documentId, $change->sealedAt, $change->plainSha256),
        default                   => null,
    };
    markProcessed($change->id);
});

Messaging

Your service can hold a conversation with a connected person — the same messaging surface the person already uses, with your service as the counterpart. Two shapes:

  • 1-on-1 — sendMessage($connectionId, $text). End-to-end encrypted: the SDK encrypts one copy to the person's public key and one to your service key before anything leaves the process, so the person reads it in their app and you can re-read your own outbound text. The platform stores ciphertext only.
  • Broadcast — broadcastMessage($text). One plaintext message to every person connected to the service (one body cannot be single-key-encrypted to all of them, exactly as for a broadcast document). It seeds each person's ordinary 1-on-1 thread; their reply comes back end-to-end encrypted.

sendMessage answers 201 with the created message carrying message_id, and returns that id — the value you hand back as the acknowledgement boundary.

Inbound messages arrive on the changes pump / webhook as a message_received event — a person→company message only. A broadcast raises no event of its own.

$client->processChanges(function (\Allus\CompanyData\Model\Change $change) use ($client): void {
    if ($change->event !== 'message_received') {
        return;
    }
    echo $change->personId, ': ', $change->messageBody, "\n";  // already decrypted for you

    // Reply on the same connection. personPublicKey rides the event, so no second
    // key lookup is needed.
    $client->sendMessage(
        $change->connectionId,
        "Thanks — we're on it.",
        personPublicKey: $change->personPublicKey,
    );

    // Acknowledge what you handled. REQUIRED: without it the message stays unread
    // forever, your unread count grows, and the person never sees a read receipt.
    // Sending a reply does NOT acknowledge anything.
    $client->markMessagesRead($change->connectionId, $change->messageId);
});

markMessagesRead is bounded by the boundary message: a message that arrived while you were working is not swept, and a repeat is a no-op. The boundary must be a message the person sent on that connection — anything else is refused with an ApiError carrying company_data.ack_boundary_invalid (400).

// One plaintext announcement to everyone connected to the service.
$client->broadcastMessage("We're closed on Friday.");

Refusals surface as ApiError carrying the platform error_key:

error_key Status Meaning
messages.messaging_not_entitled 403 The company's plan does not include messaging.
messages.not_connected 403 The person is not connected to this service.
messages.messaging_suspended 403 Messaging is suspended for this service (or the whole company).
messages.broadcast_suspended 403 Broadcast alone is suspended for this service.
messages.encryption_required 400 A 1-on-1 body was not a valid encrypted wrapper.
messages.broadcast_audience_too_large 422 The service has more connections than a broadcast allows.
messages.rate_limited 429 Too many 1-on-1 messages to the same person.
company_data.ack_boundary_invalid 400 The ack boundary is not a message the person sent on that connection.

The typed value model

You work with these objects and nothing else (use Allus\CompanyData\Model\…):

RequestField { slug, label, type, oneTime, mandatory, verified, verifiedMaxAgeDays }
Connection   { id, personId, displayName, connectedAt, values: array<slug, Value> }
Value        { value, live, updatedAt, verified, verifiedAt, verifiedExpiresAt,
               verifiedMethod, verifiedProvider, verificationId, unreadable }
Change       { id, event, personId, slug?, value?, live?, at }
LogEntry     { type, message, metadata, at }

All model properties are public readonly.

Keyed by your slug

$conn->values['work_email']->value → "alice@acme.com". The key is the stable, explicit slug you set per request field in the portal — rename the label freely, the slug is the contract. The person's source field is never exposed: no source slug, no field_id, not even via ->raw.

Value

Property Meaning
value The typed plaintext (see the table below).
live true if the person chose "keep connected" (auto-updates); false for a one-time snapshot.
updatedAt ?DateTimeImmutable of when this answer last changed (per-answer, rides on the Value).
verified true only when the verification hash recomputes over the decrypted plaintext and the verification has not lapsed. Absent metadata reads false, which means "not attested", not "wrong".
verifiedAt ?DateTimeImmutable the answering field was verified. A stamp, not a promise about today.
verifiedExpiresAt ?DateTimeImmutable that verification lapses; null when it does not. A document-backed verification dies with the document; once this is past, verified reads false.
verifiedMethod HOW allme bound the value: email_code | sms_code | sumsub_id | sumsub_address.
verifiedProvider WHO established the proof: allme | sumsub.
verificationId The proof id to quote back to allme in a dispute — it resolves the full record, including facts you never receive.
unreadable true when the answer is present but the configured service key cannot open it — sealed to a key the service has since replaced, or a wrong configured key. value is then null and verified false; every other property is read as for a readable value.

Not readable is not empty. An unanswered value is value null with unreadable false; a value that could not be opened is value null with unreadable true. A binary value is a lazy handle and is never marked: a binary whose file cannot be opened fails when its bytes are read. When every value of every connection reads unreadable, check the configured service_private_key.

verifiedMethod, verifiedProvider and verificationId are the proof metadata and arrive together or not at all: a value bound before the proof log existed carries the four verification keys and none of these, so all three read null. They are readable whatever the verified boolean says — that boolean stays the only trust decision.

Value types — from the type's RESOLVED definition

A contact-field TYPE is a ROW in the served field-type registry, not a name this SDK knows by heart. The client fetches that registry (GET /api/contact-field-types) beside your request-field catalog and holds it for its life; a value's shape follows the type's resolved storage LANE and PRIMITIVE, so a type added as a row types itself with no SDK release.

The type's resolved… PHP value
storage lane photo / document a lazy BinaryHandle — see below
primitive composite array — the decrypted plaintext is a JSON object, parsed for you
primitive date DateTimeImmutable (date-only, UTC midnight; falls back to the raw string if it can't be parsed)
primitive multilist array of the chosen option strings
the reserved type key plugin (typed first, before the registry) a PluginValue — see below
anything else, and a type the registry does not carry string
unanswered / no value null

For the seeded types that means, unchanged: email/phone/url/text → string (phone is a single E.164-style string, + and digits); country/nationality → string, an ISO 3166-1 alpha-2 code (e.g. 'US', 'NL'), not a display name; personal_number/place_of_birth → string, and a verified place of birth is <city>, <alpha-2> (e.g. 'Amsterdam, NL') — a country CODE, not a name; address/bank/creditcard → array; date/date_of_birth → DateTimeImmutable; photo, document, legal_document and the ID-document subtypes passport, photo_id, drivers_license → a lazy BinaryHandle.

An address's country/state sub-fields are an ISO alpha-2 code / USPS 2-letter state code respectively. $client->fieldTypes()->isFieldValueValid($type, $value) validates a plaintext against its type; FieldValidation::isValidCountryCode($code) / FieldValidation::dialCodeFor($code) check a code or look up its E.164 dial code.

Reach the registry itself as $client->fieldTypes() for resolve(), accepts($requested, $actual), descendants(), isBinary(), labelFor(), ordered() and validate($type, $value) (null when valid, else the name of the first failing rule). A request row of a PARENT type MAY be answered by a field of any DESCENDANT — a legal_document slot can be answered with a passport — but that matching is the API's and stays there. The answer reaches you keyed by YOUR slug and typed by the SLOT's own type: the person's source field is never exposed, so there is no source slug, no field_id and no source type to resolve, not even via .raw. For a binary slot the API resolves slot → source → file itself, so the bytes you fetch are the answering field's whatever type that field carries.

The derived sets. requestableTypes() (what a request row may declare), flowTypes() (that set plus the option-less choice types a flow element supplies options for) and claimableTypes() (the requestable types on the inline lane) leave out a SYSTEM-ONLY type — one the platform alone writes. resolve($type)['system_only'] reads that flag off the type's own row (never inherited; false for a type the registry does not carry). id_selfie, the ID-check selfie, is one: no company configures a request row of it, and it reaches a service only as the age_scan_selfie row a venue's age-scan toggle adds — a document-lane value, so a lazy BinaryHandle like any other.

A CHOICE type's options. select and multiselect carry no options of their own — a flow element supplies them — so validate / isFieldValueValid / fieldValueError take an optional third argument, the caller's own option list. The ROW's resolved options govern whenever it has any, else the supplied list, and never a merge of the two; $client->fieldTypes()->optionsFor($type, $options) answers exactly the domain the validator will enforce, or null when neither source has one. A choice with no domain at all is refused (rule name options_unavailable) rather than measured against an empty list. submitFlowAnswers passes the flow element's options for you.

$addr = $conn->values['home_address']->value;   // array, e.g. ['street' => '...', 'city' => '...', ...]
$dob  = $conn->values['birthday']->value;         // DateTimeImmutable

Plugin rows — RequestField->plugin and PluginValue

A company can ask a request row through a plugin it added in the portal. Such a row's type is the reserved key plugin, and its definition carries ->plugin — RequestFieldPlugin { pluginName, fieldType, snapshot }, snapshot being the field type's frozen description (label, blocks, inputs, outputs); every other row reads null. A plugin row is always one-time and is asked of people only.

Its value is a Allus\CompanyData\Model\PluginValue — the self-describing answer, readable without the plugin:

PluginValue {
    ?string $plugin;   // the plugin's name, e.g. 'Flex'
    ?string $type;     // the field type, e.g. 'cao'
    array $blocks;     // [[key, kind, label, id, value]] in declared order; a search_select pick carries id + its option label as value
    array $outputs;    // [[key, type, label, value]] typed as the plugin declared them
    array $raw;
}

$cao = $conn->values['cao']->value;              // PluginValue
foreach ($cao->outputs as $o) { if ($o['key'] === 'min_wage') { echo $o['value']; } }   // 9.5

A plugin answer is what the person's client submitted — sealed to your service key but not signed. When you must rely on an output, check it with the plugin itself.

Binary fields — the lazy BinaryHandle

A photo/document value is a BinaryHandle. Nothing is fetched or decrypted until you call ->bytes(), ->pages(), ->metadata() or ->save():

$handle = $conn->values['passport_scan']->value;   // BinaryHandle (no network yet)

$pages = $handle->pages();                          // GET the slot file → every page, in order
$meta  = $handle->metadata();                       // the entries the type declares
$data  = $handle->bytes();                          // the primary file bytes (single-file answers)
$n     = $handle->save('/tmp/contract.pdf');        // same, written to disk; returns bytes written
echo $handle->valueUrl();                            // the opaque slot-keyed URL it fetches from

foreach ($pages as $page) {
    echo $page->label, ' ', $page->name, ' ', $page->mime, ' ', strlen($page->bytes), PHP_EOL;
}
echo $meta['document_number'] ?? '', ' ', $meta['expiry_date'] ?? '', ' ', $meta['name'] ?? '';

All four accessors share ONE lazy fetch of the slot-keyed file endpoint; the result is cached on the handle, so repeated calls don't re-fetch. ->save() writes crash-safely (temp file → fsync → atomic rename).

That endpoint has three 200 shapes and you cannot predict which one you get. It depends on whether the person's source field is private AND on the TYPE of the field they answered with — neither yours to choose, both changeable, and neither exposed:

answer response the handle does
private source application/json · {"encrypted": true, "value": <wrapper>} decrypt with your service key → the JSON ENVELOPE string
non-private source whose type stores pages or declares entries application/json · {"encrypted": false, "value": "<envelope>"} read that envelope string as-is; no key needed
every other non-private source the file's own Content-Type · the body is the file return the bytes as-is; no key needed

The SDK tells the raw-bytes shape apart on Content-Type and never by sniffing the body; inside a JSON body it is encrypted that decides. A plaintext answer works on a handle with no decrypt wiring at all.

The envelope is a photo's {"full": "data:…", "thumb": …}, a single-file document's {"file": "data:…", …}, or a multi-page document's {"pages": [{"label": …, "file": "data:…", …}], …}, with every declared entry beside it. ->bytes()/->save() throw DecryptError('multi-page envelope: use pages') on a multi-page envelope rather than handing back the front page as though it were the whole document — read ->pages() instead. ->metadata() carries no ordering guarantee; read the envelope string yourself if you need the declared order.

echo $handle->contentSha256();   // X-Allus-Content-Sha256 — the digest of the SERVED ARTIFACT
echo $handle->contentType();     // what the answer arrived as

The digest is over the raw bytes on the bytes shape and over the served value string on either JSON shape. A photo slot serves the authoritative image; there is no thumbnail variant to select.

A Share once answer's bytes are kept 90 days. Afterwards the slot still reads as answered but ->bytes() throws ApiError with status === 410 and errorKey === 'company_data.file_expired'; its ->details carry content_sha256 and expired_at, so you can still identify what you once held. The values map also gains content_sha256, and expired/expired_at after expiry, reachable on $value->raw; the field_deleted the expiry emits carries content_sha256 on $change->raw.

Change

A change-feed / webhook event.

Property Meaning
id The stable server change-row id — your dedup key (captured before the server delete).
event connection_created, connection_deleted, field_updated, field_deleted, consent_accepted, consent_declined, document_status_changed, document_sealed, message_received.
personId The person the change is about (may be null).
shareCode The person's profile share code — present on every event (may be null).
slug, value, live Present only on field_updated; value is typed exactly like Value->value (incl. a lazy BinaryHandle for binaries). Connection/consent events carry no slot/value.
documentId, status documentId is present on document_status_changed and document_sealed; status only on document_status_changed — the document's new lifecycle status.
signerFirstName, signerLastName, signerNameVerified, note Present on document_status_changed — the signer evidence and the cancellation note. The names and the note arrive encrypted to your service key; the SDK decrypts them. null otherwise.
sealedAt, plainSha256 Present only on document_sealed — the platform sealed a file document; the same seal state the document read carries. null otherwise.
connectionId, messageId, personPublicKey, messageBody Present only on message_received — a person messaged your service. messageBody is the decrypted text. See Messaging.
verified, verifiedAt, verifiedExpiresAt Present on field_updated, with the same meaning as on Value.
verifiedMethod, verifiedProvider, verificationId The proof metadata, same meaning and same all-or-none rule as on Value.
at ?DateTimeImmutable of the change. (There is no separate updatedAt on a change.)

->raw

Every model carries ->raw — the underlying hardened API array — for debugging or an edge case the SDK didn't model. It still never contains the person's source field.

See docs/model.md for the full reference.

The changes pump

The changes feed is a server-side drain-on-fetch queue: GET /api/company-data/changes?limit=N returns up to N events (default 100, max 500) and deletes exactly those rows in the same transaction — no offset/cursor, and the API keeps no copy afterward. So consumption can't be a plain list: a consumer crash mid-batch would lose events the API already deleted, and a huge backlog must not materialize in memory. processChanges solves both.

Per run, repeating until the feed is empty then returning:

  1. Replay first. Deliver any un-acked events already in the local buffer (from a previous crashed run), oldest-first.
  2. Drain. When the buffer is empty, fetch one batch and persist it to the durable file buffer (fsync) BEFORE handing anything out. This is the backup the API no longer has.
  3. Deliver one-by-one. For each buffered event, oldest-first: decrypt its value at delivery (never on disk), build the typed Change, call $handler.
  4. Ack / retry / dead-letter. On success, remove the event from the buffer (ack). On a handler error, retry with backoff up to maxRetries; then either move it to the dead-letter store and continue (onError='deadletter', default — one poison event never wedges the stream) or stop and re-throw (onError='halt'). A DecryptError on a buffered event (corrupt/truncated ciphertext, rotated key) is dead-lettered immediately — re-decrypting can't fix it, so it does not burn retries (under onError='halt' it re-throws). Either way it never propagates out and wedges replay.
  5. Repeat until a drain returns empty and the buffer is drained → return.

The durable buffer

  • Plain files under cache_dir (zero extra dependencies): pending/ for un-acked events, deadletter/ for ones that exhausted retries.
  • Stored events keep their ciphertext value — no plaintext PII is ever written to disk. Decryption happens only at delivery.
  • Writes are crash-safe (temp file → fsync → atomic rename → dir fsync). Files are named with a monotonic, zero-padded sequence so they replay oldest-first.

Crash safety, at-least-once, and idempotency

A batch is durably buffered before any delivery, and acked per-item only after the handler succeeds. The ack can't be atomic with your side-effects — a crash between your handler's success and its ack re-delivers that event on the next run. That makes delivery at-least-once, so:

Your handler must be idempotent. Dedup on Change->id.

Change->id is the stable server change-row id, captured before the server delete, so it survives crash + replay unchanged.

No follow mode

processChanges returns when the feed empties. You schedule re-runs — a cron job, a while (true) { $client->processChanges($handler); sleep(5); } loop, a worker queue, whatever fits. The feed is cheap to poll (see Rate limits).

Worked example

<?php
require 'vendor/autoload.php';

use Allus\CompanyData\Client;
use Allus\CompanyData\Model\Change;

$client = Client::fromConfig('allus.json');

$handle = function (Change $change): void {
    if (seen($change->id)) {          // idempotent: skip anything already applied
        return;
    }
    match ($change->event) {
        'field_updated'  => storeValue($change->personId, $change->slug, $change->value, $change->live),
        'field_deleted'  => clearValue($change->personId, $change->slug),
        'connection_deleted' => dropPerson($change->personId),
        'connection_created', 'consent_accepted', 'consent_declined'
                         => noteEvent($change->personId, $change->event, $change->at),
        default          => null,
    };
    recordSeen($change->id);
};

// Schedule your own re-runs; processChanges itself returns when empty.
while (true) {
    $client->processChanges($handle, batchSize: 200, maxRetries: 5);
    sleep(5);
}

If a handler keeps failing, the event lands in the dead-letter store instead of blocking the stream; inspect with $client->deadLetters() and re-drive with $client->retryDeadLetters($handle) after fixing the cause. See docs/pump.md.

Webhooks

Webhooks are the lower-latency push alternative to polling the changes feed. The platform POSTs each change event to your configured webhook URL with:

  • X-Allus-Webhook-Id — which webhook this is (selects the HMAC secret from config).
  • X-Allus-Signature — HMAC-SHA256(rawBody, secret) as lowercase hex.
  • the body — the same slug-keyed Change shape as the pull feed (JSON or XML).

All secrets/keys come from config; the helpers take no key or secret arguments. Use the raw request body bytes (do not re-serialize a parsed body — the HMAC is over the exact bytes the platform sent, and the SDK parses XML in an XXE-safe way over those raw bytes).

In a web route — framework-agnostic (raw PHP)

<?php
require 'vendor/autoload.php';

use Allus\CompanyData\Client;
use Allus\CompanyData\Errors\WebhookError;

$client = Client::fromConfig('allus.json');

$rawBody = file_get_contents('php://input');
$headers = function_exists('getallheaders') ? getallheaders() : [];   // ['X-Allus-Signature' => '…', …]

try {
    $change = $client->handleWebhook($rawBody, $headers);
} catch (WebhookError) {
    http_response_code(401);   // bad / unknown signature, or unparseable envelope
    exit;
}

applyChange($change);   // see "Delivery contract" below before adding dedup
http_response_code(200);   // 200 — the ONLY status allus counts as delivered

If you only have $_SERVER (no getallheaders()), reconstruct the headers the SDK needs — it only reads X-Allus-Webhook-Id and X-Allus-Signature (lookup is case-insensitive):

$headers = [
    'X-Allus-Webhook-Id' => $_SERVER['HTTP_X_ALLUS_WEBHOOK_ID'] ?? '',
    'X-Allus-Signature'  => $_SERVER['HTTP_X_ALLUS_SIGNATURE'] ?? '',
];

In a PSR-7 route (e.g. Slim)

use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Message\ResponseInterface as Response;
use Allus\CompanyData\Errors\WebhookError;

$app->post('/allus/webhook', function (Request $request, Response $response) use ($client) {
    $rawBody = (string) $request->getBody();
    // PSR-7 getHeaders() returns array<string, string[]>; the SDK looks up
    // X-Allus-* case-insensitively and takes the first value of an array.
    try {
        $change = $client->handleWebhook($rawBody, $request->getHeaders());
    } catch (WebhookError) {
        return $response->withStatus(401);
    }
    applyChange($change);
    return $response->withStatus(200);
});

verifyWebhook / parseWebhook let you split the steps if you prefer:

if (!$client->verifyWebhook($rawBody, $headers)) {
    http_response_code(401);
    exit;
}
$change = $client->parseWebhook($rawBody, $headers);

Delivery contract — effectively unique, rarely replayed

Each queued event is POSTed once, and only HTTP 200 counts as delivered — a 202, a 204, a 3xx redirect and every 4xx/5xx are all treated as a failure. On anything other than 200 (or a timeout or connection error) the event is not retried in place: it and the rest of the webhook's queue move to a durable server-side backlog and the webhook is marked bad. The backlog is delivered later, either automatically when the webhook next probes healthy, or when you drain it yourself with GET /api/company-data/changes?webhook_id=… (delete-on-read).

So deliveries are effectively unique — with one rare exception. If your endpoint processed an event but the platform never saw your 200 (your response timed out, or you crashed after committing but before responding), the event is treated as failed and replayed on recovery, so you receive it again. Nothing caps that at two: a failed probe leaves its backlog row in place, so every later recovery attempt whose 200 is likewise lost replays the same event once more. Inside that window the contract is at-least-once — plan for one or more repeats, not for exactly one.

Do not use $change->id as an idempotency key here. On the webhook path the id is neither reliably stable nor reliably fresh, and a receiver cannot tell which one it is holding. A live delivery is built with no change row behind it, so its id is minted for that single POST — the later replay of the same event is rebuilt from a durable backlog row and therefore carries a different id. But a replayed delivery carries that row's id, and the row stays in place until it is delivered successfully, so a re-attempted replay arrives with the same id — which changes again if the event is re-backlogged after a further failure. An id check therefore misses the duplicate you are most likely to see and matches only a rarer one; it is not a contract. If you need strict idempotency, key on the content — event + person + slug/document + payload — never on the id.

Webhooks and the pull feed are alternative integrations — consume one, never both. The id-dedup guidance under The changes pump applies to the pump only, where $change->id is the real server change-row id.

Config-driven secrets

Per-webhook HMAC secrets live in the config webhooks map, keyed by webhook id; the SDK reads X-Allus-Webhook-Id off the request and looks up the matching secret. A single-webhook service can use the flat "webhook_secret": "…" shortcut (or ALLUS_WEBHOOK_SECRET). An unknown/unconfigured id ⇒ verification returns false (and handleWebhook throws WebhookError).

The encrypt_payload account-key envelope

If a webhook has encrypt_payload enabled, the body is replaced by a {"_enc":1,…} envelope encrypted to your company account key (and the HMAC is over that envelope — the final bytes sent). parseWebhook/handleWebhook unwrap it transparently using the configured account_private_key + account_passphrase, then decrypt the inner field value with the service key — so an encrypted-payload Change is identical to a plain one. If you receive such a webhook without an account_private_key configured, you get a WebhookError.

The account-key envelope uses OAEP-SHA1 (OpenSSL's default), distinct from the OAEP-SHA256 used for person field values — the SDK handles this difference internally; you only supply the account key in config.

See docs/webhooks.md.

Rate limits

Endpoint Limit Use it for
changes (the pump) generous Poll as often as you like — it's a cheap drain-on-fetch queue.
request-fields, logs moderate Occasional reads.
connections, connection(id), binary /file heavily limited Initial full sync + occasional reconciliation only — never as a poll substitute.

A 429 carries Retry-After. The SDK backs off and retries automatically:

  • The transport (HttpClient) retries a 429 a bounded number of times honoring Retry-After, then throws RateLimitError.
  • The connections(...) generator additionally backs off per Retry-After on a surfaced RateLimitError and retries the page a bounded number of times before re-throwing — so it paces itself within the limit instead of hammering.

If you catch a RateLimitError, its ->retryAfter is the seconds to wait (or null when the header was absent).

Your client_credentials token requests (/oauth2/token) are on their own rate-limit bucket, separate from person logins — but it is keyed by source IP, not by your client_id, so it is shared with every other client_credentials caller reaching the API from the same address (another service on your network, a second client on the same host). Caching the token, as described under How it's wired below, is what keeps that shared window from being spent needlessly — by you or anyone else behind the same IP. Every rate-limit refusal — this 429, and the platform's 503 when its own limiter store is unreadable — now carries a populated ->errorKey, readable off the same RateLimitError/ApiError.

Errors

All under Allus\CompanyData\Errors\…. Same taxonomy + names across all six SDKs.

Error When
ConfigError Missing/invalid config, unreadable key file, or wrong passphrase — at construction (fail fast).
AuthError Token fetch/refresh failed (bad client_id/secret, revoked client); or a 401 survives the one automatic refresh-and-retry.
ApiError Any non-2xx from the API; carries ->status, ->errorKey (when present), and the message.
DecryptError A ciphertext wrapper is malformed, the key is wrong, or the GCM tag mismatches. Surfaces when a binary value's bytes are read, on a change event (the pump dead-letters it; a webhook parse throws it) and from flow-run routing and generation. connections/connection never throw it for a value — they mark it unreadable — and flowRunAnswers lists such an answer under unreadable.
WebhookError Signature verification failed, or an envelope couldn't be unwrapped/parsed.
RateLimitError A 429 from a rate-limited endpoint. Subclass of ApiError (status fixed at 429); carries ->retryAfter (seconds, or null).
ValidationError A value failed its field type (->slug, ->fieldType), or a flow field's minimum/maximum (->bound, ->boundValue).
PluginInputUnavailable A required plugin input is unwired, unanswered, another party's private value, or not convertible (->input, ->source, ->reason).
use Allus\CompanyData\Client;
use Allus\CompanyData\Errors\{ConfigError, AuthError, ApiError, DecryptError, WebhookError, RateLimitError};

try {
    $client = Client::fromConfig('allus.json');
    foreach ($client->connections() as $conn) {
        // …
    }
} catch (ConfigError $e) {
    // fix the config / key file
} catch (RateLimitError $e) {
    waitSeconds($e->retryAfter ?? 60);
} catch (ApiError $e) {
    log($e->status, $e->errorKey, $e->getMessage());
}

ApiError/RateLimitError are not final (the latter extends the former); ConfigError, AuthError, DecryptError, WebhookError are final.

One request waits 45 seconds for the platform's answer. The SDK's own transport — for Client, CustomerClient and OAuthClient alike — waits 45 seconds for the platform's answer to one request, and the call then fails as it does when the connection drops; a Transport you pass in keeps its own limit (CurlTransport takes its limit in seconds as its constructor argument). A request given up may still have completed on the platform.

Every request opens its own connection. The SDK's own transport never reuses a connection, so one the platform closed between two requests never fails a call, and the SDK sends no request twice.

503 db.writes_paused — saving is paused, retry. While the platform cannot complete a save in every region, any call that is not a GET, the change-feed drains (processChanges, drainBatch) and OAuthClient::pollResult can throw ApiError with ->status 503 and ->errorKey db.writes_paused, sent with Retry-After: 30. Nothing was written (a drain drained nothing), so wait 30 seconds and repeat the same call; the SDK does not retry it. Reads and token requests keep working.

503 platform.out_of_order — the platform is out of order, retry. While the region serving a call is being rebuilt, every call — reads, the change-feed drains (processChanges, drainBatch) and the OAuthClient calls included — can throw ApiError with ->status 503 and ->errorKey platform.out_of_order, sent with Retry-After: 300. The request was not processed (a drain drained nothing), so wait 300 seconds and repeat the same call; the SDK does not retry it. The client_credentials token request keeps working; every other /oauth2/token grant answers it.

See docs/errors.md.

How it's wired

Everything below is what the SDK hides so your code only ever sees conclusions.

Auth / token. An HttpClient owns a client_credentials-only token. On the first call (or when the cached token nears expiry) it POSTs client_id/client_secret to {api_url}/oauth2/token and caches the bearer token + its expiry; refresh is automatic. A mid-flight 401 triggers exactly one refresh-and-retry, then AuthError. The token is scoped server-side to one service, so every call is implicitly that service's data. The HTTP layer goes through a small Transport seam (CurlTransport by default; tests inject a fake).

Regions. The configured api_url is the platform's global front door and also the starting point for every request, including the token request. A client_credentials token is minted at your company's home region, and the token response names that region's base in an api_url member — the SDK stores it and sends every subsequent request there, token requests included, because the token is valid only at that region and a company that moves region is followed by the next mint. A data call that still reaches the front door is refused with 421 + error_key: region.rebase_required, carrying the same api_url; the SDK stores it and retries the call exactly once. The SDK does not validate a server-returned api_url against anything — it stores the base the server names and uses it. An absent or empty api_url is never stored and the response surfaces as the error it is.

Slug resolution. requestFields() is fetched once and cached; its slug→type map types every value (so address parses to an array, photo becomes a lazy binary handle, etc.). A value or a change naming a slug that map does not carry — a request slot configured after the client started — refetches the catalog ONCE, and a slug still absent afterwards is remembered and never asked for again. The connection/changes endpoints return values keyed by your request slug — the person's source field is dropped server-side and never reaches the SDK.

Decryption (zero-knowledge). The service private key is loaded once at construction from the configured encrypted PEM + passphrase into an in-memory phpseclib RSA key. A decryptValue closure over it is handed to every model factory and the pump — the key never appears in a method signature. Each value is a hybrid wrapper ({"_enc":1,"k":rsa_oaep_sha256(aesKey),"iv":…,"d":aes256gcm(…)}); the SDK RSA-OAEP-SHA256 (MGF1-SHA256) unwraps the AES key via phpseclib (PHP's openssl_private_decrypt can only do SHA-1 OAEP), then AES-256-GCM decrypts the payload via the openssl ext. The platform only ever holds ciphertext — it never sees your plaintext.

Binary fetch. A binary value is a lazy BinaryHandle over a slot-keyed value_url (slot-keyed, never source-field-keyed). On ->bytes()/->save() it GETs that file endpoint and branches on the response Content-Type: an application/json {"encrypted":true,"value":<wrapper>} envelope runs the same service-key decrypt to a JSON file-envelope and base64-decodes its data URI, while any other content type IS the file and is returned untouched. The body is never sniffed — reading a wrapper as bytes would write ciphertext to disk with no error, so the ambiguous case (no Content-Type at all) resolves to the JSON path, which fails loudly instead.

The drain-on-fetch feed. processChanges delegates to a Pump wired to a fetchChanges closure (GET /changes?limit=, returning raw ciphertext events) and a decrypt closure (builds a typed Change). Because the fetch deletes the rows it returns, the pump persists each batch to the durable file buffer (ciphertext at rest) before delivery, acks per-item after your handler succeeds, and replays the buffer on restart — see The changes pump.

XML safety. When format: "xml", responses (and webhook bodies) are parsed with a hardened DOMDocument (XXE-safe: LIBXML_NONET, DOCTYPE rejected, no entity substitution). The webhook HMAC is always computed over the raw bytes, never the parsed tree.

Development

composer install        # pulls phpseclib3 + phpunit
composer test           # vendor/bin/phpunit

The test suite proves crypto parity with the other five SDKs against a shared, cross-language decryption fixture: it loads the PBES2 service PEM, decrypts a text wrapper to its known plaintext, and decrypts a binary wrapper through the envelope to the expected inner-bytes hash. It also runs an independent openssl CLI cross-check, so the crypto is proven platform-correct, not merely self-consistent.

Sign in with allme (OAuth, #195)

use Allus\CompanyData\OAuthClient;

$oauth = OAuthClient::fromConfig('idw-config.json');
$url = $oauth->authorizeUrl('signin', state: $state, codeChallenge: $ch);
// ...user approves; your redirect receives ?code=...
$res = $oauth->completeSignIn($code, $verifier); // $res['user'], $res['mode'], $res['values'], $res['values_cipher'], $res['attestations']

Modes: signin | one_time (claim values decrypted for you) | connect | 2fa_enroll (opt a person into 2FA — see below). pollResult($state) drives the detached mode.

The sign-in address is one optional setting of the same config: authorize_url (env ALLUS_AUTHORIZE_URL). authorizeUrl() builds the button link on it, and on the live address https://web.allme.fyi/auth when it is absent. The $authorizeBase constructor argument stays and wins over the config value.

#498 — a claim IS a request field. You describe what you need and the person picks which of their own fields answers it; you never name a field. A claim carries a mandatory unique name (everything that comes back is keyed by it — values, values_cipher, attestations, and their stored choice for a repeat login), a field type, an optional suggested slug, required, and verified ("only a #311-verified answer will do"). A nameless or duplicate claim raises a config error at the call rather than failing at the API. verified is accepted only on the OIDC flow and only for a type allme can verify (today email); elsewhere it is refused with invalid_request rather than quietly dropped.

verifiedMaxAgeDays narrows a verified claim to a RECENT verification, and the merge is tighten-only: the app's registered configuration is a FLOOR, a request may only tighten it, and the effective limit is the minimum of the two stated ages. An omitted age tightens nothing — omitting it sends nothing at all, never an explicit null — and a value below 1 raises ConfigError at the call.

The sign-in result carries values, values_cipher and attestations.

  • sub is the person's share code and equals share_code — byte-identical to the id_token's sub. display_name is gone: ask for a name claim and read the value under that key.
  • values_cipher is an additive sibling of values, keyed the same way: the raw app-key ciphertext wrapper each plaintext value was decrypted from, exactly as userinfo delivered it. Lets you show that a value really came from encrypted delivery rather than trusting it verbatim. Empty for a mode/claim that carries no ciphertext (signin, or plaintext delivery) — that emptiness is the honest answer.
  • attestations is an additive sibling map keyed by the same claim name, present only for a verified claim under encrypted delivery. Each entry carries a verified boolean the SDK computes itself, in constant time, over the plaintext it just decrypted — plus the raw hash/salt/verifiedAt/verifiedExpiresAt, and the proof metadata verifiedMethod/verifiedProvider/verificationId read from the opened seal (HOW the value was bound, by WHOM, and the id to quote back to allme in a dispute — all three together or not at all; a seal built before the proof log existed carries none of them and every one reads null). A slug ABSENT from the map is "not attested", never "wrong" (treat that value as unverified); an entry present with verified false is a MISMATCH and you must reject the value. verifiedAt attests the value as verified at that moment, not verified today; verifiedExpiresAt is when that verification lapses on its own (null = it does not), and an expired attestation is unverified — the computed verified already reads false once it has passed.

resolveUserinfo($accessToken, $fallbackMode = null) is the second half of completeSignIn — the userinfo read + decrypt + attest, without the token exchange — for a caller whose exchange already ran through a different client (a standards-only third-party OIDC library, say, that verified the id_token itself but cannot read a claim value the id_token never carries). Config-only key handling applies exactly as it does to completeSignIn: you pass no key or passphrase, only the access token you already hold. Returns the identical shape (values, values_cipher, attestations) and carries the same mismatch-rejection duty on the caller. completeSignIn is implemented on top of this method. $fallbackMode is used only when userinfo itself omits mode — pass the mode your own token response carried, or null if you have none.

Plugin claims. An app may declare a plugin claim; its answer arrives in values[name] as the plugin answer's JSON string. Read it with OAuthClient::parsePluginValue($value) → PluginValue (plugin, type, blocks, outputs; a ValidationError when the value is not a finished plugin answer). A plugin answer is always one-time — the consent screen asks it at every sign-in — and is sealed to your app key but not signed: check an output with the plugin itself when you must rely on it.

Building a plugin

A plugin is your own HTTPS service: GET {base_url}/manifest describes it (with your RSA-2048 public key) and POST {base_url}/call answers sealed requests. The SDK carries the sealing for your server — functions for the plugin's server, never calls on the allme API:

use Allus\CompanyData\Crypto\Crypto;

// POST /call — body {"request": "<wrapper string>"}
$req = Crypto::pluginOpenRequest(file_get_contents('php://input'), file_get_contents('plugin-key.pem'), null); // unencrypted PKCS#8, or pass its passphrase
// $req = [field_type, op => 'options'|'outputs', block?, query?, picks, values, inputs, reply_key]
echo json_encode(Crypto::pluginSealReply(['options' => [['id' => 'hrc', 'label' => 'Horeca Fictief']], 'more' => false], $req['reply_key']));

Crypto::loadPrivateKey($pem, $passphrase) accepts an unencrypted PKCS#8 PEM with a null passphrase. Crypto::generateReplyKeyPair() makes an RSA-2048 pair ([$privateKey, $publicKeySpki]) and Crypto::exportPublicKeySpki($key) exports a public key as base64 SPKI. pluginSealReply encodes the reply as given: pass an object ((object) []) where the protocol wants a JSON object that may be empty, such as an empty outputs. A request sealed to a key your plugin no longer holds should be answered 409 {"error":"key_unknown"}.

2FA by allme (#436, #481)

Ask a connected person to approve a login inside the allme app. On the same service data client (no new config), via the twoFactor() sub-client:

use Allus\CompanyData\Client;

$client = Client::fromConfig('allus.json');

// Raise a challenge. The idempotency key is REQUIRED — a repeat with the same key within the TTL returns
// the SAME challenge and sends no second push. The context is plain text shown on the person's card.
$ch = $client->twoFactor()->challenge('2I6UF3', 'login-8f3c1a', 'Sign-in from Chrome');
if ($ch->matchingDigits !== null) {                // number matching is on for this service
    showOnLoginPage($ch->matchingDigits);          // the person types these back into the app; the server checks them
}

// Wait for the terminal outcome — polls result() for you (defaults: 600s timeout, 2s interval),
// throws ApiError on timeout.
$res = $client->twoFactor()->waitForResult($ch->challengeId); // or result($ch->challengeId) to poll once yourself
if ($res->status === 'approved') {
    grantLogin();
}
  • Burn-on-read. The first read of a terminal state (approved | denied | expired | revoked) delivers it and burns it — a later read is gone. Read it once and persist your own outcome; waitForResult returns that first terminal read and never re-reads a consumed challenge.
  • Webhook variant. The 2fa_challenge_completed change/webhook carries the same terminal status, so a webhook consumer need not poll. Expiry fires no webhook/Change — only approved/denied/revoked reach the feed, so a lapsed challenge is observable only by polling.
  • Enrollment. Only an enrolled person can be challenged (an un-enrolled share_code is 404). Enrollment is a one-time consent on the web.allme.fyi/auth surface via the OAuth helper's 2fa_enroll mode — a redirect button ($oauth->authorizeUrl('2fa_enroll', state: $state)), or server-to-server with responseMode: 'detached' + pollResult($state), which returns ['enrolled' => true, 'state' => ...] once the person confirms.
  • Errors. 404 (unknown / not-enrolled share code). A 429 is either the plain rate limit (retried with backoff → RateLimitError) or twofa.pending_cap (too many challenges already open for this person) — the latter surfaces immediately as ApiError and is never retried, since a retry cannot clear it.