fingerly/fingerly-php reads stored events by request ID, verifies webhook signatures, and forwards the browser SDK's requests through a route on your own domain. It has no Composer dependencies.
Requirements
- PHP 8.1 or newer, with
ext-curlandext-json. Both ship with almost every PHP build. - A secret key for reading events, a webhook signing secret, and a proxy key for the proxy.
Install
composer require fingerly/fingerly-php
Read an event
Create one client with your secret key and reuse it. The key names the environment the client reads.
<?php
$fingerly = new \Fingerly\Client(getenv('FINGERLY_SECRET_KEY'));
$event = $fingerly->events->get('01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4');
An event has the fields listed in Get an event. suspect_score is null when the request was not scored.
Verify a checkout
Read the event your client identified, check it belongs to this action and is recent, then decide on its level. See server-side verification.
<?php
use Fingerly\ApiException;
function decide(string $orderId, string $requestId): string
{
global $fingerly;
try {
$event = $fingerly->events->get($requestId);
} catch (ApiException $e) {
if ($e->getStatus() === 404) {
return 'refuse';
}
throw $e;
}
if ($event->tag !== "checkout:{$orderId}") {
return 'refuse';
}
if ($event->occurredAt < new DateTimeImmutable('-2 minutes')) {
return 'refuse';
}
return match ($event->suspectLevel) {
'high' => 'review',
'medium' => 'challenge',
default => 'allow',
};
}
Verify a webhook
Check the signature over the raw request body before parsing it. The helper rejects timestamps more than five minutes from now.
<?php
use Fingerly\Webhook;
use Illuminate\Http\Request;
Route::post('/webhooks/fingerly', function (Request $request) {
// getContent() is the exact bytes; a body rebuilt from json() is not.
$payload = $request->getContent();
$valid = Webhook::verify(
secret: config('services.fingerly.webhook_secret'),
payload: $payload,
timestamp: $request->header('x-fingerly-timestamp'),
signature: $request->header('x-fingerly-signature'),
);
abort_unless($valid, 400);
$event = Webhook::parse($payload);
ProcessFingerlyEvent::dispatch($event->id, $payload); // deduplicate on the ID
return response()->noContent();
});
No dependencies
ext-curl is the only HTTP client PHP ships, so it is what the package uses and there is nothing to install. Everything else is a suggest, pulled in only if you use it: a PSR-18 client to route API calls through your own HTTP stack, and psr/http-server-middleware, illuminate/http or symfony/http-kernel for whichever proxy adapter you mount. Webhook verification touches neither the network nor ext-curl, so a receiver that does nothing else pulls in nothing else.
$fingerly = new \Fingerly\Client($secretKey, [
'http_client' => $psr18Client,
'request_factory' => $psr17Factory,
]);
Deadlines
timeout bounds the whole exchange, including receiving the body, so a server that answers and then stalls times out like one that never answered. It defaults to 5 seconds. CURLOPT_TIMEOUT_MS covers the transfer end to end, which is what makes that true here without extra bookkeeping.
Errors
Everything thrown implements \Fingerly\FingerlyException, so one catch handles the lot. A non-2xx response throws ApiException with getStatus(), and optional getErrorCode(), getDetails() and getRequestId(); the secret key is stripped from all three and the body is never echoed. A deadline throws TimeoutException and an unreachable API throws ConnectionException. A bad option throws ConfigurationException, which is an InvalidArgumentException, because a key that was never valid is a programming mistake rather than something to catch around a call.
Unscored events
$event->suspectScore is null when the request was not scored. Never read null as zero: a visitor about whom nothing fired scores 0, while a submission that was never enriched has no score at all. $event->isScored() states the distinction at the call site, and $event->raw keeps the response body so a field the API adds later needs no upgrade.
Read an event you just identified
An event is readable as soon as identification answers, so this needs nothing special.
$event = $fingerly->events->get($requestId);
Earlier versions had a waitFor that polled through the gap between identification and the event becoming queryable. There is no gap, so isEventNotFound() now means a request id that was never issued. The archived document arrives moments after the event itself, so check hasDocument rather than assuming it is there.
Serve the browser SDK from your domain
Content blockers refuse browser requests to third-party APIs, and a refused identification is a visitor never identified. Mount the proxy at the path the dashboard issued for it, in Integration > Proxy keys, and those requests become first-party. See proxy integrations.
PHP has three live server request shapes and no equivalent of Rack to unify them, so one framework-neutral core ships with an adapter for each.
$proxy = new \Fingerly\Proxy([
'proxy_key' => getenv('FINGERLY_PROXY_KEY'), // fly_px_production_…
'path' => '/k7x2qm4a',
'client_ip' => fn (\Fingerly\Proxy\ProxyRequest $r) => $r->header('cf-connecting-ip'),
]);
// bootstrap/app.php (11) or app/Http/Kernel.php (10)
$middleware->append(\Fingerly\Proxy\LaravelMiddleware::class);
The adapters are conveniences; handle() is the interface. $proxy->handles($request) answers whether a request is for the proxy's path, so everything else reaches your application untouched rather than getting a 404 of the proxy's, and $proxy->maxBodyBytes() lets glue code read no further than the limit.
A middleware adapter passes every other path on to your application untouched. It answers 405 to anything but POST, and 401 when the request carries no public key for the same environment as the proxy key. Every request is forwarded to https://api.fingerly.io/api/v1/ingest, and only content-type, x-request-id and the rate limit headers are relayed back, so nothing on the response names Fingerly. Event reads need a secret key and are unreachable through it. Responses are marked no-store, customer cookies and authorization headers are never forwarded, and the proxy reports itself upstream as php/0.1.0, which the dashboard shows per proxy.
API
new \Fingerly\Client($secretKey, $options)
$secretKeystringrequiredA secret key. Its prefix names the environment read.endpointstringOverride the API origin.api_versionstringDefault'v1'API route version; the server must support the selected version.timeoutfloatDefault5Deadline per request, in seconds, covering the response body.http_clientPsr\Http\Client\ClientInterfaceSend through your own PSR-18 client instead of ext-curl. Needsrequest_factoryalongside it.
An option this build does not know is refused rather than ignored, because a silently dropped timeout or endpoint is exactly the kind of thing nobody notices until it matters.
| Method | Returns | Calls |
|---|---|---|
$fingerly->events->get($requestId) | Event | GET /events/{request_id}. |
$fingerly->events->list($query) | EventPage | GET /events. An omitted filter is not sent, so the server defaults it. |
\Fingerly\Webhook::verify(...) | bool | Never throws for malformed input. tolerance sets the replay window, five minutes by default. |
\Fingerly\Webhook::parse($payload) | WebhookEvent | Decodes the envelope. Verify before you parse. |
new \Fingerly\Proxy($options) | Proxy | The browser SDK's submissions only, never event reads. path is the issued path. |