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

ConcernOwnerLives on
Customer record, billing address, tax IDsYouYour Stripe account
Subscriptions, Prices, InvoicesYouYour Stripe account
PaymentRecords (external charge attribution)YouYour Stripe account
PaymentMethod (the saved card / bank account / wallet)OutpostOutpost PSP account
SetupIntents, off-session PaymentIntents, money movementOutpostOutpost PSP account
Invoicing / tax / compliance to the end customerOutpostOutpost

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-intents for immediate-payment signups, or POST /api/payments/stripe/setup-intents for 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() (or confirmSetup()) is the atomic source of truth — Outpost is informed of the outcome via its own payment_intent.succeeded / setup_intent.succeeded webhook, no extra Outpost API call needed.
  • Subscription bootstrap on your own Stripe account with collection_method=send_invoice, followed by payment_records/report_payment + attach_payment to mark the first invoice paid.
  • Parallel webhook delivery from your Stripe account so Outpost receives invoice.created events for renewals.
  • An HTTPS webhook endpoint on your backend that verifies the Mor-Signature HMAC and reacts to payment.* renewal events.

Signup — immediate payment

sequenceDiagram autonumber participant B as Browser (Payment Element) participant M as Merchant backend participant SM as Stripe participant O as Outpost participant SO as Outpost PSP Note over B,SO: Phase 1 — Signup with immediate payment B->>M: POST /signup/customer M->>SM: customers.create SM-->>M: cus_xxx M-->>B: customer id B->>M: request payment-intent M->>O: create payment-intent O->>SO: payment_intents.create SO-->>O: pi + client_secret O-->>M: client_secret M-->>B: client_secret B->>SO: stripe.confirmPayment() SO-->>B: succeeded (pm_xxx, ch_xxx) SO-->>O: payment_intent.succeeded webhook B->>M: payment confirmed Note over B,SO: Phase 2 — Subscription + report payment M->>SM: subscriptions.create SM-->>M: sub + invoice in_xxx M->>SM: invoices.finalize M->>SM: payment_records.report_payment SM-->>M: payment_record pr_xxx M->>SM: invoices.attach_payment SM-->>M: invoice.status=paid

Signup — free trial

sequenceDiagram autonumber participant B as Browser (Payment Element) participant M as Merchant backend participant SM as Stripe participant O as Outpost participant SO as Outpost PSP Note over B,SO: Phase 1 — Signup with free trial (card saved, no charge) B->>M: POST /signup/customer M->>SM: customers.create SM-->>M: cus_xxx M-->>B: customer id B->>M: request setup-intent M->>O: create setup-intent O->>SO: setup_intents.create SO-->>O: seti + client_secret O-->>M: client_secret M-->>B: client_secret B->>SO: stripe.confirmSetup() SO-->>B: succeeded (pm_xxx saved) SO-->>O: setup_intent.succeeded webhook B->>M: setup success Note over B,SO: Phase 2 — Trialing subscription M->>SM: subscriptions.create (trial_period_days) SM-->>M: sub (trialing) Note over B,SO: Phase 3 — Trial-end charge reuses the renewal flow

Renewal flow

sequenceDiagram autonumber participant SM as Stripe participant O as Outpost participant SO as Outpost PSP participant M as Merchant backend Note over SM,M: Renewal cycle SM->>O: invoice.created webhook O->>O: skip if billing_reason=subscription_create O->>SO: payment_intents.create SO-->>O: status O->>M: payment.{succeeded,failed,requires_action} M->>M: verify Mor-Signature alt payment.succeeded M->>SM: payment_records.report_payment M->>SM: invoices.attach_payment SM->>O: invoice.paid else failed / requires_action M-->>M: leave invoice open — Smart Retries end

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

Stripe

Payment-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_payment

Tax 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 Tax of Record.

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.

ActionWhere it runs
Customer fills infoBrowser
Create Customer on your own Stripe account→ Stripe
Calculate tax for the upcoming charge — pass amount, currency, and customer address; receive a tax_calculation_id + tax-inclusive total to render in your checkout→ Outpost
Mount Stripe Elements with Outpost's publishable keyBrowser
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.

ActionNotable fields
POST /v1/subscriptionscollection_method=send_invoice
days_until_due=30
POST /v1/invoices/{id}/finalizeauto_advance=false
POST /v1/payment_records/report_paymentoutcome=guaranteed
payment_method_details[type]=custom
processor_details[type]=custom
processor_details[custom][payment_reference]={ch_… from Outpost}
POST /v1/invoices/{id}/attach_paymentinvoice → 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.

ActionWhere 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.

FieldEndpointValueWhy
collection_methodPOST /v1/subscriptionssend_invoiceStops Stripe Billing from trying to auto-charge a card. Payment is reported externally via PaymentRecords.
auto_advancePOST /v1/invoices/{id}/finalizefalsePrevents Stripe from advancing the invoice state machine (no auto emails, no auto void). You drive the state via attach_payment.
confirmPOST /v1/payment_intents (Outpost-side)trueCreate + confirm in one call. Avoids a second round-trip and a partially-created PI on failure.
outcomePOST /v1/payment_records/report_paymentguaranteedTells 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_paymentcustomYou are not handing Stripe a real PaymentMethod object; the payment method lives on Outpost’s account.
processor_details[type]POST /v1/payment_records/report_paymentcustomSame reason — the processor is Outpost-as-MoR, not a Stripe-native PSP.
processor_details[custom][payment_reference]POST /v1/payment_records/report_paymentch_… on Outpost’s accountThe 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_recordPOST /v1/invoices/{id}/attach_paymentpr_… (from report_payment)Atomically marks the invoice paid and the subscription active.
billing_reasoninvoice.created webhookcompare against "subscription_create"Outpost skips the signup-time invoice (`subscription_create`) because Phase 1+2 already paid it. Renewal cycles use `subscription_cycle`.
metadatamultiplepayment_id, merchant_invoice_id, merchant_customer_idRecommend stamping these on Stripe objects on both sides for traceability and idempotent reconciliation.

API reference

Three outbound endpoints covering the payment lifecycle (provision a SetupIntent, confirm the payment, refund), plus four inbound webhook events on a single channel.

Authentication uses the same Bearer token described in Merchant of Record → Authentication.

Create Setup Intent

POST/api/payments/stripe/setup-intentsStripe

Provisions a SetupIntent on Outpost’s PSP account with usage=off_session.

The returned client_secret is passed to Stripe Elements in the browser so the customer can confirm payment details directly against Stripe — no sensitive payment data ever touches the merchant or Outpost servers.

The set of payment methods offered to the customer is controlled by payment_method_types on the request body (defaults to ["card"]).

Request Headers

ParameterTypeRequiredDescription
AuthorizationstringYesBearer access token — see Authentication above.

Request Body

application/json

{
  "payment_method_types": ["card"],
  "tax_calculation_id": "taxc_a1b2c3d4e5f6789"
}

Request Fields

ParameterTypeRequiredDescription
payment_method_typesstring[]NoArray of Stripe PM types to enable on the SetupIntent. Defaults to ["card"]. Supported: card, us_bank_account, sepa_debit, bacs_debit, au_becs_debit. Mandate text for direct debits is rendered automatically by Payment Element.
tax_calculation_idstringNoReference to a tax calculation returned by POST /api/tax/calculate. Binds the tax breakdown to this SetupIntent so it carries through to the first chargeable invoice (e.g. at trial end).

Response 200 OKStripe

{
  "client_secret": "seti_1QwxyzABC_secret_xyzXYZ123",
  "setup_intent_id": "seti_1QwxyzABC",
  "mor_customer_id": "cus_a1b2c3d4e5f6789",
  "status": "requires_payment_method"
}

Response Fields

ParameterTypeRequiredDescription
client_secretstringPass to Stripe Elements via loadStripe(OUTPOST_PUBLISHABLE_KEY) and confirmSetup({ clientSecret, ... }).
setup_intent_idstringSetupIntent ID on Outpost’s PSP account.
mor_customer_idstringOutpost’s Stripe Customer ID (empty Customer with metadata pointing back at your merchant_customer.stripe_id). Persist for future reference.
statusstringOne of requires_payment_method, requires_confirmation, requires_action, processing, succeeded.

Error Responses

HTTPCodeDescription
400invalid_requestMalformed body, or invalid value in payment_method_types.
401Missing or invalid Bearer token.
403merchant_suspendedMerchant account is suspended, or the token lacks the required scope.
404tax_calculation_not_foundThe tax_calculation_id does not resolve to a known calculation.
409idempotency_conflictSame Idempotency-Key replayed with a different request body.
422validation_errorA payment_method_types value is not enabled for your account on Outpost’s PSP.
429rate_limitedBurst exceeded your quota. Retry after backoff.
503psp_unavailableUpstream PSP (Stripe) returned 5xx or timed out. Safe to retry with the same Idempotency-Key.

Create Payment Intent

POST/api/payments/stripe/payment-intentsStripe

Used for the immediate-payment signup variant — the customer is charged at signup (no free trial). Outpost provisions a PaymentIntent on its PSP account with setup_future_usage=off_session so the same card can be charged for renewals later.

The browser confirms with stripe.confirmPayment() (atomic card collection + 3DS + charge). Outpost learns the outcome via its own payment_intent.succeeded webhook — no follow-up Outpost API call is required.

Request Headers

ParameterTypeRequiredDescription
AuthorizationstringYesBearer access token — see Authentication above.

Request Body

application/json

{
  "amount": 1990,
  "currency": "BRL",
  "merchant_customer": {
    "stripe_id": "cus_QmerchantSide",
    "email": "jordi@example.com.br",
    "name": "Jordi Silva",
    "country": "BR",
    "address": { "line1": "Av. Paulista 1234", "city": "São Paulo" }
  },
  "description": "Pro Monthly subscription — first payment",
  "tax_calculation_id": "taxc_a1b2c3d4e5f6789"
}

Request Fields

ParameterTypeRequiredDescription
amountintegerYesSmallest currency unit (e.g. cents for USD, centavos for BRL). Minimum 1.
currencystring (ISO 4217)YesThree-letter currency code, e.g. BRL.
merchant_customer.stripe_idstringYesCustomer ID on your own Stripe account. Stored as metadata on the empty MoR-side Customer.
merchant_customer.emailstringNoCustomer email. Used for receipt routing and fraud signals.
merchant_customer.namestringNoCustomer name.
merchant_customer.countrystring (ISO 3166-1 alpha-2)NoCustomer country code.
merchant_customer.addressobjectNoBilling address (line1, city, postal_code, etc.).
descriptionstringNoFree-form description shown on the underlying PaymentIntent. Max 255 chars.
tax_calculation_idstringNoReference to a tax calculation returned by POST /api/tax/calculate. Binds the calculated tax breakdown to this payment.

Response 200 OKStripe

{
  "client_secret": "pi_3QzABC_secret_xyzXYZ123",
  "payment_intent_id": "pi_3QzABC",
  "mor_customer_id": "cus_a1b2c3d4e5f6789",
  "status": "requires_payment_method"
}

Response Fields

ParameterTypeRequiredDescription
client_secretstringPass to Stripe Elements via loadStripe(OUTPOST_PUBLISHABLE_KEY) and confirm with stripe.confirmPayment({ clientSecret, … }).
payment_intent_idstringPaymentIntent ID on Outpost’s PSP account.
mor_customer_idstringOutpost’s Stripe Customer ID (empty Customer with metadata pointing back at your merchant_customer.stripe_id). Persist for future reference.
statusstringPaymentIntent status — typically requires_payment_method at this point; the browser will transition it via confirmPayment().

Error Responses

HTTPCodeDescription
400invalid_requestMissing or malformed amount / currency / merchant_customer.stripe_id.
401Missing or invalid Bearer token.
403merchant_suspendedMerchant account is suspended, or the token lacks the required scope.
404tax_calculation_not_foundThe tax_calculation_id does not resolve to a known calculation.
409idempotency_conflictSame Idempotency-Key replayed with a different request body.
422unsupported_currency / amount_out_of_rangeCurrency not enabled on Outpost’s PSP, amount below the PSP’s minimum, or amount above your per-transaction cap.
429rate_limitedBurst exceeded your quota. Retry after backoff.
503psp_unavailableUpstream PSP (Stripe) returned 5xx or timed out. Safe to retry with the same Idempotency-Key.

After stripe.confirmPayment() succeeds in the browser, you receive payment_intent.id and latest_charge (Stripe ch_…). Use that ch_… as processor_details.custom.payment_reference on payment_records/report_payment on your Stripe account.

Refund Payment

POST/api/payments/{paymentId}/refund

Refunds a previously confirmed payment, in full or in part. Outpost executes the refund on its PSP account and emits a payment.refunded webhook so you can update the corresponding PaymentRecord on Stripe.

Partial refunds are supported — supply amount to refund less than the full charge. Multiple refunds are allowed until the cumulative amount equals the original payment.

Path Parameters

ParameterTypeRequiredDescription
paymentIdstringYesThe Outpost payment_id from the Confirm Payment response.

Request Headers

ParameterTypeRequiredDescription
AuthorizationstringYesBearer access token.
Idempotency-KeystringYesRequired to safely retry refund attempts. Outpost derives downstream PSP idempotency keys from this value.

Request Body

application/json — body is optional; omit for a full refund.

{
  "amount": 990,
  "reason": "requested_by_customer",
  "metadata": {
    "merchant_invoice_id": "in_1QzABC"
  }
}

Request Fields

ParameterTypeRequiredDescription
amountintegerNoSmallest currency unit. Defaults to the unrefunded remainder of the payment, i.e. a full refund. Must be ≤ remaining refundable amount.
reasonstringNoOne of requested_by_customer, duplicate, fraudulent. Free-form strings are also accepted and forwarded to the PSP.
metadataobjectNoArbitrary key/value pairs persisted on the refund and forwarded to the PSP refund object.

Response 200 OK

{
  "id": "rfnd_a1b2c3d4e5f6789",
  "payment_id": "pay_a1b2c3d4e5f6789",
  "amount": 990,
  "currency": "BRL",
  "status": "succeeded",
  "reason": "requested_by_customer",
  "processor_references": {
    "refund_id": "re_3QzABC..."
  },
  "created_at": "2026-05-12T11:00:00Z"
}

Response Fields

ParameterTypeRequiredDescription
idstringOutpost-owned refund identifier (rfnd_…).
payment_idstringThe payment this refund applies to.
amountintegerRefunded amount in smallest currency unit.
currencystringEchoes the payment currency.
statusstringOne of succeeded, pending, failed. For cards and Link this is synchronous; for bank debits the refund starts as pending and transitions via webhook.
reasonstring | nullEchoes the request reason.
processor_references.refund_idstringRefund ID on Outpost’s PSP account (e.g. re_… on Stripe).
created_atstring (ISO 8601)When the refund was created.

Error Responses

HTTPCodeDescription
400invalid_requestamount exceeds the unrefunded remainder, or currency mismatch.
401Missing or invalid Bearer token.
404payment_not_foundNo payment exists for the given paymentId.
422payment_not_refundablePayment is not in a refundable state (e.g. already fully refunded, never succeeded, or charged back).

On success, also call Stripe payment_records.report_refund on your account with processor_details.custom.refund_reference = processor_references.refund_id so the PaymentRecord reflects the refund. The payment.refunded webhook (below) also fires — use whichever signal fits your reconciliation flow.

Verifying webhooks

Outpost signs every webhook delivery with HMAC-SHA256.

The header is Mor-Signature: t=<unix_seconds>,v1=<hex>.

The signed payload is the literal byte string "{t}.{raw_request_body}", keyed with the shared webhook secret Outpost gave you during onboarding.

Recommended: reject deliveries where t is older than five minutes, and dedupe on event.id.

Webhooks — payment.*

WEBHOOKPOST <your_webhook_url>

During each renewal cycle Outpost posts exactly one event to the webhook URL you registered.

All three event types share the same envelope and the same HMAC verification — see Verifying webhooks.

Request Headers

ParameterTypeRequiredDescription
Mor-SignaturestringYest=<unix_seconds>,v1=<hex_hmac_sha256>. HMAC computed over "{t}.{raw_request_body}" using the shared webhook secret. Reject deliveries older than ~5 minutes.

Payload Fields

ParameterTypeRequiredDescription
idstringOutpost event ID. Use to dedupe.
typestringOne of payment.succeeded, payment.failed, payment.requires_action, payment.refunded.
createdintegerUnix seconds.
data.object.payment_idstringOutpost-owned payment identifier for this renewal cycle (pay_…). Use as the {paymentId} path param on POST /api/payments/{paymentId}/refund.
data.object.merchant_invoice_idstringStripe Invoice ID on your account that triggered this renewal. Pass to attach_payment.
data.object.merchant_customer_idstringCustomer ID on your Stripe account.
data.object.processor_charge_idstringCharge ID on Outpost’s PSP account. Use as payment_reference on report_payment. Empty on failed / requires_action.
data.object.processor_payment_intent_idstringPaymentIntent ID on Outpost’s PSP account.
data.object.amountintegerFor payment.succeeded / failed / requires_action: the payment amount. For payment.refunded: the amount refunded by this event.
data.object.currencystringThree-letter currency code.
data.object.statusstringOne of succeeded, failed, requires_action, partially_refunded, refunded.
data.object.failure_messagestringNoHuman-readable decline reason (present on failed / requires_action).
data.object.decline_codestringNoPSP decline code, e.g. insufficient_funds.
data.object.refund_idstringNoOutpost-owned refund identifier. Only on payment.refunded.
data.object.processor_refund_idstringNoRefund ID on Outpost’s PSP account. Only on payment.refunded.
data.object.amount_refunded_totalintegerNoCumulative amount refunded across all refunds on this payment. Only on payment.refunded.
data.object.reasonstringNoRefund reason. Only on payment.refunded.

Example — payment.succeeded

After verifying the signature, call Stripe POST /v1/payment_records/report_payment on the merchant’s account with outcome=guaranteed and processor_details.custom.payment_reference = processor_charge_id, then POST /v1/invoices/{merchant_invoice_id}/attach_payment to mark the invoice paid.

{
  "id": "evt_a1b2c3d4",
  "type": "payment.succeeded",
  "created": 1747051200,
  "data": {
    "object": {
      "payment_id": "pay_a1b2c3",
      "merchant_invoice_id": "in_1QzABC",
      "merchant_customer_id": "cus_QmerchantSide",
      "processor_charge_id": "ch_3QzABC",
      "processor_payment_intent_id": "pi_3QzABC",
      "amount": 1990,
      "currency": "BRL",
      "status": "succeeded"
    }
  }
}

Example — payment.failed

Leave the merchant invoice open — Stripe Billing’s Smart Retries will trigger another invoice.created on the next attempt, which Outpost will pick up and retry.

{
  "id": "evt_failed1",
  "type": "payment.failed",
  "created": 1747051200,
  "data": {
    "object": {
      "payment_id": "pay_b2c3d4",
      "merchant_invoice_id": "in_1QzABC",
      "merchant_customer_id": "cus_QmerchantSide",
      "processor_charge_id": "",
      "processor_payment_intent_id": "pi_3QzABC",
      "amount": 1990,
      "currency": "BRL",
      "status": "failed",
      "failure_message": "Your card was declined.",
      "decline_code": "insufficient_funds"
    }
  }
}

Example — payment.requires_action

Triggered when the renewal PaymentIntent requires customer authentication — SCA / 3DS for cards, mandate re-confirmation for direct debits, etc.

As with payment.failed, leave the invoice open and let Stripe Billing’s dunning surface the action to the customer.

{
  "id": "evt_action1",
  "type": "payment.requires_action",
  "created": 1747051200,
  "data": {
    "object": {
      "payment_id": "pay_c3d4e5",
      "merchant_invoice_id": "in_1QzABC",
      "merchant_customer_id": "cus_QmerchantSide",
      "processor_charge_id": "",
      "processor_payment_intent_id": "pi_3QzABC",
      "amount": 1990,
      "currency": "BRL",
      "status": "requires_action",
      "failure_message": "Authentication required.",
      "decline_code": "authentication_required"
    }
  }
}

Example — payment.refunded

Fired after every successful refund — full or partial. Use amount_refunded_total to decide whether the payment is now fully or partially refunded and call payment_records.report_refund on Stripe to keep the PaymentRecord in sync.

{
  "id": "evt_refund1",
  "type": "payment.refunded",
  "created": 1747051200,
  "data": {
    "object": {
      "payment_id": "pay_a1b2c3",
      "merchant_invoice_id": "in_1QzABC",
      "merchant_customer_id": "cus_QmerchantSide",
      "refund_id": "rfnd_a1b2c3d4e5f6789",
      "amount": 990,
      "amount_refunded_total": 990,
      "currency": "BRL",
      "status": "partially_refunded",
      "reason": "requested_by_customer",
      "processor_refund_id": "re_3QzABC"
    }
  }
}

Response Codes

HTTPCodeDescription
200Acknowledged. Outpost will not retry.
4XXOutpost gives up after 4xx (treat as permanent failure on your side).
5XXOutpost retries with exponential backoff.

Async-settled payment methods

For cards and Link, payment.succeeded arrives within seconds of the PaymentIntent confirming.

For bank-debit methods (ACH, SEPA, BACS, BECS) the PaymentIntent first transitions through processing and the succeeded event only fires after settlement (days later).

Returns and mandate disputes that arrive after settlement are not yet exposed as a dedicated event — contact Outpost before rolling out async methods in production.

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 the PaymentRecord entries appear in your Stripe dashboard. To reconcile, follow processor_references.charge_id from 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 corresponding PaymentRecord on Stripe.