Manage subscriptions
Creating, listing, reading, cancelling, resuming and rescheduling subscriptions with the SUQO PHP SDK.
Eight operations on $suqo->subscriptions: list a page, iterate every page, read one, create, cancel, resume, renew, and move the next billing date.
Creating one
use Suqo\Params\CreateSubscriptionParams;
use Suqo\Params\CustomerBilling;
use Suqo\Params\CustomerInput;
use Suqo\Params\CustomerShipping;
$created = $suqo->subscriptions->create(new CreateSubscriptionParams(
pbpId: 'pbp_3n9k2x',
customer: new CustomerInput(
phone: '9800000001',
fullName: 'John Doe',
email: '[email protected]',
address: 'Kathmandu, Nepal',
billing: new CustomerBilling(
billingBusinessName: 'ABC Pvt Ltd.',
billingEmail: '[email protected]',
billingAddress: 'Kathmandu, Nepal',
billingPanVat: '111111111',
),
shipping: new CustomerShipping(
phone: '9800000001',
fullName: 'John Doe',
email: '[email protected]',
),
),
returnUrl: 'https://merchant.example.com/thanks',
));
echo $created->checkoutUrl, PHP_EOL; // send the buyer here
echo $created->subscriptionId, ' ', $created->status?->value, PHP_EOL;pbpId comes from the catalog — see Browse the catalog.
The return is not proof of payment. The 201 body is its own schema, not an echo of the request: CreateSubscriptionResponse carries subscriptionId, pbpId, status, checkoutUrl, nextBillingCycle and createdAt, and neither return_url nor the customer comes back. The real outcome arrives on the checkout.succeeded / checkout.failed webhooks.
The SDK says customer; the wire says client
The rename is applied in both directions at the serialisation boundary. A raw error body, and anything read back through toArray(), always keeps the wire name:
$subscription->customer?->email; // typed accessor
$subscription->toArray()['client']['email'] ?? null; // the same value, wire namestoWire() is public, so you can see exactly what will be sent without making a request — handy when a ValidationError names a key you did not expect:
echo json_encode($params->toWire(), JSON_PRETTY_PRINT);
// {"pbp_id":"pbp_3n9k2x","client":{"phone":"…","full_name":"…","email":"…"}}Sending a field the SDK does not type yet
Every params object ends with extra, merged into the serialised object under wire names, verbatim. A declared key always wins over an extra key of the same name.
new UpdateBillingCycleParams(
subscriptionId: '3fa85f64-…',
nextBillingCycle: '2026-09-03',
extra: ['reason' => 'customer request'],
);Listing
$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, PHP_EOL;
echo ' next billing ', $subscription->nextBillingCycle, PHP_EOL;
}SubscriptionPage is a page plus four account-level counters — totalSubscriptions, activeSubscriptions, dueSubscriptions, inactiveSubscriptions. They live on the page, so they are not reachable through autoPaging(); call list() when you want them.
Reading one
use Suqo\Model\SubscriptionStatus;
$subscription = $suqo->subscriptions->read('3fa85f64-5717-4562-b3fc-2c963f66afa6');
$status = $subscription->status;
echo $status instanceof SubscriptionStatus ? $status->value : ($status ?? '-'), PHP_EOL;
echo $subscription->customer?->email ?? '-', PHP_EOL;A 404 here also covers an id belonging to another environment — the two look identical.
Nothing on the record says whether it is recurring. Match $subscription->product?->pbpId against products->list() and read that billing period's intervalType: one_time means the subscription cannot be cancelled, resumed or rescheduled, and is left out of the counters on the list response.
Cancelling
$result = $suqo->subscriptions->cancel('3fa85f64-5717-4562-b3fc-2c963f66afa6');
echo $result->message, PHP_EOL;Cancellation is scheduled, not immediate: the subscription stays usable until the end of the current billing period. A 404 here also covers an id belonging to the other environment — it looks identical. An already-cancelled or pending-cancellation subscription is returned unchanged with a 200, so a retry is safe. A one-time purchase has nothing to cancel and answers 400.
Resuming
$suqo->subscriptions->resume('3fa85f64-5717-4562-b3fc-2c963f66afa6');Undoes a scheduled cancellation and returns the subscription to active. An already-active subscription is returned unchanged, so a retry is safe.
Throws ValidationError on 400 when the subscription is a one-time purchase, its product or billing period is no longer available, or its status has no resume transition — already cancelled is the common one. Unlike create(), this 400 is a bare list of messages rather than a field-keyed object — there is no request body to key a message on. The SDK surfaces the first message as getMessage(); fieldErrors stays empty. See Handle errors.
Renewing
Opens a checkout session for the next payment on an existing subscription, reusing its customer and billing period rather than resubmitting them:
$session = $suqo->subscriptions->renew('3fa85f64-5717-4562-b3fc-2c963f66afa6');
echo $session->checkoutUrl, PHP_EOL; // send the buyer here to payReturns a Suqo\Model\CheckoutSession — the same type checkoutSessions->create() returns. See Create checkout sessions for reading one back while it is open. Throws NotFoundError on 404; ValidationError on 400 (again a bare list) when the subscription is cancelled or archived, or is a paid one-time purchase. Not retried — it is a write.
Changing the billing date
use Suqo\Params\UpdateBillingCycleParams;
$suqo->subscriptions->updateBillingCycle(new UpdateBillingCycleParams(
subscriptionId: '3fa85f64-5717-4562-b3fc-2c963f66afa6',
nextBillingCycle: '2026-09-03',
));The subscription id travels in the body for this one, not the path. nextBillingCycle is YYYY-MM-DD and stays a string — the SDK does no date parsing, for the same reason it does no decimal parsing.
Status values
Known values arrive as a SubscriptionStatus case; a value the server adds later arrives as the raw string rather than failing the read. That is why $subscription->status is typed SubscriptionStatus|string|null, and why you narrow before matching:
use Suqo\Model\SubscriptionStatus;
$status = $subscription->status;
if ($status instanceof SubscriptionStatus) {
match ($status) {
SubscriptionStatus::Active => activate(),
SubscriptionStatus::Due => chase(),
default => null,
};
} else {
error_log("unrecognised status: {$status}");
}Cases: PendingCheckout, Active, Due, Cancelled, PendingCancellation, Inactive. The subscription statuses page in the API reference is the authority on what each one means.
Decimals and dates
price, vat, totalSubscribers and every timestamp are string end to end, never parsed into a float inside the SDK. Parse at your own boundary:
$total = bcmul($subscription->product->price, '2', 2);Retries
None of the write methods above are retried on failure — see Handle errors for why, and what changes once the backend supports idempotency keys.