SUQODocs
User GuideAPI ReferenceSDKs

Handle errors

The SuqoError hierarchy in the SUQO PHP SDK.

Every failure the SDK raises derives from Suqo\Exception\SuqoError, which extends \RuntimeException. A single catch (SuqoError) is total — configuration faults included. Check the type, never the message string or the raw response shape; neither is part of the contract.

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()}");
}

Order matters: catch the specific types first, then SuqoError as the backstop.

The hierarchy

ClassWhen
AuthenticationError401 — a bad, revoked or wrong-environment key
KycRequiredError403 with a KYC-shaped body; carries kycStatus
PermissionDeniedError403 without one — the key is valid, the account may not use this endpoint
ValidationError400 — the only type that populates fieldErrors
NotFoundError404 — including an id from the other environment
RateLimitError429 — retryAfter when the server sent one
ServerError5xx, and any status the table does not name
NetworkErrortransport failure, DNS, TLS or timeout (status 0)
CancelledErroryour Cancellation token was tripped (status 0)
SuqoConfigErrorbad configuration, before any request exists (status 0)

The Error suffix is kept rather than PHP's Exception idiom so the type names read the same across SUQO's SDKs. Every one is still a \Throwable.

What's on every error

PropertyTypeNotes
statusintHTTP status, or 0 for a non-HTTP failure. Also getCode().
requestIdstringThe X-Request-Id actually sent on the failing attempt — quote it in a support ticket. Empty when the error predates a request.
rawBodymixedThe parsed body, wire names preserved. Null when there was none or it was not JSON.
fieldErrorsarray<string, list<string>>Always present, empty when not applicable.
retryAfter?floatSeconds, from the Retry-After header.

getMessage() carries the server's message when the body supplied one, else a per-status default.

ValidationError — keys are wire names

fieldErrors is populated only for a 400 with a field-shaped body, and its keys are wire names (pbp_id, billing_email), not the SDK's surface names — the body is reported as it arrived.

$emailProblems = $e->fieldErrors['email'] ?? [];

getMessage() is the first message in the body's own key order.

Two kinds of 403

KYC is the narrower case and is checked first, so a genuine KYC response raises KycRequiredError with kycStatus populated; anything else 403 raises PermissionDeniedError. Catch both if you want to handle every 403.

ValidationError on a bare list of messages

Four write methods — subscriptions->cancel(), ->resume(), ->renew() and checkoutSessions->create()'s state-conflict cases — report an illegal state transition as a bare list of strings rather than a field-keyed object, because none of those calls has a request body to key a message on. The first message becomes getMessage(); fieldErrors stays empty, same as any 400 that isn't field-shaped.

use Suqo\Exception\ValidationError;

try {
    $suqo->subscriptions->resume($id);
} catch (ValidationError $e) {
    echo $e->getMessage(), PHP_EOL;   // e.g. "This subscription is already cancelled."
    // $e->fieldErrors is empty here — there was no field to key it on.
}

subscriptions->create() has one case of the same shape: ["You already have a subscription to this product."], raised when the customer already holds a live subscription on another billing period of that product.

404 isn't always a mistake

Two places where a 404 is the routine end of something's life rather than an error to debug:

  • checkoutSessions->read() serves a session only while it is open. Once it is paid, or once it has expired, reading it answers 404 — and that body carries the session's own return_url beside detail whenever the session existed, which is enough to send the buyer onward:

    use Suqo\Exception\NotFoundError;
    
    try {
        $detail = $suqo->checkoutSessions->read($publicId);
    } catch (NotFoundError $e) {
        $returnUrl = is_array($e->rawBody) ? ($e->rawBody['return_url'] ?? '') : '';
    }

    rawBody keeps wire names, so it is return_url there, not returnUrl.

  • subscriptions->updateBillingCycle() never 404s: it names the subscription in the body rather than the path, so an unknown or inactive id comes back as a 400 keyed on subscription_id instead.

Rate limiting is real for one endpoint

checkoutSessions->create() is the one rate-limited call in the SDK today — 20 sessions a minute per account by default. Past that, the API answers 429 and RateLimitError carries retryAfter from the response header:

use Suqo\Exception\RateLimitError;

try {
    $session = $suqo->checkoutSessions->create($params);
} catch (RateLimitError $e) {
    sleep((int) ceil($e->retryAfter ?? 1.0));
}

It is a write, so it is not retried automatically — see Create checkout sessions. Nothing else in the SDK enforces a published quota yet; see Not yet implemented.

Configuration errors are inside the tree

SuqoConfigError is raised by new SuqoClient(...) and Config::resolve(...), and sits inside the SuqoError tree so one catch-all covers startup and runtime. status is 0, requestId is '' and rawBody is null, because no request exists — it is a deployment mistake rather than an API outcome.

Writes aren't automatically retried

GET requests are retried up to maxRetries times on a network failure, a timeout, a 429 or a 5xx. Writes — create, cancel, resume, renew, updateBillingCycle, and every checkoutSessions or webhooks call that changes state — are not, pending idempotency keys, because a blindly-retried write could double-act.

So a NetworkError on a write means the request may or may not have landed. Reconcile — list the subscription, check the webhook — rather than resending blindly. Not yet implemented covers what changes when idempotency keys ship.

CancelledError is never retried, and is deliberately distinct from the NetworkError a timeout produces: "I stopped it" and "the network stopped it" stay separable.

On this page