# Recipe: Ruby on Rails

For Rails 7 or later (Ruby 3.1+), with ERB views. The API client uses only the standard library (`net/http`). This file has the code; [the playbook](https://checkouts.md/playbook.md) has the reasons, every error code and what to tell the person. Follow both.

| | |
|---|---|
| Server code runs in | Controllers |
| Database | The project's database through Active Record |
| Webhook path | `/webhooks/railbed` (no CSRF check, no sign-in filter) |
| Connect | `npx checkouts.md login` (Test into `.env`, Live into `.env.production`) |

## What this adds

| File | What it does |
|---|---|
| `db/migrate/20261014000000_create_railbed_tables.rb` | The `orders` and `railbed_events` tables |
| `app/models/order.rb`, `app/models/railbed_event.rb` | The models |
| `app/services/railbed.rb` | Products, the API client and signature verification |
| `app/controllers/checkouts_controller.rb` | Creates the order and its checkout session; the success page |
| `app/controllers/railbed_webhooks_controller.rb` | Verifies, re-reads the payment, applies it once |
| `config/routes.rb` | The three routes |
| `app/views/checkouts/success.html.erb`, the Pay button | The views |
| `CHECKOUTS.md` | How this project takes payments |

If the project already has an `Order` model or an `orders` table, call these `CheckoutOrder` and `checkout_orders` (or add the columns to the existing table).

## Environment

| Name | Development (`.env`, gitignored) | Production (the host's environment) |
|---|---|---|
| `RAILBED_SECRET_KEY` | `rb_test_…` | `rb_live_…` |
| `RAILBED_WEBHOOK_SECRET` | `whsec_…` of the Test endpoint | `whsec_…` of the Live endpoint |
| `APP_URL` | `http://localhost:3000` | `https://yourapp.com` |

Rails doesn't read `.env` by itself: use `dotenv-rails` if the project has it, or export the file into the shell without printing it, `set -a; . ./.env; set +a`.

## 1. Connect

```bash
npx checkouts.md login
```

The Test key goes to `.env` and the Live key to `.env.production`, both gitignored. Relay the link and code to the person and wait for the command to finish.

## 2. Database

`db/migrate/20261014000000_create_railbed_tables.rb` (set the bracketed version to the project's Rails version):

```ruby
class CreateRailbedTables < ActiveRecord::Migration[7.1]
  def change
    create_table :orders, id: :string do |t|      # order_…: sent as reference and in the Idempotency-Key
      t.string :product_id, null: false
      t.string :amount, null: false               # "49.00"
      t.string :currency, null: false
      t.boolean :livemode, null: false
      t.string :status, null: false, default: "awaiting_payment"
      t.string :payment_id, index: { unique: true } # pay_…
      t.bigint :user_id
      t.datetime :paid_at
      t.timestamps
    end

    create_table :railbed_events, id: :string do |t| # evt_…: each webhook event is handled once
      t.string :event_type, null: false              # not "type": Active Record reserves it
      t.string :payment_id
      t.timestamps
    end
  end
end
```

```bash
bin/rails db:migrate
```

`app/models/order.rb`:

```ruby
class Order < ApplicationRecord
end
```

`app/models/railbed_event.rb`:

```ruby
class RailbedEvent < ApplicationRecord
end
```

## 3. The code

`app/services/railbed.rb`:

```ruby
require "json"
require "net/http"
require "openssl"
require "rack/utils"

module Railbed
  # Prices live here, on the server, as decimal strings (R3). The browser only ever sends an id.
  PRODUCTS = {
    "pro" => { id: "pro", name: "Pro plan", amount: "49.00", currency: "USD" }
  }.freeze

  class Error < StandardError
    attr_reader :status, :code

    def initialize(status, code, message)
      super(message)
      @status = status
      @code = code
    end
  end

  module_function

  # Calls the Railbed API with the secret key. Server code only.
  def request(path, body: nil, idempotency_key: nil)
    key = ENV["RAILBED_SECRET_KEY"].to_s
    raise "RAILBED_SECRET_KEY is not set" if key.empty?

    uri = URI("#{ENV.fetch('RAILBED_API_BASE', 'https://pay.railbed.io/v1')}#{path}")
    req = body.nil? ? Net::HTTP::Get.new(uri) : Net::HTTP::Post.new(uri)
    req["Authorization"] = "Bearer #{key}"
    req["Accept"] = "application/json"
    req["Idempotency-Key"] = idempotency_key if idempotency_key
    unless body.nil?
      req["Content-Type"] = "application/json"
      req.body = JSON.generate(body)
    end
    res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 15) do |http|
      http.request(req)
    end
    data = begin
      JSON.parse(res.body.to_s)
    rescue JSON::ParserError
      nil
    end
    return data if res.is_a?(Net::HTTPSuccess)

    error = data.is_a?(Hash) ? (data["error"] || {}) : {}
    raise Error.new(res.code.to_i, error["code"] || "unknown", error["message"] || "Railbed answered #{res.code}")
  end

  def live?
    ENV["RAILBED_SECRET_KEY"].to_s.start_with?("rb_live_")
  end

  # Railbed-Signature: t=<seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">, one or more v1 (two during a secret roll).
  def verify_signature(raw_body, header, secret, tolerance_seconds = 300)
    match = /\At=(\d{1,12})((?:,v1=[0-9a-f]{64})+)\z/.match(header.to_s)
    return false unless match

    timestamp, signatures = match.captures
    return false if (Time.now.to_i - timestamp.to_i).abs > tolerance_seconds

    expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}")
    signatures[1..].split(",").any? { |part| Rack::Utils.secure_compare(expected, part[3..]) }
  end
end
```

`app/controllers/checkouts_controller.rb`:

```ruby
class CheckoutsController < ApplicationController
  MESSAGES = {
    "paid" => "Payment received. Your order is confirmed.",
    "awaiting_payment" => "Confirming your payment…",
    "held" => "Your payment is being reviewed. We’ll email you once it’s done.",
    "failed" => "This payment didn’t complete.",
    "expired" => "This payment didn’t complete."
  }.freeze

  def create
    product = Railbed::PRODUCTS[params[:product].to_s]
    return render(plain: "Unknown product.", status: :bad_request) unless product

    order = Order.create!(
      id: "order_#{SecureRandom.uuid}",
      product_id: product[:id],
      amount: product[:amount],
      currency: product[:currency],
      livemode: Railbed.live?,
      user_id: (current_user&.id if respond_to?(:current_user, true))
    )
    app_url = ENV.fetch("APP_URL", request.base_url).chomp("/")
    begin
      session = Railbed.request(
        "/checkout_sessions",
        idempotency_key: order.id,
        body: {
          amount: product[:amount],
          currency: product[:currency],
          description: product[:name],
          reference: order.id,
          metadata: { order_id: order.id, product_id: product[:id] },
          success_url: "#{app_url}/checkout/success?order={REFERENCE}",
          cancel_url: "#{app_url}/"
        }
      )
    rescue Railbed::Error, Timeout::Error, SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError => e # timeouts and network errors too
      Rails.logger.error("Railbed: checkout session not created #{order.id} #{e.respond_to?(:code) ? e.code : e.class}")
      return redirect_back(fallback_location: "/", alert: "We couldn't start the payment. Please try again.")
    end

    order.update!(payment_id: session["id"])
    redirect_to session["url"], allow_other_host: true, status: :see_other
  end

  # Shows the order's status from the database. It never marks anything paid: only the webhook does (R2).
  def success
    @order = Order.find_by(id: params[:order].to_s)
    @message = @order ? MESSAGES.fetch(@order.status, "Checking your order…") : "We couldn’t find that order."
    response.headers["Cache-Control"] = "no-store"
  end
end
```

`app/controllers/railbed_webhooks_controller.rb` inherits from `ActionController::Base`, not `ApplicationController`, so the app's sign-in filters don't apply (in an API-only app, inherit from `ActionController::API` and drop `skip_forgery_protection`):

```ruby
class RailbedWebhooksController < ActionController::Base
  skip_forgery_protection # Railbed's request is signed, not a form

  def create
    raw = request.raw_post # the exact bytes Railbed signed: verify before parsing
    secret = ENV["RAILBED_WEBHOOK_SECRET"].to_s
    return head(:bad_request) if secret.empty? || !Railbed.verify_signature(raw, request.headers["Railbed-Signature"], secret)

    event = JSON.parse(raw)
    ref = event.dig("data", "payment") || {}
    return head(:ok) unless event["type"].to_s.start_with?("payment.") && ref["id"].is_a?(String)

    if event["livemode"] != Railbed.live?
      Rails.logger.error("Railbed webhook: mode mismatch. RAILBED_SECRET_KEY and RAILBED_WEBHOOK_SECRET must be the same mode.")
      return head(:internal_server_error)
    end

    begin
      payment = Railbed.request("/payments/#{ERB::Util.url_encode(ref['id'])}")
    rescue Railbed::Error => e
      # "Send test event" samples carry a made-up payment the API doesn't know: acknowledge them.
      sample = !event["livemode"] && ref.dig("metadata", "sample") == "true"
      return head(:ok) if e.status == 404 && sample

      Rails.logger.error("Railbed webhook: payment not read #{event['id']} #{e.code}")
      return head(:internal_server_error)
    end

    order = Order.find_by(payment_id: payment["id"])
    return head(:ok) unless order # not a payment this app created

    unless payment["livemode"] == order.livemode && payment["reference"] == order.id &&
           payment["amount"] == order.amount && payment["currency"] == order.currency
      Rails.logger.error("Railbed webhook: payment #{payment['id']} does not match order #{order.id}")
      return head(:ok)
    end

    result = apply_payment(event, payment, order)
    Rails.logger.info("Railbed webhook #{event['type']} #{event['id']}: #{result}")
    head(:ok)
  end

  private

  # Exactly once (playbook 6.4, step 8): the event, the paid update and the grant commit together. Only the event
  # insert may fail as a duplicate (inside its own savepoint); any other error rolls back and answers 500.
  def apply_payment(event, payment, order)
    ActiveRecord::Base.transaction do
      begin
        ActiveRecord::Base.transaction(requires_new: true) do
          RailbedEvent.create!(id: event["id"], event_type: event["type"], payment_id: payment["id"])
        end
      rescue ActiveRecord::RecordNotUnique
        next "duplicate" # this event id was already handled
      end
      if payment["status"] == "paid"
        changed = Order.where(id: order.id).where.not(status: "paid")
                       .update_all(status: "paid", paid_at: Time.at(payment["paid_at"] || Time.now.to_i), updated_at: Time.current)
        if changed == 1
          fulfil(order.reload)
          next "fulfilled"
        end
      elsif %w[held failed expired].include?(payment["status"])
        Order.where(id: order.id).where.not(status: "paid").update_all(status: payment["status"], updated_at: Time.current)
      end
      "recorded"
    end
  end

  # Grants what the order bought. Runs once per order, inside the transaction that marks it paid.
  # This is the one project-specific piece: replace the body, for example
  #   User.where(id: order.user_id).update_all(plan: order.product_id)
  # Enqueue slow work (emails, shipping labels) only once this transaction commits, for example from an after_commit
  # callback, so a rolled-back payment never sends anything.
  def fulfil(order)
    Rails.logger.info("fulfil #{order.id} (#{order.product_id})")
  end
end
```

`config/routes.rb`:

```ruby
post "checkout", to: "checkouts#create", as: :checkout
get "checkout/success", to: "checkouts#success", as: :checkout_success
post "webhooks/railbed", to: "railbed_webhooks#create"
```

`app/views/checkouts/success.html.erb` (rendered inside the app's layout; the refresh tag in the body works in every browser):

```erb
<% if @order&.status == "awaiting_payment" %>
  <meta http-equiv="refresh" content="3">
<% end %>
<main>
  <h1><%= @message %></h1>
  <% if @order&.status == "awaiting_payment" %>
    <p>This usually takes a few seconds.</p>
  <% end %>
  <% if %w[failed expired].include?(@order&.status) %>
    <p><%= link_to "Try again", "/" %></p>
  <% end %>
</main>
```

The Pay button, in any view. Turbo can't follow a redirect to another site, so the form opts out of it:

```erb
<%= button_to "Pay $49.00", checkout_path, params: { product: "pro" }, form: { data: { turbo: false } } %>
<% if flash[:alert] %><p role="alert"><%= flash[:alert] %></p><% end %>
```

## 4. The webhook endpoint

```bash
set -a; . ./.env; set +a
bin/rails server                                 # http://localhost:3000
cloudflared tunnel --url http://localhost:3000   # in a second terminal; prints https://….trycloudflare.com
npx checkouts.md webhook https://<tunnel>.trycloudflare.com/webhooks/railbed
```

Allow the tunnel's host in development (`config.hosts << ".trycloudflare.com"` in `config/environments/development.rb`), or Rails answers `403 Blocked host`. The CLI adds `RAILBED_WEBHOOK_SECRET` to `.env`; load the file again and restart the server.

## 5. Test it

1. Create an order through the app's own route: open the page with the Pay button and click it; the address bar shows `https://pay.railbed.io/p/pay_…`.
2. Pay it: choose **Simulate successful payment** on the test provider page, or run the playbook's `simulate.mjs` with the `pay_…` id ([7.1](https://checkouts.md/playbook.md#71-the-end-to-end-run)).
3. Check: `log/development.log` shows `Railbed webhook payment.paid evt_…: fulfilled`; `bin/rails runner 'puts Order.find_by(payment_id: "pay_…").status'` prints `paid`; `/checkout/success?order=order_…` says it's confirmed.
4. Run the other scenarios in the playbook's [7.2](https://checkouts.md/playbook.md#72-the-scenarios), including `replay.mjs`.
5. Record the ids under Evidence in `CHECKOUTS.md`, and delete the tunnel's endpoint.

## 6. Go live

1. Put `RAILBED_SECRET_KEY` (from `.env.production`) and `APP_URL=https://…` into the production environment without printing the key (for example `kamal` secrets, or the host's environment settings), and run `bin/rails db:migrate` there.
2. Deploy.
3. Add the Live endpoint: `npx checkouts.md webhook https://yourapp.com/webhooks/railbed --live` (plus the same `--live-to` as when connecting, if you gave one). It writes the Live `RAILBED_WEBHOOK_SECRET` into the Live key's file; move it to production the same way and restart.
4. Check: `npx checkouts.md status` (with that `--live-to`, if any); `curl -s -o /dev/null -w '%{http_code}\n' -X POST https://yourapp.com/webhooks/railbed -d '{}'` answers `400` (a `422` means forgery protection still applies).
5. Set `mode: live` in `CHECKOUTS.md`, add the Live endpoint's id under Wiring and the checks under Evidence. The first real payment is the final evidence.

## 7. CHECKOUTS.md

Fill in [the template](https://checkouts.md/templates/CHECKOUTS.md) with `webhook: /webhooks/railbed`. The Wiring section for this recipe:

```markdown
## Wiring

- `app/services/railbed.rb`: the products and their prices, the API client and signature verification.
- `app/controllers/checkouts_controller.rb`: `create` saves the order, creates the checkout session (Idempotency-Key = order id), redirects; `success` shows status only, never marks paid.
- `app/controllers/railbed_webhooks_controller.rb`: verifies Railbed-Signature, re-reads the payment, applies it once in a transaction (`fulfil` is the grant).
- `app/models/order.rb`, `app/models/railbed_event.rb`; `db/migrate/20261014000000_create_railbed_tables.rb`.
- `config/routes.rb`: `/checkout`, `/checkout/success`, `/webhooks/railbed`.
- Webhook endpoints: Test `we_…` (tunnel, removed after testing), Live `we_…`, at `/webhooks/railbed`.
- Secrets: `RAILBED_SECRET_KEY`, `RAILBED_WEBHOOK_SECRET`. Test in `.env` (gitignored); Live in the production environment.
```

## Notes

- **Sinatra:** the same `Railbed` module; the raw body is `request.body.read` (call `request.body.rewind` first if anything read it), the header `request.env["HTTP_RAILBED_SIGNATURE"]`.
- **Turbo and external redirects:** without `data: { turbo: false }`, Turbo fetches the redirect and the browser blocks it, so the button seems to do nothing.
