proof-holdings / sdk
Official PHP SDK for the proof.holdings verification API
Requires
- php: >=8.3
- guzzlehttp/guzzle: ^7.15.2
Requires (Dev)
- firebase/php-jwt: ^7.0
- phpunit/phpunit: ^13.0
Suggests
- firebase/php-jwt: Required for offline proof verification (^7.0 — every 6.x release carries a security advisory)
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-14 11:13:13 UTC
README
Official PHP SDK for the proof.holdings verification API.
Installation
composer require proof-holdings/sdk
For offline JWT verification:
composer require "firebase/php-jwt:^7.0"
Quick Start
use ProofHoldings\Proof; $proof = new Proof('pk_live_...'); // Create a phone verification $v = $proof->verifications->create([ 'type' => 'phone', 'channel' => 'whatsapp', 'identifier' => '+1234567890', ]); echo "Verification {$v['id']} created: {$v['status']}\n"; // Wait for user to complete verification $result = $proof->verifications->waitForCompletion($v['id']); echo "Result: {$result['status']}\n";
Resources
Verifications
// Create $v = $proof->verifications->create([ 'type' => 'domain', 'channel' => 'dns', 'identifier' => 'example.com', ]); // Retrieve $v = $proof->verifications->retrieve('ver_abc123'); // List with filters $page = $proof->verifications->list(['status' => 'verified', 'type' => 'phone', 'limit' => 10]); // Trigger DNS/HTTP check $v = $proof->verifications->verify('ver_abc123'); // Submit OTP code $v = $proof->verifications->submit('ver_abc123', 'ABC123'); // Poll until complete $v = $proof->verifications->waitForCompletion('ver_abc123', interval: 2.0, timeout: 300.0);
Verification Requests (Multi-Asset)
$req = $proof->verificationRequests->create([ 'assets' => [ ['type' => 'phone', 'required' => true], ['type' => 'email', 'identifier' => 'user@example.com'], ], 'reference_id' => 'user_123', 'callback_url' => 'https://yourapp.com/webhook', 'expires_in' => 86400, ]); echo "Send user to: {$req['verification_url']}\n"; // Poll until complete $result = $proof->verificationRequests->waitForCompletion($req['id']);
Proofs
// Validate online (checks revocation) $result = $proof->proofs->validate('eyJhbGciOi...'); // Revoke $response = $proof->proofs->revoke('ver_abc123', 'User requested'); // Get revocation list $revoked = $proof->proofs->listRevoked(); // Offline verification (requires firebase/php-jwt) — no API call with your key. Two public // documents are read: the JWKS for the signature, and the issuer's status list for revocation, // which is checked by default. $result = $proof->proofs->verifyOffline('eyJhbGciOi...'); if (!$result['valid']) { // 'revoked' | 'suspended' | 'expired' | 'invalid' | 'status_unavailable', or one of the // key-rotation reasons below echo $result['reason']; } // 'status_unavailable' means the revocation status could NOT be read — an unreachable list, one // that failed its own checks, or a slot the list does not cover. It is a refusal to guess, not a // claim that the proof is withdrawn. A token minted without a status-list slot stays valid and // reports revocation_checked = false, exactly as the issuer's own validate answers for it. // Signature only, for an air-gapped check against a warm key cache: $signatureOnly = $proof->proofs->verifyOffline('eyJhbGciOi...', false); var_dump($signatureOnly['revocation_checked']); // false — never assume it was checked // The key set and the status list are held between verifications. refreshJWKS() makes the next // verification refetch the key set and drops the cached status list. $proof->proofs->refreshJWKS();
Signing-key rotation
verifyOffline fetches and caches the issuer's key set (JWKS) itself and follows its rotation
rules. The cached set is reused for 10 minutes; a kid it does not know triggers one refetch, at
most once every 30 seconds.
key_revoked— the issuer revoked the key that signed the token. The result carriespull_hint: ['issuer_origin' => ..., 'handle' => ...]: where to fetch the re-signed token (handleis the proof'sproof_id, or a delegation'ssub). The handle is read from the token itself, whose signature a revoked key no longer vouches for: URL-encode it before building a request from it. A revocation takes effect only after two key-set fetches at least 5 minutes apart both carry it, and is then kept for a year.jwks_stale— the key set could not be refreshed and the cached copy is older thanmaxJwksAge(seconds; default 24 hours, at most 25 days;new Proof(..., maxJwksAge: ...)). The result carriescache_age_seconds. CallrefreshJWKS()once the issuer is reachable.wrong_key_purpose,outside_key_window,token_lifetime_exceeded— the key belongs to another token family, the token was signed outside the key's validity window, or it lives longer than the key allows.unknown_key— no published key matches the token'skid.
Revoked-key tombstones are kept in memory only — they are not persisted, so every new process
(each PHP-FPM request, unless the Proof instance lives longer, as under Octane or Swoole) learns
them again from the issuer. clearTombstones() forgets them on purpose. refreshJWKS() makes the
next verification refetch the key set (and drops the cached status list); it does not forget
tombstones.
Sessions (Phone-First Flow)
$session = $proof->sessions->create(['channel' => 'telegram']); echo "Deep link: {$session['deep_link']}\n"; $result = $proof->sessions->waitForCompletion($session['id']);
Webhook Deliveries
$deliveries = $proof->webhookDeliveries->list(['status' => 'failed']); $result = $proof->webhookDeliveries->retry('del_abc123');
Error Handling
use ProofHoldings\Proof; use ProofHoldings\Exceptions\NotFoundException; use ProofHoldings\Exceptions\RateLimitException; use ProofHoldings\Exceptions\ProofException; $proof = new Proof('pk_live_...'); try { $v = $proof->verifications->retrieve('nonexistent'); } catch (NotFoundException $e) { echo "Not found: {$e->errorCode}\n"; } catch (RateLimitException $e) { echo "Rate limited, try again later\n"; } catch (ProofException $e) { echo "API error {$e->statusCode}: {$e->errorCode} - {$e->getMessage()}\n"; }
Configuration
$proof = new Proof( apiKey: 'pk_live_...', baseUrl: 'https://api.proof.holdings', // Default timeout: 30.0, // Seconds, default 30 maxRetries: 2, // Default 2 maxJwksAge: 86400, // Offline verification: stale key-set bound, seconds );
Credentials
Sign in once with npx @proof-holdings/mcp-server login and construct the client with no key:
$proof = new Proof(); // or new Proof(baseUrl: '...')
When the key argument is omitted it is resolved in this order:
PROOF_API_KEY, when set and non-empty.- The entry for this client's base URL in
~/.proof-holdings/credentials, the file the login command writes. The file is keyed by base URL, so a key saved against staging is never sent to production. - Otherwise construction throws
InvalidArgumentException, naming the three roads: pass the key, setPROOF_API_KEY, or runnpx @proof-holdings/mcp-server login.
The base URL is the baseUrl argument, else a non-empty PROOF_BASE_URL, else
https://api.proof.holdings. An explicit key is used as given, and an explicit empty key
(new Proof('')) is an error — neither reads the environment or the file.
Requirements
- PHP >= 8.3 (the SDK uses typed class constants)
- guzzlehttp/guzzle >= 7.15.2
- firebase/php-jwt ^7.0 (optional, for offline verification — every 6.x release carries a security advisory)