SDKs

PHP

Read identification events, verify signed webhooks, and serve the browser SDK from your own domain on PHP 8.1 and newer, with Laravel, Symfony and PSR-15 adapters.

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-curl and ext-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

Terminal
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
<?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
<?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
<?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.

psr18.php
$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.

read.php
$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.php
$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)

  • $secretKeystringrequired
    A secret key. Its prefix names the environment read.
  • endpointstring
    Override the API origin.
  • api_versionstringDefault 'v1'
    API route version; the server must support the selected version.
  • timeoutfloatDefault 5
    Deadline per request, in seconds, covering the response body.
  • http_clientPsr\Http\Client\ClientInterface
    Send through your own PSR-18 client instead of ext-curl. Needs request_factory alongside 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.

MethodReturnsCalls
$fingerly->events->get($requestId)EventGET /events/{request_id}.
$fingerly->events->list($query)EventPageGET /events. An omitted filter is not sent, so the server defaults it.
\Fingerly\Webhook::verify(...)boolNever throws for malformed input. tolerance sets the replay window, five minutes by default.
\Fingerly\Webhook::parse($payload)WebhookEventDecodes the envelope. Verify before you parse.
new \Fingerly\Proxy($options)ProxyThe browser SDK's submissions only, never event reads. path is the issued path.