Skip to content

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:

  1. explicit_override (admin-forced, rare)
  2. tenant_settings.payments_provider (NULL = geography default)
  3. Patient preferred_currency == 'INR' or country_of_residence == 'IND' → Razorpay
  4. Tenant data_residency_region starts with ap-south → Razorpay
  5. 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

  1. https://dashboard.stripe.com/webhooks → Add endpoint
  2. URL: https://services.curaway.ai/api/v1/webhooks/stripe
  3. Subscribe to events:
  4. payment_intent.succeeded
  5. payment_intent.canceled
  6. payment_intent.amount_capturable_updated
  7. charge.refunded
  8. invoice.paid
  9. invoice.finalized
  10. Copy the Signing secret (whsec_…) → set STRIPE_WEBHOOK_SECRET in Railway.

Razorpay dashboard

  1. https://dashboard.razorpay.com/app/webhooks → Add new webhook
  2. URL: https://services.curaway.ai/api/v1/webhooks/razorpay
  3. Active events:
  4. payment.captured
  5. payment.authorized
  6. payment.failed
  7. refund.processed
  8. refund.created
  9. invoice.paid
  10. invoice.expired
  11. 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

  1. Look up the consultation in the BE (Postgres) to get consultation_charges.stripe_payment_intent_id.
  2. https://dashboard.stripe.com/payments/{intent_id}
  3. Audit timeline shows authorize / capture / refund events with timestamps.

Razorpay

  1. Get consultation_charges.razorpay_order_id (created at booking) or razorpay_payment_id (set after Checkout).
  2. https://dashboard.razorpay.com/app/orders/{order_id}
  3. 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 if authorized, refunds if captured.
  • /end after a < mso_min_billable_minutes session 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_… with sk_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_SECRET and RAZORPAY_WEBHOOK_SECRET on 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.

  1. [ ] Verify webhook URLs registered in Stripe test dashboard: https://<your-railway-url>/api/v1/mso/webhooks/stripe Events: payment_intent.succeeded, payment_intent.payment_failed, invoice.payment_succeeded
  2. [ ] Verify webhook URL registered in Razorpay test dashboard: https://<your-railway-url>/api/v1/mso/webhooks/razorpay Events: payment.captured, payment.failed, refund.processed
  3. [ ] Verify env vars on Railway production (test keys for initial launch):
    • STRIPE_SECRET_KEY (starts with sk_test_)
    • STRIPE_WEBHOOK_SECRET (starts with whsec_)
    • RAZORPAY_KEY_ID (starts with rzp_test_)
    • RAZORPAY_KEY_SECRET
    • RAZORPAY_WEBHOOK_SECRET
  4. [ ] Run main-smoke E2E manually with test keys; verify both providers' webhook signatures verify correctly in Railway logs.
  5. [ ] Pre-live only: swap from test keys to Curaway-owned production keys in both providers when ready to go live.
  6. [ ] Re-register webhook endpoints with production-key URLs (dashboards auto-invalidate test webhooks when you move to production).
  7. [ ] Update STRIPE_SECRET_KEY / RAZORPAY_KEY_ID etc. on Railway production.
  8. [ ] Re-issue webhook signing secrets; update STRIPE_WEBHOOK_SECRET and RAZORPAY_WEBHOOK_SECRET on Railway production.
  9. [ ] 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