Stripe Payment — Integration Guide
This document covers two parts:
- Frontend Integration — API endpoints, payloads, responses, and 3DS handling
- Backend Architecture — Service internals, webhook handling, and how to extend
Overview
Stripe payment supports two modes:
| Session Type | Description | Use Case |
|---|---|---|
PAY (default) | Charge the card immediately | Prepaid packages |
AUTHORIZATION | Save the card without charging | On-hold packages (charged later at restaurant) |
Both modes support 3D Secure (3DS) authentication.
Booking Flow
The Stripe payment flow involves 3 API calls:
1. POST /api/v5/temporary_reservations → Create temp reservation (get tmp_reservation_id)
2. POST /api/v5/stripe_payment_sessions → Create Stripe payment (get payment_intent_id)
3. POST /api/v5/reservations → Confirm reservation (pass payment_intent_id)
Frontend Backend Stripe
│ │ │
│ 1. POST /temporary_reservations│ │
│ ──────────────────────────────> │ │
│ <── { tmp_reservation_id } │ │
│ │ │
│ 2. Collect card via │ │
│ Stripe Elements │ │
│ → get pm_xxx │ │
│ │ │
│ 3. POST /stripe_payment_sessions │
│ ──────────────────────────────> │ │
│ │ → Find/create customer │
│ │ → Create PaymentIntent/SetupIntent
│ │ ────────────────────────────────> │
│ │ <──────────────────────────────── │
│ <── { payment_intent_id, status } │
│ │ │
│ 4a. If status=requires_action │ │
│ → stripe.handleNextAction()│ │
│ → (3DS popup) │ │
│ │ │
│ 5. POST /reservations │ │
│ { stripe_payment_intent_id }│ │
│ ──────────────────────────────> │ │
│ <── { reservation } │ │
│ │ │
│ │ 6. Webhook: payment_intent.succeeded
│ │ <──────────────────────────────── │
│ │ → Mark reservation as paid │
API Endpoints
1. Create Temporary Reservation
POST /api/v5/temporary_reservations
Standard reservation creation — no Stripe-specific params. Returns tmp_reservation_id.
2. Create Stripe Payment Session
POST /api/v5/stripe_payment_sessions
Creates a Stripe PaymentIntent (charge) or SetupIntent (save card).
Request
| Param | Type | Required | Description |
|---|---|---|---|
tmp_reservation_id | Integer | Yes | Reservation ID from step 1 |
first_name | String | Yes | Customer first name |
last_name | String | No | Customer last name |
email | String | Yes | Customer email |
amount | Float | Yes | Payment amount (e.g., 1500.00) |
payment_method_id | String | Conditional | Stripe PaymentMethod ID (pm_xxx) from Stripe Elements. Required if card_id is not provided |
card_id | Integer | Conditional | ID of externals_gb_primepay_cards record. Used to resolve payment_method_id from the card’s token column. Required if payment_method_id is not provided |
session_type | String | No | "PAY" (default) or "AUTHORIZATION" |
phone | String | No | Customer phone |
user_id | Integer | No | User ID (if logged in) |
success_return_url | String | No | Custom URL to redirect after 3DS |
web_v2_host | String | No | Override web host for 3DS return URL |
Example Request — with payment_method_id
{
"tmp_reservation_id": 12345,
"first_name": "John",
"last_name": "Doe",
"email": "john@example.com",
"phone": "+66812345678",
"amount": 1500.00,
"payment_method_id": "pm_1abc2def3ghi",
"session_type": "PAY"
}
Example Request — with card_id (saved card)
{
"tmp_reservation_id": 12345,
"first_name": "John",
"last_name": "Doe",
"email": "john@example.com",
"phone": "+66812345678",
"amount": 1500.00,
"card_id": 42,
"session_type": "PAY"
}
Response — Payment Succeeded (No 3DS)
{
"success": true,
"data": {
"payment_intent_id": "pi_xxx",
"status": "succeeded",
"amount": 150000,
"currency": "THB",
"customer_id": "cus_xxx",
"payment_method_id": "pm_xxx",
"latest_charge": "ch_xxx",
"metadata": {
"reservation_id": "12345",
"restaurant_id": "67",
"user_id": "890"
},
"created": 1699000000
},
"message": null
}
Response — 3DS Required
{
"success": true,
"data": {
"payment_intent_id": "pi_xxx",
"status": "requires_action",
"amount": 150000,
"currency": "THB",
"customer_id": "cus_xxx",
"client_secret": "pi_xxx_secret_yyy", # pragma: allowlist secret
"next_action": {
"type": "redirect_to_url",
"redirect_to_url": {
"url": "https://hooks.stripe.com/3d_secure_2/..."
}
}
},
"message": null
}
Response — Card Declined
{
"success": false,
"data": null,
"message": "Card declined"
}
Response — Invalid/Expired Saved Card
When a previously-saved card is no longer valid, the backend auto-cleans the card and returns:
{
"success": false,
"data": null,
"message": "บัตรไม่สามารถใช้งานได้ กรุณากรอกข้อมูลบัตรใหม่อีกครั้ง",
"action_required": "RE_ENTER_CARD"
}
When action_required == "RE_ENTER_CARD", prompt the user to enter new card details.
Response — AUTHORIZATION (Save Card Only)
For session_type: "AUTHORIZATION", the response contains setup_intent_id instead of payment_intent_id:
{
"success": true,
"data": {
"setup_intent_id": "seti_xxx",
"client_secret": "seti_xxx_secret_yyy", # pragma: allowlist secret
"status": "succeeded",
"customer_id": "cus_xxx",
"payment_method_types": ["card"]
},
"message": null
}
3. Confirm Reservation
POST /api/v5/reservations
After the Stripe payment session is created, call this endpoint to confirm the reservation. The key Stripe-specific param is stripe_payment_intent_id.
Request
Include all standard reservation params, plus:
| Param | Type | Required | Description |
|---|---|---|---|
stripe_payment_intent_id | String | Yes | pi_xxx from PAY or seti_xxx from AUTHORIZATION |
Example Request
{
"tmp_reservation_id": 12345,
"stripe_payment_intent_id": "pi_xxx",
"payment_type": "creditcard",
"reservation": {
"date": "2025-12-01",
"start_time": "19:00",
"adult": 2,
"kids": 0,
"service_type": "dine_in",
"restaurant_id": 67
},
"packages": [
{ "id": 123, "quantity": 1 }
]
}
Important:
stripe_payment_intent_idis what tells the backend to use Stripe instead of other payment gateways (Omise, GBPrimePay, Xendit). Without this param, the backend will not know it’s a Stripe payment.
Handling 3DS Authentication
When status == "requires_action", the card requires 3DS verification. Use Stripe.js to handle it:
// response = result from POST /stripe_payment_sessions
const { data } = response;
if (data.status === "requires_action") {
// Open 3DS popup
const { error, paymentIntent } = await stripe.handleNextAction({
clientSecret: data.client_secret,
});
if (error) {
// 3DS failed or user cancelled
showError(error.message);
} else {
// 3DS succeeded — proceed to confirm reservation
confirmReservation(paymentIntent.id);
}
} else if (data.status === "succeeded") {
// No 3DS needed — proceed to confirm reservation
confirmReservation(data.payment_intent_id);
}
After 3DS completes, Stripe sends a webhook to the backend automatically. The backend will mark the reservation as paid via MarkTransactionAsPaidWorker.
Payment Status Reference
| Status | Meaning | Frontend Action |
|---|---|---|
succeeded | Payment completed | Proceed to confirm reservation |
requires_action | 3DS required | Call stripe.handleNextAction() with client_secret |
requires_payment_method | Payment failed | Show error, ask user to retry |
processing | Still processing | Wait for webhook |
canceled | Payment cancelled | Show error |
Error Handling
| Error | Description | Frontend Action |
|---|---|---|
"Card declined" | Card was declined by issuer | Show error, ask to use another card |
action_required: "RE_ENTER_CARD" | Saved card expired/invalid | Clear saved card, show card input |
"Reservation not found" | Invalid tmp_reservation_id | Restart booking flow |
"Reservation is already paid" | Duplicate payment attempt | Show already-paid state |
"payment_method_id or card_id is required" | Neither param provided | Provide one of the two |
"Card not found" | card_id doesn’t exist | Verify card_id value |
"Card does not have a valid token" | Card record has no token | Use payment_method_id instead |
"session_type must be one of: PAY, AUTHORIZATION" | Invalid session type | Fix request |
Frontend Setup Requirements
- Stripe.js — Load via
<script src="https://js.stripe.com/v3/"></script> - Stripe Elements — Use to collect card details and get
payment_method_id(pm_xxx) - Publishable key — Use
STRIPE_PUBLIC_KEY(pk_...) to initialize Stripe.js
const stripe = Stripe("pk_live_xxx"); // or pk_test_xxx for testing
const elements = stripe.elements();
const cardElement = elements.create("card");
cardElement.mount("#card-element");
// When user submits payment:
const { paymentMethod, error } = await stripe.createPaymentMethod({
type: "card",
card: cardElement,
billing_details: {
name: "John Doe",
email: "john@example.com",
},
});
if (paymentMethod) {
// Use paymentMethod.id as payment_method_id in the API call
createStripePaymentSession({
payment_method_id: paymentMethod.id,
// ... other params
});
}
Quick Summary
| Step | Endpoint | Stripe-specific Param |
|---|---|---|
| 1. Create temp reservation | POST /api/v5/temporary_reservations | None |
| 2. Create payment session | POST /api/v5/stripe_payment_sessions | payment_method_id or card_id, session_type |
| 3. Handle 3DS (if needed) | Stripe.js handleNextAction() | client_secret from step 2 |
| 4. Confirm reservation | POST /api/v5/reservations | stripe_payment_intent_id from step 2 |
Part 2: Backend Architecture
Service Architecture
app/services/stripe_payment/
├── base_config.rb # Shared Stripe config, error handling, Retriable
├── payment_service.rb # Core: create PaymentIntent / SetupIntent
├── customer_service.rb # Stripe Customer CRUD + payment method management
├── webhook_handler_service.rb # Process Stripe webhook events
├── charge_updater_service.rb # Persist/update charge records from webhooks
├── card_saver_service.rb # Save card info to shared card table
└── bin_retrieval_service.rb # Retrieve card BIN for voucher validation
app/models/externals/stripe/
├── charge.rb # Externals::Stripe::Charge (payment records)
└── customer.rb # Externals::Stripe::Customer (user ↔ Stripe mapping)
app/controllers/api/
├── v5/stripe_payment_sessions_controller.rb # API endpoint
└── webhooks_controller.rb # Webhook endpoint (POST /api/webhook/stripe)
Environment Variables
| Variable | Description | Required |
|---|---|---|
STRIPE_SECRET_KEY | Stripe API secret key (sk_...) | Yes |
STRIPE_PUBLIC_KEY | Stripe publishable key (pk_...) | Yes |
STRIPE_WEBHOOK_SECRET | Webhook signing secret (whsec_...) | Yes |
All managed via Figaro (config/application.yml).
Service Reference
StripePayment::PaymentService
Main entry point. Requires a Reservation to initialize.
service = StripePayment::PaymentService.new(reservation: reservation)
The reservation is used to:
- Get the restaurant’s currency (
reservation.restaurant.currency_code) - Store
reservation_id,restaurant_id,user_idin Stripe metadata - Build 3DS return URLs
- Link charges to the reservation
Instance Methods
#create_payment_intent — Create a PaymentIntent (charge immediately)
customer_params = {
email: "user@example.com",
name: "John Doe",
phone: "+66812345678", # optional
payment_method_id: "pm_xxx", # from Stripe.js on frontend
}
payment_currency = reservation.restaurant&.currency_code&.upcase || "THB"
amount_cents = Money.from_amount(1500.00, payment_currency).cents
result = service.create_payment_intent(
amount_cents: amount_cents,
customer_params: customer_params,
confirm: true,
return_url: "https://web.hungryhub.com/restaurants/slug/payment-success/hash",
)
#create_setup_intent — Create a SetupIntent (save card only)
result = service.create_setup_intent(customer_params: customer_params)
Class Methods
# Fetch PaymentIntent details
StripePayment::PaymentService.fetch_payment_intent("pi_xxx")
# Fetch SetupIntent details
StripePayment::PaymentService.fetch_setup_intent("seti_xxx")
# Confirm a pending PaymentIntent
StripePayment::PaymentService.confirm_payment_intent("pi_xxx", payment_method_id: "pm_xxx")
Handling Results
if result.success?
data = result.data
# PAY: :payment_intent_id, :status, :amount, :currency, :customer_id,
# :payment_method_id, :metadata, :latest_charge, :created
# :client_secret, :next_action (only when status == "requires_action")
#
# AUTHORIZATION: :setup_intent_id, :client_secret, :status, :customer_id,
# :payment_method_types
else
error_message = result.errors&.first || result.message
end
StripePayment::CustomerService
Standalone service (no reservation needed). Manages Stripe customers.
service = StripePayment::CustomerService.new
# Find or create customer
result = service.find_or_create(email: "user@example.com", name: "John", metadata: { user_id: "123" })
customer = result.data[:customer] # Stripe::Customer object
# Attach payment method
service.attach_payment_method(customer_id: "cus_xxx", payment_method_id: "pm_xxx")
# List saved cards
service.list_payment_methods(customer_id: "cus_xxx")
# Detach a card
service.detach_payment_method(payment_method_id: "pm_xxx")
StripePayment::BinRetrievalService
Retrieves card BIN (first 6 digits) for voucher prefix validation. Works with both PaymentIntents and SetupIntents.
result = StripePayment::BinRetrievalService.call("pi_xxx")
# or
result = StripePayment::BinRetrievalService.call("seti_xxx")
if result.success?
card_bin = result.data[:card_bin] # e.g., "408837"
end
Note: Requires “Full BIN” access enabled on the Stripe account (Dashboard → Settings → Card Networks).
StripePayment::WebhookHandlerService
Processes incoming Stripe webhook events. Called automatically by Api::WebhooksController#stripe at POST /api/webhook/stripe. You should not need to call this directly.
Handled events:
| Event | Action |
|---|---|
payment_intent.succeeded | Update charge record, save card, enqueue MarkTransactionAsPaidWorker |
payment_intent.payment_failed | Update charge record, notify via Firebase |
payment_intent.canceled | Update charge record |
setup_intent.succeeded | Save card info, enqueue MarkTransactionAsPaidWorker |
setup_intent.setup_failed | Log failure, notify via Firebase |
charge.succeeded | Update charge record with receipt URL and card details |
The webhook controller verifies the Stripe signature using STRIPE_WEBHOOK_SECRET, then delegates to WebhookHandlerService. Returns 200 OK to Stripe even if business logic fails (to prevent unnecessary retries).
StripePayment::ChargeUpdaterService
Persists charge data to externals_stripe_charges table. Called by WebhookHandlerService.
StripePayment::ChargeUpdaterService.new(
charge_data: {
stripe_payment_intent_id: "pi_xxx",
status: "succeeded",
amount_cents: 150000,
currency: "thb",
reservation_id: 123,
}
).update
StripePayment::CardSaverService
Saves card info to the shared Externals::GbPrimepay::Card table (unified card storage across payment gateways). Called by WebhookHandlerService.
StripePayment::CardSaverService.new(
payment_method_id: "pm_xxx",
customer_id: "cus_xxx",
reservation_id: 123,
).save
How ReservationService::Create Detects Stripe
When POST /api/v5/reservations is called with stripe_payment_intent_id, the reservation service detects the Stripe flow:
# In app/services/reservation_service/create.rb
stripe_payment = params[:stripe_payment_intent_id].present?
stripe_setup_intent = stripe_payment && reservation.using_on_hold_channel?
stripe_payment_intent_idpresent → Stripe flow (instead of Omise/GBPrimePay/Xendit)- SetupIntent (
seti_xxx) + on-hold package → AUTHORIZATION flow: reservation marked valid immediately - PaymentIntent (
pi_xxx) → PAY flow: reservation stays temporary until webhook confirms payment - If voucher has
prefix_numberrestriction →BinRetrievalServiceretrieves card BIN for validation
Database Models
Externals::Stripe::Charge
Table: externals_stripe_charges
| Field | Description |
|---|---|
stripe_payment_intent_id | (unique) Links to Stripe PaymentIntent |
stripe_charge_id | Stripe Charge ID |
stripe_customer_id | Stripe Customer ID |
stripe_payment_method_id | Stripe PaymentMethod ID |
reservation_id, user_id | Associations |
amount_cents, currency | Monetized via money-rails |
status | succeeded, requires_action, processing, failed, canceled |
paid_at | Set when status transitions to succeeded |
error_message, error_code | Populated on failure |
receipt_url, card_last4, card_brand | Card/receipt details |
Externals::Stripe::Customer
Table: externals_stripe_customers
| Field | Description |
|---|---|
stripe_customer_id | (unique) Stripe Customer ID |
user_id | (unique) Links to HungryHub user |
email, name | Customer info |
Error Handling & Retry Logic
All services use StripePayment::BaseConfig which provides:
with_stripe_error_handling— catches all Stripe errors and returnsServiceResult.failurewith_retriable— retries transient errors (connection, rate limit) up to 3 times with exponential backoff- Idempotency keys — prevents duplicate PaymentIntents/SetupIntents on retries
Error types caught:
| Stripe Error | Message Returned |
|---|---|
Stripe::CardError | “Card declined” |
Stripe::RateLimitError | “Rate limit exceeded” |
Stripe::InvalidRequestError | “Invalid request to Stripe” |
Stripe::AuthenticationError | “Stripe authentication failed” |
Stripe::APIConnectionError | “Network error communicating with Stripe” |
Invalid saved cards are automatically cleaned up when Stripe rejects a previously-saved payment method.
Stripe API Settings
Configured in StripePayment::BaseConfig:
| Setting | Value |
|---|---|
| API Version | 2023-10-16 |
| Max Network Retries | 2 |
| Open Timeout | 5 seconds |
| Read Timeout | 30 seconds |
| Retriable Tries | 3 |
| Retriable Backoff | 1s base, 2x multiplier |
Adding Stripe to a New API Namespace
If you need Stripe payments in another namespace (e.g., admin, partner):
1. Create a controller that calls StripePayment::PaymentService (see Api::V5::StripePaymentSessionsController as reference).
2. Add a route:
resources :stripe_payment_sessions, only: [:create]
3. Pass stripe_payment_intent_id when confirming the reservation — ReservationService::Create detects the Stripe flow automatically.
What you do NOT need to change:
- Webhooks — handled globally at
POST /api/webhook/stripe - Services —
StripePayment::*is namespace-agnostic - Models — charge/customer records are shared