Frontend Payment Integration Guide
Frontend Payment Integration Guide
This document describes what the backend expects from the frontend and what the backend returns, step by step, for both card and cash payment flows.
Prerequisites — All Requests
Every authenticated request must include a JWT token:
Authorization: Bearer <jwt_token>
Any request that creates or confirms a payment must also include an idempotency key (minimum 16 characters, typically a UUID):
Idempotency-Key: <uuid>
The idempotency key lets the frontend safely retry a request if the network drops — the backend will return the same response it gave the first time. Generate a new UUID per payment action (create, confirm, refund), not per retry of the same action.
Card Payment Flow (passenger pays with card)
Step 1 — Create a payment record and get a client secret
Who calls this: The passenger’s app, after the ride is in progress and the fare is set.
POST /api/v1/rides/:ride_id/payments
Content-Type: application/json
Authorization: Bearer <token>
Idempotency-Key: <uuid>
{
"payment_method": "card"
}
Success response — 201 Created:
{
"data": {
"attributes": {
"id": 42,
"ride_id": 7,
"stripe_payment_intent_id": "pi_3Ox...",
"stripe_client_secret": "pi_3Ox..._secret_...",
"amount_cents": 1500,
"amount_dollars": 15.0,
"status": "requires_payment_method",
"payment_method": "card",
"idempotency_key": "...",
"created_at": "2026-03-05T12:00:00.000Z",
"updated_at": "2026-03-05T12:00:00.000Z",
"ride": null
}
}
}
If a payment already exists for this ride the backend returns the existing record with 200 OK instead of 201.
The field the frontend needs: data.attributes.stripe_client_secret
Error responses:
| Status | Body | Meaning |
|---|---|---|
| 400 | { "error": "Payment method must be either \"card\" or \"cash\"" } |
Missing or invalid payment_method param |
| 403 | { "error": "Only passengers can create card payments" } |
Non-passenger tried to create a card payment |
| 503 | { "error": "stripe_error", "message": "Payment service temporarily unavailable.", "details": "..." } |
Stripe API unreachable |
Step 2 — Present the Stripe payment sheet
Use stripe_client_secret from Step 1 to initialise and present the Stripe Payment Sheet in the mobile SDK. The backend is not involved in this step — it is handled entirely between the Stripe SDK and Stripe’s servers.
// Flutter example
await Stripe.instance.initPaymentSheet(
paymentSheetData: SetupPaymentSheetParameters(
paymentIntentClientSecret: clientSecret,
merchantDisplayName: 'SafeRide',
),
);
await Stripe.instance.presentPaymentSheet();
After presentPaymentSheet() returns successfully, the SDK has collected and confirmed payment with Stripe. However, the backend does not yet know the payment succeeded — that information arrives via webhook (Step 3), not from the SDK.
Step 3 — Backend receives Stripe webhook (automatic, no frontend action needed)
When Stripe processes the payment it sends a payment_intent.succeeded webhook to POST /api/v1/stripe/webhooks. The backend:
- Verifies the webhook signature
- Updates the
Paymentrecord status tosucceeded - Broadcasts a
ride_updatedevent over ActionCable to all connected clients
This happens asynchronously — typically within a few seconds of Step 2 completing.
Step 4 — Listen for the ride update over ActionCable
Subscribe to RideChannel for the ride as soon as the ride detail screen loads (not just at payment time). When payment succeeds, the backend pushes this message:
{
"type": "ride_updated",
"data": {
"id": 7,
"status": "in_progress",
"passenger_id": 1,
"driver_id": 2,
"dispatcher_id": null,
"vehicle_id": 3,
"fare_cents": 1500,
"payment_method": "card",
"requires_payment": true,
"payment_completed": true,
"started_at": "2026-03-05T12:00:00Z",
"completed_at": null,
"canceled_at": null,
"updated_at": "2026-03-05T12:05:00Z"
}
}
The fields to watch: payment_completed and requires_payment.
requires_payment: true+payment_completed: false→ payment still pending, block the “Complete Ride” buttonrequires_payment: true+payment_completed: true→ payment confirmed, enable the “Complete Ride” buttonrequires_payment: false→ free ride, no payment needed, “Complete Ride” always available
Note on subscription: When the frontend first subscribes to RideChannel, the backend immediately sends a ride_state message with the current ride snapshot. This initial message does not currently include payment_completed or requires_payment — those fields only appear in subsequent ride_updated broadcasts. Fetch the ride via REST on screen load to get the authoritative initial state.
Subscribing to RideChannel:
{
"command": "subscribe",
"identifier": "{\"channel\":\"RideChannel\",\"ride_id\":7}"
}
The subscription is rejected (and the WebSocket message rejected is sent back) if the authenticated account is not the ride’s passenger, driver, dispatcher, or an admin.
Step 5 — Complete the ride
Who calls this: The driver (or dispatcher).
POST /api/v1/rides/:id/complete
Authorization: Bearer <token>
No request body needed.
Success response — 200 OK: Full ride object (via RideSerializer).
Error response if payment not done — 422 Unprocessable Content:
{
"error": {
"message": "Cannot complete ride: payment not completed",
"details": { "type": "payment_required" }
}
}
The frontend should prevent this call when payment_completed is false, but must handle this error defensively in case of a race condition.
Cash Payment Flow (driver collects cash from passenger)
Step 1 — Driver creates a cash payment record
Who calls this: The driver’s app.
POST /api/v1/rides/:ride_id/payments
Content-Type: application/json
Authorization: Bearer <token>
Idempotency-Key: <uuid>
{
"payment_method": "cash"
}
Success response — 201 Created:
{
"data": {
"attributes": {
"id": 43,
"ride_id": 7,
"stripe_payment_intent_id": null,
"stripe_client_secret": null,
"amount_cents": 1500,
"amount_dollars": 15.0,
"status": "pending",
"payment_method": "cash",
"idempotency_key": "...",
"created_at": "2026-03-05T12:00:00.000Z",
"updated_at": "2026-03-05T12:00:00.000Z",
"ride": null
}
}
}
Error responses:
| Status | Body | Meaning |
|---|---|---|
| 403 | { "error": "Only drivers can create cash payments" } |
Non-driver tried to create a cash payment |
Step 2 — Driver confirms cash received
After physically collecting the cash, the driver confirms it in the app. Use the id from Step 1.
POST /api/v1/payments/:id/confirm
Content-Type: application/json
Authorization: Bearer <token>
Idempotency-Key: <uuid>
{
"amount_cents": 1500
}
amount_cents is optional. If provided, the backend validates it matches the fare — if it doesn’t match, the request is rejected with 400.
Success response — 200 OK:
{
"data": {
"attributes": {
"id": 43,
"ride_id": 7,
"stripe_payment_intent_id": null,
"stripe_client_secret": null,
"amount_cents": 1500,
"amount_dollars": 15.0,
"status": "succeeded",
"payment_method": "cash",
...
}
}
}
Confirming cash triggers the same after_commit callback as a webhook: the backend broadcasts a ride_updated ActionCable message with payment_completed: true.
Error response — 400 Bad Request:
{ "error": "Amount does not match expected fare" }
Step 3 — Complete the ride
Same as Step 5 in the card flow:
POST /api/v1/rides/:id/complete
Authorization: Bearer <token>
Ride with No Fare (free / donation-only)
If ride.fare_cents is 0 or null, requires_payment is false. No payment record needs to be created. The driver can call POST /api/v1/rides/:id/complete immediately without any payment step.
Error Reference
Payment creation / confirmation errors
| Status | error field |
Cause |
|---|---|---|
| 400 | "Invalid idempotency key" |
Idempotency-Key header shorter than 16 characters |
| 402 | "insufficient_funds" |
Card declined — not enough funds |
| 402 | "requires_action" |
3DS or other additional authentication needed |
| 402 | "payment_error" |
Generic card error |
| 503 | "stripe_error" |
Stripe API unavailable |
Ride completion error
| Status | error.details.type |
Cause |
|---|---|---|
| 422 | "payment_required" |
payment_completed? is false on the ride |
Summary Checklist for the Frontend
Card flow:
- Call
POST /api/v1/rides/:ride_id/paymentswithpayment_method: "card"— save thestripe_client_secret - Present Stripe Payment Sheet using the client secret
- Subscribe to
RideChanneland watch forpayment_completed: trueinride_updatedmessages - Enable “Complete Ride” button only when
!requires_payment || payment_completed - Call
POST /api/v1/rides/:id/completewhen the driver taps the button
Cash flow:
- Driver calls
POST /api/v1/rides/:ride_id/paymentswithpayment_method: "cash" - Driver calls
POST /api/v1/payments/:id/confirmafter collecting cash - Subscribe to
RideChannel—payment_completed: truewill arrive after confirm - Call
POST /api/v1/rides/:id/complete