Search by

proofage / php-sdk

dragg

PHP SDK for the ProofAge API: HMAC request signing, resources, webhook verification. No framework dependencies.

v0.4.0 2026-09-30 18:37 UTC

This package is auto-updated.

Last update: 2026-09-30 18:38:05 UTC


README

Framework-neutral PHP client for the ProofAge age and identity verification API: HMAC request signing, the resource methods, streaming media downloads, and inbound webhook verification. Runtime dependencies are php ^8.1, ext-curl, ext-json and the psr/http-message interfaces — nothing else, so it installs cleanly next to whatever your project already uses.

On Laravel, use proofage/laravel-client instead: it wraps this SDK with a service provider, a facade, the webhook middleware and the proofage:verify-setup command.

Install

composer require proofage/php-sdk

First request

use ProofAge\Sdk\Client;

$client = new Client([
    'api_key' => getenv('PROOFAGE_API_KEY'),
    'secret_key' => getenv('PROOFAGE_SECRET_KEY'),
    'base_url' => 'https://api.proofage.xyz',
]);

$workspace = $client->workspace()->get();

Every request is signed with X-API-Key and X-HMAC-Signature; you never touch either. It also says which SDK sent it: X-ProofAge-Sdk: php/0.3.0 and User-Agent: ProofAge-PHP/0.3.0 (PHP 8.4.1) (see SDK identification).

Configuration

Key Default
api_key required Workspace API key
secret_key required Workspace secret key, used only to sign
base_url required https://api.proofage.xyz; must have no path component
version v1 API version segment
timeout 30 Seconds per attempt; a positive integer (sub-second timeouts are not supported)
retry_attempts 3 Attempts for interactive requests; see Retries
retry_delay 1000 Milliseconds between attempts, constant; an integer, 0 allowed
download_retry_attempts 1 Attempts for media downloads; only a transport failure is retried, never an HTTP status
sdk_tokens [] For a package wrapping this SDK: name/version tokens prepended to X-ProofAge-Sdk
user_agent_prefix '' For a package wrapping this SDK: Name/version products prepended to the default User-Agent

Retries

A GET gets another attempt after a transport failure, a 429, or any non-2xx status that is not a 4xx (a 3xx or a 5xx).

A POST — create(), acceptConsent(), uploadMedia(), submit(), blockFace() — is retried only when repeating it cannot make the server act twice: when the connection failed before the request was sent (DNS, connection refused, TLS handshake), or on a 429 that carries Retry-After. A 5xx or a timeout on a POST is thrown at once, because the server may already have created the verification, stored the upload or submitted it. Catch the exception and decide: get() tells you whether a submit went through.

The four numeric settings must be integers (integer-valued strings such as getenv() returns are accepted); a float or anything else throws ProofAgeException at construction.

Resources

$verification = $client->verifications()->create([
    'callback_url' => 'https://example.com/verification/done',   // where the browser goes afterwards
    'external_id' => 'user-42',
]);

$verification['url'];                          // the link the person opens
\ProofAge\Sdk\Enums\VerificationStatus::tryFrom($verification['status']);

$v = $client->verifications($verification['id']);

$v->get();
$v->acceptConsent(['consent_version_id' => $consent['id'], 'text_sha256' => $consent['text_sha256']]);
$v->uploadMedia(['type' => 'document', 'side' => 'front', 'document' => 'passport', 'file' => '/tmp/front.jpg']);
$v->uploadMedia(['type' => 'selfie', 'file' => new \SplFileInfo('/tmp/selfie.jpg')]);
$v->submit();
$v->document();
$v->estimation();
$v->blockFace(['reason_code' => \ProofAge\Sdk\Enums\BlockFaceReasonCode::UNDERAGE->value]);

$client->workspace()->get();
$client->workspace()->getConsent();

Methods return the decoded JSON as array|null; uploadMedia() and submit() return null, because the API answers them with an empty body. Every request and response shape is documented in AGENTS.md and in the @param/@return PHPDoc on src/Resources/.

uploadMedia() accepts a path, any \SplFileInfo (Symfony's and Laravel's UploadedFile included) or a ProofAge\Sdk\Http\Body\FilePart. A path that does not exist throws \InvalidArgumentException before anything is sent. Form fields are sent as the server will read them back: a null field is left out and a boolean is sent as 1 / 0, so the signature matches whichever transport sends the body.

makeRequest($method, $endpoint, $data, $files) takes the endpoint relative to the version segment, with raw (not pre-encoded) path segments: each segment is percent-encoded once, so the signed path is exactly the path sent. An endpoint containing . or .. segments, a #fragment, whitespace or control characters — things a transport rewrites before sending — throws \InvalidArgumentException rather than producing a 401 "HMAC signature is invalid".

Media downloads

$stream = $v->downloadMedia($mediaId);          // Psr\Http\Message\StreamInterface
$path = $v->downloadMediaTo($mediaId, '/var/media/front.jpg');

Downloads send Accept: application/json, */*;q=0.8: the endpoint answers with the file's bytes regardless, and the header makes the server render a 403 or a 404 as a JSON error the exception can read instead of an HTML page.

With the bundled cURL transport the body is received into php://temp, which spills to disk past 2 MB, and is never held as a PHP string. Downloads never retry an HTTP status (429 included): they usually run from a queue whose own backoff owns the wait. Raise download_retry_attempts to retry connection failures only.

downloadMediaTo() writes to a temporary file next to the destination and renames it into place only after a 2xx; a 404 or a timeout leaves nothing at the destination (and a file already there untouched), and the exception still carries the error body.

Errors

use ProofAge\Sdk\Exceptions\AuthenticationException;   // 401
use ProofAge\Sdk\Exceptions\ValidationException;       // 422, getErrors()
use ProofAge\Sdk\Exceptions\TransportException;        // connection refused, DNS, TLS, timeout
use ProofAge\Sdk\Exceptions\ProofAgeException;         // every other non-2xx, and the base class
use ProofAge\Sdk\Exceptions\ExceptionInterface;        // marker: catch the whole family

try {
    $client->verifications()->create($data);
} catch (ValidationException $e) {
    $errors = $e->getErrors();
} catch (TransportException $e) {
    // no response: $e->getResponse() is null, $e->getCode() is the cURL errno
} catch (ProofAgeException $e) {
    $e->getCode();       // HTTP status
    $e->getErrorCode();  // e.g. MEDIA_NOT_FOUND, PAYMENT_METHOD_REQUIRED, FACE_NOT_FOUND; null when the body has none
    $e->getErrorData();  // the `error` object, or the whole body when it is not nested
    $e->getResponse();   // ProofAge\Sdk\Http\Response
}

The API nests most errors as {"error": {"code", "message"}} but answers 402 and the upload quality checks with a flat {"code", "message"}, request validation with {"message", "errors"} and 403 with {"message"}; the exception reads all of them. A 422 from an upload quality check is a ValidationException whose getErrors() is empty and whose getErrorCode() says what failed.

Missing verification IDs and missing files throw \InvalidArgumentException.

Dumping SDK objects

print_r() and var_dump() of a client, a request, a response or an SDK exception — including the exception's trace with zend.exception_ignore_args=0 — never show the secret key, show the API key masked to its last four characters, and show request bodies and uploaded files as sizes and sha256 hashes. So error_log(print_r($e, true)) in a catch block is safe.

That protection comes from __debugInfo(), which var_export(), (array) casts, reflection and Symfony's VarDumper (Laravel's dd() / dump()) do not honour or only merge with the real properties. Do not point those at a Client, Signer, Request or WebhookVerifier. On PHP 8.1, where #[\SensitiveParameter] does not exist, a failure inside the Client constructor still leaves the config array in the trace unless zend.exception_ignore_args=1.

Webhooks

Status webhooks go to the workspace's webhook URL (set in the console; workspace()->get() returns it as webhook_url), not to the callback_url given to create(), which is only where the person's browser is sent afterwards. ProofAge signs every delivery with X-Auth-Client, X-Timestamp and X-HMAC-Signature (HMAC-SHA256 of {timestamp}.{rawBody}) using the workspace's active secret key; API requests accept any key that has not been deleted, so give the verifier the active one. The body is documented in AGENTS.md.

use ProofAge\Sdk\Webhooks\WebhookVerifier;
use ProofAge\Sdk\Exceptions\WebhookVerificationException;

$verifier = new WebhookVerifier(getenv('PROOFAGE_API_KEY'), getenv('PROOFAGE_SECRET_KEY'));

try {
    $verifier->verifyHeaders(getallheaders(), file_get_contents('php://input'));
} catch (WebhookVerificationException $e) {
    http_response_code($e->statusCode);
    echo json_encode($e->toArray());   // {"error": {"code": "INVALID_SIGNATURE", "message": "..."}}
    exit;
}

Codes, in the order they are checked: MISSING_SIGNATURE, MISSING_TIMESTAMP, MISSING_AUTH_CLIENT, INVALID_AUTH_CLIENT, TIMESTAMP_TOO_OLD, INVALID_SIGNATURE. The timestamp tolerance defaults to 300 seconds (third constructor argument).

Middleware

A middleware is callable(Request $request, callable $next): Response. It runs once per HTTP attempt and before signing, so whatever it changes is what gets signed — a middleware can add a header or rewrite the body and the signature stays valid. It never sees X-API-Key, X-HMAC-Signature, X-ProofAge-Sdk or the default User-Agent; those are added below it. The first middleware pushed is the outermost.

use ProofAge\Sdk\Http\Request;
use ProofAge\Sdk\Http\Response;

$client->pushMiddleware(function (Request $request, callable $next): Response {
    return $next($request->withHeader('X-Request-Id', bin2hex(random_bytes(8))));
}, 'request-id');

// Once per logical call rather than per attempt:
$client->pushMiddleware(function (Request $request, callable $next): Response {
    if ($request->attempt === 1) {
        $quota->consume();
    }

    return $next($request);
});

$client->removeMiddleware('request-id');

A middleware that returns a Response without calling $next short-circuits: nothing is signed, no event fires, nothing is sent.

SDK identification

Every request carries X-ProofAge-Sdk: name/version tokens separated by single spaces, the outermost wrapper first and this SDK's php/{Client::VERSION} last. On its own the SDK sends php/0.3.0; proofage/laravel-client sends laravel/0.9.0 php/0.3.0 (versions here are examples). The User-Agent is ProofAge-PHP/0.3.0 (PHP 8.4.1) unless the request already has one. Neither header is part of the HMAC signature.

Both are set below the middleware, on every attempt, so a middleware cannot remove or replace X-ProofAge-Sdk (whatever it sets there is overwritten); it can set its own User-Agent, which is then sent as is. A package that wraps the SDK adds itself with two options, validated at construction:

$client = new Client($config + [
    'sdk_tokens' => ['acme-shop/2.1.0'],              // lowercase name, outermost first
    'user_agent_prefix' => 'AcmeShop/2.1.0',          // User-Agent products, space-separated
]);
// X-ProofAge-Sdk: acme-shop/2.1.0 php/0.3.0
// User-Agent: AcmeShop/2.1.0 ProofAge-PHP/0.3.0 (PHP 8.4.1)

A PSR-18 client's own default User-Agent (Guzzle's GuzzleHttp/7, or one set in its headers option) is replaced, because the SDK puts its User-Agent on the request itself; set yours through a middleware instead.

Events

Events observe the signed request going down and the response or transport failure coming back, once per attempt.

use ProofAge\Sdk\Events\{RequestEvent, ResponseEvent, ErrorEvent};

$client->onRequest(fn (RequestEvent $e) => $log->info('proofage.request', [
    'method' => $e->method(),
    'url' => $e->url(),
    'attempt' => $e->attempt(),
    'headers' => $e->headers(),   // ['X-API-Key' => '****7f2a', 'X-HMAC-Signature' => '4c6daa63...', ...]
    'body' => $e->body(),         // ['kind' => 'multipart', 'fields' => [...], 'files' => [['name' => 'file', 'filename' => 'front.jpg', 'bytes' => 183422, 'sha256' => '...']]]
]));

$client->onResponse(fn (ResponseEvent $e) => $metrics->timing('proofage.request_ms', $e->durationMs(), [
    'status' => $e->status(),
    'attempt' => $e->attempt(),
]));

$client->onError(fn (ErrorEvent $e) => $log->warning('proofage.transport', [
    'attempt' => $e->attempt(),
    'error' => $e->exception()->getMessage(),
]));

An HTTP error status is a response and arrives through onResponse; onError fires only for transport failures. A listener that throws aborts the request.

What the events redact, and what raw() exposes

Events are views built for logging. RequestEvent::headers() masks X-API-Key to its last four characters and X-HMAC-Signature to its first eight; RequestEvent::body() reduces a JSON body to its byte count and sha256 and each file to name, filename, size and sha256 (scalar form fields such as type and side are shown verbatim); ResponseEvent has no body accessor at all.

raw() on any event returns the underlying Request or Response with everything in it: the API key, a signature that — since API signing carries no timestamp or nonce — replays the request verbatim, document photos and selfies in multipart bodies, and response bodies carrying names, dates of birth and document numbers. Call it deliberately, and do not log what it returns.

Transports

The default transport is a bundled cURL client (ProofAge\Sdk\Http\Curl\CurlHttpClient): one handle per request, TLS verification on, no redirects, 10 s connect timeout.

PSR-18

If you already have a PSR-18 client and PSR-17 factories:

use ProofAge\Sdk\Http\Psr18\Psr18HttpClient;

$factory = new \GuzzleHttp\Psr7\HttpFactory;
$transport = new Psr18HttpClient(new \GuzzleHttp\Client(['timeout' => 30]), $factory, $factory);

$client = new Client($config, $transport);

PSR-18 has no per-request timeout, so timeout from the config is not applied there; configure it on your client. Any Psr\Http\Client\ClientExceptionInterface surfaces as TransportException with the original as getPrevious().

Your own

Implement ProofAge\Sdk\Http\HttpClient — one method, send(Request): Response — and pass it as the second constructor argument. A transport sends exactly what it is given and never retries or throws on an HTTP status; the SDK owns both. When it fails below HTTP it throws TransportException; pass requestMayHaveBeenSent: false only when it knows the request never left (the connection was never made), which is what allows a POST to be retried.

Testing your integration

ProofAge\Sdk\Testing\FakeHttpClient ships in the package and needs no network:

use ProofAge\Sdk\Testing\FakeHttpClient;

$fake = new FakeHttpClient([
    'api.proofage.xyz/v1/workspace' => FakeHttpClient::json(['id' => 'ws_1', 'name' => 'Acme']),
    'api.proofage.xyz/v1/verifications/*' => [               // a sequence
        FakeHttpClient::json(['error' => ['code' => 'RATE_LIMIT']], 429, ['Retry-After' => '1']),
        FakeHttpClient::json(['id' => 'ver_1', 'status' => 'created']),
    ],
    '*' => FakeHttpClient::failedConnection(),
]);

$client = new Client($config, $fake);

$client->workspace()->get();

$fake->assertSent(fn ($request) => $request->method === 'GET' && str_ends_with($request->url, '/v1/workspace'));
$fake->assertSentCount(1);

Patterns use * wildcards and are tried in order. FakeHttpClient::failedConnection() fails before sending (a POST is retried after it); FakeHttpClient::timeout() fails after the request may have arrived (a POST is not). sent() returns the requests as the transport received them — signed, one per attempt — so you can assert X-HMAC-Signature and $request->body->bytes directly. An unmatched URL throws \LogicException.

The wait between retry attempts is usleep() unless you pass a fourth constructor argument, callable(int $microseconds): void; a test that exercises retries passes a recorder or static fn () => null so it does not sleep for real (the Laravel package passes Sleep::usleep(...) so Sleep::fake() covers it).

Contract

resources/openapi.json is the bundled API spec and resources/hmac-vectors.json the golden signature vectors this SDK executes in its test suite. The fixture ships in the dist so the ProofAge server's tests can execute the same file; until they do, the two implementations agree by inspection. See AGENTS.md.

License

MIT. See LICENSE.md.