BTCPay plugin reference

This page lists everything the OpenReceive BTCPay Server plugin exposes:

For a step-by-step setup, see the BTCPay Server quickstart.

Versions

Plugin BTCPay Server .NET NNostr.Client
0.4.11 2.4.4 or later (compiled against 2.4.4) 10 0.0.55

The plugin identifier is BTCPayServer.Plugins.OpenReceive. The plugin is versioned and published separately from the npm and gem releases.

Upgrading to 0.4.11 needs planning. It adds database migrations and changes how existing rows are read. Before you install it over an earlier version, read the plugin’s payment safety upgrade notes.

The connection string

type=openreceive;nwc=<NWC URI>[;allow-spend=true]

BTCPay saves the string as the store’s BTC-Lightning payment-method configuration. Every Lightning backend uses this same slot. Store owners can see it, and so can Greenfield callers, just like an LND macaroon.

What saving checks

Every save runs the receive-only preflight, whether it comes from the setup page or from Greenfield. The preflight runs through BTCPay’s own Lightning validation. These are the checks, in order, with the code each refusal carries:

Check Code Message says
The relay answers and has a kind-13194 info event relay_unreachable, no_info_event which relay, and what to check
get_info answers get_info_failed the wallet’s error
make_invoice and list_transactions are granted missing_required_method which one is missing
The wallet advertises nip44_v2 or nip04 unsupported_encryption the modes it advertised
No spend method (pay_invoice, multi_pay_invoice, pay_keysend, multi_pay_keysend) unless overridden spend_capability_advertised the methods found and the help link
The wallet’s network equals BTCPay’s network_mismatch both networks

lookup_invoice is never required. When the wallet grants it, the client uses it to refresh a single hash between scans.

A relay host or LSC provider host on the local network needs the server-settings permission. Local means loopback, RFC 1918, link-local, .internal/.local/.lan, or a bare single-label name. BTCPay already applies this rule to server= in a Lightning connection string. BTCPay cannot see these hosts, so the setup page and the Greenfield route apply the same rule to them. BTCPay’s generic Lightning node page does not look inside nwc=. On a shared server, have store owners use the plugin’s page.

Store settings

These live in BTCPay’s per-store settings under the name OpenReceive. They are never in the connection string.

Field Meaning
LscPrimary The Lightning Swap Connect URI (lightning+swapconnect://host/path?key=…&secret=…). It stays on the server. The setup page shows it redacted and never sends it back to the browser.
LscBackup A second LSC URI. It is used only while the primary has failed within the last 60 seconds.
SwapsEnabled Whether the checkout offers swap pills. The setup page turns it on when LscPrimary holds a saved code. Greenfield follows the same rule unless the request sends swapsEnabled explicitly. The store’s Lightning node must be an OpenReceive connection.
LastPreflight A non-secret snapshot of the last wallet test: when it ran, ok or the refusal code, methods, encryption, notifications, network, and relay round trip.

Turning swaps on raises the store’s invoice expiration to 60 minutes if it is shorter. The plugin never lowers it.

Routes

Merchant (Greenfield API key, store permissions)

Route Permission Body / result
GET /api/v1/stores/{storeId}/openreceive/settings view store settings lightningNodeIsOpenReceive, lightningNode (redacted), allowSpendCapableWallet, swapsEnabled, lscPrimaryConfigured, lscBackupConfigured, invoiceExpirationMinutes, lastPreflight
PUT /api/v1/stores/{storeId}/openreceive/settings modify store settings Any of nwcUri, allowSpendCapableWallet, lscPrimary, lscBackup, swapsEnabled. Sending nwcUri runs the preflight and makes the wallet the Lightning node. Sending allowSpendCapableWallet alone re-saves the current code with that override. Every field is checked before anything is written. A refusal returns 422 with wallet_refused, wallet_required, lsc_required, invalid_lsc_uri or endpoint_not_allowed.
POST /api/v1/stores/{storeId}/openreceive/wallet/test modify store settings { nwcUri?, allowSpendCapableWallet? } → the preflight snapshot, also stored as lastPreflight. If you omit the override, the saved one is used.
GET /api/v1/stores/{storeId}/openreceive/swaps?limit=50 view store settings recent swap rows
GET /api/v1/stores/{storeId}/openreceive/invoices/{invoiceId}/swaps view store settings the invoice’s swap rows

A swap row: id, invoiceId, paymentHash, provider, providerOrderId, payInAsset, depositAddress, depositAmount, providerExpiresAt, state, stateReason, attention, attentionReason, pluginReason, refundReason, refundAddress, refundTxId, depositTxId, payoutTxId, walletSettledAt, createdAt, updatedAt. It never includes the provider token.

Payer (anonymous; the invoice id is the bearer, as for BTCPay’s own checkout)

Route Body / result
POST /api/plugins/openreceive/swaps { invoiceId, payInAsset } → the swap snapshot. Returns 409 with a reason when swaps are not offered for the invoice.
GET /api/plugins/openreceive/swaps/{invoiceId}?after={swapId}&limit=50 A paged recovery list for the invoice, { attempts, next_cursor }, including retired orders. Maximum 100.
GET /plugins/openreceive/invoices/{invoiceId}/recovery The payer recovery page. It works after expiry and when swaps are disabled.
GET /api/plugins/openreceive/swaps/{invoiceId}/{swapId} The snapshot, including invoice_status and wallet_settled. The checkout polls it every 5 s.
POST /api/plugins/openreceive/swaps/{invoiceId}/{swapId}/refund { refundAddress } → the snapshot. Errors: 400 invalid_refund_address, 409 refund_not_required or refund_already_requested.

These routes are not in a BTCPay rate-limit zone. BTCPay’s public-invoices zone delays excess requests (4 per minute, burst 10). That would stall the poll and slow down a payer who tries several assets. Abuse is limited in other ways:

Snapshot fields are snake_case:

Reasons a swap is not offered. These are the POST 409 reasons and the reasons the pills are hidden: lightning_node_not_openreceive, swaps_disabled, provider_unconfigured, invoice_not_payable, top_up_invoice, no_lightning_prompt, partial_payment, invoice_reminted, invoice_expires_too_soon. Per-asset refusals: amount_too_small, amount_too_large, pair_temporarily_unavailable, provider_rate_limited, provider_unreachable.

Checkout integration

BTCPay’s checkout is a Vue 2 app. The plugin renders one pill per offered asset, with the pseudo payment-method id OpenReceiveSwap_<asset>. An asset the invoice cannot use shows as a greyed pill. One line under the pill row tells the shopper why, for example “Below the minimum for this invoice: USDT · Tron (at least 9.12 USD)”. The limit is converted at the invoice’s own rate, the same way the JS checkout does it.

The plugin registers a Vue component named OpenReceiveSwap_<asset>Checkout. BTCPay mounts a component with that name for a plugin payment method. BTCPay stops refreshing invoice status while a plugin method is selected. So the component polls the swap every 5 seconds and refreshes the invoice through BTCPay’s own status endpoint. BTCPay’s paid screen then takes over on its own. The component is Resources/js/openreceive_swap_checkout.js, served at /Resources/js/openreceive_swap_checkout.js.

Swap states and what to do

States, phases and reasons use the shared OpenReceive vocabulary in spec/data/kernel-tables.json.

State Phase The payer sees The merchant does
awaiting_deposit awaiting_deposit address, amount, QR, countdown nothing
confirming, exchanging, paying_invoice processing a spinner and the step nothing
completed settling “Finalizing checkout” until BTCPay’s paid screen nothing. BTCPay records the Lightning payment.
refund_required refund the refund-address form, with the reason (underpaid, overpaid, late_deposit, underpaid_and_late, overpaid_and_late) nothing, unless the payer cannot reach the page. The invoice page shows the provider order id to give the provider’s support.
refund_pending, refunded refund the refund address and, once known, the refund transaction nothing
expired terminal “Expired” nothing. stateReason says why (no_deposit_before_provider_expiry, superseded_near_provider_expiry).
attention attention “Needs attention” and the provider order id review with the provider. The reason is provider_reported_emergency, provider_status_unrecognized, or provider_completed_without_wallet_settlement. The last means the provider says it paid, but the wallet has not seen the payment after 30 minutes.
failed terminal “Failed” nothing

pluginReason = invoice_reminted_after_partial_payment marks rows whose invoice received a partial Lightning payment, after which BTCPay created a new invoice (re-minted it). The plugin stops offering swaps for that invoice. It keeps polling the rows, because an order that is already paying the old BOLT11 will most likely still arrive.

Polling works like this:

Every row carries a version, the Postgres xmin column. The poller, a payer’s refund and BTCPay’s payment event each write only if the row still has the version they loaded. If a write loses that race, what happens next depends on the write:

Creating and refunding also take a Postgres advisory lock, per invoice and asset or per swap. This keeps two workers from creating two orders or sending the provider two refund addresses.

Settlement

BTCPay’s LightningListener settles invoices. The plugin’s Lightning client only answers its questions.

The doctor

The doctor lives at /plugins/{storeId}/openreceive/doctor. Only store owners can open it, and it changes nothing. The setup page’s “Run a health check” button shows the same probes right on the page. The page is titled “OpenReceive health check”.

Probe Green when
Lightning node is an OpenReceive connection the BTC-LN config carries type=openreceive
Wallet preflight (now) the checks above pass right now
Wallet pushes payment notifications the info event advertises payment_received
Last wallet scan this process has walked the wallet at least once. It also shows how many invoices it watches and whether any could not be reached.
Spend-capable override is ON shown only when the override is set. Always a warning.
Top-up invoices are not supported always informational
Swap provider configured / reachable with swaps on, an LSC is saved and its catalog loads. It lists the available assets.
Invoice expiration covers the provider window 45 minutes or more (60 recommended)
Swaps needing attention no row in attention
Invoice expiration within a day 24 hours or less. Lightning invoices are minted for at most a day.

Troubleshooting

Log events

Events log at Information level unless noted, under the BTCPayServer.Plugins.OpenReceive.* categories. Secrets never appear. The wallet is identified by its pubkey.

Event When
nwc.encryption.negotiated the scheme was chosen from the info event (nwc.encryption.renegotiate warns on a decrypt failure)
nwc.preflight.ok, nwc.preflight.refused (warning) a save or test ran
nwc.invoice.created make_invoice succeeded (hash, msats, expiry)
nwc.listen.start BTCPay opened a listener (mode=notifications or poll)
nwc.notification.received a payment_received arrived (type, hash)
nwc.scan.settled, nwc.scan.memo (debug), nwc.scan.failed (warning once, then debug until nwc.scan.recovered) the poll listener
nwc.notification.settled, nwc.sweep.failed (warning once, then debug until nwc.sweep.recovered) the notification listener’s emit and its periodic sweep
openreceive.setup.lightning_node_set, openreceive.setup.invoice_expiration_raised the setup page or API wrote store config
swap.created, swap.state, swap.wallet_settled, swap.refund.requested the swap lifecycle
swap.create.failed, swap.catalog.failed, swap.poll.failed, swap.provider.down (warnings) provider trouble. swap.provider.down starts the 60-second backup window.
nwc.preflight.spend_override (warning) on every preflight of an overridden connection

Database

The plugin uses the schema BTCPayServer.Plugins.OpenReceive, with two tables. Each table has one migration: 20260903000000_InitialSwaps and 20260920000000_MintedInvoices. BTCPay applies them at startup and tracks them in its own migrations history table.

openreceive_invoices holds one row per Lightning invoice the plugin mints. The columns are payment_hash, bolt11, amount_msats, created_at and expires_at. The plugin commits the row before BTCPay shows the invoice to a payer, and never updates it. It plays the role of an openreceive_payments row, cut down to what only the plugin knows. Status, settlement and fulfillment live in BTCPay’s own invoice and payment rows and are not copied.

openreceive_swaps holds one row per provider swap order. Its indexes are:

Every update is conditional on the row’s xmin, so no extra version column is needed. The provider token is stored as a plain column, like every other BTCPay credential. Protect the database.

BTCPay’s invoices and payments remain the record of what was paid. The plugin’s rows only make sure a payment is found.

Operations

Testing

Command What it proves
npm run test:dotnet 283 unit tests. They cover every shared vector family the dotnet coverage entry does not exclude, the kernel against an in-process wallet, and the swap service against the fake provider.
packages/dotnet/docker/up.sh, then e2e.sh the whole path over HTTP against BTCPay 2.4.4 in Docker
packages/dotnet/docker/test-e2e.sh the same legs as xunit, inside the .NET SDK image
packages/dotnet/docker/browser-e2e.sh or npm run test:e2e:btcpay the setup page, doctor and checkout in Chromium, including the swap component to “Invoice Paid”
docs/internal/btcpay-e2e.md the manual checklist: mutinynet with Alby Hub, coexistence with the Nostr plugin, one real provider swap per release