# Managed customer payments

Launchbase manages a curated Stripe billing integration for each app. This is separate from the app owner's Launchbase subscription, card spending permission and domain purchases.

## Start here

1. Enable `payments: "managed"` in the app configuration. Read `get_payment_guide` in MCP, or this document and `/docs/payments/actions.json`.
2. Call `manage_app_payments` with `{appId, action:"onboard", input:{mode:"test"}}`. This creates a separate Stripe sandbox account using test fixtures. Poll `account.refresh` until `chargesEnabled` is true; test activation is asynchronous. Never enter real card details in test mode.
3. Create plans and customers in test mode. Run `configure_app_runtime`, then deploy to inject `LB_RUNTIME_URL` and `LB_RUNTIME_TOKEN` securely. No Stripe secret key is needed in the app.
4. Build your own pricing and billing screens. Use hosted, embedded or custom Checkout; use Stripe Elements for card fields. Test purchase, payment failure, cancellation, refund and two-user isolation.
5. For live sales, call `onboard` with `mode:"live"` and the owner's actual country/business type. Agents receive an owner URL. The owner completes Stripe verification in Launchbase. Stripe may require a sign-in popup. Never invent identity details or accept live Stripe terms on someone's behalf.
6. Create the live catalog separately, switch the backend to `mode:"live"`, and check `account.refresh`. `chargesEnabled` permits checkout; `payoutsEnabled` separately controls payouts. Outstanding future requirements do not automatically mean payments are blocked.

Live charges are direct charges on the connected account. The app owner is the merchant, pays Stripe fees and manages their customers. Live accounts use a full Stripe dashboard, Stripe-collected requirements and Stripe responsibility for connected-account payment losses. This does not eliminate Launchbase's platform obligations. Test fixture accounts have different controller settings and never move real money. Launchbase adds no application fee to customer-app payments.

## HTTP and authentication

Every action uses POST with JSON. The public API contract lists all supported action names, examples and access rules at `/docs/payments/actions.json` and `/docs/payments`.

- Management: `POST https://launchbase.build/api/v1/apps/APP_ID/payments/actions/ACTION` with `Authorization: Bearer YOUR_LAUNCHBASE_TOKEN`. Read actions require `app:read`; mutations require `app:write`. Live onboarding and Stripe embedded account sessions require an owner browser session. An agent receives a link for the owner instead of a secret. Normal management credentials may be restricted to one app.
- Deployed backend: `POST ${LB_RUNTIME_URL}/payments/ACTION` with `Authorization: Bearer ${LB_RUNTIME_TOKEN}`. This token is restricted to one app and the documented runtime action list. It cannot create/archive plans, issue refunds, manage Stripe accounts, retry platform event jobs, or list all customers.
- Keep both credentials server-only. No `NEXT_PUBLIC_` or `VITE_` prefix. Never send them to the browser, logs, source control or a third-party agent prompt. `publishableKey`, `stripeAccount` and an individual customer's short-lived Checkout/SetupIntent client secret may go to that customer's browser. AccountSession client secrets belong only in the owner's Launchbase session.
- Explicitly send `mode:"test"` or `mode:"live"`. Omission defaults to test. Test/live plans, customers, events and billing objects are separate.
- Runtime requests other than `plans.list` must include `customerRef`, a stable user/organization ID derived by your authenticated backend. **Never forward an arbitrary browser customerRef.** For organization billing, verify the signed-in user is that organization's billing administrator. The runtime token trusts your backend; it does not authenticate your end users.
- Authenticate requests and apply your normal CSRF protection, rate limits and authorization before calling the runtime API. No browser-to-runtime integration.

Success: `{ "data": { ... } }` (management also includes `requestId`). Errors: `{ "error": { "code", "message", "details" }, "requestId" }`. Handle non-2xx before using data. A missing object or wrong customer/app gives 404. Invalid input gives 400, forbidden actions 403, conflicting/in-progress requests 409, rate limits 429. Stripe/provider errors are failures, never successful payments.

## Idempotency and money

All mutating billing actions (except account setup/session) require `requestId`: 8–100 letters, digits, underscores or hyphens. Persist it with the local business operation before sending. Retry the **same action, same input and same requestId** after a timeout. Reusing it with different input returns 409. In-progress requests return 409; back off and retry. An uncertain request older than 23 hours requires reconciliation; inspect Stripe/events or contact support before attempting a new purchase. Do not generate a fresh key to bypass an uncertain charge.

Prices use integer currency minor units: USD 1500 is $15; JPY 1500 is ¥1500. Prices are immutable: create a new code when changing amounts. `interval` is `month`, `year` or omitted for a one-off purchase. Features are boolean flags or non-negative numeric limits. Your app enforces those limits. Archive stops new checkout while existing subscriptions continue.

Keep one recurring subscription per customer with this v1 contract. A customer can make multiple one-off purchases. Only one unfinished subscription checkout is allowed; finish or expire it before creating another. Keep the returned session ID. An uncertain creation keeps the slot reserved until reconciliation or expiry (at most 24 hours). Existing subscriptions should use preview/update or the billing portal.

Obtain explicit user approval before changing a paid subscription, retrying an invoice or issuing a refund. Send `confirm:true` only after that approval. A boolean is an assertion by the trusted backend/agent, not proof of user consent; your app must implement the confirmation UI. The Launchbase agent card-spending toggle is unrelated to these customer billing operations.

## Backend example (Node / Next.js)

```js
// SERVER ONLY. requireUser must verify your actual session, not decode an unverified JWT.
async function billing(action, input, user) {
  if (!user?.id) throw new Error('Authentication required');
  const response = await fetch(`${process.env.LB_RUNTIME_URL}/payments/${action}`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.LB_RUNTIME_TOKEN}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ ...input, mode: process.env.PAYMENT_MODE || 'test', customerRef: user.id })
  });
  const result = await response.json();
  if (!response.ok) throw new Error(result.error?.message || 'Billing unavailable');
  return result.data;
}
// In your authenticated checkout route: obtain user from requireUser(request).
// Persist operationId in your database; reuse it on retries.
await billing('customers.upsert', { email: user.email, requestId: `customer-${user.id}` }, user);
const session = await billing('checkout.create', {
  planCode: 'pro-monthly', ui: 'hosted',
  returnUrl: `${process.env.APP_ORIGIN}/billing`, requestId: operationId
}, user);
// Send session.url to this customer's browser and navigate there.
```

## Backend example (Python)

```python
import os, requests

def billing(action, values, verified_user):
    # verified_user comes from your authenticated session; never request JSON.
    if not verified_user or not verified_user.get('id'):
        raise PermissionError('Authentication required')
    payload = dict(values, mode=os.getenv('PAYMENT_MODE', 'test'),
                   customerRef=verified_user['id'])
    response = requests.post(
        os.environ['LB_RUNTIME_URL'] + '/payments/' + action,
        headers={'Authorization': 'Bearer ' + os.environ['LB_RUNTIME_TOKEN']},
        json=payload, timeout=60)
    response.raise_for_status()
    return response.json()['data']
```

## Checkout and saved cards: your UI

`checkout.create` accepts `ui:"hosted"` (returns `url`), `"embedded"` or `"custom"` (returns `clientSecret`). All return `publishableKey` and `stripeAccount`. Initialize Stripe.js using **both**, e.g. `Stripe(publishableKey, {stripeAccount})`. The connected account owns all the payment objects. `returnUrl` must use one of this app's active public origins. Local arbitrary/external redirect URLs are rejected; test on a deployed preview URL.

- Hosted: redirect to `url`.
- Embedded: initialize `stripe.initEmbeddedCheckout({clientSecret})` and mount its UI.
- Custom: initialize Checkout using Stripe's current Checkout Elements SDK with the returned client secret, mount the Payment Element, and confirm using that Checkout instance. Follow https://docs.stripe.com/payments/advanced/build-subscriptions for the current browser API. The agent can design every surrounding pricing, billing and confirmation screen.
- Card saving: call `cards.setup`; initialize `stripe.elements({clientSecret,appearance})`, mount a Payment Element, and call `stripe.confirmSetup({elements,confirmParams:{return_url:yourBillingUrl},redirect:"if_required"})`. Handle required authentication and failures. After success, call `cards.default` with the saved `pm_...` ID if the customer chose it for future invoices. Show consent for future off-session charges. List only brand/last4/expiry through `cards.list`. Existing subscriptions may have their own default payment method; use the portal to replace their card.
- Stripe keeps secure card fields in its frames. Never collect raw card numbers in your own inputs or route them through Launchbase. You can style supported Elements properties; you cannot replace its secure fields with arbitrary HTML.
- `portal.create` opens a Stripe-hosted billing portal for invoices, replacing payment methods and cancelling at period end. It is an alternative to your custom billing screen, not the only supported UI.

## Subscriptions and paid access

Use `subscriptions.preview` to show the estimated prorated invoice before asking for approval. Then `subscriptions.update` with the chosen plan and `confirm:true`. Changes use `always_invoice` and `pending_if_incomplete`: an unpaid upgrade remains pending. The original plan keeps access while a paid invoice covers the current time; the new features unlock only after payment. Amounts can change between preview and confirmation. The returned `pendingUpdate` flag indicates an incomplete change; provide the latest open invoice's hosted URL for payment/authentication. Retrieve invoices again instead of assuming an upgrade succeeded.

`subscriptions.cancel` defaults to cancellation at period end; `immediate:true` ends it now. Cancellation does not automatically refund prior charges. `subscriptions.resume` removes a scheduled cancellation; it cannot resurrect a cancelled subscription. V1 supports one recurring item, card payments, fixed one-off/monthly/yearly prices and immediate prorated plan changes. For metered billing, complex multi-item subscriptions, tax automation, coupons, arbitrary invoice creation or subscription schedules, use the connected Stripe dashboard or a direct BYOK integration. These are not silently simulated by Launchbase.

Use `access.get` on your authenticated backend to decide what the customer may use. It retrieves current Stripe subscriptions and returns `{features,subscriptions,checkedAt}`. Access is granted for trialing subscriptions, or active subscriptions with a paid latest invoice, and no paused collection. Past-due, unpaid, paused and cancelled subscriptions grant nothing. Scheduled cancellation retains access while still active/paid. Flags are combined with OR; numeric limits use the maximum across recognized plans, not quantity multiplication. Enforce usage yourself. On an API error, do not assume payment succeeded; fail closed or use a deliberately short, previously verified cache.

One-off purchases do not grant subscription features. Verify `checkout.get.paymentStatus === "paid"` or `transactions.get.status === "succeeded"` server-side, then fulfil once using a unique payment/session key in your own database. Never fulfil from a return URL alone. Refunds do not cancel subscriptions or automatically revoke features; implement your refund/access policy explicitly.

## Events, retries and recovery

Launchbase verifies Stripe webhook signatures, stores each event once, and processes it in a durable retryable job. Processing fetches the current Stripe object; `summary` describes current state, not an immutable copy of the original event payload. Duplicate or out-of-order delivery must not change your fulfillment twice. Stripe remains the financial source of truth.

Poll `events.list` from your backend with a persisted `after` cursor and `limit` (1–100). Runtime queries are customer-scoped. Returned `nextAfter` is the next cursor; store it only after your own transaction commits. Deduplicate by `event_id`, even after restoring your app database. `events.get` inspects a known event. Runtime feeds stop before an unprocessed/failed event and return `pending:true`, so delayed customer attribution cannot be skipped. Back off and retry; the owner/agent can inspect the app Events tab and use `events.retry` if processing fails. Management feeds include received/failed entries for diagnosis. Don't busy-loop when nextAfter is unchanged.

An hourly reconciliation scans the previous 24 hours of Stripe events, with overlapping deduplication and paginated jobs, to repair missed deliveries. Webhooks may still arrive late or be missed. `access.get`, checkout, subscription, transaction and invoice reads query Stripe directly. Recheck current state at sign-in, billing return and before sensitive paid operations; run periodic checks for long-running work. Do not treat a local event cache as the sole source of truth. This v1 provides polling, not arbitrary outbound app webhook delivery.

## BYOK and migration

With your own Stripe keys, your backend calls Stripe directly and owns its events, subscriptions and entitlements. Managed runtime billing is disabled. Use server-only environment variables; the BYOK storage API does not automatically put a key into your application. Configure your own Stripe webhook endpoint and signing secret, and deploy those server variables through the documented variables API.

Do not switch keys or disable Payments while abandoning active subscriptions. Export mappings with paginated `plans.list` (includes Stripe price/product IDs) and `customers.list` (stable customerRef → Stripe customer ID), then transaction/subscription/invoice lists. Live customers belong to the connected Stripe account, not a new unrelated account. Continue with that account's own keys and webhooks when migrating; moving card data across Stripe accounts needs Stripe's supported migration process. Contact Launchbase support to coordinate disconnecting managed billing after verifying the replacement integration. Disabling hosting, pausing an app, deleting local data or disconnecting Launchbase does **not** cancel Stripe subscriptions.

## Required checks before launch

Test two customers and two apps: no cross-customer subscriptions, cards, invoices, refunds or events. Test a successful purchase, failed card, authentication-required card, cancelled checkout, duplicate/retried requests, pending upgrade, period-end cancellation, resumed renewal, partial refund, delayed event and repeated event. Confirm live mode uses live catalog IDs; test IDs never become live. Never report live payments ready until Stripe reports charges enabled and your deployed billing integration passes its checks.

## Every action and request example

The served version of this guide appends the current action catalogue below. The same catalogue is available as JSON at `/docs/payments/actions.json`.

### status

Cached test/live connection status; no provider call.

Access: app:read. Runtime: no.

```json
{
  "mode": "test"
}
```

### account.refresh

Read current Stripe charges, payouts and requirements.

Access: app:read. Runtime: no.

```json
{
  "mode": "test"
}
```

### plans.list

Paginated catalog, including archived entries and Stripe IDs. Render only active plans for new purchases.

Access: app:read. Runtime: yes.

```json
{
  "mode": "test"
}
```

### customers.list

Paginated customerRef → Stripe customer mapping for this app and mode. Management only.

Access: app:read. Runtime: no.

```json
{
  "mode": "test",
  "limit": 25
}
```

### customers.get

Read name, email and default payment method.

Access: app:read. Runtime: yes.

```json
{
  "mode": "test",
  "customerRef": "user_123"
}
```

### checkout.get

Read status, paymentStatus, paymentId and subscriptionId; verify payment server-side before fulfilment.

Access: app:read. Runtime: yes.

```json
{
  "mode": "test",
  "customerRef": "user_123",
  "id": "cs_..."
}
```

### cards.list

List this customer’s saved cards (brand, last4, expiry; never full details).

Access: app:read. Runtime: yes.

```json
{
  "mode": "test",
  "customerRef": "user_123"
}
```

### subscriptions.list

Paginated subscription history; runtime is customer-scoped.

Access: app:read. Runtime: yes.

```json
{
  "mode": "test",
  "customerRef": "user_123",
  "limit": 25
}
```

### subscriptions.get

Read status, period end, items and pendingUpdate. Wrong-customer objects return 404.

Access: app:read. Runtime: yes.

```json
{
  "mode": "test",
  "customerRef": "user_123",
  "id": "sub_..."
}
```

### transactions.list

Paginated PaymentIntent history, amounts, refund totals and receipt links.

Access: app:read. Runtime: yes.

```json
{
  "mode": "test",
  "customerRef": "user_123",
  "limit": 25
}
```

### transactions.get

Read current payment status and refund total.

Access: app:read. Runtime: yes.

```json
{
  "mode": "test",
  "customerRef": "user_123",
  "id": "pi_..."
}
```

### invoices.list

Paginated invoices with amounts, status, hosted payment URL and PDF URL.

Access: app:read. Runtime: yes.

```json
{
  "mode": "test",
  "customerRef": "user_123",
  "limit": 25
}
```

### invoices.get

Read an invoice; its hosted URL supports customer payment/authentication.

Access: app:read. Runtime: yes.

```json
{
  "mode": "test",
  "customerRef": "user_123",
  "id": "in_..."
}
```

### refunds.list

List refunds for an owned paymentId.

Access: app:read. Runtime: yes.

```json
{
  "mode": "test",
  "customerRef": "user_123",
  "paymentId": "pi_..."
}
```

### events.list

Cursor feed. after defaults to 0; limit 1–100. Runtime returns customer events and pending:true when an earlier refresh blocks progress. Persist nextAfter only after processing.

Access: app:read. Runtime: yes.

```json
{
  "mode": "test",
  "customerRef": "user_123",
  "after": "0",
  "limit": 25
}
```

### events.get

Inspect one event by evt_ ID and its current processing status/summary.

Access: app:read. Runtime: yes.

```json
{
  "mode": "test",
  "customerRef": "user_123",
  "id": "evt_..."
}
```

### access.get

Current Stripe-backed paid features for a customer. Active plus paid latest invoice, or trialing; no paused collection. No automatic one-off purchase grants.

Access: app:read. Runtime: yes.

```json
{
  "mode": "test",
  "customerRef": "user_123"
}
```

### customers.upsert

Create a customer once per customerRef; returns existing mapping unchanged if it already exists.

Access: app:write. Runtime: yes.

```json
{
  "mode": "test",
  "customerRef": "user_123",
  "email": "buyer@example.com",
  "name": "Alex",
  "requestId": "customer-user-123"
}
```

### customers.update

Update optional name and/or email on a mapped customer.

Access: app:write. Runtime: yes.

```json
{
  "mode": "test",
  "customerRef": "user_123",
  "email": "new@example.com",
  "requestId": "update-customer-001"
}
```

### checkout.create

Create Checkout. ui: hosted, embedded or custom. Requires an active plan, customer and charges-enabled account. quantity defaults to 1 (max 1000). returnUrl must belong to this app. Optional trialDays 1–30 is administrator-only. Persist requestId and session ID.

Access: app:write. Runtime: yes.

```json
{
  "mode": "test",
  "customerRef": "user_123",
  "planCode": "pro-monthly",
  "ui": "custom",
  "returnUrl": "https://YOUR_APP_URL/billing",
  "requestId": "checkout-user-123-001"
}
```

### checkout.expire

Expire an open checkout and free its subscription checkout slot.

Access: app:write. Runtime: yes.

```json
{
  "mode": "test",
  "customerRef": "user_123",
  "id": "cs_...",
  "requestId": "expire-session-001"
}
```

### cards.setup

Create a SetupIntent. Returns clientSecret, publishableKey and stripeAccount for that customer’s browser.

Access: app:write. Runtime: yes.

```json
{
  "mode": "test",
  "customerRef": "user_123",
  "requestId": "save-card-001"
}
```

### cards.remove

Detach a card only when the customer has no non-terminal subscription. Use the portal to replace cards otherwise.

Access: app:write. Runtime: yes.

```json
{
  "mode": "test",
  "customerRef": "user_123",
  "id": "pm_...",
  "requestId": "remove-card-001"
}
```

### cards.default

Set a customer default invoice payment method. Does not override a subscription-specific default.

Access: app:write. Runtime: yes.

```json
{
  "mode": "test",
  "customerRef": "user_123",
  "id": "pm_...",
  "requestId": "default-card-001"
}
```

### portal.create

Create a short-lived customer billing portal URL for payment methods, invoices and cancellation.

Access: app:write. Runtime: yes.

```json
{
  "mode": "test",
  "customerRef": "user_123",
  "returnUrl": "https://YOUR_APP_URL/billing",
  "requestId": "billing-portal-001"
}
```

### subscriptions.preview

Estimate the invoice for an immediate prorated plan change. Requires a recurring plan; amounts may change before confirmation.

Access: app:write. Runtime: yes.

```json
{
  "mode": "test",
  "customerRef": "user_123",
  "id": "sub_...",
  "planCode": "pro-yearly",
  "requestId": "preview-change-001"
}
```

### subscriptions.update

Apply a confirmed prorated change. An unpaid change remains pending. Read invoices for an authentication/payment link.

Access: app:write. Runtime: yes.

```json
{
  "mode": "test",
  "customerRef": "user_123",
  "id": "sub_...",
  "planCode": "pro-yearly",
  "confirm": true,
  "requestId": "change-plan-001"
}
```

### subscriptions.cancel

confirm:true required. Defaults to period-end cancellation; immediate:true cancels now. No automatic refund.

Access: app:write. Runtime: yes.

```json
{
  "mode": "test",
  "customerRef": "user_123",
  "id": "sub_...",
  "confirm": true,
  "immediate": false,
  "requestId": "cancel-plan-001"
}
```

### subscriptions.resume

confirm:true required. Remove a scheduled period-end cancellation; cancelled subscriptions cannot resume.

Access: app:write. Runtime: yes.

```json
{
  "mode": "test",
  "customerRef": "user_123",
  "id": "sub_...",
  "confirm": true,
  "requestId": "resume-sub-001"
}
```

### invoices.retry

Retry an open invoice using its saved payment method. confirm:true required; authentication may still be needed via the invoice URL.

Access: app:write. Runtime: yes.

```json
{
  "mode": "test",
  "customerRef": "user_123",
  "id": "in_...",
  "confirm": true,
  "requestId": "retry-invoice-001"
}
```

### onboard

Create a sandbox for testing, or return owner verification for live mode. Country and businessType apply to live accounts.

Access: app:write; live activation requires owner. Runtime: no.

```json
{
  "mode": "live",
  "country": "GB",
  "businessType": "individual"
}
```

### account.session

Owner browser only. Short-lived embedded Stripe account session; never expose to an agent or app customer.

Access: owner. Runtime: no.

```json
{
  "mode": "live"
}
```

### plans.create

Create an immutable price. amount is an integer in currency minor units; interval is month, year or omitted for a one-off. features maps names to booleans or non-negative numeric limits.

Access: app:write. Runtime: no.

```json
{
  "mode": "test",
  "code": "pro-monthly",
  "name": "Pro",
  "amount": 1500,
  "currency": "usd",
  "interval": "month",
  "features": {
    "pro": true,
    "workspaces": 10
  },
  "requestId": "plan-pro-monthly-001"
}
```

### plans.archive

Stop new purchases. Existing subscriptions continue.

Access: app:write. Runtime: no.

```json
{
  "mode": "test",
  "planCode": "pro-monthly",
  "requestId": "archive-plan-001"
}
```

### refunds.create

Administrator only; confirm:true and positive amount required. Refunds move money, do not cancel subscriptions, and do not automatically revoke paid access.

Access: app:write. Runtime: no.

```json
{
  "mode": "test",
  "paymentId": "pi_...",
  "amount": 500,
  "confirm": true,
  "requestId": "refund-order-001"
}
```

### events.retry

Administrator retry of an event refresh; does not resend or repeat the payment.

Access: app:write. Runtime: no.

```json
{
  "mode": "test",
  "id": "evt_...",
  "requestId": "retry-event-001"
}
```
