MSO Teleconsultation — Payments Runbook¶
Status: Phase 2a.4 (ADR-0018) — backend complete; FE Stripe Elements / Razorpay Checkout follow in Phase 2a.4-FE.
This runbook covers operational tasks for the dual-provider payments
stack: Stripe (cross-border default) and Razorpay (India). Spec:
docs/specs/mso-teleconsultation-feature.md §5.
Quick reference¶
| Provider | Used for | Webhook URL (production) |
|---|---|---|
| Stripe | USD / GBP / EUR / AED / non-INR | https://services.curaway.ai/api/v1/webhooks/stripe |
| Razorpay | INR or country=IND |
https://services.curaway.ai/api/v1/webhooks/razorpay |
Routing is decided per-booking by app.services.payments.routing.pick_payment_provider:
- explicit_override (admin-forced, rare)
tenant_settings.payments_provider(NULL = geography default)- Patient
preferred_currency == 'INR'orcountry_of_residence == 'IND'→ Razorpay - Tenant
data_residency_regionstarts withap-south→ Razorpay - Otherwise → Stripe
Patients never see the provider name (Gate 4c).
Environment variables (Railway)¶
STRIPE_SECRET_KEY=sk_test_… or sk_live_…
STRIPE_WEBHOOK_SECRET=whsec_…
RAZORPAY_KEY_ID=rzp_test_… or rzp_live_…
RAZORPAY_KEY_SECRET=…
RAZORPAY_WEBHOOK_SECRET=…
Missing credentials degrade gracefully — booking returns MSO_PROVIDER_NOT_CONFIGURED_001 (503) at runtime.
Webhook setup¶
Stripe dashboard¶
- https://dashboard.stripe.com/webhooks → Add endpoint
- URL:
https://services.curaway.ai/api/v1/webhooks/stripe - Subscribe to events:
payment_intent.succeededpayment_intent.canceledpayment_intent.amount_capturable_updatedcharge.refundedinvoice.paidinvoice.finalized- Copy the Signing secret (
whsec_…) → setSTRIPE_WEBHOOK_SECRETin Railway.
Razorpay dashboard¶
- https://dashboard.razorpay.com/app/webhooks → Add new webhook
- URL:
https://services.curaway.ai/api/v1/webhooks/razorpay - Active events:
payment.capturedpayment.authorizedpayment.failedrefund.processedrefund.createdinvoice.paidinvoice.expired- Set the Secret → put it in Railway as
RAZORPAY_WEBHOOK_SECRET.
Both providers retry failed deliveries; Curaway returns 200 even on
duplicate so the retry queue drains. Idempotency lives in the
webhook_events table — UNIQUE on (provider_name, provider_event_id).
Verifying a charge in the provider dashboard¶
Stripe¶
- Look up the consultation in the BE (Postgres) to get
consultation_charges.stripe_payment_intent_id. - https://dashboard.stripe.com/payments/{intent_id}
- Audit timeline shows authorize / capture / refund events with timestamps.
Razorpay¶
- Get
consultation_charges.razorpay_order_id(created at booking) orrazorpay_payment_id(set after Checkout). - https://dashboard.razorpay.com/app/orders/{order_id}
- The linked payment_id appears once the patient completes Checkout.
Manual refund procedure¶
Refunds should normally be issued via the cancel/end flow:
POST /api/v1/mso/consultations/{id}/cancel→ voids ifauthorized, refunds ifcaptured./endafter a <mso_min_billable_minutessession voids/refunds automatically.
For an out-of-band refund (post-session billing dispute):
# Identify the charge
psql $DATABASE_URL -c "
SELECT id, charge_status, provider_name, stripe_payment_intent_id, razorpay_payment_id, amount_cents, currency
FROM consultation_charges WHERE id='<charge_uuid>'
"
Then issue the refund through the provider dashboard (Stripe: payment → Refund; Razorpay: payment → Refund). Update the local row:
UPDATE consultation_charges
SET charge_status='refunded',
refunded_at=now(),
refund_reason='manual_dispute'
WHERE id='<charge_uuid>';
INSERT INTO audit_log(...) VALUES (...); -- add manual audit row with actor_id
Pre-launch checklist (test → live)¶
Before flipping the MSO panel into production traffic:
- [ ] Replace Stripe
sk_test_…withsk_live_…(Curaway's account, not the demo account). - [ ] Replace Razorpay
rzp_test_…with the Curaway production key id + secret. - [ ] Re-issue webhook signing secrets; update
STRIPE_WEBHOOK_SECRETandRAZORPAY_WEBHOOK_SECRETon Railway production. - [ ] Verify both webhook endpoints in their respective dashboards (test event → 2xx).
- [ ] Confirm the HIPAA-tier check on Daily.co is green for the MSO tenants (separate runbook:
mso-video.md). - [ ] Manual smoke: create a $0.50 test consultation, run /schedule → /end, verify the captured charge in Stripe AND in
consultation_charges.charge_status='captured'. - [ ] Manual smoke: same flow with INR/Razorpay against an India-tenant fixture.
- [ ] Confirm Telegram alerts fire on a deliberately broken webhook (revoke the secret temporarily).
Common failure modes¶
| Symptom | Likely cause | Mitigation |
|---|---|---|
/schedule returns 402 MSO_CHARGE_FAILED_001 |
Card declined OR provider config error | Check the provider dashboard for the failed PaymentIntent / Order; SAVEPOINT rolled back the session row, no orphan |
/schedule returns 503 MSO_PROVIDER_UNAVAILABLE_001 |
Provider 5xx, network timeout, or circuit breaker open | Provider-side outage; retry. Telegram alert should already be firing |
/schedule returns 503 MSO_PROVIDER_NOT_CONFIGURED_001 |
Missing env var | Set the relevant *_KEY / *_SECRET on Railway and redeploy |
Webhook returns 400 MSO_WEBHOOK_INVALID_SIGNATURE_001 |
Wrong signing secret OR clock skew (Stripe enforces a tolerance window) | Reissue the webhook secret; verify Railway env matches dashboard |
Webhook returns 200 MSO_WEBHOOK_DUPLICATE_001 |
Provider retried | Expected — provider stops retrying on 2xx |
Charge stuck in pending |
Authorize call returned but webhook never landed | Check the provider dashboard; replay the webhook from there if available |
Telegram channel: daily_co_outage / stripe_outage / razorpay_outage — separate categories so the cooldown (5 min for WARNING) doesn't suppress unrelated providers.
Pre-launch Rollout (payments — Phase 2a.8)¶
Run these steps after mso-video.md pre-launch steps 1–3. Both checklists
must be green before flipping mso_video_enabled=true.
- [ ] Verify webhook URLs registered in Stripe test dashboard:
https://<your-railway-url>/api/v1/mso/webhooks/stripeEvents:payment_intent.succeeded,payment_intent.payment_failed,invoice.payment_succeeded - [ ] Verify webhook URL registered in Razorpay test dashboard:
https://<your-railway-url>/api/v1/mso/webhooks/razorpayEvents:payment.captured,payment.failed,refund.processed - [ ] Verify env vars on Railway production (test keys for initial launch):
STRIPE_SECRET_KEY(starts withsk_test_)STRIPE_WEBHOOK_SECRET(starts withwhsec_)RAZORPAY_KEY_ID(starts withrzp_test_)RAZORPAY_KEY_SECRETRAZORPAY_WEBHOOK_SECRET
- [ ] Run main-smoke E2E manually with test keys; verify both providers' webhook signatures verify correctly in Railway logs.
- [ ] Pre-live only: swap from test keys to Curaway-owned production keys in both providers when ready to go live.
- [ ] Re-register webhook endpoints with production-key URLs (dashboards auto-invalidate test webhooks when you move to production).
- [ ] Update
STRIPE_SECRET_KEY/RAZORPAY_KEY_IDetc. on Railway production. - [ ] Re-issue webhook signing secrets; update
STRIPE_WEBHOOK_SECRETandRAZORPAY_WEBHOOK_SECRETon Railway production. - [ ] Confirm Telegram alerts fire on a deliberately broken webhook (revoke the secret temporarily; verify alert received; restore).
See also¶
- Spec:
docs/specs/mso-teleconsultation-feature.md§5 - ADR:
docs/adr/adr-0018-mso-portal.md - Error codes:
docs/api/error-codes.md - Video runbook:
docs/runbook/mso-video.md