Rails quickstart
Requires Ruby ≥ 3.2 and Rails ≥ 8.0.
Add the Rails engine gem to your Gemfile:
gem "openreceive-rails"
That is the whole install. openreceive-rails depends on openreceive,
openreceive-server and nwc-ruby, so the default wallet client works with
nothing else added. It is built from NWC_URI. If your app brings its own NWC
client, set config.nwc_client instead.
There is one native prerequisite. nwc-ruby uses rbsecp256k1, which builds
libsecp256k1 from source. Minimal images (ruby:3.3-slim, fresh Docker builds)
need the autotools for that, or bundle install fails with
autoreconf: not found. Install them before bundling:
apt-get install -y autoconf automake libtool build-essential pkg-config
Full Ruby images and typical developer machines already have these.
Then run:
bin/rails generate openreceive:install
bin/rails db:migrate
The migration adapts to your app’s database adapter. PostgreSQL, SQLite, and
MySQL (mysql2/trilogy) are supported.
→ openreceive:install
The openreceive:install generator emits three things:
db/migrate/*_create_openreceive_tables.rb: one migration that creates both engine tables (openreceive_paymentsandopenreceive_meta).- A simplified
config/initializers/openreceive.rb. - The
OpenReceive::Engineroute mount at/openreceive.
The engine owns the OpenReceivePayment model, so no model file is generated.
The engine also owns the table’s commit locking, write-once settlement, and
reconciliation state machine. reference is indexed but not unique, because
one reference may have many historical attempts. payment_hash is globally
unique.
Fulfill exactly once
Within OpenReceive’s own settlement paths, on_paid runs at most once per
reference. If a second invoice for the same reference is paid, OpenReceive
records that payment with status_reason = "duplicate_settlement" and does
not fulfill again.
One case is yours to handle. If anything other than OpenReceive can also
fulfill an order, such as an admin action, a second payment processor, or a
replayed job, those paths race each other. Then on_paid must be idempotent.
The generated initializer explains this and shows the guarded transition:
config.on_paid = lambda do |settlement|
claimed = Order
.where(id: settlement.reference, state: "awaiting_payment")
.update_all(state: "paid", paid_at: Time.at(settlement.paid_at).utc)
next if claimed.zero? # someone else already fulfilled it
# FulfillOrder — like Order — is your own application code: ship the goods,
# enqueue the confirmation email. OpenReceive provides neither.
FulfillOrder.call(Order.find(settlement.reference), payment_hash: settlement.payment_hash)
end
Delivery is at-least-once. on_paid runs inside the settlement transaction. If
it raises, the transaction rolls back and the next pass retries. So keep
on_paid to database writes on the order. An email or webhook sent from here
would survive the rollback and go out again. The state: "paid" transition
above is the flag. Let your own job drain it after commit.
update_all fires no Active Record callbacks. That is intended. It runs one
conditional UPDATE, so the claim is atomic and no model code runs between the
check and the write. It also means there is no after_commit to attach a
post-commit side effect to. That is fine for a background job that drains the
flag. It does not help a page that needs to know right away. If you push
settlement over Action Cable, or your model owns the transition through
callbacks, take a row lock for the duration instead:
config.on_paid = lambda do |settlement|
order = Order.lock.find_by(id: settlement.reference) # SELECT … FOR UPDATE
next unless order && order.state == "awaiting_payment"
order.update!(state: "paid", paid_at: Time.at(settlement.paid_at).utc) # callbacks fire
end
Unlocking a download works the same way. If the payer bought a file, do not
unlock it in the browser. Gate the download route on the paid order row, and
serve the file only if that row exists:
Order.find_by(id: params[:id], user: current_user, state: "paid"), or a 404
otherwise. The state: "paid" written above is the unlock. The client never
decides that an order was fulfilled. It re-reads the row. Buy a Button’s
ShopController#download does this in twenty lines.
Both shapes are idempotent and correct. They differ only in whether your model layer runs:
update_allskips the model layer. It is the right default.- The row lock holds the row for the duration of the block. Use it when the transition has to go through your model.
The generated fulfillment note says the same thing. If your fulfillment is a
read-modify-write that one conditional UPDATE cannot express, take the lock.
Either way, the rule above still holds: the callback must only make database
writes on the order. after_commit on the settlement transaction runs after
OpenReceive’s own commit. So an email enqueued there is as safe as one enqueued
from a job that drains the flag. An email sent inline from on_paid is not
safe, in either shape.
Buy a Button
(examples/buttons/server/rails)
is a runnable illustration of this boundary. It is not a template to copy
models from. It has products, visitors, and orders, and the three hooks are the
only bridge. Map that shape onto the models in THIS app.
Supply the receive-only wallet connection as ENV["NWC_URI"]. Never put it in
browser code, logs, or assets. Your application refuses to start when the code
advertises spend methods such as pay_invoice. To override that explicitly,
set config.allow_spend_capable_wallet = true or
OPENRECEIVE_ALLOW_SPEND_CAPABLE_NWC=true (Security).
OpenReceive reads ENV. Rails does not load a .env file on its own, so
something has to put the values there first: dotenv-rails, an exported shell
environment, or your production secret manager.
→ Environment variables.
Configure the host hooks
The initializer needs three things: authorization, the trusted price, and
fulfillment. All three receive the reference. This is a string you choose,
and it is the fulfillment identity. Use your order id:
- one per thing you fulfill,
- created before checkout,
- kept across retries,
- never reused.
OpenReceive never looks inside it. But on_paid commits fulfillment once per
reference, and a new checkout under a reference that already settled is
refused with 409. A fresh id per page load would let one order be paid twice.
OpenReceive.configure do |config|
# `Order` throughout is YOUR model — it could be named anything. OpenReceive
# never sees it or touches its table; these hooks are the only bridge
# between the engine and your data.
#
# Your policy, called before every checkout/payment/swap request. `context`
# is a Hash with three symbol keys:
# context[:action] — which route: "checkout.prepare", "checkout.create",
# "payment.check", "swap.quote", "swap.create",
# "swap.read", or "swap.refund"
# context[:request] — the ActionDispatch::Request; read your session,
# cookies, or headers from it, as in a controller
# context[:resource] — { reference:, payment_hash: } copied from the
# payer's JSON body. It names an order; it does not
# prove this caller owns it. reference is always a
# validated non-empty String (≤200 chars); payment_hash
# is nil except on payment.check / swap.read / swap.refund.
# Return true to allow, false for a 403. Here: only the signed-in customer
# who placed the order may act on it.
config.authorize = lambda do |context|
order = Order.find_by(id: context[:resource][:reference])
order && order.user_id == context[:request].session[:user_id]
end
# The price for a reference — here, your order id — from your own data;
# nil when there is nothing to pay for (a 404). `value` is a decimal STRING
# from the order row, never a float and never a request param. `description`
# is what the payer is buying, in your own words.
config.amount_for = lambda do |reference|
order = Order.find_by(id: reference)
order && { currency: "USD", value: order.total.to_s,
description: "#{order.line_items.size} items" }
end
# Runs inside the settlement transaction, only for the order's first settled
# attempt. The WHERE clause is the lock: a second fulfillment path of yours
# (admin action, replayed job) updates zero rows and does nothing. Plain
# ActiveRecord, because the engine WRAPS this block in the transaction.
# (The JS engine instead hands onPaid a `query` handle, since nothing wraps
# it there; that is the one shape difference between the two stacks.)
config.on_paid = lambda do |settlement|
claimed = Order
.where(id: settlement.reference, state: "awaiting_payment")
.update_all(state: "paid", paid_at: Time.at(settlement.paid_at).utc)
next if claimed.zero?
end
end
OpenReceive.configure sets the three host hooks. on_paid runs inside the
settlement transaction, only for the first settled attempt for a reference.
→ OpenReceive.configure
The engine’s controllers inherit from config.parent_controller. The generated
initializer sets it to "ApplicationController". That is how the engine picks
up your application’s protect_from_forgery. Keep csrf_meta_tags in the
layout that renders the checkout. The checkout client sends X-CSRF-Token from
it automatically.
The same inheritance also brings every global before_action that your
ApplicationController declares. A filter that redirects signed-out users to a
login page will redirect the engine’s JSON routes too. A guest checkout then
never gets an invoice. The engine reads nothing from the parent except forgery
protection. config.authorize receives the request, and your policy reads its
own session from it. So if your ApplicationController has such filters, do one
of these:
- Point
config.parent_controllerat a slimmer controller that still callsprotect_from_forgery. - Skip the filter for the engine only:
# config/initializers/openreceive.rb (after OpenReceive.configure)
Rails.application.config.to_prepare do
OpenReceive::ApplicationController.skip_before_action :require_login
end
Keep the filters your authorize policy depends on, such as a tenant resolver
or Current attributes. They run before config.authorize.
The generated initializer ships two placeholders. Replace both, not just
on_paid:
config.on_paid = OpenReceive::LOGGING_ON_PAIDonly logs the settlement and fulfills nothing. Replace it with your real fulfillment (as above). Until you do, orders would be recorded as settled without ever being fulfilled, so the engine warns every time your application boots.config.authorize = OpenReceive::ALLOW_ALL_AUTHORIZEallows everything. It treats possession of the reference as authorization, which is safe only while references are unguessable. The engine warns at boot until you replace it with your own ownership check (as above).
The amount always comes from your own order record. Payer-supplied amounts are
rejected. The advanced hooks resolve_checkout and on_checkout_created remain
as overrides for apps with a custom repository. They are not part of the
quickstart.
For public web shops, turn on the per-IP invoice cap with
config.rate_limiting = true. Leave it off (the default) when many payers
share one IP. → Rate limiting
In production, the engine builds the wallet client when your app boots. It
also runs the receive-only preflight right away: it reaches the wallet and
checks that the code cannot spend. A missing NWC_URI, a dead relay, or a
spend-capable wallet then stops the deploy. Otherwise those problems would
show up as 500 errors for customers on the first checkout. Outside production
(tests, consoles), the engine builds the client lazily, on first use, so no
live wallet is needed.
Render the checkout
Serve the compiled styles.css without Tailwind processing. Either import it
from JavaScript (with a CSS-capable bundler) or use a plain
<link rel="stylesheet">. Do not @import it into your Tailwind entry. Its
rules have zero specificity, so your own styles can override checkout styles.
Scoping does not prevent that.
The engine serves JSON checkout routes only. Your view does the rendering. Any
OpenReceive frontend package works against the /openreceive mount. The
smallest is the custom element. Its default prefix is already
/openreceive. The package ships a self-contained styles.css that a plain
stylesheet link can serve, scoped to what OpenReceive renders.
<%# app/views/orders/pay.html.erb %>
<openreceive-checkout reference="<%= @order.id %>"></openreceive-checkout>
// In your JS bundle (esbuild/webpacker with CSS support):
import { defineElements } from "@openreceive/elements";
import "@openreceive/elements/styles.css"; // or link the compiled styles.css
// Registers the <openreceive-checkout> tag with the browser. Without this,
// the tag in the ERB above is unknown markup and renders as nothing; with it,
// the element wakes up wherever the tag appears. Call once per page — order
// relative to the markup does not matter.
defineElements();
If you bundle with esbuild (jsbundling-rails), build ESM and load it as a
module. esbuild’s default IIFE output runs a dependency’s Node fallback in the
browser, which throws ReferenceError: __filename is not defined:
esbuild app/javascript/application.js --bundle --format=esm --outdir=app/assets/builds
<%= javascript_include_tag "application", type: "module" %>
Everything the checkout draws ships inside the JavaScript: the payment-method
icons, the wallet logos and the pay tutorials. There is no image file to copy
or serve and no asset option to set. Deploy your normal JavaScript and CSS
build output, including any generated JavaScript chunks. Bundlers with code
splitting can load tutorial screenshots only when a tutorial is first opened.
Single-file builds, including the standalone checkout, include them upfront. If
your Content-Security-Policy has a strict img-src, allow data:
(Provider registry).
Then open the checkout in a browser. Confirm the payment-method icons and
wallet logos render, and open a wallet’s pay tutorial to check its screenshots.
If an image is missing, check the console for CSP violations and the Network
panel for failed JavaScript chunks. Allow data: in img-src and deploy the
complete build output. Do not add image routes, copy package source images, or
use registry icon_path / tutorial path keys as browser URLs.
The element creates the checkout for reference, then renders and polls
itself. React, Vue, Svelte, and Angular apps use the matching wrapper package
instead, with the same props and defaults
(Frontend checkout). Build a custom checkout only if
this app cannot use a drop-in. In that case @openreceive/browser/headless is
the API (Headless checkout).
Reconciliation
Settlement runs on the request path. You do not need a cron job. Disable or
tune it with config.opportunistic_reconcile (false, or
{ min_interval_seconds: … }).
Optionally, run one worker so settlement does not wait for the next page load:
bin/rails openreceive:notifications
→ rake openreceive:notifications
To run a pass yourself, use the one-shot OpenReceive.reconcile! or
bin/rails openreceive:reconcile.
→ OpenReceive.reconcile!
Swap secrets
The Ruby server recognizes LSC_URI_PRIMARY and LSC_URI_BACKUP, using the
shared Lightning Swap Connect vectors. Setting
either one auto-builds the matching provider. So an app that wants swaps only
supplies the connection strings
(Environment variables). To override this, use
config.swap_providers. Pass your own adapters to replace the auto-built set,
or an empty array to disable swaps.
One openreceive_payments row holds at most one provider order, in its
server-only swap_data. The engine hides swap_data from Active Record
inspection and ordinary serialization. Do not serialize it explicitly, log it,
or return it from your own API. It may contain a provider credential.
Setting either connection string commits you to refunds. A swap deposit can
arrive short or late. The provider then marks it refund_required, and only
your UI can claim it. The payer claims it on a second visit, after leaving your
page to get an address in another wallet. That needs three things:
- a per-order URL your app serves,
- a route that restores the order behind it,
- something that restores the ATTEMPT, since
/checkouts/preparereturns none.
Swap refunds covers all of it. Read it before you set
LSC_URI_PRIMARY.