DELETE /v1/subscriptions/:id — immediate cancellation. Stripe ignores
idempotency keys on DELETE ("no effect" per its idempotency docs), so
none is sent; replay safety is verified instead: when the cancel call
rejects, the subscription is re-fetched and an already-canceled one
resolves as success — a replayed cancel can never fail on its own
earlier success. A UGX subscription is cancelled all the same, but its
record cannot be reported: the call rejects with unsupported_operation
marked outcomeUnknown.
Resolves "canceled" only when the PSP confirms it. Under getCapabilities().modificationOutcome "asynchronous" the PSP merely acknowledges the request and the outcome arrives by webhook, so the returned status is "processing" — never an unconfirmed terminal state.
A capture that states amount first reads the PaymentIntent (one more
request) to send it in Stripe's units for the payment's currency, and
refuses one Stripe cannot be sent (see the class notes) before the
capture. An earlier release sent such captures unconverted, so the
refusal of a UGX amount, or of an MGA amount that is not whole ariary, is
marked outcomeUnknown unless the PaymentIntent read is still
requires_capture, which shows nothing was captured; either way it asks
the host to check the Stripe Dashboard under the key before sending
another. Without amount, Stripe captures its own amount_capturable
and nothing is read first.
Off-session (merchant-initiated) charge of a vaulted instrument. The
networks' credential-on-file rules ride the off_session flag; a bank
demanding authentication surfaces as authentication_required
(retryable ONLY by bringing the customer back on-session).
Creates the PSP-side customer that saved instruments attach to.
POST /v1/subscriptions against customer + default_payment_method (the
vaulted token — it must belong to that customer, so pspCustomerId is
required here). planId (when given) is an existing Stripe Price id;
without one the adapter builds the price inline via items[].price_data,
which requires an existing Product — created on the fly (name
merchantRefNum, falling back to "Subscription"; it shows on customer
invoices) under a derived idempotency key so replays reuse it.
off_session: true + payment_behavior: "error_if_incomplete": a first
invoice that cannot be paid rejects with the mapped card error instead of
leaving an incomplete subscription behind — server-only creation has no
customer present to finish authentication. startAt maps to a future
billing_cycle_anchor with proration_behavior: "none", making the span
until the anchor free and the first full invoice due at startAt.
Deletes by SavedPaymentMethod.token — the token is dead afterwards.
Missed-webhook recovery over GET /v1/events: the same payload shapes webhooks deliver, normalized by the same mapper — dedupe by event.id works identically for delivered and fetched events. Stripe retains ~30 days.
Pages GET /v1/subscriptions. Stripe's default listing is what this returns: every subscription that has NOT been canceled (canceled ones are excluded unless queried directly by id) — the live set an adoption flow needs, newest first. A page holding a subscription whose installment cannot be reported fails whole, as listPayments describes.
A page holding a PaymentIntent whose amounts cannot be reported (UGX, or
an ISK amount that is not a multiple of 100) fails whole with
unsupported_operation: raw.records names each such PaymentIntent, and
raw.nextCursor is the cursor the page would have carried, so later
pages stay reachable (a limit of 1 steps past each one).
A page holding a refund whose amount cannot be reported fails whole, as listPayments describes.
Throws PayFanoutError (code "invalid_request") on unparseable payloads; maps genuinely unknown-but-valid event types to type "unknown" rather than throwing.
A refund that states amount first reads the PaymentIntent (one more
request) to send it in Stripe's units for the payment's currency, and
refuses one Stripe cannot be sent (see the class notes) before the
refund. An earlier release sent such refunds unconverted, so on the way
to refusing a UGX amount, or an MGA amount that is not whole ariary, the
adapter lists the PaymentIntent's refunds (GET /v1/refunds, one request
more): the refusal is final only when that list is complete and holds
nothing but failed or canceled refunds, and is marked outcomeUnknown
otherwise, including when the list cannot be read. Either way it asks the
host to check the Stripe Dashboard under the key before sending another.
A refund that is sent lists nothing. Without amount, Stripe refunds what
remains and nothing is read first.
Stripe keys subscriptions by id alone — savedPaymentMethodToken is not needed and ignored.
Only present if getCapabilities().supportsPaymentRetrieval. Push-only PSPs expose no read for a single payment — their hosts follow payment state through webhook deliveries instead.
Polls an async refund ("pending" from refundPayment) to its terminal state.
Cart total changed? Stripe PaymentIntents update in place — same pspSessionId, same clientSecret, no client remount needed (the Payment Element re-fetches the amount on confirm).
Stripe's units for the amount depend on the currency, so an update that
carries amount without currency, or currency without amount,
first reads the PaymentIntent (GET /v1/payment_intents/:id, one more
request): the first to learn the currency the amount is sent in, the
second to keep the session's amount, in PayFanout's minor units, across
the change. A currency change always sends the kept amount converted for
the new currency, so its parameters depend only on the state it asks for
and a retry under the same key sends the same request: a session of
amount 1000 moved from ISK to USD still has amount 1000 (USD 10.00),
where Stripe alone would keep its 100000 (USD 1,000.00). An update that
names both, or neither, reads nothing first. Refusals of the amount (UGX,
MGA amounts that are not whole ariary, and, when the update names both
amount and currency, three-decimal amounts that are not multiples of 10)
come before the update is sent. A UGX refusal, whether the update names
UGX or sends an amount for a UGX PaymentIntent, is marked outcomeUnknown,
as an earlier release sent such updates unconverted under the same key.
A currency change without an amount writes the amount it read, so an amount update landing between that read and its write is overwritten: send the updates of one session one at a time.
"Test connection" probe: one read-only GET /v1/events, classified so a host
UI can tell a bad key (auth) from a transient outage (network). Returns a
result on every path instead of throwing, and never surfaces the credential.
Zero-amount verification via SetupIntent (§8 option a). A succeeded SetupIntent attaches a PaymentMethod, which collides with no-vaulting — so the PaymentMethod is detached on EVERY path (success, failed verification, or error) before this method returns.
Webhook handling — same adapter, separate concerns. Both async: future PSPs may need remote key retrieval; the contract stays uniform. MUST operate on the RAW request body bytes/string — re-serializing a parsed body breaks signatures (the conformance suite tests this).
The Stripe server adapter: PaymentIntents, Refunds, Customers and Billing over the Stripe Node SDK, confirm-on-client.
Amounts cross it in PayFanout's minor units (core's getCurrencyExponent, which follows ISO 4217) and are converted where Stripe's API reads a currency in other units (see "Currencies with Stripe-specific units" in the setup guide). ISK is sent multiplied by 100 and reported divided by 100, as Stripe takes it as a two-decimal value whose decimals are always 00; an ISK amount Stripe reports that is not a multiple of 100 cannot be reported and fails the read. MGA, a zero-decimal currency at Stripe, is sent divided by 100 and reported multiplied by 100, and an MGA amount that is not whole ariary (a multiple of 100) is refused with
invalid_requestbefore any request. Where the caller names the currency (session creation, an update naming amount and currency, saved-method charges, native subscriptions), three-decimal amounts must be multiples of 10, a rule from an earlier version of Stripe's currencies page that the adapter keeps.UGX is refused, as Stripe's currencies page gives it both units: a call that would send a UGX amount rejects with
invalid_requestbefore the request that would carry it; reads of a UGX record reject withunsupported_operation, as do the answers of calls that went through without sending an amount (a capture, cancellation or refund with no amount, a metadata-only update, a subscription cancellation), marked outcomeUnknown; a list page holding one fails whole, itsrawnaming each such record and carrying the page'snextCursor; and webhook and polled events in UGX carry noamount. A zero-amount session is a SetupIntent, which carries no amount or currency, and is left alone in every currency. UGX is declared inunsupportedCurrencies, so the router skips Stripe for a UGX session and PaymentService refuses one withunsupported_operation, zero-amount sessions included (from the@payfanout/serverrelease that reads the field).Earlier releases sent UGX amounts, and MGA amounts that are not whole ariary, unconverted, so a retry under the same key after the upgrade may meet money they already moved. Their refusal is therefore marked outcomeUnknown on
chargeSavedPaymentMethodandcreateNativeSubscription, whose earlier attempt the adapter cannot read back; oncapturePayment, unless the PaymentIntent it reads is stillrequires_capture; onrefundPayment, unless the PaymentIntent's refund list, read on the way to the refusal, is complete and holds only failed or canceled refunds; and onupdatePaymentSessionwhen the update names UGX or sends an amount for a UGX PaymentIntent (a currency change away from a UGX PaymentIntent, whose amount cannot be kept, is refused final). These refusals, final or not, ask the host to check the Stripe Dashboard for that request under the idempotency key before sending another.updatePaymentSession,capturePaymentandrefundPaymentread the PaymentIntent first whenever the currency of the amount they send is not in the call (see each method), one more request each time.