suqo / sdk-php
Official PHP SDK for the SUQO API.
Requires
- php: ^8.1
- ext-curl: *
- ext-hash: *
- ext-json: *
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.58
- nyholm/psr7: ^1.8
- phpstan/phpstan: ^1.11 || ^2.0
- phpunit/phpunit: ^10.5 || ^11.0 || ^12.0
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
Suggests
- psr/http-client: Inject any PSR-18 HTTP client via Suqo\Http\Psr18HttpClient.
- psr/http-factory: Required alongside psr/http-client when using Suqo\Http\Psr18HttpClient.
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-14 10:46:55 UTC
README
Official PHP SDK for the SUQO API.
This page is the quick start. For a per-method reference — every parameter, return type and thrown exception — see docs/. For code you can run against your own sandbox, see examples/.
Requires PHP 8.1+ with ext-curl, ext-json and ext-hash.
composer require suqo/sdk-php
Quick start
use Suqo\SuqoClient; $suqo = new SuqoClient(); // api key from $SUQO_API_KEY
The environment is inferred from the key prefix — su_test_key_ is sandbox,
su_key_ is live. Passing environment: is a check, never an override: it can
only agree with the prefix or raise SuqoConfigError.
Full reference: docs/client.md.
$suqo = new SuqoClient( apiKey: 'su_test_key_…', environment: 'sandbox', // optional; must agree with the prefix timeout: 30.0, // seconds maxRetries: 2, logLevel: 'warn', // or $SUQO_LOG );
Products
Full reference: docs/products.md. Runnable: examples/list_products.php.
$page = $suqo->products->list(page: 1, pageSize: 50); echo $page->count; // total across all pages foreach ($page->results as $product) { echo $product->productId, ' ', $product->name, PHP_EOL; echo ' vat ', $product->vat?->vatPercentage ?? 'n/a', ' subscribers ', $product->totalSubscribers, PHP_EOL; foreach ($product->plan as $plan) { echo ' plan ', $plan->planId, ' ', $plan->planName, PHP_EOL; foreach ($plan->billingPeriods as $period) { // pbpId is what subscriptions->create() needs. echo ' ', $period->pbpId, ' ', $period->label, ' ', $period->price, ' ', $period->currency, PHP_EOL; } } }
Price is not on the product record — it lives on the plan billing point
($plan->billingPeriods[…]->price), and surfaces again as
$subscription->product->price once a subscription exists. vat is an object
(ProductVat) whose members are null when VAT is switched off, and
productImage is a list<ProductImage>.
Auto-paging is lazy — a page is fetched only when you exhaust the previous one:
foreach ($suqo->products->autoPaging() as $product) { echo $product->name, PHP_EOL; }
Subscriptions
Full reference: docs/subscriptions.md. Runnable: examples/create_subscription.php.
use Suqo\Params\CreateSubscriptionParams; use Suqo\Params\CustomerBilling; use Suqo\Params\CustomerInput; use Suqo\Params\CustomerShipping; use Suqo\Params\UpdateBillingCycleParams; $created = $suqo->subscriptions->create(new CreateSubscriptionParams( pbpId: 'pbp_3n9k2x', customer: new CustomerInput( phone: '9841000100', fullName: 'Ram Shrestha', email: 'ram@client.com', address: 'Kathmandu, Nepal', billing: new CustomerBilling( billingBusinessName: 'ABC Pvt Ltd.', billingEmail: 'ram@client.com', billingAddress: 'Kathmandu, Nepal', billingPanVat: '9841000100', ), shipping: new CustomerShipping( phone: '9841000100', fullName: 'Ram Shrestha', email: 'ram@client.com', ), ), returnUrl: 'https://merchant.example.com/thanks', )); // Send the buyer here to pay. The return is not proof of payment — the real // outcome arrives on the checkout.succeeded / checkout.failed webhooks. echo $created->checkoutUrl, PHP_EOL; echo $created->subscriptionId, ' ', $created->status?->value, PHP_EOL;
The 201 body is its own schema, not an echo of the request: subscriptionId,
pbpId, status, checkoutUrl, nextBillingCycle, createdAt. Neither
return_url nor client comes back. Anything the server adds beyond that is
still reachable through $created->toArray() without an SDK upgrade.
The SDK says customer; the wire says client. The rename is applied in both
directions at the serialisation boundary, and raw error bodies always keep the wire
name.
$page = $suqo->subscriptions->list(); echo $page->totalSubscriptions, ' / ', $page->activeSubscriptions, PHP_EOL; foreach ($page->results as $subscription) { echo $subscription->subscriptionId, ' ', $subscription->customer?->email, PHP_EOL; echo ' ', $subscription->product?->pbpId, ' ', $subscription->product?->price, ' ', $subscription->product?->currency, PHP_EOL; echo ' next billing ', $subscription->nextBillingCycle, PHP_EOL; echo ' billed to ', $subscription->customer?->billing?->businessName, PHP_EOL; } $suqo->subscriptions->cancel('3fa85f64-5717-4562-b3fc-2c963f66afa6'); $suqo->subscriptions->updateBillingCycle(new UpdateBillingCycleParams( subscriptionId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', nextBillingCycle: '2026-09-03', // a date, kept as a string ));
Subscription status
Known values arrive as a SubscriptionStatus case; a value the server adds later
arrives as the raw string rather than failing the read.
use Suqo\Model\SubscriptionStatus; $status = $subscription->status; if ($status instanceof SubscriptionStatus) { match ($status) { SubscriptionStatus::Active => activate(), SubscriptionStatus::Due => chase(), default => null, }; } else { log("unrecognised status: {$status}"); }
Money is a string
Every monetary and decimal value — price, vatPercentage, totalSubscribers
— is a string, end to end, and is never parsed into a float inside the SDK.
vat_percentage arrives as a JSON number and is still surfaced as a string,
without ever being cast through a float type. Dates are strings for the same
reason, and they carry microseconds and a +05:45 offset rather than Z.
Parse at your own boundary if you need arithmetic:
$total = bcmul($subscription->product->price, '2', 2);
Errors
Full reference: docs/errors.md.
Every SDK error derives from Suqo\Exception\SuqoError and carries status,
requestId, rawBody, fieldErrors and retryAfter.
use Suqo\Exception\KycRequiredError; use Suqo\Exception\RateLimitError; use Suqo\Exception\SuqoError; use Suqo\Exception\ValidationError; try { $suqo->subscriptions->create($params); } catch (ValidationError $e) { foreach ($e->fieldErrors as $field => $messages) { echo $field, ': ', implode(', ', $messages), PHP_EOL; } } catch (KycRequiredError $e) { echo $e->kycStatus; // wire `status_code` } catch (RateLimitError $e) { echo $e->retryAfter; // seconds, from Retry-After } catch (SuqoError $e) { error_log("suqo {$e->status} req={$e->requestId}: {$e->getMessage()}"); }
| Type | Raised on |
|---|---|
AuthenticationError |
401 |
KycRequiredError |
403 with a KYC-shaped body |
PermissionDeniedError |
403 without one — a plain authorization failure |
ValidationError |
400 |
NotFoundError |
404 |
RateLimitError |
429 |
ServerError |
≥ 500, and any status not mapped above |
NetworkError |
transport failure, timeout, or a refused URL (status 0) |
CancelledError |
caller cancelled (status 0) |
NotImplementedError |
a resource exposed ahead of its operations (status 0) — currently unused |
SuqoError |
the base type every one of the above derives from |
SuqoConfigError extends SuqoError and is raised during construction, before any
request exists, so it carries no status, request id or body. A single
catch (SuqoError) is therefore total across the SDK.
Retries
Full reference: docs/http.md#retrypolicy.
GET requests are retried up to twice — three attempts total — on a network
failure, a timeout, a 429 or a 5xx. Writes are not retried, pending idempotency
keys. Backoff is full jitter over 500 ms → 1 s → … capped at 8 s, and a
server-supplied Retry-After wins over the computed delay (capped at 60 s).
Absolute-URL page fetches take the same policy as page 1.
Cancellation
Full reference: docs/http.md#cancellation.
use Suqo\Cancellation; $token = new Cancellation(); pcntl_signal(SIGTERM, static fn () => $token->cancel()); foreach ($suqo->subscriptions->autoPaging(cancellation: $token) as $subscription) { handle($subscription); }
A cancelled token raises CancelledError, which is never retried and stays
distinct from the NetworkError a timeout produces. It is honoured before an
attempt, mid-flight, during backoff, and between pages.
Webhooks
Full reference: docs/webhooks.md. Runnable: examples/webhook_handler.php.
Verification needs no client, no API key and no network — call it straight from a
serverless handler. It never throws; every failure path returns false.
use Suqo\Webhook; $verified = Webhook::verify( rawBody: file_get_contents('php://input'), // the exact bytes received signature: $_SERVER['HTTP_X_SUQO_SIGNATURE'] ?? null, timestamp: $_SERVER['HTTP_X_SUQO_TIMESTAMP'] ?? null, secret: getenv('SUQO_WEBHOOK_SECRET'), ); if (!$verified) { http_response_code(400); exit; }
Pass the raw bytes. A body re-serialised from a parse will not verify — the
signature covers bytes, not structure. The comparison is constant-time; the
freshness window is 300 s back (configurable via maxAge) and a fixed 60 s
forward.
Injecting an HTTP client
Full reference: docs/http.md#injecting-a-client.
use Suqo\Http\CurlHttpClient; use Suqo\Http\Psr18HttpClient; $suqo = new SuqoClient(httpClient: new CurlHttpClient([CURLOPT_PROXY => '…'])); // Or bring your own PSR-18 client. Note that PSR-18 cannot express a per-request // timeout or mid-flight cancellation; configure the timeout on your client. $suqo = new SuqoClient(httpClient: new Psr18HttpClient($client, $requestFactory, $streamFactory));
Or implement Suqo\Http\HttpClientInterface yourself — one method, and the
transport handles everything else.
Customers
Full reference: docs/customers.md. Runnable: examples/list_customers.php.
Read-only — a customer record is created implicitly the first time someone
subscribes, through subscriptions->create()'s customer field.
foreach ($suqo->customers->autoPaging() as $customer) { echo $customer->id, ' ', $customer->buyerPhone, ' ', $customer->fullName ?? '-', PHP_EOL; } $customer = $suqo->customers->read('cus_0390b1820');
id is a prefixed public id (cus_0390b1820) — not an integer and not a UUID,
unlike the subscription ids elsewhere in the API. Every field except id,
buyerPhone and createdAt can be null.
Not yet exposed
openapi declares these operations, which this SDK does not expose. Each needs a specification revision first, because §14 forbids public surface the specification itself does not describe:
| openapi operationId | Path |
|---|---|
subscriptions_read |
GET /api/v1/subscriptions/{id}/ |
subscriptions_resume |
POST /api/v1/subscriptions/{id}/resume/ |
customers_create |
POST /api/v1/customers/ |
customers_partial_update |
PATCH /api/v1/customers/{id}/ |
webhooks_* |
the eight /api/v1/webhooks/… operations |
The Webhooks management resource is unexposed; Suqo\Webhook::verify() — which
verifies an inbound delivery and needs no network — is unrelated to it and is
fully supported.
Examples
Four runnable scripts in examples/. Each takes the key from
$SUQO_API_KEY, so none of them needs editing:
| Script | What it shows |
|---|---|
| list_products.php | Walks the catalogue and prints each plan's billing periods. Run this first — it is where pbpId comes from. |
| create_subscription.php | Creates a subscription from a pbpId and prints the checkoutUrl to send the buyer to. |
| list_customers.php | Auto-pages the customer records, then reads one back by its public id. |
| webhook_handler.php | A complete endpoint: verify the signature against the raw bytes, then acknowledge. |
SUQO_API_KEY=su_test_key_… php examples/list_products.php SUQO_API_KEY=su_test_key_… php examples/create_subscription.php pbp_3n9k2x
Playground
A local web app that drives every operation, with the API key typed into its homepage — no file in this project needs editing to switch keys or environments.
composer playground # http://127.0.0.1:8000
See examples/playground/README.md.
Development
composer install composer test # PHPUnit: the §13 conformance suite composer stan # PHPStan, level max composer lint # PHP-CS-Fixer, dry run composer invariants # the §5.1 I1/I4/I6 grep gate composer ci # all of the above
Licence
MIT.