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:

  1. Verifies the webhook signature
  2. Updates the Payment record status to succeeded
  3. Broadcasts a ride_updated event 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” button
  • requires_payment: true + payment_completed: true → payment confirmed, enable the “Complete Ride” button
  • requires_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/payments with payment_method: "card" — save the stripe_client_secret
  • Present Stripe Payment Sheet using the client secret
  • Subscribe to RideChannel and watch for payment_completed: true in ride_updated messages
  • Enable “Complete Ride” button only when !requires_payment || payment_completed
  • Call POST /api/v1/rides/:id/complete when the driver taps the button

Cash flow:

  • Driver calls POST /api/v1/rides/:ride_id/payments with payment_method: "cash"
  • Driver calls POST /api/v1/payments/:id/confirm after collecting cash
  • Subscribe to RideChannelpayment_completed: true will arrive after confirm
  • Call POST /api/v1/rides/:id/complete