In-App Payment Provider Management
Pharos apps can let authorized business or tenant users connect and manage a payment provider from inside the app while the platform operator keeps custody of platform secrets.
The first provider implementation is Stripe Connect. The runtime surface is provider-neutral so future providers can use the same account, secret, audit, callback nonce, route, and UI-state model.
App-owned payment provider contract
An app opts in from data/payment_provider.yml, which is loaded through the app manifest:
owner_resource_type: business
owner_state_section: businesses
owner_route_field: slug
owner_id_field: id
owner_user_id_field: owner_id
tenant_state_section: tenants
tenant_owner_id_field: source_business_id
tenant_id_field: id
tenant_owner_user_id_field: owner_user_id
membership_state_section: tenant_memberships
membership_user_id_field: user_id
membership_tenant_id_field: tenant_id
membership_status_field: membership_status
owner_path_prefix: /businesses/
owner_route_param: business_slug
settings_return_path: /businesses/:business_slug/
policy_gate: manage_business_payments
supported_providers:
- provider: stripe
connect_action_id: payment_provider_connect_stripe
callback_action_id: payment_provider_callback_stripe
refresh_action_id: payment_provider_refresh_stripe
disconnect_action_id: payment_provider_disconnect_stripe
credentials_action_id: payment_provider_credentials_stripe
credentials_delete_action_id: payment_provider_credentials_delete_stripe
checkout_mode_action_id: payment_provider_mode_stripe
credential_fields:
- name: stripe_client_id
secret_kind: client_id
required: true
- name: stripe_client_secret
secret_kind: client_secret
required: true
provider_checkout:
kind: appointment_booking
action_id: create_appointment
provider: stripe
reference_prefix: APPT
success_path: /appointments/
cancel_path: /explore/
webhook_action_id: payment_provider_checkout_webhook_stripe
The app owns the policy gate, the return path, the owner-to-tenant mapping, the optional owner and tenant membership fields used for authorization, and the templates that present the provider state. The runtime owns callback token verification, provider requests, state transitions, audit events, and secret redaction.
Operator-owned runtime configuration
The operator enables the provider and supplies platform-owned secret references through app.conf or the deployment environment:
payment_providers=stripe
stripe_client_id=<stripe-connect-client-id>
stripe_client_secret_env=PHAROS_STRIPE_CLIENT_SECRET
stripe_webhook_secret_env=PHAROS_STRIPE_WEBHOOK_SECRET
payment_provider_state_key_env=PHAROS_PAYMENT_PROVIDER_STATE_KEY
payment_provider_secret_key_env=PHAROS_PAYMENT_PROVIDER_SECRET_KEY
payment_provider_secret_key_version=v1
# During rotation only: retain one prior key until every encrypted row is rewrapped.
payment_provider_secret_previous_key_version=v0
payment_provider_secret_previous_key_env=PHAROS_PAYMENT_PROVIDER_SECRET_KEY_V0
# Optional local proof overrides. Production should use the default HTTPS Stripe endpoints.
# payment_provider_allow_loopback_http=false
# payment_provider_allow_unsigned_local_webhook=false
# payment_provider_allow_plaintext_local=false
# stripe_oauth_authorize_url=https://connect.stripe.com/oauth/authorize
# stripe_oauth_token_url=https://connect.stripe.com/oauth/token
# stripe_account_api_base_url=https://api.stripe.com/v1/accounts
# stripe_oauth_deauthorize_url=https://connect.stripe.com/oauth/deauthorize
# stripe_checkout_sessions_url=https://api.stripe.com/v1/checkout/sessions
base_url=https://example.test/ucal
stripe_client_id is not a secret. The values referenced by stripe_client_secret_env, payment_provider_state_key_env, and payment_provider_secret_key_env must never be committed to an app or template. payment_provider_secret_key_version identifies the active AES-256-GCM key. During rotation the runtime may decrypt rows tagged with the one declared previous version, then rewrap them with the active key; revocation clears ciphertext before marking the record revoked.
The Stripe endpoint URL settings are optional and exist for local proof harnesses and controlled operator overrides. When omitted, the runtime uses the official HTTPS Stripe endpoints. A non-HTTPS override is rejected unless it points to a loopback HTTP address and payment_provider_allow_loopback_http=true; this is for local smoke tests only and must not be enabled in production config. Unsigned checkout webhooks are accepted only when payment_provider_allow_unsigned_local_webhook=true; that key is also local-only and must not be enabled in production.
base_url must match the externally visible app origin and base path because the runtime derives callback URLs from it.
Business-admin flow
For UCAL, a business owner opens the business settings summary and uses the payment-provider controls rendered from the app templates:
- Connect starts Stripe hosted onboarding and records
connect_requestedand - The callback validates the signed state token and single-use nonce, exchanges
- Refresh re-reads provider account status and can move the account to
- Disconnect records
disconnect_requested, calls the provider deauthorization
redirect_pending.
the authorization code, refreshes the provider account, and records the resulting durable state.
connected, restricted, verification_pending, or revoked_externally.
endpoint, and records disconnected. A duplicate disconnect after that is a no-op.
Errors use stable reason codes and redacted reason detail. Provider response bodies, platform secrets, callback signing keys, and Stripe-style secret tokens must not be stored in audit rows or displayed directly.
State and audit model
The durable account row is keyed by:
app_idowner_resource_typeowner_resource_idprovider_id
This keeps the framework contract reusable beyond UCAL. UCAL maps its business records to owner_resource_type=business; another app can bind the same runtime surface to a different owner resource by changing manifest-declared section and field names rather than runtime code.
The runtime also records:
- callback nonces, so callbacks are single-use and replay-safe
- audit events, so connect, callback, refresh, disconnect, and failure states
- provider account snapshots, so UI can render a state summary without treating
are inspectable
legacy app-specific booleans as authoritative
Provider-backed checkout
Apps opt in to provider-backed checkout through the app-owned data/payment_provider.yml contract. The runtime dispatch remains generic: the app declares the checkout action, provider, owner mapping, return paths, and webhook action. Stripe is the first concrete provider.
Beacon Beats uses the provider checkout contract for cart checkout:
provider_checkout:
action_id: complete_purchase
provider: stripe
reference_prefix: BB
owner_route_value: beacon-beats
success_path: /checkout/success/
cancel_path: /checkout/cancel/
webhook_action_id: beacon_payment_provider_webhook_stripe
When owner credentials exist and the owner selects Live mode, Beacon Beats creates a Stripe-hosted Checkout Session from the cart, records the order as payment_pending, and unlocks downloads only after the signed provider webhook confirms payment. Demo remains the default fallback when credentials are absent or the owner selects Demo mode.
UCAL uses the same provider checkout contract for prepaid appointment booking. When a business has saved credentials and selects Live mode, booking a service creates a Stripe-hosted Checkout Session for the service cost and records the appointment as payment_pending. The appointment is confirmed only after the signed provider webhook marks the provider checkout reference as paid.
The checkout webhook handler models these Stripe events:
checkout.session.completed: marks the matching live checkout paid.checkout.session.expired: marks the matching checkout expired withoutcheckout.session.async_payment_failedandpayment_intent.payment_failed:charge.refunded,refund.created, andrefund.updated: mark the matchingcharge.dispute.createdandcharge.dispute.funds_withdrawn: mark thepayout.paid,payout.failed, andpayout.canceled: accepted as
granting access.
mark the matching checkout failed without granting access.
checkout refunded; Beacon Beats download entitlements for that order are revoked.
matching checkout disputed; Beacon Beats download entitlements for that order are revoked.
reconciliation notifications without checkout-state mutation.