Stripe Billing
A use case for engineering teams already running Stripe Billing on their own Stripe account who want to move payment collection and money movement onto Outpost as Merchant of Record - without changing the way their subscriptions, invoices, or pricing live in Stripe.
Outpost holds the saved payment method on its own PSP account, charges it for every renewal cycle, and reports the result back to you.
You keep your existing Subscription / Invoice objects and use Stripe's native PaymentRecords API to mark invoices paid by an external processor.
Your Stripe account and Outpost’s PSP account are completely independent.
What you own vs. what Outpost owns
| Concern | Owner | Lives on |
|---|---|---|
| Customer record, billing address, tax IDs | You | Your Stripe account |
| Subscriptions, Prices, Invoices | You | Your Stripe account |
| PaymentRecords (external charge attribution) | You | Your Stripe account |
| PaymentMethod (the saved card / bank account / wallet) | Outpost | Outpost PSP account |
| SetupIntents, off-session PaymentIntents, money movement | Outpost | Outpost PSP account |
| Invoicing / tax / compliance to the end customer | Outpost | Outpost |
Supported payment methods
Any Stripe payment method that supports off-session reuse with a SetupIntent works with this architecture.
Your integration code stays the same regardless of the underlying method - only the payment_method_types you pass to POST /api/payments/stripe/setup-intents changes.
Architecture
Two independent Stripe accounts plus parallel webhook delivery from your account to Outpost.
On your side the integration is a handful of well-defined pieces:
- One signup-time backend call to Outpost, picking the variant per plan:
POST /api/payments/stripe/payment-intentsfor immediate-payment signups, orPOST /api/payments/stripe/setup-intentsfor free-trial signups (card is saved, no charge yet). - Frontend integration with Stripe Payment Element using Outpost's publishable key. The browser’s
stripe.confirmPayment()(orconfirmSetup()) is the atomic source of truth - Outpost is informed of the outcome via its ownpayment_intent.succeeded/setup_intent.succeededwebhook, no extra Outpost API call needed. - Subscription bootstrap on your own Stripe account with
collection_method=send_invoice, followed bypayment_records/report_payment+attach_paymentto mark the first invoice paid. - Parallel webhook delivery from your Stripe account so Outpost receives
invoice.createdevents for renewals. - An HTTPS webhook endpoint on your backend that verifies the
Mor-SignatureHMAC and reacts topayment.*renewal events.
Signup - immediate payment
Signup - free trial
Renewal flow
Other PSPs on Outpost’s side
The example above uses Stripe Payment Element and Stripe-shaped SetupIntent / PaymentIntent objects.
The Outpost API is PSP-namespaced - additional PSPs are exposed via parallel /api/payments/<psp>/... route families (e.g. /api/payments/adyen/...), each returning that PSP’s native objects and integrated with its native browser SDK.
Outpost’s account abstraction (“Outpost PSP account”) and the merchant-side flow, Stripe Billing, PaymentRecords, the HMAC-signed renewal webhook, stay the same regardless of which PSP Outpost uses underneath.
Payment Element
StripePayment-method collection continues to use Stripe's Payment Element in your existing checkout.
There is no Outpost-hosted form currently.
Instantiate Elements with Outpost's publishable key (not yours) and the client_secret returned by Outpost. For immediate-payment signups, call stripe.confirmPayment() (atomic card collection + 3DS + charge). For free-trial signups, call stripe.confirmSetup() - no charge, just save the card.
Sensitive payment data goes directly from the browser to Stripe - neither you nor Outpost ever see it.
Outpost is informed of the outcome via its own payment_intent.succeeded / setup_intent.succeeded webhook - no follow-up Outpost API call is required from your side.
// Immediate-payment signup - uses /payment-intents + confirmPayment.
// For free-trial signups, swap in /setup-intents + confirmSetup (same shape).
const stripe = await loadStripe(import.meta.env.OUTPOST_PUBLISHABLE_KEY)
const elements = stripe.elements({ clientSecret }) // from /api/payments/stripe/payment-intents
elements.create('payment').mount('#payment-element')
const { paymentIntent, error } = await stripe.confirmPayment({
elements,
clientSecret,
redirect: 'if_required',
})
// paymentIntent.latest_charge -> use as payment_reference on payment_records/report_paymentTax Calculation
Compute the tax breakdown for the initial charge with POST /api/tax/calculate. Call it during the checkout phase with amount, currency, and the customer’s billing address.
The response returns a tax_calculation_id and the tax-inclusive total. Render the total in your checkout, then pass the tax_calculation_id to POST /api/payments/stripe/setup-intents to bind it to the payment.
Full request / response shapes, supported jurisdictions, and exemption handling live in the API reference, and the Taxes module how-to is under Taxes.
Integration flow
Three discrete phases: a one-off signup that puts a card on file with Outpost (Phase 1), your own subscription bootstrap (Phase 2), and a renewal cycle that repeats for the life of the subscription (Phase 3).
Phase 1 - Signup
Goal: a saved payment method sits in Outpost's Stripe account, attached to a MoR-side Customer, and is ready to be charged off-session for future renewals.
| Action | Where it runs |
|---|---|
| Customer fills info | Browser |
| Create Customer on your own Stripe account | → Stripe |
Calculate tax for the upcoming charge - pass amount, currency, and customer address, and receive a tax_calculation_id + tax-inclusive total to render in your checkout | → Outpost |
| Mount Stripe Elements with Outpost's publishable key | Browser |
Request a client_secret - POST /api/payments/stripe/payment-intents for immediate-payment signups (pass amount, currency, merchant_customer, tax_calculation_id), or POST /api/payments/stripe/setup-intents for free-trial signups | → Outpost |
stripe.confirmPayment() (or confirmSetup()) - atomic card collection, 3DS, and charge (no charge for trial). The browser holds the result. | Browser |
Outpost receives payment_intent.succeeded / setup_intent.succeeded on its own PSP and caches the PaymentMethod for renewals - no follow-up Outpost API call from your side | → Outpost |
Carry forward to Phase 2
From stripe.confirmPayment() in the browser, hold onto paymentIntent.latest_charge (a Stripe ch_… on Outpost's PSP). You’ll use it as payment_reference on payment_records/report_payment in Phase 2.
For free-trial signups there’s nothing to report yet - the first chargeable invoice fires at trial end and is handled by the renewal flow.
Phase 2 - Subscription + report payment
Phase 2 happens entirely on your Stripe account.
No Outpost call is involved.
Use collection_method=send_invoice so Stripe Billing doesn't try to auto-charge - payment comes from the PaymentRecord you report below.
| Action | Notable fields |
|---|---|
| POST /v1/subscriptions | collection_method=send_invoice days_until_due=30 |
| POST /v1/invoices/{id}/finalize | auto_advance=false |
| POST /v1/payment_records/report_payment | outcome=guaranteed payment_method_details[type]=custom processor_details[type]=custom processor_details[custom][payment_reference]={ch_… from Outpost} |
| POST /v1/invoices/{id}/attach_payment | invoice → paid, subscription → active |
Phase 3 - Renewal cycle
Renewals are driven by parallel webhook delivery: configure your Stripe account to forward webhook events to two destinations - yours (for your own logs) and Outpost's.
Outpost reacts to invoice.created and drives the renewal charge end-to-end.
| Action | Where it runs |
|---|---|
| Cycle elapses, Stripe Billing creates the next invoice. | Stripe |
invoice.created lands at Outpost via parallel delivery. Outpost skips billing_reason=subscription_create - that's the Phase-1 invoice, already paid. | Outpost |
| Outpost looks up the saved PaymentMethod and creates an off-session PaymentIntent on its own account. | Outpost ↔ PSP |
Result is one of succeeded / requires_action / failed. | Outpost |
Outpost POSTs an HMAC-signed charge.{succeeded,failed,requires_action} event to your webhook URL (see Webhooks). | Outpost → Merchant |
Verify Mor-Signature. On payment.succeeded call payment_records/report_payment with the new processor_charge_id. | Merchant ↔ Stripe |
Call /v1/invoices/{id}/attach_payment → invoice transitions to paid. | Merchant ↔ Stripe |
invoice.paid lands at Outpost via parallel delivery (logged, no action). | Stripe → Outpost |
Failures + dunning
On payment.failed or payment.requires_action leave the invoice open.
Stripe Billing's Smart Retries policy will create another invoice.created event on the next attempt, and Outpost will run the renewal again.
Your dunning emails, hosted invoice page, and customer portal continue to work unchanged.
Stripe API fields
The non-default Stripe fields that make the integration work.
Everything else on the Subscription, Invoice, and PaymentRecord can stay at the defaults you already use today.
| Field | Endpoint | Value | Why |
|---|---|---|---|
| collection_method | POST /v1/subscriptions | send_invoice | Stops Stripe Billing from trying to auto-charge a card. Payment is reported externally via PaymentRecords. |
| auto_advance | POST /v1/invoices/{id}/finalize | false | Prevents Stripe from advancing the invoice state machine (no auto emails, no auto void). You drive the state via attach_payment. |
| confirm | POST /v1/payment_intents (Outpost-side) | true | Create + confirm in one call. Avoids a second round-trip and a partially-created PI on failure. |
| outcome | POST /v1/payment_records/report_payment | guaranteed | Tells Stripe the external processor has already taken the money. Triggers the paid transition on attach_payment. |
| payment_method_details[type] | POST /v1/payment_records/report_payment | custom | You are not handing Stripe a real PaymentMethod object - the payment method lives on Outpost’s account. |
| processor_details[type] | POST /v1/payment_records/report_payment | custom | Same reason - the processor is Outpost-as-MoR, not a Stripe-native PSP. |
| processor_details[custom][payment_reference] | POST /v1/payment_records/report_payment | ch_… on Outpost’s account | The only cross-account link recorded on the PaymentRecord. Use processor_references.charge_id from Outpost’s response (Phase 1) or the renewal webhook. The value is a charge ID on Outpost’s PSP, regardless of underlying payment method. |
| payment_record | POST /v1/invoices/{id}/attach_payment | pr_… (from report_payment) | Atomically marks the invoice paid and the subscription active. |
| billing_reason | invoice.created webhook | compare against "subscription_create" | Outpost skips the signup-time invoice (`subscription_create`) because Phase 1+2 already paid it. Renewal cycles use `subscription_cycle`. |
| metadata | multiple | payment_id, merchant_invoice_id, merchant_customer_id | Recommend stamping these on Stripe objects on both sides for traceability and idempotent reconciliation. |
API map
Request and response schemas live in the API reference, including the OpenAPI download. This guide covers how the pieces fit together - not field tables.
- POST
/api/payments/stripe/setup-intents - POST
/api/payments/stripe/payment-intents - POST
/api/payments/{paymentId}/refund - POST
/api/tax/calculations
Billing-specific webhook events are documented under Stripe Billing → webhooks in the API reference.
FAQ
- Do I need a Stripe Connect account?
- No. Outpost runs on its own PSP account, completely independent of your Stripe account. There is no Stripe Connect involved on either side - the two accounts never talk to each other directly.
- Do you use payment method cloning?
- No. The PaymentMethod is collected directly on Outpost’s PSP account via a SetupIntent confirmed in the browser with Outpost’s publishable key. There is no token sharing or PaymentMethod cloning between your Stripe account and Outpost’s PSP account.
- Whose name appears on the customer’s card statement?
- Outpost’s. As the merchant of record, Outpost owns the descriptor that lands on the customer’s card statement - not yours. The exact format follows the card-network requirements for MoR descriptors.
- Who issues the receipt / tax invoice to the end customer?
- Outpost. As the merchant of record, Outpost is responsible for issuing the customer-facing receipt and any required tax invoice in the customer’s jurisdiction. Your Stripe Billing invoice remains an internal record of the subscription.
- Does my existing Stripe customer portal still work?
- Yes for invoice viewing, subscription cancellation, and metadata management - those objects still live on your Stripe account. Payment-method updates are the exception: they run through a fresh SetupIntent on Outpost rather than Stripe’s built-in update flow, since the card lives on Outpost’s PSP account.
- Will Outpost-side charges show up in my Stripe dashboard?
- No. The actual charges (
ch_…) live on Outpost’s PSP account - only thePaymentRecordentries appear in your Stripe dashboard. To reconcile, followprocessor_references.charge_idfrom the PaymentRecord back to the Outpost-side charge. - How do I refund a payment?
- Call Outpost’s refund endpoint with the
payment_id. Outpost executes the refund on its PSP account and posts a follow-up webhook so you can update the correspondingPaymentRecordon Stripe.