BTCPay Server + MedusaJS v2 Payment Architecture¶
Overview¶
Research Relay LLC accepts Bitcoin payments (on-chain and Lightning) via a self-hosted BTCPay Server instance integrated with MedusaJS v2. This document covers deployment architecture, MedusaJS integration design, wallet segregation, and security.
1. Deployment Architecture¶
Hosting Decision: NixOS via nix-bitcoin¶
The existing NixOS server (shops-btc-01) is the deployment target. Rather than running BTCPay's default Docker-based deployment, we use nix-bitcoin — a collection of NixOS modules purpose-built for Bitcoin infrastructure.
Why nix-bitcoin over Docker: - Declarative, reproducible configuration matching our existing NixOS workflow - Security-hardened by default (systemd sandboxing, minimal attack surface) - Native integration between bitcoind, CLN, and BTCPay Server - No Docker overhead or container management complexity - Tor enabled by default for privacy
nix-bitcoin provides NixOS modules for:
- bitcoind — full or pruned Bitcoin node
- clightning — Core Lightning with plugin support (including CLBOSS)
- btcpayserver — payment processor
- electrs or fulcrum — Electrum server (optional)
- rtl — Ride The Lightning web UI for channel management
- mempool — block explorer (optional)
Minimal NixOS Configuration¶
# In the server's NixOS configuration (flake module)
{ config, pkgs, ... }:
{
imports = [ <nix-bitcoin/modules/presets/secure-node.nix> ];
# Bitcoin Core — pruned to save disk space
services.bitcoind = {
enable = true;
prune = 100000; # Keep ~100GB of blocks (supports Lightning)
listen = true;
# dataDir defaults to /var/lib/bitcoind
};
# Core Lightning with CLBOSS for automated channel management
services.clightning = {
enable = true;
plugins.clboss.enable = true;
# Announce via Tor onion service
extraConfig = ''
large-channels
experimental-dual-fund
'';
};
# BTCPay Server
services.btcpayserver = {
enable = true;
lightningBackend = "clightning";
};
# Ride The Lightning for channel management UI
services.rtl = {
enable = true;
nodes.clightning.enable = true;
};
# Tor onion services for BTCPay (optional, for .onion access)
nix-bitcoin.onionServices.btcpayserver.enable = true;
# Reverse proxy for public HTTPS access
services.nginx = {
enable = true;
virtualHosts."btcpay.research-relay.com" = {
forceSSL = true;
enableACME = true; # Let's Encrypt
locations."/" = {
proxyPass = "http://127.0.0.1:${toString config.services.btcpayserver.port}";
proxyWebsockets = true;
};
};
};
security.acme.acceptTerms = true;
security.acme.defaults.email = "admin@research-relay.com";
}
Hardware Requirements¶
| Component | Minimum | Recommended |
|---|---|---|
| CPU | 2 cores | 4 cores |
| RAM | 4 GB | 8 GB |
| Storage (pruned ~100GB) | 150 GB SSD | 250 GB NVMe |
| Storage (full node) | 800 GB SSD | 1 TB+ NVMe |
| Network | Port 9735 (Lightning), 443 (HTTPS) | Static IP or DDNS |
Recommendation: Run a pruned node at 100GB (prune=100000). CLN supports pruned nodes natively, which is why BTCPay Server officially recommends CLN. This keeps disk usage under 150GB total for the full stack (bitcoind + CLN + BTCPay + PostgreSQL).
Full Node vs Pruned Node Tradeoffs¶
| Aspect | Full Node | Pruned Node |
|---|---|---|
| Disk usage | 700GB+ (growing ~60GB/year) | 25-100GB (configurable) |
| Payment processing | Identical | Identical |
| Security/validation | Full history | Same UTXO validation |
| Lightning support | All implementations | CLN only (LND requires full node) |
| Electrum server | Required for Electrs/Fulcrum | Not supported |
| Historical lookups | Yes | No |
A pruned node validates the entire chain on initial sync, then discards old blocks. Payment processing and security are identical.
Domain and TLS¶
- Domain:
btcpay.research-relay.com(A record pointing to server IP) - TLS: Let's Encrypt via ACME (NixOS
security.acmemodule) - Alternative: Cloudflare proxied DNS (provides DDoS protection but breaks websockets unless configured — prefer direct Let's Encrypt for BTCPay)
- Tor:
.onionaddress available via nix-bitcoin for admin access over Tor
Network Ports¶
| Port | Service | Exposure |
|---|---|---|
| 443 | BTCPay Server (HTTPS via nginx) | Public |
| 80 | ACME challenge (redirect to 443) | Public |
| 9735 | Lightning Network (CLN) | Public |
| 8333 | Bitcoin P2P (optional) | Public (helps network) |
| 23000 | BTCPay Server (local) | Localhost only |
| 9836 | RTL (local) | Localhost only / VPN |
2. MedusaJS v2 Integration¶
Plugin Assessment: medusa-plugin-btcpay (v0.0.6)¶
The existing npm package medusa-plugin-btcpay (by SGFGOV) is tagged medusa-v2 and claims Medusa v2 compatibility:
What it provides:
- BTCPay Server integration via Greenfield API
- Invoice creation for BTC payments
- Webhook handling for payment status updates
- Part of medusa-payment-plugins monorepo (also includes Razorpay)
- TypeScript support
- Cypress e2e tests
Assessment:
- Use as starting point. Install and evaluate against the Medusa v2 AbstractPaymentProvider interface. The plugin is at v0.0.6, which signals early-stage development.
- If it works with our Medusa v2 version, use it directly.
- If it has gaps, fork it and extend — the codebase is a good reference for the BTCPay Greenfield API integration patterns.
- Source: https://github.com/SGFGOV/medusa-payment-plugins
Custom Payment Provider Architecture (if needed)¶
If the plugin is insufficient, build a custom Payment Module Provider. Medusa v2 requires implementing AbstractPaymentProvider from @medusajs/framework/utils.
File Structure¶
src/modules/btcpay/
index.ts # ModuleProvider definition
service.ts # BtcPayPaymentProviderService extends AbstractPaymentProvider
types.ts # BTCPay-specific types
client.ts # Greenfield API client wrapper
Required Methods¶
import { AbstractPaymentProvider } from "@medusajs/framework/utils"
import { BigNumber } from "@medusajs/framework/utils"
import type {
InitiatePaymentInput, InitiatePaymentOutput,
AuthorizePaymentInput, AuthorizePaymentOutput,
CapturePaymentInput, CapturePaymentOutput,
RefundPaymentInput, RefundPaymentOutput,
CancelPaymentInput, CancelPaymentOutput,
UpdatePaymentInput, UpdatePaymentOutput,
DeletePaymentInput, DeletePaymentOutput,
RetrievePaymentInput, RetrievePaymentOutput,
ProviderWebhookPayload, WebhookActionResult,
} from "@medusajs/framework/types"
type BtcPayOptions = {
apiKey: string
serverUrl: string // https://btcpay.research-relay.com
storeId: string
webhookSecret: string
speedPolicy: "HighSpeed" | "MediumSpeed" | "LowMediumSpeed" | "LowSpeed"
}
class BtcPayPaymentProviderService extends AbstractPaymentProvider<BtcPayOptions> {
static identifier = "btcpay"
// Create a BTCPay invoice when customer selects BTC payment
async initiatePayment(input: InitiatePaymentInput): Promise<InitiatePaymentOutput>
// Check BTCPay invoice status — for BTC, authorization = payment confirmed
async authorizePayment(input: AuthorizePaymentInput): Promise<AuthorizePaymentOutput>
// For BTC, capture is a no-op — payment is already settled on-chain/Lightning
async capturePayment(input: CapturePaymentInput): Promise<CapturePaymentOutput>
// BTC refunds must be handled manually (send on-chain tx to buyer's address)
async refundPayment(input: RefundPaymentInput): Promise<RefundPaymentOutput>
// Mark BTCPay invoice as invalid/cancelled
async cancelPayment(input: CancelPaymentInput): Promise<CancelPaymentOutput>
// Fetch invoice status from BTCPay Greenfield API
async retrievePayment(input: RetrievePaymentInput): Promise<RetrievePaymentOutput>
// Update invoice metadata
async updatePayment(input: UpdatePaymentInput): Promise<UpdatePaymentOutput>
// Archive invoice in BTCPay
async deletePayment(input: DeletePaymentInput): Promise<DeletePaymentOutput>
// Handle BTCPay webhook events
async getWebhookActionAndData(
payload: ProviderWebhookPayload["payload"]
): Promise<WebhookActionResult>
}
Module Provider Definition¶
// src/modules/btcpay/index.ts
import BtcPayPaymentProviderService from "./service"
import { ModuleProvider, Modules } from "@medusajs/framework/utils"
export default ModuleProvider(Modules.PAYMENT, {
services: [BtcPayPaymentProviderService],
})
Registration in medusa-config.ts¶
module.exports = defineConfig({
modules: [
{
resolve: "@medusajs/medusa/payment",
options: {
providers: [
{
resolve: "./src/modules/btcpay",
id: "btcpay",
options: {
apiKey: process.env.BTCPAY_API_KEY,
serverUrl: process.env.BTCPAY_SERVER_URL,
storeId: process.env.BTCPAY_STORE_ID,
webhookSecret: process.env.BTCPAY_WEBHOOK_SECRET,
speedPolicy: "MediumSpeed", // 1 confirmation
},
},
],
},
},
],
})
BTCPay Greenfield API Endpoints Used¶
| Operation | Endpoint | Method |
|---|---|---|
| Create invoice | /api/v1/stores/{storeId}/invoices |
POST |
| Get invoice | /api/v1/stores/{storeId}/invoices/{invoiceId} |
GET |
| Mark invoice invalid | /api/v1/stores/{storeId}/invoices/{invoiceId}/status |
PUT |
| Create webhook | /api/v1/stores/{storeId}/webhooks |
POST |
| Get payment methods | /api/v1/stores/{storeId}/invoices/{invoiceId}/payment-methods |
GET |
| Create refund | /api/v1/stores/{storeId}/invoices/{invoiceId}/refund |
POST |
API client approach: Use direct fetch calls against the Greenfield API rather than the stale btcpay-greenfield-node-client npm package (last updated 2021). The API is straightforward REST with API key auth via Authorization: token <apiKey> header.
Invoice State to Medusa Status Mapping¶
| BTCPay Invoice State | BTCPay Webhook Event | Medusa Action | Medusa Payment Status |
|---|---|---|---|
| New | InvoiceCreated | (initial) | pending |
| Processing | InvoiceProcessing | authorized |
authorized |
| Settled | InvoiceSettled | captured |
captured |
| Expired | InvoiceExpired | failed |
canceled |
| Invalid | InvoiceInvalid | failed |
canceled |
| Settled (paidOver) | InvoiceSettled | captured |
captured (flag overpayment) |
Key behavior differences from card payments:
- BTC payments are push payments — the customer sends funds directly. There is no "authorize then capture" two-step.
- authorizePayment should check if the BTCPay invoice is in Processing state (payment seen in mempool).
- capturePayment is effectively a no-op — once settled, funds are already received.
- Refunds require creating a new on-chain transaction (manual or via BTCPay's refund API).
Webhook Handling¶
Medusa v2 provides a built-in webhook endpoint at:
(Format:/hooks/payment/{identifier}_{id})
The getWebhookActionAndData method processes incoming BTCPay webhooks:
async getWebhookActionAndData(
payload: ProviderWebhookPayload["payload"]
): Promise<WebhookActionResult> {
const { data, rawData, headers } = payload
// Verify BTCPAY-SIG header using HMAC-SHA256 with webhook secret
const sig = headers["btcpay-sig"]
const expectedSig = crypto
.createHmac("sha256", this.options_.webhookSecret)
.update(rawData)
.digest("hex")
if (`sha256=${expectedSig}` !== sig) {
return { action: "failed", data: { session_id: "", amount: new BigNumber(0) } }
}
const event = data as BtcPayWebhookEvent
const sessionId = event.metadata?.medusa_session_id
switch (event.type) {
case "InvoiceSettled":
return {
action: "captured",
data: { session_id: sessionId, amount: new BigNumber(event.amount) },
}
case "InvoiceProcessing":
return {
action: "authorized",
data: { session_id: sessionId, amount: new BigNumber(event.amount) },
}
case "InvoiceExpired":
case "InvoiceInvalid":
return {
action: "failed",
data: { session_id: sessionId, amount: new BigNumber(event.amount) },
}
default:
return {
action: "not_supported",
data: { session_id: sessionId, amount: new BigNumber(0) },
}
}
}
Checkout UX Flow¶
1. Customer adds items to cart
2. At checkout, selects "Pay with Bitcoin"
3. Medusa calls initiatePayment() → creates BTCPay invoice via Greenfield API
4. Frontend receives BTCPay checkout link (or invoice data for embedded display)
5. Customer is shown:
- BTC on-chain address + QR code
- Lightning invoice (BOLT11) + QR code
- Amount in BTC
- Countdown timer (default 15 minutes)
6. Customer pays via their wallet
7. BTCPay detects payment:
- Lightning: instant settlement → InvoiceProcessing + InvoiceSettled webhooks fire immediately
- On-chain: InvoiceProcessing fires when tx seen in mempool
InvoiceSettled fires after configured confirmations (default: 1)
8. Webhook triggers getWebhookActionAndData() → Medusa creates order
9. Customer sees order confirmation
Frontend integration options:
- Redirect to BTCPay checkout: Simplest. Use checkoutLink from invoice creation response.
- Embedded modal: Use BTCPay's JavaScript modal library for in-page payment.
- Custom UI: Fetch payment methods from Greenfield API and render your own BTC/Lightning payment UI.
Recommendation: Start with redirect to BTCPay checkout page. It handles QR codes, countdown timers, payment detection, and both on-chain/Lightning display. Customize later.
3. Wallet Segregation and Security¶
Wallet Architecture¶
+------------------+
| Customer Pays |
+--------+---------+
|
+--------------+--------------+
| |
+---------v---------+ +------------v-----------+
| Lightning (CLN) | | On-chain (BTCPay) |
| Hot Wallet | | Hot Wallet (xpub) |
| ~100K-500K sats | | Receiving only |
+--------+----------+ +-----------+------------+
| |
| Loop out / close channels | Manual sweep
| |
+--------v------------------------------v------------+
| On-chain Staging Wallet |
| (BTCPay internal wallet) |
| Threshold: sweep at 0.01 BTC |
+----------------------------+-----------------------+
|
| PSBT → Hardware wallet sign
|
+----------------------------v-----------------------+
| Cold Storage |
| Hardware wallet (Coldcard / Trezor) |
| xpub-only in BTCPay for watch |
+----------------------------------------------------+
Hot Wallet (Lightning + Small On-chain)¶
- Lightning wallet: Managed by CLN. Inherently hot (keys on server). Keep channel capacity proportional to expected daily volume.
- On-chain receiving wallet: BTCPay generates addresses from an xpub. Two options:
- xpub-only (cold): Funds land in addresses controlled by a hardware wallet. Safest. Requires hardware wallet to spend.
- Hot wallet enabled: BTCPay stores keys on server. Enables Payjoin and automated payouts. Higher risk.
Recommendation for Research Relay: Use xpub-only for on-chain receiving. Connect a Coldcard or Trezor via BTCPay's hardware wallet interface for spending. Keep Lightning hot wallet funded at a level matching expected weekly Lightning volume (start with 500K sats / ~$200).
Cold Storage Strategy¶
- Store the hardware wallet seed phrase on stamped metal (e.g., Seedplate) in a secure location.
- Do NOT store seed phrases digitally.
- Use a single-sig setup — multi-sig is unnecessary complexity for a solo operator with modest volume.
- Consider a passphrase (25th word) for additional security.
Sweep Policy¶
- Lightning to on-chain: When Lightning channel local balance exceeds threshold, use submarine swaps (Boltz, Loop) to move funds on-chain. CLBOSS can automate this.
- On-chain to cold storage: When BTCPay on-chain wallet balance exceeds 0.01 BTC (~$400 at time of writing), manually sweep to cold storage using PSBT signed on hardware wallet.
- Frequency: Weekly review of balances; sweep whenever threshold is hit.
Seed Phrase Management¶
| Secret | Location | Backup |
|---|---|---|
CLN hsm_secret |
Server (auto-generated by nix-bitcoin) | Encrypted backup to offsite |
| On-chain xpub | BTCPay Server config | Hardware wallet has master seed |
| Hardware wallet seed | Metal plate in safe | Second copy in separate location |
| BTCPay admin password | Password manager | N/A |
| Greenfield API key | Medusa .env |
Password manager |
| Webhook secret | Medusa .env + BTCPay config |
Password manager |
Backup Strategy¶
| What | How | Frequency |
|---|---|---|
CLN hsm_secret |
Copy from /var/lib/clightning/bitcoin/hsm_secret, encrypt, store offsite |
One-time (does not change) |
| CLN database | tar of CLN data directory (careful: old state is toxic) |
Daily automated + before updates |
| BTCPay PostgreSQL | pg_dump |
Daily automated |
| BTCPay Server config | NixOS configuration is in git (declarative) | Every change (git commit) |
| Bitcoin Core chainstate | Not backed up (re-syncable) | Never needed |
| Hardware wallet seed | Metal plate | One-time |
Encrypted backup script (add to NixOS config):
systemd.services.btcpay-backup = {
description = "BTCPay + CLN encrypted backup";
serviceConfig.Type = "oneshot";
script = ''
BACKUP_DIR="/var/backups/btcpay"
mkdir -p $BACKUP_DIR
# PostgreSQL dump
${pkgs.postgresql}/bin/pg_dump btcpayserver > $BACKUP_DIR/btcpay-db.sql
# CLN database (only if CLN is stopped or using backup plugin)
cp /var/lib/clightning/bitcoin/lightningd.sqlite3 $BACKUP_DIR/
# Encrypt and upload
${pkgs.gnupg}/bin/gpg --symmetric --cipher-algo AES256 \
--batch --passphrase-file /run/secrets/backup-passphrase \
--output $BACKUP_DIR/backup-$(date +%Y%m%d).tar.gz.gpg \
<(tar czf - -C $BACKUP_DIR btcpay-db.sql lightningd.sqlite3)
# Upload to offsite (S3, rsync, etc.)
# ${pkgs.rclone}/bin/rclone copy $BACKUP_DIR/backup-*.gpg remote:backups/
'';
};
systemd.timers.btcpay-backup = {
wantedBy = [ "timers.target" ];
timerConfig.OnCalendar = "daily";
};
4. Accounting Integration¶
BTC Payment to Accounting Flow¶
BTCPay Invoice Settled
|
+---> BTCPay records: BTC amount, tx hash, timestamp
|
+---> Medusa records: order ID, USD amount, payment session data
|
+---> Export flow:
1. BTCPay CSV export (on-chain wallets report, payments report)
2. Map each payment to Medusa order (via orderId in invoice metadata)
3. Record FMV (fair market value) in USD at time of confirmation
4. Import to Koinly for tax tracking
5. Reconcile with Medusa order records monthly
BTCPay Reporting Capabilities¶
BTCPay Server has built-in reports accessible from the dashboard:
- On-Chain Wallets: Each line shows on-chain transactions affecting the wallet (txid, amount, date, confirmations).
- Payments: Each line represents an accounted payment to an invoice (invoice ID, amount, currency, payment method, date).
- Products Sold: Each line represents a quantity of items sold (useful for POS).
All reports are exportable as CSV.
Tax Tracking with Koinly¶
- Export BTCPay CSV monthly (Payments report)
- Import to Koinly as custom CSV:
- Map columns: date, amount (BTC), type (income/payment received)
- Koinly calculates FMV in USD at time of receipt
- Koinly tracks cost basis for any future BTC sales
- Koinly generates:
- Income report (BTC received as business income at FMV)
- Capital gains report (if BTC is later sold/exchanged)
- Form 8949 and Schedule D for US tax filing
- TurboTax-compatible export
Automated Reconciliation (Future)¶
For automated reconciliation between BTCPay and Medusa:
// Pseudocode for monthly reconciliation script
async function reconcile(month: string) {
// 1. Fetch all settled invoices from BTCPay for the month
const invoices = await btcpayClient.getInvoices(storeId, {
status: "Settled",
startDate: monthStart,
endDate: monthEnd,
})
// 2. Fetch all BTC orders from Medusa for the month
const orders = await medusaClient.getOrders({
payment_provider: "btcpay",
created_at: { gte: monthStart, lte: monthEnd },
})
// 3. Match by orderId stored in invoice metadata
const matched = invoices.map(inv => ({
btcpay_invoice_id: inv.id,
medusa_order_id: inv.metadata.orderId,
btc_amount: inv.amount,
fmv_usd: inv.currency === "USD" ? inv.amount : lookupFMV(inv.settlementDate, inv.btcAmount),
status: orders.find(o => o.id === inv.metadata.orderId) ? "matched" : "UNMATCHED",
}))
// 4. Flag discrepancies
const unmatched = matched.filter(m => m.status === "UNMATCHED")
if (unmatched.length > 0) {
alert(`${unmatched.length} unmatched payments found`)
}
return matched
}
Key Accounting Considerations¶
- Income recognition: BTC received as payment is income at FMV on date of receipt (IRS Revenue Ruling 2014-21).
- Invoice currency: Create BTCPay invoices in USD (BTCPay handles BTC conversion at current rate). This simplifies accounting — the USD amount is the revenue.
- Holding vs. converting: If holding BTC, track cost basis. If converting to USD immediately (not planned initially), record as income + immediate sale.
- Mercury bank reconciliation: BTC payments do not flow through Mercury. Reconcile separately.