Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Stripe Payment — Integration Guide

This document covers two parts:

  1. Frontend Integration — API endpoints, payloads, responses, and 3DS handling
  2. Backend Architecture — Service internals, webhook handling, and how to extend

Overview

Stripe payment supports two modes:

Session TypeDescriptionUse Case
PAY (default)Charge the card immediatelyPrepaid packages
AUTHORIZATIONSave the card without chargingOn-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

ParamTypeRequiredDescription
tmp_reservation_idIntegerYesReservation ID from step 1
first_nameStringYesCustomer first name
last_nameStringNoCustomer last name
emailStringYesCustomer email
amountFloatYesPayment amount (e.g., 1500.00)
payment_method_idStringConditionalStripe PaymentMethod ID (pm_xxx) from Stripe Elements. Required if card_id is not provided
card_idIntegerConditionalID 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_typeStringNo"PAY" (default) or "AUTHORIZATION"
phoneStringNoCustomer phone
user_idIntegerNoUser ID (if logged in)
success_return_urlStringNoCustom URL to redirect after 3DS
web_v2_hostStringNoOverride 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:

ParamTypeRequiredDescription
stripe_payment_intent_idStringYespi_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_id is 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

StatusMeaningFrontend Action
succeededPayment completedProceed to confirm reservation
requires_action3DS requiredCall stripe.handleNextAction() with client_secret
requires_payment_methodPayment failedShow error, ask user to retry
processingStill processingWait for webhook
canceledPayment cancelledShow error

Error Handling

ErrorDescriptionFrontend Action
"Card declined"Card was declined by issuerShow error, ask to use another card
action_required: "RE_ENTER_CARD"Saved card expired/invalidClear saved card, show card input
"Reservation not found"Invalid tmp_reservation_idRestart booking flow
"Reservation is already paid"Duplicate payment attemptShow already-paid state
"payment_method_id or card_id is required"Neither param providedProvide one of the two
"Card not found"card_id doesn’t existVerify card_id value
"Card does not have a valid token"Card record has no tokenUse payment_method_id instead
"session_type must be one of: PAY, AUTHORIZATION"Invalid session typeFix request

Frontend Setup Requirements

  1. Stripe.js — Load via <script src="https://js.stripe.com/v3/"></script>
  2. Stripe Elements — Use to collect card details and get payment_method_id (pm_xxx)
  3. 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

StepEndpointStripe-specific Param
1. Create temp reservationPOST /api/v5/temporary_reservationsNone
2. Create payment sessionPOST /api/v5/stripe_payment_sessionspayment_method_id or card_id, session_type
3. Handle 3DS (if needed)Stripe.js handleNextAction()client_secret from step 2
4. Confirm reservationPOST /api/v5/reservationsstripe_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

VariableDescriptionRequired
STRIPE_SECRET_KEYStripe API secret key (sk_...)Yes
STRIPE_PUBLIC_KEYStripe publishable key (pk_...)Yes
STRIPE_WEBHOOK_SECRETWebhook 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_id in 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:

EventAction
payment_intent.succeededUpdate charge record, save card, enqueue MarkTransactionAsPaidWorker
payment_intent.payment_failedUpdate charge record, notify via Firebase
payment_intent.canceledUpdate charge record
setup_intent.succeededSave card info, enqueue MarkTransactionAsPaidWorker
setup_intent.setup_failedLog failure, notify via Firebase
charge.succeededUpdate 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_id present → 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_number restriction → BinRetrievalService retrieves card BIN for validation

Database Models

Externals::Stripe::Charge

Table: externals_stripe_charges

FieldDescription
stripe_payment_intent_id(unique) Links to Stripe PaymentIntent
stripe_charge_idStripe Charge ID
stripe_customer_idStripe Customer ID
stripe_payment_method_idStripe PaymentMethod ID
reservation_id, user_idAssociations
amount_cents, currencyMonetized via money-rails
statussucceeded, requires_action, processing, failed, canceled
paid_atSet when status transitions to succeeded
error_message, error_codePopulated on failure
receipt_url, card_last4, card_brandCard/receipt details

Externals::Stripe::Customer

Table: externals_stripe_customers

FieldDescription
stripe_customer_id(unique) Stripe Customer ID
user_id(unique) Links to HungryHub user
email, nameCustomer info

Error Handling & Retry Logic

All services use StripePayment::BaseConfig which provides:

  1. with_stripe_error_handling — catches all Stripe errors and returns ServiceResult.failure
  2. with_retriable — retries transient errors (connection, rate limit) up to 3 times with exponential backoff
  3. Idempotency keys — prevents duplicate PaymentIntents/SetupIntents on retries

Error types caught:

Stripe ErrorMessage 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:

SettingValue
API Version2023-10-16
Max Network Retries2
Open Timeout5 seconds
Read Timeout30 seconds
Retriable Tries3
Retriable Backoff1s 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