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 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.
| 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; 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 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
/api/payments/stripe/setup-intentsStripeProvisions 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
| Parameter | Type | Required | Description |
|---|---|---|---|
| Authorization | string | Yes | Bearer access token — see Authentication above. |
Request Body
application/json
{
"payment_method_types": ["card"],
"tax_calculation_id": "taxc_a1b2c3d4e5f6789"
}Request Fields
| Parameter | Type | Required | Description |
|---|---|---|---|
| payment_method_types | string[] | No | Array 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_id | string | No | Reference 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
| Parameter | Type | Required | Description |
|---|---|---|---|
| client_secret | string | — | Pass to Stripe Elements via loadStripe(OUTPOST_PUBLISHABLE_KEY) and confirmSetup({ clientSecret, ... }). |
| setup_intent_id | string | — | SetupIntent ID on Outpost’s PSP account. |
| mor_customer_id | string | — | Outpost’s Stripe Customer ID (empty Customer with metadata pointing back at your merchant_customer.stripe_id). Persist for future reference. |
| status | string | — | One of requires_payment_method, requires_confirmation, requires_action, processing, succeeded. |
Error Responses
| HTTP | Code | Description |
|---|---|---|
| 400 | invalid_request | Malformed body, or invalid value in payment_method_types. |
| 401 | — | Missing or invalid Bearer token. |
| 403 | merchant_suspended | Merchant account is suspended, or the token lacks the required scope. |
| 404 | tax_calculation_not_found | The tax_calculation_id does not resolve to a known calculation. |
| 409 | idempotency_conflict | Same Idempotency-Key replayed with a different request body. |
| 422 | validation_error | A payment_method_types value is not enabled for your account on Outpost’s PSP. |
| 429 | rate_limited | Burst exceeded your quota. Retry after backoff. |
| 503 | psp_unavailable | Upstream PSP (Stripe) returned 5xx or timed out. Safe to retry with the same Idempotency-Key. |
Create Payment Intent
/api/payments/stripe/payment-intentsStripeUsed 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
| Parameter | Type | Required | Description |
|---|---|---|---|
| Authorization | string | Yes | Bearer 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
| Parameter | Type | Required | Description |
|---|---|---|---|
| amount | integer | Yes | Smallest currency unit (e.g. cents for USD, centavos for BRL). Minimum 1. |
| currency | string (ISO 4217) | Yes | Three-letter currency code, e.g. BRL. |
| merchant_customer.stripe_id | string | Yes | Customer ID on your own Stripe account. Stored as metadata on the empty MoR-side Customer. |
| merchant_customer.email | string | No | Customer email. Used for receipt routing and fraud signals. |
| merchant_customer.name | string | No | Customer name. |
| merchant_customer.country | string (ISO 3166-1 alpha-2) | No | Customer country code. |
| merchant_customer.address | object | No | Billing address (line1, city, postal_code, etc.). |
| description | string | No | Free-form description shown on the underlying PaymentIntent. Max 255 chars. |
| tax_calculation_id | string | No | Reference 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
| Parameter | Type | Required | Description |
|---|---|---|---|
| client_secret | string | — | Pass to Stripe Elements via loadStripe(OUTPOST_PUBLISHABLE_KEY) and confirm with stripe.confirmPayment({ clientSecret, … }). |
| payment_intent_id | string | — | PaymentIntent ID on Outpost’s PSP account. |
| mor_customer_id | string | — | Outpost’s Stripe Customer ID (empty Customer with metadata pointing back at your merchant_customer.stripe_id). Persist for future reference. |
| status | string | — | PaymentIntent status — typically requires_payment_method at this point; the browser will transition it via confirmPayment(). |
Error Responses
| HTTP | Code | Description |
|---|---|---|
| 400 | invalid_request | Missing or malformed amount / currency / merchant_customer.stripe_id. |
| 401 | — | Missing or invalid Bearer token. |
| 403 | merchant_suspended | Merchant account is suspended, or the token lacks the required scope. |
| 404 | tax_calculation_not_found | The tax_calculation_id does not resolve to a known calculation. |
| 409 | idempotency_conflict | Same Idempotency-Key replayed with a different request body. |
| 422 | unsupported_currency / amount_out_of_range | Currency not enabled on Outpost’s PSP, amount below the PSP’s minimum, or amount above your per-transaction cap. |
| 429 | rate_limited | Burst exceeded your quota. Retry after backoff. |
| 503 | psp_unavailable | Upstream 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
/api/payments/{paymentId}/refundRefunds 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
| Parameter | Type | Required | Description |
|---|---|---|---|
| paymentId | string | Yes | The Outpost payment_id from the Confirm Payment response. |
Request Headers
| Parameter | Type | Required | Description |
|---|---|---|---|
| Authorization | string | Yes | Bearer access token. |
| Idempotency-Key | string | Yes | Required 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
| Parameter | Type | Required | Description |
|---|---|---|---|
| amount | integer | No | Smallest currency unit. Defaults to the unrefunded remainder of the payment, i.e. a full refund. Must be ≤ remaining refundable amount. |
| reason | string | No | One of requested_by_customer, duplicate, fraudulent. Free-form strings are also accepted and forwarded to the PSP. |
| metadata | object | No | Arbitrary 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
| Parameter | Type | Required | Description |
|---|---|---|---|
| id | string | — | Outpost-owned refund identifier (rfnd_…). |
| payment_id | string | — | The payment this refund applies to. |
| amount | integer | — | Refunded amount in smallest currency unit. |
| currency | string | — | Echoes the payment currency. |
| status | string | — | One of succeeded, pending, failed. For cards and Link this is synchronous; for bank debits the refund starts as pending and transitions via webhook. |
| reason | string | null | — | Echoes the request reason. |
| processor_references.refund_id | string | — | Refund ID on Outpost’s PSP account (e.g. re_… on Stripe). |
| created_at | string (ISO 8601) | — | When the refund was created. |
Error Responses
| HTTP | Code | Description |
|---|---|---|
| 400 | invalid_request | amount exceeds the unrefunded remainder, or currency mismatch. |
| 401 | — | Missing or invalid Bearer token. |
| 404 | payment_not_found | No payment exists for the given paymentId. |
| 422 | payment_not_refundable | Payment 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.*
POST <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
| Parameter | Type | Required | Description |
|---|---|---|---|
| Mor-Signature | string | Yes | t=<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
| Parameter | Type | Required | Description |
|---|---|---|---|
| id | string | — | Outpost event ID. Use to dedupe. |
| type | string | — | One of payment.succeeded, payment.failed, payment.requires_action, payment.refunded. |
| created | integer | — | Unix seconds. |
| data.object.payment_id | string | — | Outpost-owned payment identifier for this renewal cycle (pay_…). Use as the {paymentId} path param on POST /api/payments/{paymentId}/refund. |
| data.object.merchant_invoice_id | string | — | Stripe Invoice ID on your account that triggered this renewal. Pass to attach_payment. |
| data.object.merchant_customer_id | string | — | Customer ID on your Stripe account. |
| data.object.processor_charge_id | string | — | Charge ID on Outpost’s PSP account. Use as payment_reference on report_payment. Empty on failed / requires_action. |
| data.object.processor_payment_intent_id | string | — | PaymentIntent ID on Outpost’s PSP account. |
| data.object.amount | integer | — | For payment.succeeded / failed / requires_action: the payment amount. For payment.refunded: the amount refunded by this event. |
| data.object.currency | string | — | Three-letter currency code. |
| data.object.status | string | — | One of succeeded, failed, requires_action, partially_refunded, refunded. |
| data.object.failure_message | string | No | Human-readable decline reason (present on failed / requires_action). |
| data.object.decline_code | string | No | PSP decline code, e.g. insufficient_funds. |
| data.object.refund_id | string | No | Outpost-owned refund identifier. Only on payment.refunded. |
| data.object.processor_refund_id | string | No | Refund ID on Outpost’s PSP account. Only on payment.refunded. |
| data.object.amount_refunded_total | integer | No | Cumulative amount refunded across all refunds on this payment. Only on payment.refunded. |
| data.object.reason | string | No | Refund 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
| HTTP | Code | Description |
|---|---|---|
| 200 | — | Acknowledged. Outpost will not retry. |
| 4XX | — | Outpost gives up after 4xx (treat as permanent failure on your side). |
| 5XX | — | Outpost 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 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.