sugarcommerce / stripe
A small Stripe client for Connect platforms: pinned API version, Stripe-Account per request, a fetch that throws instead of answering null, signed event parsing with payload redaction, and readers for the fields Stripe moves between API versions.
Requires
- php: >=8.5
- sugarcommerce/http-client: ^1.2
- sugarcommerce/webhooks: ^1.0
Requires (Dev)
- phpunit/phpunit: ^13.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A small Stripe client for Connect platforms: pinned API version, Stripe-Account per request, a fetch that throws instead of answering null, signed event parsing with payload redaction, and readers for the fields Stripe moves between API versions.
Why
A platform that calls a handful of Stripe endpoints does not need the official SDK, but the few things it does need are each easy to get quietly wrong:
- "No answer" read as "an empty answer". A webhook that treats a failed line-item read as a session with nothing in it finishes a paid order empty and acknowledges the event, so Stripe never retries.
- Fields that move. When the pinned version passed
2025-03-31.basil,current_period_endleft the subscription for its items and read asnull. Nothing failed; grace windows and lapse sweeps just switched off. - The envelope is forgotten.
accountis the only thing telling a connected account's event from the platform's own, andcreatedis the only defence against Stripe's out-of-order delivery. - Payloads kept verbatim. Stored events are useful for replay and carry names, addresses, phone numbers and tax ids nobody needed.
- The first page read as the whole list. One
GET prices?limit=100was a shop's whole catalogue, and the hundred-and-first price silently vanished. - One key for two modes. A live key cannot read a test checkout; a platform with one key fails every call for the other mode, and an account installed in a Stripe sandbox needs the sandbox's key.
- Stripe's reason thrown away.
post()answeringnullis right until a buyer is looking at the result and Stripe said exactly which item it would not take. - One install, two events. Since
2026-09-30.endiveStripe announces an app install asaccount.application.authorizedandapps.install.created(app.install.*on older endpoints) โ two ids, so event-id idempotency lets both through, and a handler that treats each as news records the install twice. - A version split that removes a value. Endive reports a platform's rejection as
rejected.platform_fraud, notrejected.fraud; a list of rejection reasons written before it stops matching without an error. - Half of Managed Payments.
managed_payments[enabled]makes Stripe the merchant of record, and a request that still sendsautomatic_tax,tax_id_collection,invoice_creation,customer_update[address]or a dozen others is refused whole โ and the lists differ between Checkout Sessions and Payment Links. - Cart rules learned from Stripe's error. One currency, one renewal schedule, item limits, pay-what-you-want alone โ a buyer should read that before a session is created, not after.
Features
- ๐ Pinned โ every request sends
Stripe-Version: Client::API_VERSION, currently2026-09-30.endive. Create your webhook endpoints at the same version: an event's shape, and for app installs its name, follow the endpoint's version, not the request's. - ๐ข Connect-first โ pass an account id and the request is made as that account.
- ๐ฅ
fetch()andattempt()throw โStripeErrorwith the path, status and Stripe's parsederror(code(),param(),type(),decline_code(),message());get()/post()answernullwhere a fallback is fine. - ๐
each()/fetch_all()โ every object in a list, followinghas_morewithstarting_after; a failed page throws rather than answering half a list. - ๐งพ
charge_for_intent(),receipt_url(),portal_url()โ one charge read for the receipt and the billing details; a billing portal session on request. - ๐
Keyringโ a client per mode (and the managed sandbox's), picked per call withKeyring::pick();Keyring::key_mode()reads a key's mode from its prefix.Keylessrefuses every call without making it, saying which key is missing. - โ๏ธ
Event::receive()/receive_any()โ verifiesStripe-Signature(HMAC + replay window, viasugarcommerce/webhooks) against one secret or several, says which one signed it, and unpacks id, type, account, mode, created and object. - ๐
Event::redact()โ strips name, phone, address and tax ids; keeps email. - ๐งญ
Shapeโ reads for fields that move or are easy to misread:period_end(),invoice_subscription(),invoice_line_price(),invoice_line_product(),invoice_payment_intents(),subscription_price(),session_email(),session_paid(),custom_field(),promotions_consent(),fully_refunded(),dispute_is_inquiry(),account_disabled_reason(),account_rejected()(Stripe's rejections and, since endive, a platform's:Shape::PLATFORM_REJECTED),entitlement_feature(),id(), and a flatprice()(withtiers(),currencies(),amounts(),custom_amount()). - ๐
Cartโ Stripe Checkout's item rules as codes, an application's own rule beside them, optional extras that Stripe will accept,line_items()and thecustomer_params()each mode takes. - ๐ซ
Refusal::classify()โ what Stripe refused, as a reason and a status an application can put into words. - ๐งพ
ManagedPaymentsโ Stripe as merchant of record:session()andlink()addmanaged_payments[enabled]and take out every parameter Stripe refuses beside it,eligible()checks a product's tax code againstELIGIBLE_TAX_CODES, andunsupported()names any refused parameter a request still carries. - ๐ฒ
AppInstall::of()โInstalled,Uninstalledornullfor any event, readingaccount.application.*,apps.install.*and the pre-endiveapp.install.*alike, and only an Install whose status isinstalledas an install;AppInstall::TYPESlists them for a dispatch. - ๐
SubscriptionStatusโ Stripe's eight statuses sorted into running, paid, lapsed and ended, withaccess_until(). - ๐
RequestDigestandAppSignatureโ verify a Stripe App's signed request, bound to its route and body, with the digest@arraypress/stripe-appbuilds in the browser.
Requirements
PHP 8.5+, sugarcommerce/http-client, sugarcommerce/webhooks
Installation
composer require sugarcommerce/stripe
Calling the API
use SugarCommerce\Stripe\{Client, StripeError}; $stripe = new Client( $secret_key ); $prices = $stripe->get( 'prices', 'acct_123', array( 'active' => 'true', 'expand' => array( 'data.product' ) ) ); $link = $stripe->post( 'payment_links', array( 'line_items' => array( array( 'price' => 'price_1', 'quantity' => 1 ) ) ), 'acct_123' ); try { $items = $stripe->session_line_items( 'cs_123', 'acct_123' ); } catch ( StripeError $e ) { http_response_code( 500 ); // Stripe will retry } // Every page of a list, not the first hundred. $all = $stripe->fetch_all( 'prices', 'acct_123', array( 'active' => 'true', 'expand' => array( 'data.product' ) ) ); // A write where Stripe's reason matters. try { $session = $stripe->attempt( 'checkout/sessions', $params, 'acct_123' ); } catch ( StripeError $e ) { $e->code(); // 'resource_missing' $e->param(); // 'line_items[0][price]' }
A path is checked before anything is sent: every segment must be a plain name or id (letters, digits, _, -), so 'prices/' . $id built from a buyer's input cannot become prices/../customers/โฆ or carry its own ?query. An account must look like acct_โฆ. Either refusal fails the way an outage does โ null from get()/post(), a StripeError from fetch()/attempt() โ and a dumped Client or Keyring shows no keys.
One key per mode
use SugarCommerce\Stripe\Keyring; $stripe = Keyring::from_keys( live: $live_key, test: $test_key, sandbox: $sandbox_key, sandboxed: fn ( string $account ): bool => $installs->is_sandbox( $account ), ); Keyring::pick( $stripe, $event->mode(), $event->account )->session_line_items( $id, $event->account ); Keyring::key_mode( 'rk_live_โฆ' ); // 'live'
A mode with no key is a Keyless: reads that may fail answer null, reads that must succeed throw, and nothing is sent to Stripe with an empty bearer.
Checkout carts
use SugarCommerce\Stripe\{Cart, Refusal, Shape}; $cart = new Cart( fn ( array $price ): ?string => 0 === $price['amount'] ? 'free' : null ); $checked = $cart->check( array_map( Shape::price( ... ), $prices ), 'gbp' ); if ( ! $checked['ok'] ) { echo $words[ $checked['code'] ]; // 'currency', 'interval', 'too_many', 'custom_alone', 'metered', 'free'โฆ } $params = Cart::line_items( $checked['prices'] ) + Cart::customer_params( $email, 'subscription' === $checked['mode'] ); try { $session = $stripe->attempt( 'checkout/sessions', $params, $account ); } catch ( StripeError $e ) { [ 'reason' => $reason, 'status' => $status ] = Refusal::classify( $e ); // 'tax_not_ready', 503 }
Managed Payments
use SugarCommerce\Stripe\{ManagedPayments, Refusal}; // The same parameters you would send anyway; the refused ones come out. $params = ManagedPayments::session( $params ); // + managed_payments[enabled]=true $link = ManagedPayments::link( $link_params ); // its own list: custom_text too ManagedPayments::eligible( $product['tax_code'] ); // txcd_10401100 โ true ManagedPayments::unsupported( $params, ManagedPayments::SESSION_UNSUPPORTED ); // [] โ assert it in a test Refusal::classify( $e )['reason']; // 'tax_code' (a product without an eligible code), 'managed_payments' (not enabled, terms, account)
The account turns it on in its Dashboard (Settings โ Managed Payments) and accepts the terms; each product needs a tax code from ELIGIBLE_TAX_CODES (digital goods and services). It is for direct integrations: Stripe does not support it for Connect platforms โ application fees, transfers, on_behalf_of โ or accounts a platform controls, so neither list has a way to add those back. Promotion codes, custom fields, consent collection and the recovery of an expired session are not on Stripe's lists and pass through. The lists are Stripe's as published on 7 October 2026; check update-checkout and use-payment-links when bumping the API version.
Subscription standing
use SugarCommerce\Stripe\SubscriptionStatus; $status = SubscriptionStatus::of( $subscription['status'] ); $status?->lapsed(); // unpaid, paused $status?->access_until( Shape::period_end( $subscription ) ); SubscriptionStatus::ENDED; // for SQL: canceled, incomplete_expired
Stripe App requests
use SugarCommerce\Stripe\{AppSignature, RequestDigest}; $digest = RequestDigest::of_request( $method, $path, $query, $body ); $proof = AppSignature::proof( $signature_header, $app_secret, $user_id, $account_id, array( 'request' => $digest ) ); // '' when it does not verify; otherwise "{timestamp}.{v1}", stable enough to spend once.
The browser half is @arraypress/stripe-app; both are tested against tests/fixtures/request-digest.json.
Receiving webhooks
use SugarCommerce\Stripe\{AppInstall, Event, InvalidEvent}; try { $event = Event::receive( $body, $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '', $webhook_secret ); } catch ( InvalidEvent $e ) { http_response_code( 400 ); exit( $e->getMessage() ); } $event->platform(); // true when it happened in your own account $event->account; // otherwise, the connected account $event->mode(); // 'live' | 'test' $event->created; // compare before applying an update $event->object; // data.object $store( Event::redact( $body ) ); // An install or an uninstall, whichever family of event said so. Both arrive for one install: // compare $event->created with the last change you recorded rather than acting on each. match ( AppInstall::of( $event ) ) { AppInstall::Installed => $installs->open( $event->account, $event->mode(), $event->created ), AppInstall::Uninstalled => $installs->close( $event->account, $event->mode(), $event->created ), null => null, // not an install event, or one still in progress }; // One URL registered as several endpoints: which secret verified it says where it came from. [ $event, $from ] = Event::receive_any( $body, $header, array( 'platform' => $ours, 'sandbox' => $sandbox_secret ) );
A verified event is genuine, not new: Stripe delivers some events more than once, and a captured request replays within the five-minute window. Record $event->id and do each event's work once.
Testing
composer test
194 tests: the client (pinning to endive, Connect headers, structured errors, attempt(), paging and its failure and runaway cases, charges, the portal), events (verification, several secrets, redaction), every Shape read in its current and pre-basil location (and endive's platform rejections), both families of app install event and the Install lifecycle, a failed tax calculation sorted as Stripe's failure, the keyring and keyless refusals, every subscription status, the cart rules and extras, refusal classification, the shared request-digest vectors and app signatures, and that every must-check result keeps #[\NoDiscard].
In your own tests, pass SugarCommerce\HttpClient\Fake as the client's HTTP client:
$http = ( new Fake() )->on( 'GET', 'https://api.stripe.com/v1/charges*', new Response( true, 200, '{"data":[]}' ) ); $stripe = new Client( 'sk_test_x', $http );
License
GPL-2.0-or-later