API reference

This page documents each function of the wallet client, the host, the framework adapters, persistence, the CLI, the Rails engine, the Python engine (FastAPI and the openreceive console script) and the PHP engine.

Node and TypeScript APIs return camelCase fields (reference, paymentHash, amountMsats). Mounted HTTP JSON uses the same values in snake_case (reference, payment_hash, amount_msats). Timestamps are integer Unix seconds. Money fields are integers or decimal strings, never binary floats.

Each function lists Input (what you pass) and Returns (what you get back) as separate labeled tables. A nested object, such as a quote, a swap snapshot or a callback argument, gets its own Fields of table. It is not a second input list.

Wallet client

The object createOpenReceive() returns. Examples below call it service.

createOpenReceive

const service = await createOpenReceive(); // reads NWC_URI (and LSC_URI_*) from process.env
// every option below is optional: createOpenReceive({ nwc, allowSpendCapableWallet, swap, … })

Builds the wallet client.

Where it fits: Call this once, when your server starts, and keep the result for the life of the process, the same way you keep a database pool. Every other call in this section is a method on the object it returns. If you use a framework adapter in its all-in-one form, you never call this yourself. Hand the adapter { nwc } and it builds the client for you.

A preflight check runs before the promise resolves. It fails closed, meaning it refuses to start rather than run on a bad setup. It throws ConfigError (MISSING_NWC, INVALID_NWC, WALLET_PREFLIGHT_FAILED) when:

The connection string never appears in logs or errors.

Parameters

Name Type Required Meaning
nwc string no A receive-only NWC URI passed directly. Most applications read NWC_URI instead.
env Record<string, string \| undefined> no Where to read NWC_URI, LSC_URI_PRIMARY and LSC_URI_BACKUP. Default process.env.
allowSpendCapableWallet boolean no Lets your application start on a wallet that advertises spend methods. Default false. You can also set OPENRECEIVE_ALLOW_SPEND_CAPABLE_NWC=true.
priceFetch SimplePriceFetch no Replaces the fiat price fetch for { currency, value } amounts. Default: global fetch against the live feeds. With no recent enough rate, fiat-priced creation refuses with a retryable 503. There is no mock fallback.
clock () => number no Unix-seconds clock override, for tests.
swap { provider?, failoverProviders? } no A primary swap provider plus failovers in order. A failover is used only when the primary throws, never to fill in assets the primary omits. If omitted, read from LSC_URI_PRIMARY / LSC_URI_BACKUP.
client, priceProviders, priceCurrencies, logging, logger, onEvent — no Advanced overrides. See the type.

Returns OpenReceive — the wallet client. Methods:

Name Type Meaning
priceCurrencies string[] Fiat currencies this wallet client will quote. Default ["USD"].
prepareCheckout function Resolve { amount } to millisatoshis without minting an invoice.
createCheckout function Mint a Lightning invoice for an order you own.
reconcilePayments function Batch-check pending invoices in one wallet scan.
subscribeWalletNotifications function? Opt-in subscription to NWC-02 payment_received notifications. Absent when the client cannot send notifications.
quoteSwap function Quote one pay-in asset for an amount without creating a provider order. See Automated swaps.
listSwapOptions function List configured swap pay-in methods for an invoice amount. See Automated swaps.
createSwap / getSwap / refundSwap function Create, refresh, or refund a swap attempt. See Automated swaps.
listRates function Read BTC/fiat rates. See Price feeds.
close function Close the wallet client.

service.prepareCheckout

const prepared = await service.prepareCheckout({
  amount: { currency: "USD", value: "12.00" }, // or { sats: 21000 }
});

Works out the Lightning amount to charge. It does not mint an invoice or save an attempt.

Where it fits: Use this when the payer lands on your checkout page and you want to show the total in sats, or the coins they could pay with, before they commit to anything. Nothing is minted and nothing is written, so it is safe to call on every page load. Most applications never call it directly. The mounted POST …/checkouts/prepare route wraps it, and the shipped checkout UI calls that route.

The HTTP prepare route uses it so the UI can show the sats total and swap options before create.

Parameters

Name Type Required Meaning
amount { sats } \| { currency, value } yes Your own price. Never payer input.

Returns PrepareCheckoutResult

Name Type Meaning
amountMsats number Integer millisatoshis that will be charged. This is sats × 1000, or the fiat quote rounded up to a whole sat, then × 1000. Minimum 1000 (1 sat).
fiatQuote RateQuote \| null The locked BTC/fiat quote when amount was { currency, value }. null when amount was already { sats } or in a Bitcoin unit. See RateQuote.

service.createCheckout

const checkout = await service.createCheckout({
  reference: order.id,
  amount: { currency: "USD", value: "12.00" },
  // optional: memo, descriptionHash, metadata, expirySeconds
});

Mints an invoice that is safe to show the payer.

Where it fits: This is the call for the moment your user clicks “Pay with Lightning” on an order you have already priced. Call it from server-side code only, with the price from your own database. Store the returned paymentHash against the order. That hash is how you check, refund, or resume the attempt later. If you mount an adapter, POST …/checkouts does this for you and also saves the attempt. Call it yourself only when you are building your own route.

On the wire, the same object is the generated snake_case WireCheckout. The browser polls a CheckoutSnapshot, which is its own copy of that wire shape.

The wallet must honor the requested expiry. If the minted invoice’s real payable window differs from expirySeconds by more than 60 seconds, creation fails with a 502 service error. This avoids tracking a row whose reconciliation window is wrong.

This call only talks to the wallet. Saving the attempt happens in the order bridge (createHost).

Parameters

Name Type Required Meaning
reference string yes Your order id. Use one per thing you fulfill and never reuse it. OpenReceive never looks inside it. The host fulfills once per reference and refuses a new checkout under a settled reference with 409. See onPaid.
amount { sats } \| { currency, value } yes Your own price. Never payer input.
memo string no Invoice description. Cannot be used with descriptionHash.
descriptionHash string no 64-hex description hash.
metadata Record<string, unknown> no NIP-47 metadata, ≤ 3900 serialized bytes.
expirySeconds number no Requested invoice expiry. Default 600.

Returns Checkout

Name Type Meaning
reference string The reference this invoice was minted for.
paymentHash string 64-character lowercase hex payment hash. Globally unique per attempt. You pass it to later check, swap, and refund calls.
bolt11 string The Lightning invoice string the payer scans or pastes into a wallet.
amountMsats number Integer millisatoshis encoded on the invoice. The wallet must receive this amount to settle.
createdAt number Integer Unix seconds when the wallet minted the invoice. Comes from make_invoice’s created_at, else the wallet client’s clock. Pass this exact value back in reconcilePayments attempts.
expiresAt number Integer Unix seconds after which the invoice is no longer payable. Comes from the wallet and must match the requested expiry within 60 seconds.
fiatQuote RateQuote \| null The BTC/fiat quote locked at mint time when you priced in fiat. null for { sats } amounts. See RateQuote.

RateQuote

The object in fiatQuote on prepareCheckout and createCheckout when you priced in a quoted fiat currency. It is null for { sats } and for Bitcoin units { currency: "BTC" | "SAT" | "SATS", value }. The quote is locked onto the invoice, so later price-feed moves do not change amountMsats. HTTP sends the same object as fiat_quote.

Name Type Meaning
fiat { currency, value } The fiat amount that was quoted.
fiat.currency string Uppercase currency code from your amount, e.g. "USD". Must be in priceCurrencies.
fiat.value string Decimal string of that fiat amount, e.g. "12.50". Never a binary float.
btcFiatPrice string Decimal string: units of fiat per 1 BTC at quote time, e.g. "65000.12".
amountSats number Integer satoshis after rounding the fiat amount up to a whole sat. Minimum 1.
amountMsats number Integer millisatoshis (amountSats × 1000). Same value as Checkout.amountMsats.
source "static_mock" \| "primary" \| "fallback" Which price feed produced the rate. static_mock appears only when you opt in with priceProviders: [new StaticPriceProvider()], for tests or offline development.
asOf number Integer Unix seconds when the rate was observed.
expiresAt number Integer Unix seconds when this quote stops being fresh. The quote TTL defaults to 600.

service.reconcilePayments

const checks = await service.reconcilePayments({
  attempts: [{ paymentHash, createdAt }], // one invoice or every pending attempt
  // optional: until, overlapSeconds
});

Looks up known invoices in wallet history.

Where it fits: Use this when you run your own settlement loop, or write an admin tool that asks “has this invoice been paid yet?” for one or many hashes at once. Pass it every pending attempt you have on file and act on what comes back. If you use the host, you don’t need it. reconcileHostPayments calls it for you, writes the outcome back, and fires onPaid.

It reads the wallet in one batch and saves nothing. To check a single invoice, pass a one-element attempts array.

Parameters

Name Type Required Meaning
attempts { paymentHash, createdAt }[] yes Every pending attempt to check.
until number no Scan upper bound. Default now.
overlapSeconds number no Scan-window overlap. Default 60.

Returns PaymentCheck[]: one PaymentCheck per decided attempt, in the same order as attempts after hash normalization. The whole batch uses at most two paged list_transactions walks. It never looks up invoices one by one. Hashes the walk could not prove present or absent are left out. Saving results and delivering settlement are the job of reconcileHostPayments.

PaymentCheck

Name Type Meaning
paymentHash string 64-character lowercase hex hash that was checked.
status "pending" \| "settled" \| "expired" \| "failed" \| "not_found" Wallet outcome for this invoice. See PaymentCheck status.
paidAt number? Integer Unix seconds of settlement. Present only when status is settled. Comes from the wallet’s settled_at, or from the observation time if the wallet left it out.
details PaymentDetails? Corroborating wallet row from the scan. See PaymentDetails.

PaymentCheck status

Value Meaning
pending The wallet still lists the invoice as unpaid and not terminal.
settled The wallet reports the payment is final (settled_at, or transaction_state/state of "settled"). This is the only status that fulfills an order.
expired The wallet reports the invoice expired without settlement.
failed The wallet reports the invoice failed without settlement.
not_found No matching incoming transaction in the scanned window. This is not the same as expired. Reconciliation keeps the row pending until a later scan at or after expiry plus grace.

PaymentDetails

Fields of PaymentDetails

Name Type Meaning
transaction NwcTransaction The wallet’s NWC-05 list_transactions row. Contains no connection strings or provider secrets.
observed_at number Integer Unix seconds when this scan observed the row.
paid_at_source "settled_at" \| "observed_at" Present only when settled. "settled_at" means paidAt came from the wallet. "observed_at" means the wallet left out settled_at, so the wallet client’s clock was used.

OpenReceive differs from the spec in a few names:

Settlement uses only incoming rows. A positive settled_at, or a state/transaction_state of "settled", means the payment is final. A preimage only supports that evidence and never proves it alone.

service.subscribeWalletNotifications

const unsubscribe = await service.subscribeWalletNotifications((notification) => {
  // called for each payment_received notification
});

Where it fits: This is the low-level way to be told when a payment arrives, instead of polling for it. Use it in a long-running process, not in a web request, and only if you are writing your own listener. Most applications use startNotificationWorker instead. It subscribes for you, marks matching attempts settled, and falls back to a scan when a notification is unclear.

Parameters

Name Type Required Meaning
handler (notification) => void yes Called for each payment_received notification.

The notification argument (not a return value):

Fields of the notification payload

Name Type Meaning
type string Notification type. The bundled subscription only delivers payment_received.
payment_hash string? 64-hex hash when the payload includes one. Unknown or missing hashes only wake a scan.
transaction NwcTransaction? Payload normalized like a NWC-05 list_transactions row. If the row meets the settlement rule, it may settle its matching pending attempt directly.

Returns () => Promise<void> | void. Call it to unsubscribe. The promise rejects with OpenReceiveError code UNSUPPORTED_METHOD when the wallet client cannot send notifications. Notifications are authenticated wallet data. Only the type and payment hash are ever logged. How a notification settles an attempt directly is described in startNotificationListener.

service.listSwapOptions

const { enabled, options } = await service.listSwapOptions({
  amountMsats: checkout.amountMsats,
});

Lists whether swaps are configured and the pay-in methods for that amount.

Where it fits: Call this when you are drawing the “how would you like to pay?” screen and need to know whether to show stablecoin buttons at all, and which of them are within the provider’s limits for this amount. It only reads, so call it whenever the amount is known. Over HTTP the same list arrives as payment_methods on the prepare, create, and check responses, so a browser client never needs a separate call.

Automated swaps describes the behavior.

Parameters

Name Type Required Meaning
amountMsats number yes Your own invoice amount in millisatoshis.

Returns ListSwapOptionsResult

Name Type Meaning
enabled boolean true when at least one LSC provider is configured.
options SwapPaymentMethod[] One entry per supported pay-in asset. Empty when swaps are off.

Each options[] entry:

Fields of SwapPaymentMethod

Name Type Meaning
payInAsset string Pay-in asset id, e.g. "USDT_TRON".
label string Asset ticker shown to the payer, e.g. "USDT".
networkLabel string Network name shown to the payer, e.g. "Tron".
provider string Provider that would quote this pair.
available boolean true when this amount is inside the provider’s limits right now.
unavailableReason string? Machine reason when available is false, e.g. "amount_too_small".
unavailableMessage string? Payer-facing explanation when unavailable.
payAmount string? Decimal string of crypto the payer would send, when a quote is available.
minimumPayAmount string? Decimal string minimum deposit the provider accepts.
maximumPayAmount string? Decimal string maximum deposit the provider accepts.
minimumInvoiceAmountMsats number? Smallest Lightning invoice this pair will quote, in millisatoshis.
maximumInvoiceAmountMsats number? Largest Lightning invoice this pair will quote, in millisatoshis.

service.quoteSwap

const quote = await service.quoteSwap({
  amount: { currency: "USD", value: "12.00" }, // or { sats: 21000 }
  payInAsset: "USDT_TRON",
});

Quotes one pay-in asset for an amount you set. It does not mint an invoice or create a provider order. POST …/swaps/quote calls it.

Where it fits: Use it when the payer has tapped one specific coin and you want to show “send 12.40 USDT” before they commit. Nothing is created at the provider, so it is fine to call as they browse between options. The shipped payment wizard calls the quote route as the payer picks. You only call this yourself when building your own picker.

The result is camelCase. The HTTP handler converts it to the snake_case wire shape.

Parameters

Name Type Required Meaning
amount { sats } \| { currency, value } yes Your own price. Never payer input.
payInAsset string yes Pay-in asset id, e.g. "USDT_TRON".

Returns SwapQuoteResult

Name Type Meaning
provider string Provider that quoted this pair.
payAsset SwapPayInAsset The pay-in asset id that was quoted.
available boolean true when the amount is inside the provider’s limits right now.
payAmount string? Decimal string of crypto the payer would send, when available.
minimumPayAmount / maximumPayAmount string? Provider deposit limits.
minimumInvoiceAmountMsats / maximumInvoiceAmountMsats number? Limits on the Lightning invoice side, in msats, when reported.
unavailableReason / unavailableMessage string? Machine reason and payer-facing explanation when available is false.

service.createSwap

const swap = await service.createSwap({
  reference: order.id,
  amount: { currency: "USD", value: "12.00" },
  payInAsset: "USDT_TRON",
  // optional: the createCheckout extras (memo, metadata, expirySeconds, …)
});

Creates a swap attempt: a shadow Lightning invoice plus on-chain deposit instructions. The shadow invoice is the Lightning invoice the swap provider pays on the payer’s behalf.

Where it fits: This is the swap version of createCheckout. Call it at the moment the payer confirms “pay with USDT on Tron” on a priced order. Call it server-side and store the whole result on the attempt row. Send only the PublicSwap fields and the checkout to the browser. The adapters’ POST …/swaps route does exactly that when you mount one.

swapData must stay server-only.

Parameters

Name Type Required Meaning
(createCheckout fields) — — Same as createCheckout.
payInAsset string yes Pay-in asset id.

Returns SwapCheckout

Name Type Meaning
(PublicSwap fields) — Deposit instructions and provider snapshot. See PublicSwap.
checkout Checkout The shadow Lightning invoice this swap pays. Same shape as createCheckout.
swapData SwapData Server-only state for recovering the provider order. Save it on the attempt row. Never put it in a browser response or a log.

swapData is { version: 1, providerOrder: SwapOrder }. version is the integer schema version (1). providerOrder holds provider credentials and must stay on the server.

service.getSwap / service.refundSwap

const status = await service.getSwap({ reference, paymentHash, swapData });
const refunded = await service.refundSwap({ reference, paymentHash, swapData, refundAddress });

Both refresh provider state using the swapData you loaded.

Where it fits: Call getSwap when you need the current state of a swap: on a status endpoint the browser polls, or on an admin page. Call refundSwap when a payer asks for their money back after an underpaid or late deposit and you have their refund address. Both need the swapData you saved when the swap was created, so call them from your server. The mounted …/swaps/status and …/swaps/refunds routes do this for you.

refundSwap refuses any provider state other than refund_required.

Parameters

Name Type Required Meaning
reference string yes The reference the swap attempt belongs to.
paymentHash string yes 64-hex hash of the shadow Lightning invoice.
swapData SwapData yes The server-only recovery state you saved from create.
refundAddress string refundSwap only On-chain address to return funds to.

Returns PublicSwap — see PublicSwap below.

PublicSwap

A swap snapshot that is safe to show the payer. It has no provider tokens or credentials.

Fields of PublicSwap (return value of getSwap / refundSwap)

Name Type Meaning
paymentHash string 64-character lowercase hex hash of the shadow Lightning invoice.
reference string The reference this swap attempt belongs to.
provider string Provider that issued the deposit address.
payInAsset string Pay-in asset id, e.g. "USDC_SOL".
depositAddress string On-chain address the payer sends to.
depositMemo string? Destination tag or memo the payer must include, when the network requires one.
depositAmount string Decimal string of crypto the payer must send. Never a binary float.
providerState string Provider lifecycle: creating_provider_order, awaiting_deposit, confirming, exchanging, paying_invoice, completed, expired, refund_required, refund_pending, refunded, attention, or failed. Provider completed does not mean the wallet has settled.
providerExpiresAt number Integer Unix seconds when the provider order expires.
depositTxId string? Provider-reported deposit transaction id, when known.
payoutTxId string? Provider-reported payout (Lightning pay) transaction id, when known.
refundTxId string? Provider-reported refund transaction id, when a refund was sent.
refundReason string? Why a refund is needed: "underpaid", "overpaid", "late_deposit", "underpaid_and_late", or "overpaid_and_late".
refundAmount string? Decimal string the provider will return, excluding its network fee.
attention boolean? true when this attempt needs operator review.
attentionReason string? Why the attempt needs an operator, when attention is set.
depositReceivedAmount string? Amount actually received on the deposit transaction, when the provider reports it. The payer UI compares it with depositAmount to explain an underpayment.
emergencyRepeat boolean? A second deposit hit the same provider order. Extra funds may sit at the provider even though the attempt looks like an ordinary refund.
providerOrderId string? The provider’s order reference, shown to the payer for support.
fee SwapFee? Fiat values that explain why the payer sends more than the cart total. Never use it as the price. The invoice amount is the price. See SwapFee.

SwapFee

Fiat values attached to a swap for display. The field names follow the provider’s wire shape.

Fields of SwapFee

Name Type Meaning
currency string Fiat currency the equivalents are expressed in, e.g. "USD".
pay_in_fiat string Fiat value of the crypto the payer sends. It explains the spread. It is never an amount to send.
payout_fiat string Fiat value delivered to the merchant, which is the cart total.

depositAmount, in the pay-in token, is the only amount a payer is ever told to send. The fee figures are values that explain why it is more than the cart total.

Some stablecoins are pegged to currency (pegged_to in the shared asset table). USDT and USDC are pegged to USD. For these, the packaged checkout shows the breakdown in the token, for example “You send 50.05 USDC” and “Swap + network fees 1.05 USDC (2.1%)”. It never shows pay_in_fiat. A “$50.03” one line under “50.05 USDC” would read as the same number with a typo. Floating assets (SOL, ETH) keep the fiat breakdown.

createSwapFeeBreakdown(fee, swap) applies this rule. Pass it the swap, not just the fee.

service.listRates

const { bitcoin } = await service.listRates(); // or ({ currencies: ["USD", "EUR"] })

Where it fits: You’ll use this for display, not for pricing: a “1 BTC = $65,000” footer, a currency switcher, a rough sats preview before an order exists. When it is time to charge, price the order with prepareCheckout or createCheckout instead. They lock the quote onto the invoice. A rate you read here can move before the payer pays.

Parameters

Name Type Required Meaning
currencies string[] no Fiat codes to fetch. Default priceCurrencies.

Returns { bitcoin }

Name Type Meaning
bitcoin Record<string, string> Maps each uppercase currency code to the price of 1 BTC in that currency, as a decimal string, e.g. { USD: "65000.12" }.

The wallet client also has quoteRates. It is the internal helper that builds the fiatQuote that prepareCheckout / createCheckout attach. It exists only in JS, with no HTTP route and no Ruby counterpart. Use prepareCheckout to quote an amount.

service.close

await service.close();

Returns Promise<void>. Closes the underlying wallet client and its relay connection. If you run the notifications worker, stop it first.

Where it fits: At the end of a script, a one-off job, or a test, after the last wallet call. A long-running server can usually skip it, as explained below.

The wallet client is only created on the first wallet call. So close() does nothing for a wallet client that never minted or scanned.

Call it in scripts, one-shot jobs, and tests. An open relay connection keeps the Node event loop alive. A process that skips close() finishes its work and then hangs instead of exiting.

A long-running server does not need it. No payment state lives in memory. The wallet plus the payments table hold the truth about settlement, and there is no queue to drain. So a process that is terminated loses nothing by skipping it. The Express middleware and the Next handler still expose close() if you want a predictable shutdown on SIGTERM. The Fastify plugin registers an onClose hook and closes with the app.

Host (@openreceive/http)

The object createHost() returns. It connects OpenReceive to your application: your price, your fulfillment, and your database. By convention it is held in a variable named host.

createHost

const host = createHost(options: CreateHostOptions): Host

Where it fits: Write this once, next to where you build the wallet client. It is where your own code plugs in: amountFor looks up the price, onPaid marks the order paid, and db is the database connection you already have. Pass the result to an adapter or to the reconcile functions. With an adapter in all-in-one form, you give the adapter the same three things and it calls createHost for you.

In the default db mode, OpenReceive owns the openreceive_payments rows inside your application’s existing database.

Parameters

Name Type Required Meaning
db SqlDatabase yes pg Pool/Client, node:sqlite DatabaseSync, better-sqlite3, or a custom SqlAdapter.
amountFor (reference, context) => amount \| null yes The trusted price for a reference, from your data. Return null for a 404. Called only where a price is minted or quoted. You may return an optional description beside the price. It is one display string. It is echoed on the prepare and create responses, shown above the amount by both drop-ins, and used as the invoice memo so the payer’s wallet shows it too. See Frontend checkout → Show the payer what they are buying.
onPaid PaymentSettlementHook yes Fulfillment. See onPaid.
tableName string no Default openreceive_payments.
clock () => number no Unix-seconds clock override. Used by the reconcile gate and the payment-methods cache TTL.

onPaid

In db mode, onPaid receives a PaymentSettlement. This is the callback’s argument, not a return value.

Fields of PaymentSettlement

Name Type Meaning
reference string The reference that just settled. It is the string you passed when the checkout was created. This hook fulfills once per reference, so use one reference per order and never reuse it.
paymentHash string 64-character lowercase hex hash of the settled attempt.
paidAt number Integer Unix seconds of settlement. Comes from settled_at, else the observation time.
details PaymentDetails? Wallet row that proved settlement. See PaymentDetails.
query (sql, params?) => Promise<rows> Runs SQL inside the settlement transaction. Write it for your own dialect (? on sqlite, $1-style on postgres). It reaches the driver unchanged. Use it for writes that must commit together with settlement, such as an outbox row.

onPaid runs inside the settlement transaction, and only for the order’s first settled attempt. It is write-once: a second settled attempt for the same order records duplicate_settlement and never fulfills again. Delivery is at-least-once. If onPaid throws, the transaction rolls back and the next reconciliation pass retries.

Write through the supplied query. It is the only handle inside the settlement transaction. An ORM call made here uses that ORM’s own connection, so it commits separately. It can survive a rolled-back settlement, or be lost when settlement commits and it does not.

On Rails this handle does not exist, and that is on purpose. The Rails engine wraps the on_paid block in an ActiveRecord transaction. Plain ActiveRecord inside the block is already part of that transaction, so there is nothing to pass through. PaymentSettlement there carries reference, payment_hash, paid_at and details, and no query. If you port between the engines, you only need to know which side supplies the transaction. JS hands you a handle. Rails wraps your block.

Keep onPaid to database writes. Anything that reaches outside the transaction, such as an email, a webhook, or a shipping call, survives a rollback and runs again on the retry. Flag the order here, or insert the outbox row shown below, and let your own worker process it after commit. There is no after-commit hook, by design.

onPaid: async ({ reference, query }) => {
  await query("UPDATE orders SET state = 'paid' WHERE id = ?", [reference]);
  // Same transaction: enqueue follow-up work here rather than doing it inline.
  await query("INSERT INTO outbox (kind, reference) VALUES (?, ?)", ["order_paid", reference]);
},

If your ORM can run statements on a connection you pass it, wrap query. Node ORM recipes has a recipe for each ORM.

As an advanced escape hatch, you can replace db with a full repository implementation.

Parameters

Name Type Required Meaning
payments PaymentRepository yes Your repository. It provides commit locking, write-once settlement, and reconciliation transitions.
onPaid SettlementEventHook<Transaction> yes Receives reference, paymentHash, paidAt, details?, and the repository’s typed transaction. recordSettlementWithFulfillment awaits it inside the settlement transaction. A failure rolls back both writes.

Returns Host, for the framework adapters and reconcile passes. The attempt row commits before the payer sees any payment instructions.

Either way the invoice is withheld.

Name Type Meaning
resolveCheckout function Looks up the trusted price, and any live attempt, for the create, check, and swap routes.
onCheckoutCreated function Commits the openreceive_payments row before the invoice or swap instructions are returned. Receives CheckoutCreatedInput.
onPaid function Delivers settlement. In db mode this is the write-once wrapper around your onPaid hook.
payments PaymentRepository The record of attempts (the ledger). It lists, commits, settles, and records reconciliation transitions.

CheckoutCreatedInput

Passed to onCheckoutCreated after the wallet mints and before the HTTP response is written. If it refuses by throwing a 409-shaped error, the response is 409. Any other throw becomes a retryable 503. In both cases the payer gets no payment instructions.

Fields of CheckoutCreatedInput (argument to onCheckoutCreated)

Name Type Meaning
reference string Your order this attempt belongs to.
paymentHash string 64-character lowercase hex hash of the new attempt.
checkout Checkout Invoice snapshot, safe for the payer, to save and reuse later. Same shape as createCheckout.
swapData SwapData? Server-only state for recovering the provider order. Save it on the row. Never send it to a browser.
clientIp string? The client IP the adapter assigned to this request, when one was available. Used by opt-in per-IP rate limiting.

The authorize context

Every route that acts on an order calls authorize(context) before any wallet or database work. Returning false produces 403 FORBIDDEN. The optional rateLimitHook uses the same shape. It returns false for a 429.

Where it fits: You write authorize where you would write a controller’s “is this their order?” check: look up resource.reference in your data, compare it with whoever is logged in on request or native, and return a boolean. Every payment route runs it first, so this one function is your whole access policy for OpenReceive.

There are two deliberate exceptions:

There is one callback type: (context: AuthorizeContext) => boolean | Promise<boolean>. Some snippets destructure { native, resource } or { action, request, resource }. These are not other signatures. They just name the fields they read. Sync and async returns both fit this type.

Fields of AuthorizeContext (argument to authorize, not a return value)

Name Type Meaning
action AuthorizeAction One of checkout.prepare, checkout.create, payment.check, swap.quote, swap.create, swap.read, swap.refund.
request Request The Web-standard request OpenReceive built (headers, URL, cookies).
resource { reference?, paymentHash? } Copied from the payer’s JSON before any host lookup. reference is on every route that acts on an order. paymentHash is also set on payment.check, swap.read, and swap.refund. They identify a row. They do not prove this caller owns it. After authorize returns true, the library still checks that a requested hash belongs to that reference. See Authorization and the host.
native unknown? The original framework request (Express req, Fastify request, NextRequest), when an adapter provides one. Use it for state your middleware attached, such as req.session.

An Express session example. It is the same callback, reading native instead of request:

authorize: ({ action, request, resource, native }) => {
  const userId = (native as { session?: { userId?: string } }).session?.userId;
  return userId !== undefined && orders.belongsTo(resource.reference, userId);
},

startReconciler

const reconciler = await startReconciler({
  service,
  host,
  // optional: pollIntervalMs, overlapSeconds, signal, clock, onError
});

Where it fits: Use this only when you want a background loop that scans the wallet on a timer and nothing else, for example on a wallet that cannot send notifications. Most applications skip it: the request-path pass settles orders as payers poll, and the notifications worker already includes this loop.

It is a basic polling loop. startNotificationWorker uses it internally, and you can call it directly, but no adapter or stack starts it. Most applications rely on the default opportunistic reconcile that runs on requests (maybeReconcilePayments) and never call this.

Parameters

Name Type Required Meaning
service OpenReceive yes The wallet client.
host Host yes From createHost.
pollIntervalMs number no Default 5000. Throws RangeError below 250.
overlapSeconds number no Scan overlap. Default 60.
signal AbortSignal no A signal to stop it from outside.
clock () => number no Unix-seconds clock override. Used by the reconcile gate and the payment-methods cache TTL.
onError (error) => void no Called when a pass fails. Default: console.warn, without repeats.

Returns Reconciler. Every pass goes through the durable reconcile gate (maybeReconcilePayments). So any number of reconciler instances, plus the opportunistic reconcile on requests, share one real wallet scan per gate interval. Construction throws unless the repository implements claimReconcileGate and checkpointReconcileGate.

A failed pass is reported and retried from the ledger, so delivery is at-least-once. Only pending attempts are scanned. Settled and closed rows leave the scan set. This keeps the scan window small without a saved cursor.

Name Type Meaning
stop () => void Stops scheduling further passes. In-flight work is not cancelled.
done Promise<void> Resolves after stop() once the current pass (if any) finishes.

reconcileHostPayments

const checks = await reconcileHostPayments({
  service,
  host,
  // optional: overlapSeconds, maxPages, clock
});

Where it fits: This is the one-shot call: check everything pending and fulfill what settled. Use it from a cron job, a script, or an admin button when you want a pass now and want the results back. The gated pass and the worker call it for you, so it rarely belongs in a normal request.

It runs one pass with a fixed limit. Each pass:

  1. Lists the oldest pending attempts, up to OPENRECEIVE_RECONCILE_BATCH_SIZE (200). A backlog drains over several passes.
  2. Scans the wallet once for the whole batch. maxPages caps the paged walks.
  3. Delivers settlements through host.onPaid. Delivery is at-least-once, and the repository makes it write-once.
  4. Saves final state changes.
  5. Returns the PaymentCheck result for each hash in the pass.

Like every OPENRECEIVE_* name in this section, OPENRECEIVE_RECONCILE_BATCH_SIZE is a constant exported by @openreceive/http, not an environment variable.

To close an unpaid attempt, a wallet scan must succeed at or after expiry plus the 900-second grace (OPENRECEIVE_ATTEMPT_EXPIRY_GRACE_SECONDS). The local clock alone never closes a row. A settled result without paidAt is retried on the next pass. On a wallet or repository failure it throws and leaves every row pending for the next pass.

maybeReconcilePayments

const result = await maybeReconcilePayments({
  service,
  host,
  // optional: minIntervalSeconds, scanTimeoutMs, maxPages, overlapSeconds, clock, onError
});

Where it fits: Call this from routes of your own that a waiting payer hits, such as a custom order-status endpoint, so settlement gets a chance to run there without a separate worker. It is cheap to call often: it does nothing when nothing is pending, and the durable gate makes many web instances share one wallet scan. The mounted routes already call it, so you only add it where you have built your own.

Parameters

Name Type Required Meaning
service OpenReceive yes The wallet client.
host Host yes From createHost.
minIntervalSeconds number no The shortest gate interval. Default and minimum: OPENRECEIVE_MIN_RECONCILE_INTERVAL_SECONDS (3). The interval grows with the age of pending invoices: 3 s while any pending invoice is under 2 minutes old, 6 s under 5 minutes, else 12 s.
scanTimeoutMs number no Time limit on the awaited pass. Default OPENRECEIVE_RECONCILE_SCAN_TIMEOUT_MS (9000).
maxPages number no Page cap per wallet walk. Default OPENRECEIVE_RECONCILE_SCAN_MAX_PAGES (50).
overlapSeconds number no Scan overlap. Default 60.
clock () => number no Unix-seconds clock override. Used by the reconcile gate and the payment-methods cache TTL.
onError (error) => void no Called when a scan fails. Default: console.warn.

This is the gated pass behind the handler’s default opportunistic reconcile on requests. It is exported so you can drive settlement from your own routes or middleware. Your own routes never run it automatically. It works like this:

  1. If nothing is pending, it skips without calling the wallet.
  2. It claims the durable openreceive_meta gate. The claim is an optimistic compare-and-swap (CAS) shared by every instance on your database. gate_busy means another worker just scanned.
  3. Otherwise it awaits one bounded reconcileHostPayments pass.

It never throws. A failed scan reports to onError and returns scan_failed. The gate’s claim stays in place, so a broken wallet cannot trigger a flood of retries. A page still in flight at the 9 s scan deadline is cut: when earlier pages answered, the pass returns ran and the next pass resumes after them; when none did, the pass fails like any other wallet failure.

Returns { reason: "ran", checks } (the per-hash PaymentCheck results) or { reason: "no_pending" | "gate_busy" | "scan_failed" }.

startNotificationListener

const listener = await startNotificationListener({
  service,
  host,
  // optional: overlapSeconds, onError
});

Where it fits: Use it when you already run your own periodic reconcile and only want the notification part: one long-running process that settles an order the moment the wallet announces the payment. If you don’t have that loop, use startNotificationWorker, which does both.

Parameters

Name Type Required Meaning
service OpenReceive yes Must implement subscribeWalletNotifications. Otherwise it rejects with UNSUPPORTED_METHOD.
host Host yes Where settlement goes and where pending attempts come from.
overlapSeconds number no Overlap for fallback scans.
onError (error) => void no Receives failures. Default: a console.warn with secrets removed, so a listener that keeps failing is never silent.

Returns NotificationListener

Name Type Meaning
stop () => Promise<void> \| void Unsubscribes from wallet notifications and waits for any in-flight reconcile pass.

Most applications use startNotificationWorker instead. It wraps this listener plus the periodic pass.

This is an opt-in NWC-02 listener that can settle attempts directly. Notifications are authenticated wallet data. So a payment_received payload settles its attempt directly through host.onPaid, with no wallet scan for that invoice, when both of these hold:

Settling removes the attempt from the pending set, so no later pass scans it again.

Anything less wakes one gated pass (maybeReconcilePayments). That covers no payload, no sign that the payment is final, or a hash that is unknown or not pending. A burst of notifications queues at most one follow-up pass. A pass that another worker just ran is not repeated.

If direct settlement fails, it reports to onError and falls back to a scan. A periodic pass, either the worker’s or the opportunistic reconcile on requests, remains the safety net for notifications missed while offline.

Direct settlement assumes the NWC client ties notification decryption to the connection’s wallet pubkey. The bundled SDK does this. Do not allow direct settlement for a custom client that skips author verification.

startNotificationWorker

const worker = await startNotificationWorker({
  service,
  host,
  // optional: pollIntervalMs, overlapSeconds, onError
});

Where it fits: Run this when you want orders marked paid within a second of the payment arriving, instead of on the payer’s next poll. Put it in a small script of its own and run it as a separate process next to your web server, for example a worker line in your Procfile. Stop it before you call service.close(). It is optional. A web deployment with no worker still settles every order through the pass that runs on requests.

Parameters

Name Type Required Meaning
service OpenReceive yes The wallet client.
host Host yes From createHost.
pollIntervalMs number no Interval of the periodic safety-net pass. Default 15000.
overlapSeconds number no Scan overlap. Default 60.
onError (error) => void no Receives failures. Default: a console.warn with secrets removed, so a listener that keeps failing is never silent.

The worker is optional. It is one separate long-lived process that runs two things:

Every scan it takes goes through the same durable reconcile gate as the pass on requests. So the worker plus any number of web instances still share one wallet scan per interval. If the wallet does not support notifications, the worker falls back to the periodic pass alone and reports this through onError.

There is deliberately no npx openreceive notifications CLI, because the CLI cannot see your onPaid or database. Start the worker from a small script of your own.

Returns NotificationWorker

Name Type Meaning
stop () => Promise<void> Unsubscribes, stops the periodic pass, and waits for in-flight work. Call it before service.close().
done Promise<void> Resolves after stop() once the periodic loop has drained.

Settlement entry points

Settlement arrives in two ways:

POST /payments/check never walks the wallet for its own invoice. It uses the result of the request’s pass.

Both paths are safe to replay, because they share the same write-once path. onPaid still runs inside the web request when that request wins the pass. So fulfillment work must be safe to run inside a web request. Keep it transactional, or enqueue an outbox job.

Framework adapters

All three adapters serve the route set in the OpenAPI spec and accept two forms of options.

Each adapter re-exports only a chosen part of @openreceive/http: the handler and stack factories, their options, context and hook types, the error classes, the notification worker, and the generated Wire* body types. The order-bridge internals (createSqlPayments, the reconcile gate, createHost, rate-limit internals) live only in @openreceive/http. Import them from there when you compose your own integration. npm run check:public-api locks these surfaces.

All-in-one form (the usual path): order hooks plus wallet and storage. The adapter builds the wallet client and host itself. Startup waits until the first request, which awaits the wallet preflight. The Express middleware and the Next handler expose ready (a promise) and close() (closes the wallet client they own). The Fastify plugin exposes neither. It registers an onClose hook that shuts the stack down with the app.

Parameters

Name Type Required Meaning
wallet { nwc } | { service } yes The wallet. Either a receive-only NWC connection string, where the adapter builds and owns the client, or a prebuilt OpenReceive / Promise<OpenReceive>, where you own its lifecycle.
storage { db, onPaid, tableName? } | { payments, onPaid } yes Where attempts live. This decides what onPaid receives. With the database handle createHost takes, onPaid gets the per-reference PaymentSettlement. With a custom PaymentRepository (see Storage: the escape hatch), it gets SettlementEvent<Transaction> and its transaction handle.
amountFor   yes Same hook as createHost.
authorize Authorize yes Your policy. See the authorize context.
opportunisticReconcile false \| { minIntervalSeconds } no The settlement pass that runs on every mounted payment route. GET …/rates never triggers it. On by default, through the durable openreceive_meta gate. false turns it off. { minIntervalSeconds } tunes it.
clock () => number no Unix-seconds clock override. Used by the reconcile gate and the payment-methods cache TTL.
onBootFailure (message: string) => void no Where the single boot-failure line goes. Default console.error. Boot happens before any service exists, so this is the only place to send it. It receives only the message, never the raw cause. Requests during a failed boot answer 503 WALLET_UNAVAILABLE either way. See Deploying.
rateLimiting / rateLimitHook / prefix   no As below.
trustProxyIpHeader boolean \| string no Extra option on all three adapters. It tells rateLimiting how to find the client IP behind a reverse proxy. true reads the first hop of x-forwarded-for. A string names another trusted header, e.g. "cf-connecting-ip". Only safe when your own proxy sets the header.

Composed form (CreateHttpHandlerOptions), for shared wallet clients, custom repositories, and tests:

Parameters

Name Type Required Meaning
service OpenReceive yes From createOpenReceive.
authorize Authorize yes Your policy. See the authorize context.
host Host yes From createHost.
opportunisticReconcile false \| { minIntervalSeconds } no As above. With a custom repository, leaving it on (the default) requires payments.claimReconcileGate and payments.checkpointReconcileGate. Otherwise construction throws, the same way rateLimiting does.
rateLimitHook RateLimit no Same context shape as authorize. Return false for a 429.
rateLimiting boolean \| IpRateLimitConfig no Opt-in per-IP invoice cap. Off by default. true = 60/hour. Cannot be used with rateLimitHook. See Rate limiting.
prefix string no Mount prefix. Default /openreceive.

The same all-in-one form is available without a framework as createStack(options) in @openreceive/http. It returns { handler, ready, close }.

Create routes reject amounts sent by the payer and take the price from amountFor. Payment and swap reads take reference plus payment_hash. After your authorization passes, the library checks that this exact attempt belongs to the order and supplies the server-only swap_data.

HTTP JSON is snake_case. The values are the same as the Node objects above. The table below is generated from the OpenAPI contract, which is the authority. … is the mount prefix (default /openreceive).

| Route | Status | Response body | | — | — | — | | POST …/checkouts/prepare | 200 | PrepareCheckoutResponse { reference, amount_msats, description?, fiat_quote?, payment_methods } | | POST …/checkouts | 201 | CreateCheckoutResponse { checkout: Checkout, description?, payment_methods } | | POST …/payments/check | 200 | PaymentCheck { payment_hash, status: PaymentStatus, paid_at?, details?: PaymentDetails, payment_methods } | | POST …/swaps/quote | 200 | SwapQuote { provider, pay_asset: SwapPayInAsset, available, pay_amount?, minimum_pay_amount?, maximum_pay_amount?, minimum_invoice_amount_msats?, maximum_invoice_amount_msats?, unavailable_reason?, unavailable_message? } | | POST …/swaps | 201 | CreateSwapResponse { swap: SwapCheckout } | | POST …/swaps/status | 200 | Swap { payment_hash, reference, provider, pay_in_asset: SwapPayInAsset, deposit_address, deposit_memo?, deposit_amount, provider_state: SwapProviderState, provider_expires_at, deposit_tx_id?, payout_tx_id?, refund_tx_id?, refund_reason?, refund_amount?, attention?, attention_reason?, deposit_received_amount?, emergency_repeat?, provider_order_id?, fee?: SwapFee } | | POST …/swaps/refunds | 200 | Swap { payment_hash, reference, provider, pay_in_asset: SwapPayInAsset, deposit_address, deposit_memo?, deposit_amount, provider_state: SwapProviderState, provider_expires_at, deposit_tx_id?, payout_tx_id?, refund_tx_id?, refund_reason?, refund_amount?, attention?, attention_reason?, deposit_received_amount?, emergency_repeat?, provider_order_id?, fee?: SwapFee } | | GET …/rates | 200 | RatesResponse { bitcoin } |

How the bodies map to the Node objects above:

Repeating a create: mint, or re-serve

POST …/checkouts and POST …/swaps do not always mint. A request mints only when the order has no saved attempt it can serve again. Otherwise it answers with the attempt the order already has. That means no wallet call, no provider order, no second row, and no rate-limit charge. This makes a reload, a back button, or a payer picking the same coin again safe. They get their own deposit instructions back, not a second address to send to.

Serving the old attempt again has conditions. Know them before you build resume on top of it. The attempt must be:

Past that point, the same call mints a replacement: a fresh BOLT11, or a fresh deposit address. A swap’s expiry is the expiry of its shadow Lightning invoice. The provider sizes that invoice to outlast its deposit window, roughly half an hour with FixedFloat’s defaults. So a payer returning the next day gets a new attempt rather than their old one. Two live attempts on the same rail for one reference is a 409 CONFLICT.

POST …/swaps/status has no such window. It takes { reference, payment_hash } and addresses that one attempt directly. So it still answers for an attempt that stopped being payable hours ago. That makes the payment hash, not the chosen asset, the lasting handle for bringing a payer back to a deposit or refund screen. See Swap refunds → The way back.

Errors

The error body and the per-route error statuses are generated from the spec. 429 responses also carry a Retry-After header. Both engines answer two statuses before authorize runs:

Every error status above returns the OpenReceive error body { code, message, retryable?, request_id?, details? } (normative: spec/schemas/error.schema.json). code is one of:

NOT_IMPLEMENTED, RESTRICTED, UNAUTHORIZED, FORBIDDEN, RATE_LIMITED, QUOTA_EXCEEDED, INTERNAL, UNSUPPORTED_ENCRYPTION, OTHER, NOT_FOUND, TIMEOUT, INVALID_REQUEST, WALLET_UNAVAILABLE, INVOICE_EXPIRED, UNSUPPORTED_METHOD, CONFLICT

| Route | Declared error statuses | | — | — | | POST …/checkouts/prepare | 400, 403, 404, 405, 413, 415, 429, 500, 503 | | POST …/checkouts | 400, 403, 404, 405, 409, 413, 415, 429, 500, 502, 503 | | POST …/payments/check | 400, 403, 404, 405, 409, 413, 415, 429, 500, 502, 503 | | POST …/swaps/quote | 400, 403, 404, 405, 413, 415, 429, 500, 503 | | POST …/swaps | 400, 403, 404, 405, 409, 413, 415, 429, 500, 502, 503 | | POST …/swaps/status | 400, 403, 404, 405, 413, 415, 429, 500, 502, 503 | | POST …/swaps/refunds | 400, 403, 404, 405, 409, 413, 415, 429, 500, 502, 503 | | GET …/rates | 400, 405, 500, 501, 503 |

Error codes: who retries, and whose bug

The body includes retryable when it differs from the code’s own default. Use the “Whose bug” column to decide where an error goes. A yours row belongs in your error tracker. A payer row does not.

Code Typical status Retryable Whose bug Means
INVALID_REQUEST 400, 413, 415 no payer / integrator Malformed body, unknown field, payer-supplied amount, oversized body, wrong content type.
UNAUTHORIZED 401 no payer Your authorize refused an unauthenticated caller.
FORBIDDEN 403 no payer Your authorize refused, or the browser labelled the request Sec-Fetch-Site: cross-site.
NOT_FOUND 404 no payer Unknown reference (your amountFor returned null), unknown attempt, or no route.
CONFLICT 409 no payer Already paid, a live attempt on the same rail, a non-reusable attempt, or onCheckoutCreated refused.
RATE_LIMITED 429 yes (Retry-After) payer Per-IP invoice cap. Never applied to status polls or quotes.
INTERNAL 500, 502, 503 yes at 503 yours (500) / provider (502, 503) 500 is a bug in your integration or the library. Log it. 502/503 come from the wallet or swap provider. Retry.
WALLET_UNAVAILABLE 503 yes wallet / infra The wallet client cannot answer, including a failed adapter boot.
UNSUPPORTED_METHOD 502 no wallet The wallet does not honor part of the receive contract (e.g. it ignores the requested invoice expiry).
TIMEOUT 503 yes wallet / provider An outbound call ran out of time.
INVOICE_EXPIRED 409 no payer The addressed invoice is past its expiry.
NOT_IMPLEMENTED 501 no integrator The route needs configuration you did not supply (e.g. GET /rates with no price provider).
QUOTA_EXCEEDED, RESTRICTED, UNSUPPORTED_ENCRYPTION, OTHER 502, 503 varies wallet Passed through from the wallet’s own NIP-47 error codes.

Status vocabularies: who sees which

Six different “status” vocabularies appear across the stack. They are NOT the same set of values, and each has exactly one audience.

Vocabulary Values Who reads it Where it lives
Attempt status pending, settled, expired, failed, attention Operator (and your database) The openreceive_payments.status column. attention is the one that needs a human. It reads as pending on the wire. See Storage.
Payment check status pending, settled, expired, failed, not_found Host polling payments/check The wire answer for one hash. not_found means the scanned window did not contain it. It never means “unpaid”.
NWC transaction state pending, settled, expired, failed, accepted Library, internally The wallet’s own word for a row, normalized when it enters the client. Hosts do not branch on it.
Checkout snapshot status open, paid, expired Payer UI The rough state a browser snapshot carries for the whole checkout.
Checkout phase invoice_created, verifying, settled, expired, failed, cancelled Payer UI What the panel is showing right now, for one attempt. Display only. Nothing on the server reads it.
Swap provider state creating_provider_order, awaiting_deposit, confirming, exchanging, paying_invoice, completed, expired, refund_required, refund_pending, refunded, attention, failed Payer UI (swap panel) The provider’s progress. It never decides settlement. completed does not fulfill an order. Only the wallet sweep does. See Automated swaps.

deriveStatus(invoice) gives the browser’s one-word verdict for an attempt: pending, settled, expired or failed. These are the payment check’s words minus not_found, because a snapshot always has an attempt. It comes from the server’s transaction_state. The browser never works out “settled” from settled_at itself.

openReceiveExpress

app.use(openReceiveExpress(options)): ExpressMiddleware

Express middleware. It handles requests under its prefix and calls next() for everything else. The original Express req is passed as native.

Where it fits: This is the one line that puts OpenReceive in an Express app. app.use(...) it once at startup, before your 404 handler, and every checkout, status, swap, and rates route exists under /openreceive. Put your session middleware ahead of it so authorize can read native.session.

openReceiveFastify

await fastify.register(openReceiveFastify, options)

Fastify plugin. It registers a catch-all route under prefix. The original Fastify request is passed as native. Options are the shared all-in-one or composed set above, plus trustProxyIpHeader.

Where it fits: Register it once during app setup, after whatever plugin gives you sessions or auth decorations, because authorize sees the same request object. Shutdown is handled for you. The plugin closes the wallet client with the app. Full walkthrough: Fastify quickstart.

openReceiveNextHandlers

export const { GET, POST } = openReceiveNextHandlers(options)

Next.js App Router handlers. Mount them as a catch-all route (app/openreceive/[...openreceive]/route.ts) that exports runtime = "nodejs" and dynamic = "force-dynamic". The incoming NextRequest is passed as native. Options are the shared all-in-one or composed set above, plus trustProxyIpHeader. Here trustProxyIpHeader is required when you use rateLimiting, because a web Request has no socket IP (Rate limiting). Full walkthrough: Next.js quickstart.

Where it fits: Create one catch-all route file, export what this returns, and the whole route set is live. Keep it on the Node runtime, not the Edge runtime, because it needs your database driver and a relay connection. Read cookies or headers from request inside authorize the way you would in any route handler.

Returns the App Router handler object

Name Type Meaning
GET (request) => Promise<Response> App Router GET export. Every shipped route is POST except GET …/rates. Exporting both lets the catch-all module serve all of them.
POST (request) => Promise<Response> App Router POST export. Routes requests to the OpenReceive route set.
handler (request) => Promise<Response> The same request router, for tests or a custom method map.
ready Promise<void> All-in-one form only: resolves when the wallet client is up.
close () => Promise<void> All-in-one form only: closes the owned wallet client.

Persistence

createSqlPayments

const payments = createSqlPayments(db, options?): SqlPaymentRepository

Where it fits: You’ll only call this directly in the composed form, when you want to hold the repository yourself: to look at attempts with listForReference in an admin tool, or to share one repository between a host and a test. createHost({ db }) builds it for you otherwise.

Parameters

Name Type Required Meaning
db SqlDatabase yes pg Pool/Client, SQLite handle, or an SqlAdapter.
tableName string no Default openreceive_payments.
metaTableName string no Key/value table that holds the durable reconcile gate. Default openreceive_meta.
clock () => number no Unix-seconds clock override. Used by the reconcile gate and the payment-methods cache TTL.

This is the repository the library owns behind createHost({ db }), exposed for advanced integrations. It is responsible for:

Returns SqlPaymentRepository

Name Type Meaning
listForReference (reference) => Promise<PaymentRecord[]> Every attempt row for that order, newest first. Includes settled and closed history.
listReconcilableAttempts (after?) => Promise<ReconcilableAttempt[]> Up to 200 pending rows after a (created_at, payment_hash) position (a keyset, used for paging). expiresAt is the saved wallet deadline. When it is unknown where the creation time came from, it searches a wider window.
commitAttempt (input) => void \| Promise<void> Inserts one new attempt under a lock. Throws on a settled order, or when a reusable live attempt exists on the same rail.
recordReconciliation (transition) => void \| Promise<void> Applies a final non-settled transition, but only while the row is still pending. Never overwrites a settled row.
recordSettlementWithFulfillment (settlement, fulfill) => boolean \| Promise<boolean> Required. In one atomic step it locks the reference, settles the pending row, awaits the host callback, and commits. Returns whether this call won the first-settlement claim for the reference. Rolls back on failure. Older repositories that return only a boolean are rejected.
findByPaymentHash (hash) => Promise<PaymentRecord \| undefined> Required, as the durable acknowledgment. A wallet success is served only after its row is settled.
countAttemptsFromIp (clientIp, sinceUnixSeconds) => number \| Promise<number> Attempt rows for this IP at or after that time. Used by opt-in rateLimiting.
claimReconcileGate ({ now, intervalSeconds, leaseSeconds? }) => ReconcileGateClaim \| null A durable compare-and-swap (CAS) claim holding a token and scheduler state, or null when busy. Async implementations return a Promise.
checkpointReconcileGate ({ claim, scheduler, now, release?, intervalSeconds? }) => boolean \| Promise<boolean> Saves limited keyset and scan progress, but only while the token still holds its unexpired lease.
listRepairCandidates ({ after?, limit? }?) SQL repository only. A limited, read-only report of attempts in attention and of swap attempts that were clearly closed too early. It contains no credentials.
requeueAttempt ({ paymentHash, expectedStatus, expectedUpdatedAt, reason }) SQL repository only. Puts an attempt you have reviewed back in the queue, under the reference lock. Keeps the repair audit trail. Never changes a settled row.
markPaidOnce (input, fulfill) => Promise<boolean> Write-once settlement. Sets paid_at / settled once and runs fulfill only for the first settled attempt for a reference. Resolves true only for the call that won that first-settlement claim. Later calls, such as a redelivered notification or a sibling attempt, record the settlement, skip fulfill, and resolve false. A direct caller uses that boolean to avoid doing its own work twice.

A custom PaymentRepository must also implement claimReconcileGate and checkpointReconcileGate, unless you pass opportunisticReconcile: false. Both must be durable CAS operations. Never use an in-process cooldown, because memory cannot coordinate separate workers. Handler construction throws if they are missing.

paymentsSchemaSql

paymentsSchemaSql(dialect: "postgres" | "sqlite", tableName?, metaTableName?): string

Where it fits: Use this when writing the migration that creates OpenReceive’s two tables in your own migration tool. Call it and execute the string it returns. If you use one of the supported ORMs, npx openreceive scaffold payments writes the same DDL in that ORM’s migration format instead.

Parameters

Name Type Required Meaning
dialect "postgres" \| "sqlite" yes Which SQL dialect to emit.
tableName string no Payments table name. Default openreceive_payments.
metaTableName string no Reconcile-gate table name. Default openreceive_meta.

Returns string: the DDL for two tables in that dialect.

tableName and metaTableName rename them. The statements themselves live in @openreceive/core (paymentsDdlStatements in payments-ddl.ts). This helper and the scaffold CLI’s ORM migrations are both built from that one source. Run it through your own migration workflow. The scaffold CLI wraps it per ORM. Keep every column and constraint. payment_hash is globally unique. reference is indexed, not unique.

SqlAdapter

The database interface to implement yourself when the built-in pg/SQLite bindings do not fit.

interface SqlAdapter {
  dialect: "postgres" | "sqlite";
  query(sql: string, params?: readonly unknown[]): Promise<readonly Record<string, unknown>[]>;
  transaction<T>(run: (tx: { query }) => Promise<T>): Promise<T>;
}

Where it fits: You implement this when your database access goes through something the library does not know: a driver it has no binding for, a connection wrapper of your own, or an ORM without a named factory below. It has two methods, and the rules are simple: pass the SQL through unchanged, and make transaction a real transaction. Check the ORM factories first. Most people never write one.

query receives each statement already written for the adapter’s declared dialect (? on sqlite, $1-style on postgres). It must pass the statement to the driver EXACTLY as given. Nothing rewrites placeholders, in either direction. It returns SELECT rows (Record<string, unknown>[]), or [] for a non-SELECT. transaction must be truly atomic. Write-once settlement and fulfillment both run inside it.

knexDb / prismaDb / typeOrmDb / sequelizeDb

createHost({ db: knexDb(knex, "postgres") });        // or prismaDb(prisma, …),
createHost({ db: typeOrmDb(dataSource, "sqlite") }); // sequelizeDb(sequelize, …)

Ready-made SqlAdapter factories for the ORM handles that createSqlPayments cannot accept directly.

Where it fits: Pick the one that matches your ORM. Pass its result as db wherever you would have passed a pg pool, right where you build the host, using the ORM handle your application already shares. That is all the persistence wiring you need.

The parameter types (KnexLike, PrismaLike, TypeOrmLike, SequelizeLike) only describe the shape they need, so no ORM dependency is added. dialect (SqlDialect) is required, because nothing on the handles states it reliably. Each factory handles its ORM’s raw-query quirks:

See Node ORM recipes for the wiring guide.

PaymentRecord

One openreceive_payments row as returned by payments.listForReference.

Fields of PaymentRecord

Name Type Meaning
reference string Your order this attempt belongs to.
paymentHash string 64-character lowercase hex hash. Unique across all orders.
status "pending" \| "settled" \| "expired" \| "failed" \| "attention" Where the attempt is in its lifecycle. Only pending is scanned. attention means the wallet still reports an in-progress state long after expiry.
statusReason string \| null Detail for the operator about the current status, e.g. "superseded" or "duplicate_settlement". Absent or null when there is nothing extra to say.
paidAt number \| null Integer Unix seconds of settlement, or null if this attempt never settled.
expiresAt number Integer Unix seconds after which these payer instructions must not be reused.
createdAt number Integer Unix seconds, used to sort past attempts in a stable order.
checkout Checkout Payer snapshot that is safe to show and to serve again. Same shape as createCheckout.
swapData SwapData \| null Server-only state for recovering the provider order. null or omitted for Lightning-only attempts. Never put it in a browser response.

Browser & React

These are the browser and React pieces you wire up. The Vue, Svelte and Angular wrappers hand off to the same custom element and accept the same attributes.

prefix is the only URL input the browser packages take. It is the base path where the shipped router is mounted. Every route they call is built from it: /checkouts, /checkouts/prepare, /payments/check, /swaps, /swaps/quote, /swaps/status, /swaps/refunds. There is no per-route override, so a checkout cannot be created against one mount and settled against another.

Failed status polls wait longer after each failure (exponential backoff) and honor the server’s Retry-After. Network and HTTP failures are thrown as BrowserRequestError, carrying status/code/retryable/retryAfterSeconds.

prepareCheckout

From @openreceive/browser: prepareCheckout({ reference, prefix, fetch?, headers? }). Calls POST /checkouts/prepare. It locks the amount and returns payment methods without minting.

requestCheckout

From @openreceive/browser: requestCheckout({ reference, prefix, fetch?, headers?, memo?, metadata?, previous? }). Calls POST /checkouts. It mints (or reuses) a bolt11 and returns the snapshot.

The response echoes payment_methods alongside checkout (contract 0.4.1). So the list of pay-in methods survives a mint on its own, for any client, not just this package.

Pass previous when you drive the prepare-then-mint flow yourself and want the snapshot to carry over. previous is the snapshot already on screen, normally what prepareCheckout returned. It carries sibling attempts, such as a live swap next to the new bolt11, that the mint response knows nothing about. It also still carries the method list forward against a server older than 0.4.1. The shipped renderers do this for you.

<Checkout>

From @openreceive/react. A complete checkout in one component. It has two modes:

polling={false} renders without status polling and keeps the swap flow working.

Common props:

There is no image prop. Everything the checkout draws ships inside the JavaScript (Provider registry).

csrfHeader (default X-CSRF-Token) is the header name used to send the page’s <meta name="csrf-token"> value on every request. Rails and Laravel read the default. Django reads X-CSRFToken. WordPress REST reads X-WP-Nonce. The meta tag name is fixed.

resumePaymentHash (create mode) names a swap attempt this order already has in progress. The checkout reopens it after prepare instead of showing the method grid. A hash that cannot be served is ignored.

resumable says whether a payer who closes this tab has a URL that brings them back. It is inferred from syncUrl / routeReference. Set it explicitly when your own router owns a per-order route. It picks which return warning the swap refund screen shows (SwapDisplayModel.refundReturnLabel). See Checkout UX → The refund screens.

Some props are shared with the Vue, Svelte and Angular wrappers, with the same names and defaults. These are everything in the list above up to and including csrfHeader, except theme, plus checkout and reference.

docs/internal/wrapper-parity.md has the full table.

children is React-only. It is a node, or a render prop that receives the live useCheckout model. Use it for order context, such as a line-item summary, a thumbnail, or a “you are buying” strip. The checkout shows the amount but never the order, so without it that context is missing. Children appear above the shipped payment UI, where the custom element’s order slot sits, and never replace it. See Frontend checkout → Show the payer what they are buying.

useCheckout

From @openreceive/react: useCheckout(options). The hook behind <Checkout>, for custom layouts. It drives a concrete checkout snapshot. Create mode belongs to <Checkout>. Unlike the component, it does not default prefix. Pass prefix to poll /payments/check. Omit it, or pass polling: false, to render the snapshot without polling. It returns the live snapshot, status, countdown labels, statusTitle/statusDetail, and copyInvoice/openWallet/reloadState/retry/cancel.

openWallet is for touch devices. By default it calls location.assign on the current window. On desktop, a wallet button either does nothing or takes the payer away from a checkout that is still polling. That is why <Checkout> renders no wallet button and offers components.OpenWalletButton as an opt-in slot. See Headless checkout.

PaymentWizard

From @openreceive/react. The method picker and swap deposit flow shown inside <Checkout>. You can use it on its own with checkout, prefix, and onSwapStarted. If you omit prefix, it renders only the method grid, because it has no swap backend to call.

<openreceive-checkout>

From @openreceive/elements. The custom element behind the non-React wrappers.

There is no asset attribute. Everything the element draws ships inside its JavaScript. It fires seven events: openreceive-copy, openreceive-open-wallet, openreceive-state, openreceive-settled, openreceive-provider-copy, openreceive-start-over, openreceive-error. openreceive-state and openreceive-settled carry the checkout snapshot at event.detail.state.

CLI

openreceive scaffold payments

npx openreceive scaffold payments [options]

Writes one schema or migration file for your ORM, plus an OPENRECEIVE_PAYMENTS.md wiring guide. The file creates two tables: openreceive_payments (the payment attempts) and openreceive_meta (the reconcile gate). It does nothing else. It never opens a database connection or runs migrations.

Where it fits: Run it once, at the start of the integration, from the directory that holds your ORM’s migrations. Then apply the migration the way you apply your own. Commit the generated file. You will not run this again unless you change table names.

Every generated file includes the note about fulfilling each order exactly once.

Flag Meaning
--orm <name> prisma \| drizzle \| typeorm \| sequelize \| knex.
--dialect <name> postgres \| sqlite (default postgres).
--table-name <name> Payment attempts table (default openreceive_payments).
--meta-table-name <name> Reconcile-gate table (default openreceive_meta).
--out-dir <path> Output root (default .).
--force Overwrite generated files.
-i, --interactive Prompt for missing options (default on TTY when --orm omitted).

openreceive doctor

npx openreceive doctor
npx openreceive doctor --db db/production.sqlite3 --url http://localhost:3000

Checks the server configuration and says what to fix.

Where it fits: Run it first, before writing any code, on the machine the app will run on, and again whenever a deploy misbehaves. Run it when the payment page says the wallet is unavailable and you don’t know why. Once the app is up, add --db and --url to check the tables and the routes too.

It always checks:

When NWC_URI parses, it also probes the wallet over the relay, the same preflight that runs at boot. It reports whether the code is receive-only. --offline skips the probe. No database is touched by default.

The exit code is 1 when any check fails. Every failing line states its own fix. openreceive debug-report prints the same lines as a support report with secrets hidden, and always exits 0.

Option Meaning
--db <target> Also checks that the payment tables exist. Takes a SQLite file path, or a postgres:// / mysql:// URL. The matching driver (pg / mysql2) is loaded from your project.
--url <base-url> Also checks that the OpenReceive routes answer on a running app. An unknown path under the prefix must return the router’s own JSON 404.
--prefix <path> Route prefix for --url (default /openreceive).
--table-name <name>, --meta-table-name <name> Table names for --db, when the scaffold was run with overrides.
--offline Skip the wallet relay probe.

Rails

openreceive:install

bin/rails generate openreceive:install

Creates three things:

Where it fits: Run it once right after adding the gem, then bin/rails db:migrate. The rest of the integration goes in the files it creates. The three hooks below go in the initializer, and the browser packages point their prefix at the mount.

The migration adapts to the app’s configured database adapter. PostgreSQL, SQLite, and MySQL (mysql2/trilogy) are supported. The engine owns the OpenReceivePayment model, so no model file is generated.

The generated initializer ships two placeholders:

The engine warns every time your application boots while either is still configured. Replace both before anything real.

Flag Meaning
--skip-migration Skip the migration (both tables).
--skip-initializer / --skip-route Skip those files.

OpenReceive.configure

OpenReceive.configure do |config| ... end

Where it fits: This lives in config/initializers/openreceive.rb and runs at boot. It is the only place your application and OpenReceive meet. authorize is your “is this their order?” check, amount_for is your price lookup, and on_paid is where you mark the order paid. Everything else in the engine is generic. These three hooks are the integration.

Three hooks are required: authorization, the trusted price, and fulfillment. There are also a few optional settings.

OpenReceive.configure do |config|
  # `Order` in these examples is YOUR model — any name works. OpenReceive
  # never sees it; these hooks are the only bridge into your data.

  # REQUIRED. Your policy, called before every checkout/payment/swap route.
  # `context` is a Hash with three symbol keys:
  #   context[:action]   — which route, as a String: "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: } on every action, plus
  #                        { payment_hash: } on payment.check, swap.read, and
  #                        swap.refund. Copied from the payer's JSON body
  #                        before any lookup: it names an order, it does NOT
  #                        prove this caller may touch it. reference is always
  #                        a validated non-empty String (≤200 chars);
  #                        payment_hash is nil on the other four actions.
  # Return true to allow, false for a 403. Look the order up in YOUR data and
  # decide whether THIS caller may perform THIS action 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

  # REQUIRED. The trusted price for a reference (your order id). Return
  # { currency: "USD", value: "12.00" } or { sats: 1200 } (string keys work
  # too), or nil when there is nothing to pay for (a 404). Called only where a
  # price is minted or quoted; payer input never carries an amount.
  #
  # An optional "description" beside the price is what the payer is buying, in
  # your own words: one display string, echoed on the prepare and create
  # responses, rendered above the amount by both drop-ins, and used as the
  # invoice memo so the payer's wallet shows it too. The checkout shows a total
  # and never an order, so without it the payer sees a QR and a number.
  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

  # REQUIRED. Fulfillment. Runs inside the settlement transaction, only for
  # the first settled attempt for a reference. `settlement` responds to:
  #   settlement.reference    — your order id that just settled (String)
  #   settlement.payment_hash — 64-char lowercase hex hash of the attempt
  #   settlement.paid_at      — Unix seconds of settlement (Integer)
  #   settlement.details      — wallet-observed details Hash (transaction
  #                             snapshot, observed_at, paid_at_source — the
  #                             same shape JS delivers to onPaid), or nil
  # There is deliberately no `query` handle here: the engine wraps this block
  # in an ActiveRecord transaction, so plain ActiveRecord IS the transactional
  # write. (The JS engine hands `onPaid` a `query` because nothing wraps it
  # there.) Same rule as JS otherwise: database writes only — anything
  # reaching outside the transaction survives a rollback and runs again.
  # The WHERE clause is the lock: a second fulfillment path of yours (admin
  # action, replayed job) claims zero rows and does nothing.
  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
  end

  # Per-IP invoice cap for public web shops. Off by default — never throttle
  # a shared-IP POS terminal by accident. `true` caps invoice creation at 60
  # per client IP per rolling hour, counted from the engine-owned
  # openreceive_payments rows. See Rate limiting.
  # config.rate_limiting = true
  # config.rate_limiting = { limit_per_hour: 60, limit_per_day: 300 }

  # OR a custom rate-limit hook — receives the same `context` Hash as
  # config.authorize; return false (or raise the engine's rate-limited error)
  # for a 429. Mutually exclusive with config.rate_limiting.
  # config.rate_limit = ->(context) { MyLimiter.allow?(context[:request].ip) }

  # Client-IP extractor for rate limiting and attempt-row stamping. Default:
  # ActionDispatch::Request#ip, which honors Rails' trusted-proxy
  # configuration.
  # config.client_ip = ->(request) { request.headers["CF-Connecting-IP"] }

  # Request-path settlement pass on every engine PAYMENT route (unauthenticated
  # GET /rates never triggers it), ON by default through the durable
  # openreceive_meta gate shared by all Puma workers. Set false when a
  # dedicated worker owns scanning (required with a custom repository).
  # config.opportunistic_reconcile = false
  # config.opportunistic_reconcile = { min_interval_seconds: 10 }

  # Your application otherwise refuses to start on a spend-capable NWC code
  # (also OPENRECEIVE_ALLOW_SPEND_CAPABLE_NWC=true).
  # config.allow_spend_capable_wallet = true

  # Eager production boot preflight, ON by default: the wallet is built and
  # checked at boot so a bad NWC_URI stops the deploy, not the first customer.
  # `rails assets:precompile` is detected and skipped automatically (see
  # Deploying); set false for any other boot that must come up with no wallet
  # secrets. It disables the BOOT check only — the wallet is still checked on
  # the first request.
  # config.eager_preflight = false
end

on_paid runs inside the settlement transaction through the engine’s write-once mark_paid_once!. It runs only for the first settled attempt for a reference. Delivery is at-least-once, so a raise rolls back and the next pass retries. Applications with a custom repository can use the advanced hooks resolve_checkout and on_checkout_created.

OpenReceive::ReconcileJob

OpenReceive::ReconcileJob.perform_later

One reconciliation pass, wrapped for your ActiveJob backend. It runs once.

Where it fits: Enqueue it from your own code when you want a scan soon but not inside the request. For example, after a support agent presses “re-check payment”, or from a scheduler if you run one. It is a convenience around OpenReceive.reconcile!, not something the engine requires.

You do not need to schedule it. By default, settlement is driven by the opportunistic reconcile that runs on requests.

rake openreceive:doctor

bin/rails openreceive:doctor

Step 0 of the agent directions, as one command.

Where it fits: Run it first, before touching the initializer, and again after every deploy or credential change. It tells you which of the three hooks are still placeholders and whether the wallet answers. It never prints a secret, so its output is safe to paste into an issue.

It reports whether each credential (NWC_URI, LSC_URI_*) is PRESENT. Every line is set or unset. No secret value is ever printed, echoed, or partly shown. That is what makes it safe to run in a shared terminal or paste into an issue. It also reports:

Outside Rails, the Node CLI’s npx openreceive doctor does the same job.

rake openreceive:reconcile

bin/rails openreceive:reconcile

The same single pass as a rake task. It runs once and prints the number of attempts scanned.

Where it fits: Use it from a cron entry, a console, or a runbook when you want to force a scan now. For example, after a wallet outage, or while finding out why an order is still unpaid. Day to day, the pass that runs on requests does this for you.

rake openreceive:notifications

bin/rails openreceive:notifications

The one documented worker. It is a long-running, opt-in NWC-02 listener built on OpenReceive.listen_for_notifications!, and it retries with backoff. It also reconciles on a timer (OPENRECEIVE_NOTIFICATIONS_RECONCILE_INTERVAL_SECONDS, default 15). That timer is its own safety net for notifications missed while it was down.

Where it fits: Run it as its own long-running process, for example a worker line in your Procfile, when you want orders marked paid the moment the wallet sees the payment. Without it, the app still settles every order, just on the payer’s next status poll.

OpenReceive.reconcile!

OpenReceive.reconcile!(overlap_seconds: 60, now: nil, max_pages: nil, deadline: nil) # => Array<Hash>

Where it fits: Call it from your own job, script, or console when you want one pass now and want the results back in Ruby, for example to show them in an admin view. The rake task and the job above are thin wrappers around it.

Parameters

Name Type Required Meaning
overlap_seconds Integer no Scan-window overlap. Default 60.
now Integer no Unix-seconds clock override.
max_pages Integer no Cap on wallet-history pages walked.
deadline Time no Wall-clock bound checked between page fetches.

Returns the check result for each hash in the pass: an array of { "payment_hash", "status", "paid_at"?, "details"? } hashes. It returns [] when the ledger has no pending attempts.

It runs one bounded pass over the ledger the engine owns:

  1. Scans the wallet for the oldest OpenReceive::Server::RECONCILE_BATCH_SIZE (200) pending attempts.
  2. Delivers settlements through the write-once settlement hook.
  3. Saves final state changes.

max_pages: caps the wallet-history pages walked. deadline: is a wall-clock limit the scan checks between page fetches. A pass that runs out of time simply stops walking. A hash the scan never reached stays untouched. Closing an attempt requires a successful scan at or after expiry plus the 900-second grace. The local clock alone never closes it. A wallet failure raises and leaves every row pending.

OpenReceive.maybe_reconcile!

OpenReceive.maybe_reconcile!(now: nil) # => Hash

The gated pass behind the engine’s opportunistic reconcile on requests. The engine runs it as an around_action on its controllers, with exactly one gate claim per request. It is exported for your own routes and middleware, which never run it automatically. Rack applications call it themselves.

Where it fits: Add it to a controller action of your own that a waiting payer polls, such as an order-status endpoint, so settlement gets a chance to run there too. It is safe to call on every request. It returns at once when nothing is pending or another worker just scanned.

It never raises. A failed scan warns and returns scan_failed. The gate stays claimed, so a broken wallet cannot trigger a flood of retries. A page still in flight at the 9 s scan deadline is cut: when earlier pages answered, the pass returns ran and the next pass resumes after them; when none did, the pass fails like any other wallet failure. It returns { "reason" => "ran", "checks" => [...] } (the check hash for each payment hash) or { "reason" => "disabled" | "no_pending" | "gate_busy" | "scan_failed" }.

OpenReceive.listen_for_notifications!

OpenReceive.listen_for_notifications!(overlap_seconds: 60)

Subscribes to the configured NWC client’s payment_received notifications.

Where it fits: You’ll only call this yourself when writing your own worker instead of rake openreceive:notifications, for instance to run it under your own supervisor with your own logging. It blocks, so it belongs in a dedicated process, never in a request or a job with a timeout.

It settles directly the same way the Node listener does. If a payload meets the shared settlement rule and matches a pending attempt, it settles directly through the engine’s mark_paid_once!/on_paid path, with no wallet scan for that invoice. Anything less falls back to one OpenReceive.reconcile! pass. That covers no sign that the payment is final, an unknown hash, or a failed direct settlement. The worker’s periodic pass is the safety net for notifications missed while offline. It raises OpenReceive::ConfigurationError when the client cannot send notifications. Blocking clients do not return until the subscription ends.

The built-in nwc-ruby client is already wired up. openreceive-rails declares it as a runtime dependency, so it is installed with the engine. OpenReceive::NwcRubyReceiveClient forwards subscribe_notifications to that gem’s subscribe_to_notifications. It turns the notification object the gem yields back into the NWC-02 wire payload. So the settlement rule reads state/settled_at exactly as it does on a list_transactions row.

A custom config.nwc_client opts in by supplying subscribe_notifications(&block), which yields those wire payloads (notification_type plus the transaction-shaped notification). The engine filters for payment_received itself, so the client should forward every type the wallet publishes.

Python

The Python engine (pip install openreceive) is the same contract in snake_case:

Django apps use openreceive.django, which has its own quickstart. Flask apps use the recipe. Python APIs and the wire use the same spelling (payment_hash, amount_msats). Money is int msats or decimal strings.

Host (Python)

from openreceive.server import Host
host = Host(amount_for=..., authorize=..., on_paid=..., after_paid=None)

Fields

Name Type Required Meaning
amount_for (reference: str) -> dict \| None yes Returns {"currency": "USD", "value": "12.00", "description"?: str} or {"sats": 1200} from YOUR data. None gives 404 Unknown reference.. Called only where a price is minted or quoted (prepare, quote, create), never on status polls.
authorize (context: HookContext) -> bool yes Receives context.action (checkout.prepare, checkout.create, payment.check, swap.quote, swap.create, swap.read, swap.refund) and context.request, the FRAMEWORK request: the Starlette Request on FastAPI, or the HttpRequest without a framework. Also context.resource ({"reference", "payment_hash"?}), which is a claim the payer sent. Return False for a 403.
on_paid (settlement: PaymentSettlement) -> None yes Runs INSIDE the settlement transaction, only for the reference’s first settled attempt. Gets settlement.reference, .payment_hash, .paid_at, .details, and .connection. .connection is the SQLAlchemy Connection of that transaction. It is None under the Django ORM, where the transaction is already active around your code. Database writes only.
after_paid (settlement) -> None no Runs after COMMIT. Use it for emails, jobs, and pushes.

LOGGING_ON_PAID and ALLOW_ALL_AUTHORIZE are the two named placeholders. The engine warns at boot while either is in use, and openreceive doctor names them.

openreceive_router

from openreceive.fastapi import openreceive_router
app.include_router(openreceive_router(host, engine=engine, rate_limiting=True), prefix="/openreceive")

An APIRouter that serves every route in the OpenAPI contract through the framework-free engine. The endpoint is a sync def run in Starlette’s threadpool. The engine’s own checks are unchanged: 404/405, the JSON-only rule, the Sec-Fetch-Site: cross-site refusal, the declared-fields check, and the 64 KB body cap. The mount prefix is whatever include_router was given.

Parameters

Name Type Required Meaning
host Host yes The three hooks.
engine sqlalchemy.Engine one of OpenReceive’s own sync Engine for its two tables, on the same database as your app. On SQLite, use a dedicated Engine. The repository configures it so writes happen one at a time.
repository PaymentRepository one of A custom repository instead of engine (Storage: the escape hatch). Without claim_reconcile_gate, you must set opportunistic_reconcile=False.
rate_limiting bool \| {"limit_per_hour", "limit_per_day"} no The built-in per-IP invoice cap. Off by default. True = 60/hour. It counts by request.client.host, so behind a proxy, run uvicorn with --proxy-headers. Cannot be used with rate_limit.
rate_limit (HookContext) -> bool no Your own limiter. Return False for a 429.
client_ip (Request) -> str \| None no A different way to find the client IP than request.client.host.
opportunistic_reconcile bool \| {"min_interval_seconds"} no The settlement pass that runs on requests, through the durable openreceive_meta gate. On by default.
nwc_client, price_provider, swap_providers, price_currencies, env, allow_spend_capable_wallet   no The parts of Service you can replace. By default the wallet client comes from NWC_URI, the providers from LSC_URI_*, and the price feed is the cached live feed. Tests pass the openreceive.testing fakes here (Host testing).
table_name, meta_table_name str no Table names, when the scaffold ran with overrides.
report_unexpected_error (error, request_id) -> None no Where to send an exception that became an opaque 500, such as Sentry or logger.exception.

The returned router carries .openreceive, the binding behind it. The CLI’s --app accepts the router, the FastAPI app, or an OpenReceiveApp.

openreceive_lifespan

app = FastAPI(lifespan=openreceive_lifespan(host, engine=engine, lazy=False))

Runs the receive-only wallet preflight when the server starts. Passing the same host + engine= as the router gives the same binding. You can also pass the router itself. On failure it raises ConfigurationError so uvicorn exits. A missing NWC_URI, an unreachable relay, or a spend-capable code stops the deploy. lazy=True delays the check to the first request, which answers 503 WALLET_UNAVAILABLE until the check passes. Startup also sets app.state.openreceive. Shutdown closes the wallet client the binding built.

OpenReceiveApp (Python)

from openreceive.server import OpenReceiveApp, Service
app = OpenReceiveApp(service=service, host=host, repository=repository, prefix="/openreceive", rate_limiting=False, opportunistic_reconcile=True)
status, body, headers = app.handle(HttpRequest(method=..., path=..., headers=..., body=...))
checks = app.reconcile(overlap_seconds=60)      # one bounded pass; the `openreceive reconcile` verb
app.maybe_reconcile()                           # the gated request-path pass: {"reason": "ran" | "disabled" | "no_pending" | "gate_busy" | "scan_failed"}

The framework-free engine, with storage, that the FastAPI router, the Django views and the Flask recipe mount.

payments_schema_sql (Python)

from openreceive.storage.sql import payments_schema_sql, payments_ddl_statements
payments_schema_sql("postgres" | "sqlite" | "mysql", table_name="openreceive_payments", meta_table_name="openreceive_meta") -> str

The official DDL for both tables plus the schema_version seed row, as one script. payments_ddl_statements returns the statements separately, which is what the Alembic revision executes. The FastAPI demo runs it at boot if the table does not exist yet. Production apps apply it once through their own migration workflow.

openreceive scaffold payments (Python)

openreceive scaffold payments --sql --dialect postgres|sqlite|mysql [--table-name …] [--meta-table-name …]
openreceive scaffold payments --alembic --dialect postgres [--out-dir alembic/versions] [--revision <12 hex>] [--down-revision <id>] [--force]

--sql prints the DDL to stdout, with the exactly-once fulfillment note as comments.

--alembic writes <revision>_openreceive_payments.py. The DDL is fixed in op.execute calls, and a downgrade() drops both tables. Set --down-revision to your current head (alembic heads), or edit it in afterwards. A revision with down_revision = None becomes a second base.

It never opens a database connection.

openreceive doctor (Python)

openreceive doctor [--app module:attr] [--offline] [--db <sqlalchemy-url>] [--url http://localhost:8000] [--prefix /openreceive]
openreceive debug-report [...]     # the same lines, always exit 0

It checks:

For the last check it needs your app. Pass --app with the FastAPI app, the router from openreceive_router, an OpenReceiveApp, or a zero-argument callable that returns one. Without --app, DJANGO_SETTINGS_MODULE selects the Django app.

--db checks the tables at any SQLAlchemy URL instead. --url proves the routes answer under the prefix, by getting the engine’s own JSON 404 on an unknown path. The exit code is 1 if any line fails. Every failing line states its fix.

openreceive reconcile / openreceive notifications

openreceive reconcile --app main:app [--overlap-seconds 60]
openreceive notifications --app main:app [--interval-seconds 15]

reconcile runs one bounded pass over the pending attempts and prints how many ended in each status. It matches rake openreceive:reconcile. Use it in a runbook or from cron. The pass that runs on requests covers day-to-day settlement.

notifications is the one documented worker. It is a long-lived NWC-02 payment_received listener that retries with backoff. It also runs the periodic pass (OPENRECEIVE_NOTIFICATIONS_RECONCILE_INTERVAL_SECONDS, default 15) as the safety net for notifications missed while it was down. Run one, as its own process. Without it, every order still settles on the payer’s next poll.

In Django, the commands are manage.py openreceive_reconcile / openreceive_notifications.

PHP

The Composer package openreceive/openreceive (namespace OpenReceive\) is for a plain-PHP app. The Laravel adapter (openreceive/laravel) wraps the same classes. It requires PHP ≥ 8.2, 64-bit, and ext-gmp. Public arrays use the wire’s snake_case keys. Methods are camelCase. Money is integer msats or decimal strings, never a float. The package’s own static analysis forbids floats in the money path.

OpenReceive\Host

interface Host {
    public function authorize(AuthorizeContext $context): bool;
    public function amountFor(string $reference): ?array;   // ['currency' => 'USD', 'value' => '12.00', 'description' => ?] or ['sats' => 1200]; null = 404
    public function onPaid(PaymentSettlement $settlement): void;
}

The host contract: one object with three methods. It is the only bridge between the engine and your data. Where it fits: it is the first thing Engine takes, and it is the whole quickstart.

Also implement OpenReceive\Hosts\AfterPaid when something must run after COMMIT, such as an email or a webhook. It is best-effort and never retried.

Two placeholder traits exist for scaffolding: Hosts\AllowAllAuthorize (allows everything) and Hosts\LoggingOnPaid (logs and fulfills nothing). Engine warns through its logger at construction while a host uses either, and Doctor names them. Replace both before anything real.

Fields of PaymentSettlement (readonly):

The authorize context (PHP)

OpenReceive\Server\AuthorizeContext is readonly. It has:

A custom rateLimit hook receives the same object.

OpenReceive\Server\Service

$service = Service::fromEnvironment();   // NWC_URI, LSC_URI_PRIMARY/BACKUP, OPENRECEIVE_* from getenv() + $_ENV
$service = new Service($nwcClient, $priceProvider, $swapProviders, ['USD'], $clock, $allowSpendCapableWallet, $env, $logger, $http);

The checkout service, which works with any storage. It covers prepare and create, the bounded wallet reconcile pass, swap quote/create/get/refund, rates, and the receive-only preflight at boot.

The preflight runs in the constructor. It fails closed, refusing to start, when:

fromEnvironment(?array $env, array $priceCurrencies, PriceProvider|false|null $priceProvider, ?array $swapProviders, bool $allowSpendCapableWallet, ?LoggerInterface $logger, ?HttpTransport $http):

The direct API is prepareCheckout(), createCheckout(), reconcilePayments(), quoteSwap(), createSwap(), getSwap(), refundSwap(), listRates(), listSwapOptions() and subscribeNotifications(). It takes and returns snake_case arrays. Errors are OpenReceive\Server\Errors\*, extending HttpError (status, errorCode, retryable, details, retryAfterSeconds).

OpenReceive\Server\Engine

$engine = new Engine(
    $host, $repository, $service,
    opportunisticReconcile: true,          // false when a worker owns scanning; ['min_interval_seconds' => n]
    rateLimiting: false,                   // true = 60/hour per IP; ['limit_per_hour' => , 'limit_per_day' => ]
    rateLimit: null,                       // custom fn (AuthorizeContext): bool — exclusive with rateLimiting
    clientIp: null,                        // fn (mixed $request): ?string; default REMOTE_ADDR of the PSR-7 request
    logger: null,                          // any PSR-3 logger
    prefix: '/openreceive',
    responseFactory: null,                 // a PSR-17 ResponseFactoryInterface; nyholm/psr7 is auto-discovered
);

The quickstart’s way of putting the parts together. It matches Rails’ Configuration. It takes a Host, a PaymentRepository and a Service, and from them gives you:

In plain PHP, build one per request, since each request is its own process. Under a framework, bind one in the container.

Method Returns
psr15Handler() Psr\Http\Server\RequestHandlerInterface: the mount. Every payment route first runs the gated reconcile pass. payments/check is served from that pass or from the stored row.
requestHandler() The framework-free RequestHandler (request → [status, headers, body] triples), for an app that cannot use PSR-15.
reconcile() One bounded reconciliation pass. Returns a list of results, one per attempt. This is the openreceive:reconcile one-shot.
maybeReconcile() The gated pass, for your own routes: ['reason' => 'ran'\|…, 'checks' => ?]
notificationsWorker(?array $env) Notifications. ->run(?callable $shouldContinue) blocks. It runs the NWC-02 listener plus the periodic pass (OPENRECEIVE_NOTIFICATIONS_RECONCILE_INTERVAL_SECONDS, default 15).
doctor(?array $env, ?callable $walletCheck, ?string $mountedAt) The Step 0 report lines (below).
settle(array $event) The settlement hook: write-once, then onPaid inside the transaction, then afterPaid after commit. Returns true when this call fulfilled the order.

Behind a reverse proxy, pass clientIp: fn ($request) => $request->getHeaderLine('x-forwarded-for') (or your framework’s trusted-proxy answer). That way the per-IP cap counts the payer.

Engine notificationsWorker

$engine->notificationsWorker()->run();   // blocks; ->stop() ends it

The one documented worker. It is a long-lived NWC-02 payment_received listener that retries with backoff. It also runs a periodic reconcile pass as its own safety net. PHP has no threads, so the periodic pass runs when the blocking subscription is idle, once a second. Run it as its own process. Without it, every order still settles on the payer’s next poll.

A payment_received payload that meets the settlement rule settles the matching pending attempt directly. Anything less falls back to one bounded reconcile pass.

OpenReceive\Storage

$db = new PdoConnection($pdo);                       // dialect from PDO::ATTR_DRIVER_NAME: pgsql | mysql | sqlite
$repository = new SqlPaymentRepository($db);         // (?callable $clock, string $table, string $metaTable)
PaymentsSchema::statements($dialect);                // list<string> DDL: openreceive_payments + openreceive_meta
PaymentsSchema::dropStatements();                    // the down()
PaymentsSchema::migrate($db);                        // both, in one call

PdoConnection wraps the PDO your app already opens. It sets ERRMODE_EXCEPTION, and sets the SQLite busy timeout through PDO::ATTR_TIMEOUT.

An app with no PDO implements DatabaseConnection instead, as the WordPress plugin does. It has dialect(), query(), execute(), transaction(), lastInsertId(), and positional ? placeholders.

SqlPaymentRepository is responsible for the per-reference commit lock in each dialect, write-once settlement, the reconciliation transitions, and the openreceive_meta compare-and-swap (CAS) gate. It never selects swap_data into a public array.

As an escape hatch, you can implement PaymentRepository yourself. Then set opportunisticReconcile: false, unless you also implement claimReconcileGate and checkpointReconcileGate.

OpenReceive\Server\Doctor

Doctor::report(array $env, ?Host $host, ?callable $walletCheck, ?string $mountedAt): array   // list<string>
Doctor::placeholderWarnings(Host $host): array

Step 0 of the agent directions, as report lines. It reports:

$engine->doctor() does the same for a built engine. The output is safe to paste into an issue.

OpenReceive\Testing

FakeWallet (a ReceiveNwcClient) and FakeSwapProvider (a SwapProvider), built on the testkit contract, plus Rates\StaticPriceProvider. See Testing your integration.