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-phpThe 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/guzzleuse 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, // R$19.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` payloadvalue_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.