Stripe for omnisense/omnitrade: cards and wallets through Checkout (Stripe's hosted page), what was paid, refunds, the webhook's events, the catalogue kept in Stripe's Products and Prices, Connect (Express accounts and destination charges) and subscriptions (Checkout in subscription mode, the customer portal).
The payments go through Omnipay's omnipay/stripe (its
Checkout and PaymentIntents gateways), on the application's HTTP client through a PSR-18 bridge;
the rest are plain calls of Stripe's REST API on that same client. Everything is on
https://api.stripe.com (Omnitrade\Stripe\Api::BASE_URI): Stripe has no sandbox address, a test
key (sk_test_...) is test mode.
| Operation | Stripe API | Path | Through |
|---|---|---|---|
| purchase | Checkout Sessions, mode=payment |
POST /v1/checkout/sessions |
omnipay |
| fetch | Checkout Sessions | GET /v1/checkout/sessions/{id} |
omnipay |
| refund | the session, its payment intent, then the charge | GET /v1/checkout/sessions/{id}, GET /v1/payment_intents/{id}, POST /v1/charges/{id}/refund |
omnipay |
| products | Products, Products search, Prices | GET /v1/products, GET /v1/products/search, GET /v1/products/{id}, GET /v1/prices |
REST |
| connected accounts | Accounts, Account Links | POST /v1/accounts, POST /v1/account_links, GET /v1/accounts/{id} |
REST |
| subscriptions | Checkout Sessions, mode=subscription; Subscriptions; Billing portal |
POST /v1/checkout/sessions, GET/POST/DELETE /v1/subscriptions/{id}, POST /v1/billing_portal/sessions |
REST |
Installation
composer require omnitrade/stripe
Requires PHP 8.2, ext-bcmath, omnisense/omnitrade (1.x), symfony/http-client (6.4, 7 or 8),
omnipay/stripe (^3.2) and omnipay/common (^3.0), with nyholm/psr7, php-http/discovery,
psr/http-client and psr/http-factory for the PSR-18 bridge. Omnipay requires
symfony/http-foundation for its own request object: installed with it, loaded only when a
payment is made, a library there with no kernel and no container. In a Symfony application with
Omnitrade\Bridge\Symfony\OmnitradeBundle, the factory is registered as soon as the package is
installed.
$gateway = (new StripeGatewayFactory($http))->create(['api_key' => '...', 'webhook_secret' => '...']); // $http: the application's HTTP client; none given, the factory makes its own
Credentials
- A secret key from the Stripe Dashboard (Developers → API keys):
sk_test_...in test mode,sk_live_...live. A restricted key works for what it is allowed (the catalogue's reads need Products and Prices). - A webhook endpoint for the events below; its signing secret (
whsec_...) iswebhook_secret. Locally,stripe listen --forward-to <your webhook URL>prints one. - For Connect, Connect activated on the platform's account (Dashboard → Connect), with Express accounts allowed in the holders' countries; for the subscription portal, the customer portal set up once (Billing → Customer portal).
Options
| Option | Type | Default | Required | |
|---|---|---|---|---|
api_key |
string | - | yes | the secret key |
webhook_secret |
?string | null |
for notify() |
the endpoint's signing secret; without it every webhook is an InvalidNotificationException |
adaptive_pricing |
bool | false |
no | true: Stripe may offer the buyer their own currency on Checkout. Always sent (adaptive_pricing[enabled]=false by default): the session is paid in the payment's currency |
payment_methods |
list of strings | [] |
no | the Checkout methods to insist on (['card', 'sepa_debit'], payment_method_types), and what paymentMethods() lists; []: Stripe's choice |
active_only |
bool | false |
no | true: fetchProducts() lists the active products only (catalogue) |
omnitrade:
gateways:
card:
factory: stripe
options:
api_key: '%env(STRIPE_API_KEY)%' # sk_live_... / sk_test_...
webhook_secret: '%env(STRIPE_WEBHOOK_SECRET)%' # whsec_..., for notify()
adaptive_pricing: false # true: the buyer's own currency may be offered
payment_methods: [] # ['card', 'sepa_debit']: the methods to insist on
What a Payment is sent as: reference is client_reference_id; customer->email
customer_email; the lines (when they and shipping add up to the amount, with no discount)
line_items with their name, image, unit amount and quantity, and shipping the session's one
shipping rate (shipping_options[0][shipping_rate_data], a fixed_amount named "Shipping"), else
one line of the amount named by description (else reference); returnUrl the
success_url, with session={CHECKOUT_SESSION_ID} added to its query; cancelUrl the
cancel_url (else returnUrl with cancel=1); idempotencyKey the Idempotency-Key header;
method payment_method_types (one method, over the payment_methods option); notice
custom_text[submit][message] and the payment intent's description; metadata, as strings;
locale its first two letters; destination and applicationFee a destination charge
(Connect). Not sent: the customer's other fields, a line's
sku, reference and physical, discount as such (the amount is one line then, below).
Capabilities
| Request | Supported |
|---|---|
Purchase |
yes (a Checkout session) |
Authorize, Capture, VoidAuthorization |
no: Checkout takes the money when the buyer pays |
Refund |
yes |
FetchTransaction |
yes (the session) |
GetPaymentMethods |
yes (a list from the options) |
Notify |
yes |
FetchOrder |
no |
FetchProducts, FetchProduct |
yes (Products and Prices) |
FetchInventory |
no: Stripe counts no stock |
AffiliateLink |
no |
CreateAccount, AccountLink, FetchAccount |
yes (Connect) |
Subscribe, FetchSubscription, CancelSubscription, SubscriptionPortal |
yes |
Behaviour
Purchase. A Checkout session in payment mode, answered PENDING with the session's url as
redirectUrl. A payment without returnUrl is a ProviderException before any call (A Checkout session needs a returnUrl: where Stripe sends the buyer back.); an answer without an id or a
url is one too (Stripe opened no Checkout session., or Stripe's error message). The session
always charges the payment's amount. The lines are listed when Payment::linesAddUp() and there is
no discount, the shipping on top of them as the session's shipping rate (two lines of 12.00 EUR
and 3.50 EUR of shipping: 27.50 EUR). With a discount, the amount is one line: Checkout's line
items take no negative amount, and its discounts take only a coupon or a promotion code made
beforehand (Create a Checkout Session).
Fetch. The session as Stripe has it: payment_status paid or no_payment_required is PAID,
status expired EXPIRED, anything else PENDING with the session's url. The transaction's
message is the session's status, method its first payment_method_types, metadata its
metadata, createdAt its created. The fetch asks Stripe to expand the session's payment intent
and its latest charge (expand[]=payment_intent.latest_charge): a refund is read from the
charge's amount_refunded (or the intent's, when given), as REFUNDED, or PARTIALLY_REFUNDED when
less than the session's amount_total, with refunded.
Refund. The reference is the session's id (cs_...), a payment intent's (pi_...) or a
charge's (ch_...). From a session, the session is read for its payment intent, the intent for
its latest_charge (else its first charge), and the charge is refunded: with an amount through
omnipay (POST /v1/charges/{charge}/refund, the amount in minor units), without one in full
(POST /v1/refunds with the charge alone: Stripe refunds what is left of it,
Create a refund); the refund's Idempotency-Key
is the request's key. Stripe's succeeded is REFUNDED, any other status PENDING; failed and
canceled are ProviderExceptions. The reason is not sent. The currency is the payment's (EUR
for a bare ch_... without an amount).
Payment methods. No call: the payment_methods option, else card, apple_pay, google_pay,
link. Labels and kinds come from a table of eleven codes (card; apple_pay, google_pay,
link, paypal wallets; sepa_debit, bancontact, ideal, eps banks; klarna,
afterpay_clearpay buy now pay later); another code is labelled from its name, kind other.
Given an amount, its currency narrows the list: sepa_debit, bancontact, ideal and eps take
euros only and are left out of an amount in another currency (payment method
support); the country is
not read, Stripe choosing on its page by the buyer's.
Notify. Stripe-Signature is checked first: t=<timestamp>,v1=<signature>, the HMAC-SHA256
of <timestamp>.<body> with webhook_secret, any v1 matching, the timestamp within 300
seconds. Then the event:
| Event | status |
Carries |
|---|---|---|
checkout.session.completed, checkout.session.async_payment_succeeded |
PAID when the session's payment_status is paid, else PENDING |
transaction (the session) |
checkout.session.expired |
EXPIRED | transaction |
checkout.session.async_payment_failed |
REFUSED | transaction |
account.updated |
- | account |
customer.subscription.created, .updated, .deleted |
- | subscription |
product.created, product.updated |
- | product, with its active prices (one call) |
price.created, price.updated, price.deleted |
- | product: the price's product read afresh (two calls); reference the product's id |
product.deleted |
- | reference the product's id, no product |
any other (invoice.paid, invoice.payment_failed...) |
- | raw |
reference is the event's object's id; id the event's. A body that is not a Stripe event is a
ProviderException. Subscribe the endpoint to checkout.session.completed,
checkout.session.async_payment_succeeded, checkout.session.expired,
checkout.session.async_payment_failed, and to the catalogue's, Connect's and subscriptions'
events when used.
Calls. omnipay sends the secret key as HTTP basic authentication; the REST calls as a bearer
token, form-encoded (a[b][c]=..., booleans true/false, null values left out), with a
30-second timeout. No token, no state kept between calls.
The catalogue, Connect and subscriptions: Stripe's catalogue, Connect and subscriptions.
Example
<?php
require __DIR__.'/vendor/autoload.php';
use Omnitrade\Model\Customer;
use Omnitrade\Model\Line;
use Omnitrade\Model\Money;
use Omnitrade\Model\Payment;
use Omnitrade\Stripe\StripeGatewayFactory;
use Symfony\Component\HttpClient\HttpClient;
$stripe = (new StripeGatewayFactory(HttpClient::create()))->create([
'api_key' => getenv('STRIPE_API_KEY'), // sk_test_...
'webhook_secret' => getenv('STRIPE_WEBHOOK_SECRET'),
]);
$transaction = $stripe->purchase(new Payment(
amount: Money::of(2750, 'EUR'),
reference: 'ORDER-1042',
customer: new Customer(email: 'camille@example.org'),
lines: [new Line('Margaux 2019', Money::of(1200, 'EUR'), 2), new Line('Colissimo', Money::of(350, 'EUR'))],
returnUrl: 'https://shop.example/paid', // Stripe comes back with ?session=cs_...
idempotencyKey: 'ORDER-1042-attempt-1',
locale: 'fr',
));
header('Location: '.$transaction->redirectUrl);
// on https://shop.example/paid?session=cs_...
$paid = $stripe->fetch($_GET['session'])->isPaid();
// later, part of it back
$stripe->refund($transaction->reference, Money::of(500, 'EUR'), idempotencyKey: 'ORDER-1042-refund-1');
The same flow on recorded answers, offline, with its output, is in the core's manual
(payments). The
core's Docker harness calls the gateway with the keys in docker/.env:
docker compose run --rm omnitrade purchase stripe --amount 1990.
Errors
Every failure is an Omnitrade\Exception\ProviderException whose provider is stripe:
- an HTTP status of 400 or more on a REST call: Stripe's
error.message, itserror.codeinproviderCode(resource_missing); - a body that is not JSON:
Stripe answered HTTP <status> with a body that is not JSON.; - a network failure, on a REST call or through omnipay:
Stripe request failed: ..., a copy of the HTTP client's exception asprevious; - omnipay's answers without what is needed:
Stripe opened no Checkout session.,Stripe knows no such session.,Stripe refused the refund.,Stripe knows no payment for this session.,Stripe knows no charge for this payment.; - a webhook:
InvalidNotificationExceptionfor a missing secret, an unsigned or too old event (The notification is unsigned, or too old.) or a signature that does not match.
A missing api_key is an InvalidConfigException when the gateway is built; Authorize,
Capture, VoidAuthorization, FetchOrder, FetchInventory and AffiliateLink raise
RequestNotSupportedException before any call. fetchProduct() of an unknown id (Stripe's
resource_missing), or of an address, is null. A request omnipay refuses before sending it is a
ProviderException too (Stripe request not sent: ...).
No failure carries the secret key: a network failure's message has them masked as *** (the key
parameters of a URL too), its previous is a copy of the HTTP client's exception (the same class and
code, without its request), and no trace of the chain keeps its arguments, where the request's
options stood (No secret in a
failure).
Verified, and not
Tested offline, 38 tests on answers shaped as Stripe's API reference gives them:
Tests/StripeGatewayTest.php (a Checkout session opened, a session fetched, a refund on its
charge with its idempotency key, a refund without an amount in full, a refunded session fetched REFUNDED, the shipping as Checkout's
shipping rate and a discount charged as one line, a webhook checked then read, the payment methods),
Tests/StripeConnectTest.php (an Express account opened then completed on Stripe's page, a
payment sent to a connected account with the platform's fee, the fee's checks, Stripe's errors, a
subscription started on Checkout, read, stopped and managed, the account and subscription events),
Tests/StripeCatalogueTest.php (no stock, pages, updatedSince among each page, search, a variant
per price, products without prices and recurring prices, addresses and unknown ids, product and
price events) and Tests/StripeContractTest.php, the family's conformance suite. The suite found
omnipay's network failures escaping as omnipay's exception; they now go through
Api::sendOmnipay().
Not verified: no call was made to Stripe, in test mode or live; no recorded answer was captured from a real account (the shipping rate, the expanded charge and the full refund follow Stripe's API reference).
Responsibility
An independent, unofficial client, not affiliated with, endorsed by or sponsored by Stripe, Inc. Whoever runs it complies with Stripe's terms (Stripe Services Agreement) and obtains what they require: see the README's Responsibility section.
Propose a change docs/index.md
Catalogue
The products kept in Stripe (Products and their Prices), read through the
omnitrade catalogue contract.
Stripe counts no stock: supports(FetchInventory::class) is false, fetchInventory() throws
RequestNotSupportedException, and the stock stays the site's.
Setting it up
composer require omnitrade/stripe
omnitrade:
gateways:
card:
factory: stripe
options:
api_key: '%env(STRIPE_API_KEY)%' # a secret or restricted key reading Products and Prices
webhook_secret: '%env(STRIPE_WEBHOOK_SECRET)%' # for the product and price events
active_only: false
| Option | Default | |
|---|---|---|
active_only |
false |
true: fetchProducts() lists the active products only (active=true, or active:"true" in a search). Otherwise the archived ones come too, as Product::ARCHIVED. |
The catalogue's reads are plain GETs on https://api.stripe.com through the HTTP client the
factory is given (Authorization: Bearer <api_key>); the payments still go through omnipay.
Requests
| Request | What is sent |
|---|---|
fetchProducts(?cursor, ?updatedSince, ?query, limit) |
GET /v1/products?limit=limit (≤ 100)&starting_after=cursor&expand[]=data.default_price; the next cursor is the page's last product id while has_more. With a query: GET /v1/products/search?query=name~"<query>" (name:"<query>" under three characters, Stripe's minimum for a substring), the cursor being next_page. Then, for each product, GET /v1/prices?product=<id>&active=true&limit=100. |
fetchProduct($reference) |
GET /v1/products/<prod_…>?expand[]=default_price, then its prices. An address answers null (Stripe hosts no product pages), as an unknown id does. |
fetchInventory() |
not supported |
updatedSince: Stripe's search does not know a product's update date (its product fields are
active, description, metadata, name, shippable, url), so the products of each page
whose updated is earlier are left out. A page may then come back short, or empty with a next
cursor: keep paging while hasMore(). A new or changed price does not change its
product's updated: the price.* webhooks are what tell of those. Pass the same updatedSince
and query with the cursor.
Search is eventually consistent (a minute or so), limited to 20 requests a second, and not offered to accounts in India.
What is mapped how
| Stripe | omnitrade |
|---|---|
id (prod_…) |
Product::$reference |
name |
title |
description (plain text) |
description, made HTML (escaped, line breaks as <br>) |
metadata |
attributes, all of it |
metadata.brand |
brand |
metadata.categories (or category), metadata.tags |
categories, tags (comma-separated) |
metadata.slug (or handle) |
handle |
images[] |
media (images) |
url |
url, and each offer's |
active |
Product::ACTIVE, else ARCHIVED |
updated |
updatedAt |
package_dimensions.weight (ounces) |
each variant's weight, in grams |
each active price, default_price first |
a variant: reference the price's id; title its nickname, else its metadata title/name/variant, else (several prices) "24.00 EUR / month"; an only price has no title |
price unit_amount (or unit_amount_decimal), currency |
Offer price, with reference the price id; prices chosen by the buyer or tiered are skipped |
price tax_behavior |
taxIncluded: inclusive true, exclusive false, else null |
price metadata |
the variant's attributes (its sku the variant's SKU; an only price takes the product's metadata.sku) |
price recurring |
the variant's attributes.recurring (month, 3 month) |
| no price at all | one variant, the product's id, no offer |
No stock on any variant (ProductVariant::$stock is null), no options.
Webhooks
Add product.created, product.updated, product.deleted, price.created, price.updated,
price.deleted to the webhook endpoint; notify() checks Stripe-Signature as for Checkout.
| Event | Notification |
|---|---|
product.created, product.updated |
$product: the event's product with its active prices (one call). |
price.created, price.updated, price.deleted |
$product: the price's product, read afresh with its prices (two calls); $reference the product's id. |
product.deleted |
$product null, $reference the product's id. |
Examples
$page = $stripe->fetchProducts(updatedSince: $lastSync);
while (true) {
foreach ($page->products as $product) {
// $product->brand, $product->variants[0]->offer()->reference (the price to sell at)
}
if (!$page->hasMore()) {
break;
}
$page = $stripe->fetchProducts($page->next, updatedSince: $lastSync);
}
$product = $stripe->fetchProduct('prod_Q1x');
$stripe->supports(\Omnitrade\Request\FetchInventory::class); // false
With the harness of omnisense/omnitrade (core/docker):
docker compose run --rm omnitrade catalogue stripe --query=margaux
docker compose run --rm omnitrade catalogue stripe --product=prod_Q1x
Propose a change docs/catalogue.md
Connect and subscriptions
The contract is omnisense/omnitrade's (connected accounts, subscriptions); this is what Stripe is asked.
Connect
The platform's own secret key does everything: no Stripe-Account header, no
OAuth. Connect must be activated on the platform's Stripe account (dashboard →
Connect), with Express accounts allowed in the holders' countries.
| Request | Call | Notes |
|---|---|---|
CreateAccount |
POST /v1/accounts |
type=express, country, email, business_type, capabilities[<name>][requested]=true (transfers by default), metadata; the request's idempotency key as Idempotency-Key |
AccountLink |
POST /v1/account_links |
type=account_onboarding (or account_update), return_url, refresh_url |
FetchAccount |
GET /v1/accounts/{id} |
charges_enabled, payouts_enabled, details_submitted, requirements.currently_due + past_due |
A Payment with a destination makes the Checkout session a destination
charge: payment_intent_data[transfer_data][destination] and, with an
applicationFee, payment_intent_data[application_fee_amount]. The charge is
the platform's (its name on the statement, its Stripe fees, its disputes); the
amount less the fee is transferred to the connected account at once. A refund
through refund() refunds the charge; it does not reverse the transfer nor
give the fee back (do those in Stripe's dashboard when needed).
In some countries a destination charge needs the card_payments capability
with transfers: pass capabilities: ['transfers', 'card_payments'].
Subscriptions
| Request | Call |
|---|---|
Subscribe |
POST /v1/checkout/sessions, mode=subscription; one line: the request's price, or price_data (the payment's amount, recurring[interval], [interval_count], the description as the product's name); subscription_data[metadata], [trial_period_days]; customer or customer_email |
FetchSubscription |
GET /v1/subscriptions/{id} |
CancelSubscription |
POST /v1/subscriptions/{id} with cancel_at_period_end=true, or DELETE to stop at once |
SubscriptionPortal |
POST /v1/billing_portal/sessions (customer, return_url, locale): the portal must be set up once in the dashboard (Billing → Customer portal) |
The period's dates are read from the subscription, or from its first item (where Stripe's newer API versions keep them).
What is mapped how
Model\Account, from Stripe's account:
| Stripe | omnitrade |
|---|---|
id (acct_...) |
reference |
type |
type (express when absent) |
country, email |
country, email |
details_submitted, charges_enabled, payouts_enabled |
detailsSubmitted, chargesEnabled, payoutsEnabled (isReady(): both enabled) |
requirements.currently_due + requirements.past_due |
requirements, once each |
default_currency |
defaultCurrency, upper-cased |
metadata |
metadata |
Model\Subscription, from Stripe's subscription:
| Stripe | omnitrade |
|---|---|
id (sub_...) |
reference |
status active, trialing, past_due, unpaid, paused |
the same constant |
status canceled, incomplete_expired |
Subscription::CANCELLED |
any other status (incomplete) |
Subscription::INCOMPLETE |
customer (an id, or an expanded customer's id) |
customer |
the first item's price unit_amount times its quantity, its currency |
amount |
the first item's price recurring.interval, recurring.interval_count |
interval, intervalCount |
current_period_start, current_period_end (else the first item's) |
currentPeriodStart, currentPeriodEnd |
cancel_at_period_end, canceled_at |
cancelAtPeriodEnd, cancelledAt |
the first item's price id |
price |
metadata |
metadata |
Subscribe also sends client_reference_id (the payment's reference), locale (its first two
letters), the payment's metadata on the session and in subscription_data[metadata], the
payment's description as subscription_data[description], and the request's customer (then no
customer_email) or the payment's customer's email. A payment without returnUrl is a
ProviderException before any call; the success and cancel addresses are made as for a purchase
(?session={CHECKOUT_SESSION_ID}, ?cancel=1). The session is answered PENDING with its url.
The payment's lines, method, notice, destination and Stripe's payment_methods and
adaptive_pricing options are not sent for a subscription.
Events
Forward these to the gateway's webhook, the Connect ones included
(stripe listen --forward-to … --forward-connect-to …):
| Event | Notification |
|---|---|
account.updated |
$account, $reference = acct_… |
checkout.session.completed of a subscription |
$transaction PAID; raw['subscription'], raw['customer'] |
customer.subscription.created / .updated / .deleted |
$subscription, $reference = sub_… |
invoice.paid, invoice.payment_failed |
no status: read $raw (the subscription's own events say what changed) |
Not verified against Stripe's live API here: the package's tests run on
recorded answers (Tests/StripeConnectTest.php).