The official PHP SDK for the Decisa server-side ingest API. Record conversions (sales, signups, leads, refunds...) and events (PageView, Purchase...) from your backend with a typed, framework-agnostic client.
- Framework-agnostic — built on PSR-18 / PSR-17, auto-discovers your HTTP client. Works in plain PHP, Laravel, Symfony, Slim, anywhere.
- Money guard —
value_centsis integer-only; passing a decimal is rejected before it ever hits the wire (the #1 install footgun). - Typed errors — every API failure maps to a specific exception you can catch.
- Safe retries — automatic exponential backoff with jitter on network errors,
5xx, and429(honoringRetry-After). Duplicate sends are idempotent, so retries can never double-count. - Secret-safe — your API key is kept private and never appears in an exception message, log line, or
__toString().
- PHP 8.2+
- A Decisa API key (starts with
dcs_ak_) with theconversions:writeand/orevents:writescope.
composer require decisa/decisa-php
The SDK depends only on PSR HTTP abstractions and auto-discovers a PSR-18 client. If your project doesn't already ship one, add Guzzle (or any PSR-18 client):
composer require guzzlehttp/guzzle
use Decisa\Client; $decisa = new Client(getenv('DECISA_API_KEY')); // dcs_ak_... $result = $decisa->conversions()->record([ 'type' => 'sale', 'externalId' => 'order-1001', // your idempotency key — required 'valueCents' => 1999, // R19ドル.99 as INTEGER cents 'currency' => 'BRL', 'visitorId' => $_COOKIE['dcs_vid'] ?? null, 'customerEmail' => 'buyer@example.com', // hashed server-side, raw never stored ]); $result->id(); // "conv_abc123" $result->externalId(); // "order-1001" $result->valueCents(); // 1999 $result->attributed(); // true if Decisa matched it to a click/campaign $result->raw(); // full decoded `data` payload
value_cents must always be an integer. To convert a decimal price safely, use Money::cents() (rounds half-up):
use Decisa\Money; Money::cents(19.99); // 1999 Money::cents('49.50'); // 4950 $decisa->conversions()->record([ 'type' => 'sale', 'externalId' => 'order-1002', 'valueCents' => Money::cents($order->total), // never pass the float directly 'currency' => 'USD', ]);
Passing a float straight into valueCents is rejected before any network call:
$decisa->conversions()->record([ 'type' => 'sale', 'externalId' => 'order-1003', 'valueCents' => 19.99, // throws Decisa\Exception\InvalidArgumentException ]);
Arrays are ergonomic, but you can build fully typed input with enums:
use Decisa\Input\ConversionInput; use Decisa\Enum\ConversionType; use Decisa\Money; $input = new ConversionInput( type: ConversionType::Sale, externalId: 'order-1004', valueCents: Money::cents(19.99), currency: 'BRL', ); $decisa->conversions()->record($input);
use Decisa\Client; $decisa = new Client(getenv('DECISA_API_KEY')); $decisa->events()->record([ 'eventId' => 'evt-9f3a1c20', // your idempotency key — required, 8..64 chars 'eventName' => 'Purchase', // PageView, ViewContent, AddToCart, Lead, Purchase, ... 'valueCents'=> 4999, 'currency' => 'USD', 'visitorId' => $_COOKIE['dcs_vid'] ?? null, 'url' => 'https://shop.example.com/thank-you', ]);
Events accept multiple pixel keys. Pass an array and the SDK joins it into the comma-separated string this endpoint expects:
$decisa->events()->record([ 'eventId' => 'evt-aa11bb22', 'eventName' => 'PageView', 'pixelKeys' => ['pk_main', 'pk_remarketing'], // sent as "pk_main,pk_remarketing" ]);
Note:
/v1/conversionstakespixel_keysas a JSON array, while/v1/eventstakes it as a comma-separated string. The SDK handles each correctly for you — always pass an array.
Every failure maps to a typed exception. All extend Decisa\Exception\DecisaException and carry ->httpStatus(), ->code(), ->message, and ->requestId() (when the server provides one).
use Decisa\Exception\DecisaValidationException; use Decisa\Exception\DecisaAuthException; use Decisa\Exception\DecisaForbiddenException; use Decisa\Exception\DecisaRateLimitException; use Decisa\Exception\DecisaServerException; use Decisa\Exception\DecisaException; try { $decisa->conversions()->record([ 'type' => 'sale', 'externalId' => 'order-1005', 'valueCents' => 1999, 'currency' => 'BRL', ]); } catch (DecisaValidationException $e) { // 422 — show the per-field messages back to the operator. foreach ($e->fields() as $field => $messages) { echo "$field: " . implode(', ', $messages) . PHP_EOL; } } catch (DecisaAuthException $e) { // 401 — the API key is missing, malformed, unknown, revoked, or expired. } catch (DecisaForbiddenException $e) { // 403 — valid key, but not allowed. Use ->code() to tell which: if ($e->code() === 'INSUFFICIENT_SCOPE') { // the key lacks conversions:write / events:write } elseif ($e->code() === 'ENTITLEMENT_MISSING') { // the workspace hasn't activated attribution // $e->details()['capability'] === 'attribution' } } catch (DecisaRateLimitException $e) { // 429 — retries were exhausted. Back off and retry later. $waitSeconds = $e->retryAfter(); // ?int, from the Retry-After header } catch (DecisaServerException $e) { // 5xx — already retried; treat as transient and queue for later. } catch (DecisaException $e) { // catch-all (e.g. a network error after retries). }
| Exception | HTTP | Notes |
|---|---|---|
DecisaValidationException |
422 | ->fields() returns ['<field>' => ['msg', ...]] |
DecisaAuthException |
401 | key missing / invalid / revoked / expired |
DecisaForbiddenException |
403 | ->code() is INSUFFICIENT_SCOPE or ENTITLEMENT_MISSING |
DecisaRateLimitException |
429 | ->retryAfter() returns ?int seconds |
DecisaServerException |
5xx | transient |
InvalidArgumentException |
— | client-side guard (e.g. a float value_cents); thrown before the wire |
The SDK automatically retries on network errors, 5xx, and 429 with exponential backoff plus jitter (honoring the Retry-After header on 429). It never retries other 4xx responses — those are deterministic client errors.
This is safe because the API is idempotent:
- Conversions dedupe on
external_id. Re-sending the sameexternal_idreturns the stored record and still responds201— a duplicate is not an error. - Events dedupe on
event_id. Re-sending the sameevent_idreturns the stored record and still responds202.
So you can safely retry a request whose response you never received without double-counting. Always reuse a stable external_id / event_id per logical event.
use Decisa\Client; $decisa = new Client('dcs_ak_...', [ 'base_url' => 'https://api.decisa.ai', // default 'timeout' => 10.0, // seconds, default 10 'max_retries' => 2, // default 2 // Optional: inject your own PSR-18 client / PSR-17 factories. // 'http_client' => $psr18Client, // 'request_factory' => $psr17RequestFactory, // 'stream_factory' => $psr17StreamFactory, ]);
Inject a PSR-18 client (for example Guzzle's MockHandler) so no real network calls are made:
use Decisa\Client; use GuzzleHttp\Client as GuzzleClient; use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\HandlerStack; use GuzzleHttp\Psr7\Response; $mock = new MockHandler([ new Response(201, [], json_encode([ 'data' => ['conversion' => ['id' => 'conv_test', 'external_id' => 'order-1', 'attributed' => false]], 'meta' => null, 'error' => null, ])), ]); $guzzle = new GuzzleClient(['handler' => HandlerStack::create($mock), 'http_errors' => false]); $decisa = new Client('dcs_ak_test', ['http_client' => $guzzle]); $result = $decisa->conversions()->record(['type' => 'sale', 'externalId' => 'order-1']);
A Laravel bridge (service provider + facade for auto-wired config and a Decisa::conversions() facade) is a planned fast-follow. The core SDK here stays framework-agnostic; the bridge will be a thin, optional package on top of it.
MIT. See LICENSE.