sanbuk / sanbuk-php
Official PHP SDK for Sanbuk — CPA conversion tracking and server-to-server postbacks.
Requires
- php: ^8.1
README
Official PHP SDK for Sanbuk — CPA conversion tracking.
composer require sanbuk/sanbuk-php
$sanbuk = new \Sanbuk\Client('YOUR_API_KEY'); $sanbuk->postback([ 'action' => 'purchase', 'event_id' => $orderId, // your own id, never reused 'click_id' => $clickId, // the snbk_cid from the landing page 'value' => 25000000, // Rial ]);
That is the whole integration. Everything below is detail.
How it works
Two channels report the same conversion, carrying the same event_id:
- Postback (this SDK) is the financial source of truth. Only a postback can create a payable conversion.
- Pixel (the browser) is verification and anti-fraud. It never bills on its own.
Sanbuk pairs them inside a 48-hour window. Getting that pairing right is the only thing the integration asks of you.
Requirements
PHP 7.4 or newer, with ext-curl, ext-json, and ext-mbstring. No package dependencies — so it drops into a WordPress or legacy Laravel install without fighting over a Guzzle version.
Capturing the click id
Sanbuk hands you ?snbk_cid=<uuid> on your landing page. Store it next to the order or user the moment you see it — without it a conversion is recorded but can never be attributed or paid.
use Sanbuk\ClickId; // Query string first, then the cookie the pixel left behind. $clickId = ClickId::capture(); // Or explicitly: $clickId = ClickId::fromQuery($_GET) ?? ClickId::fromCookies($_COOKIE);
Malformed values return null rather than being passed through, so junk in a query string cannot turn into a 422 at checkout.
The browser pixel
use Sanbuk\Pixel; $pixel = new Pixel('SNBK-PX-XXXXX'); // In <head>, on every page: echo $pixel->loaderTag(); // On the thank-you page — same event id as the postback: echo $pixel->trackTag('purchase', [ 'event_id' => $orderId, 'value' => 25000000, ]);
The pixel id is public and safe in page source. Output is escaped, so an order id containing </script> cannot break out of the block.
Rehearsing before you go live
$sanbuk->test()->postback([ 'action' => 'purchase', 'event_id' => 'ORD-1', 'value' => 1000, ]);
A test event is stored and verifies the action in the panel, but never spends your wallet. It is a separate event from its live twin, so rehearsing with a real order id never consumes it.
Check what actually happened rather than what you intended:
$result = $sanbuk->test()->postback(['action' => 'purchase', 'event_id' => 'ORD-1']); $result->isTest(); // false means the sandbox header did not land $result->isAccepted(); // first time $result->isDuplicate(); // already seen — nothing changed $result->getEventId(); $result->getMode(); // Mode::LIVE or Mode::TEST
Configuration
use Sanbuk\Client; use Sanbuk\Config; use Sanbuk\Mode; $sanbuk = new Client('YOUR_API_KEY', new Config([ 'mode' => Mode::LIVE, 'timeout' => 5.0, 'connect_timeout' => 2.0, 'max_retries' => 3, 'retry_base_delay_ms' => 200, 'retry_max_delay_ms' => 5000, ]));
A misspelled option raises instead of being silently ignored.
Retries
Postbacks are idempotent per (action, event_id) within a mode: sending the same pair twice answers 200 and changes nothing. The SDK leans on that and retries network failures, 429, and 5xx with exponential backoff and full jitter.
Client errors — a wrong key, an undefined action — are never retried. They will be just as wrong in 200ms.
Errors
use Sanbuk\Exception; try { $sanbuk->postback([ 'action' => 'purchase', 'event_id' => $orderId, 'value' => $total, ]); } catch (Exception\ValidationFailedException $e) { $e->getErrors(); // ['params.order_id' => ['is required']] } catch (Exception\ValueRequiredException $e) { // This action prices on value. } catch (Exception\UnknownActionException $e) { // No such action code in your workspace. } catch (Exception\InvalidApiKeyException $e) { // Wrong or missing key. } catch (Exception\RateLimitException $e) { // Retries exhausted — queue it and send the same event_id later. } catch (Exception\SanbukException $e) { // Anything else from this SDK. }
Branch on $e->getErrorCode(), not on $e->getMessage(): the contract states the message is localised and may change, while the code is stable.
Bringing your own HTTP stack
use Sanbuk\Http\HttpClient; final class MyHttpClient implements HttpClient { /* ... */ } $sanbuk = new \Sanbuk\Client('YOUR_API_KEY', new Config(), new MyHttpClient());
Retries stay in the client, so every implementation retries identically.
Tests
composer install
composer test
License
Apache-2.0