Skip to main content

Accept Bitcoin Payments in Ruby on Rails

Stop letting Stripe, PayPal, and Shopify own your checkout

If you run a Rails app and you want to take money, your default options all funnel you into the same handful of payment processors. Stripe, PayPal, Square, Shopify Payments — they all look convenient on day one, and then:

  • They set the rules for what you can sell.
  • They can freeze your funds with no notice and no appeal.
  • They take 2.9% + $0.30 on every transaction, forever.
  • They hand your customer data to whoever asks nicely.
  • If you ever want to leave, you discover your integration is welded to their proprietary APIs.

Bitcoin Lightning breaks this. Payments settle in seconds, fees are pennies, and no one can freeze a payment that has already settled. The catch, historically, has been that accepting Lightning meant running your own Lightning node — a real operational burden.

Nostr Wallet Connect (NWC) fixes that. NWC is an open protocol (NIP-47) that lets your Rails app talk to any Lightning wallet service using a single connection string. You get the benefits of Lightning without running infrastructure, and because NWC is a standard, you are not locked in to any one provider. Switch from Rizful to Alby Hub to your own self-hosted node by changing one environment variable.

This guide shows you how to do it in Rails with the nwc-ruby gem.


Quick Start

Three steps to get going

1. Sign up — create a free account at Rizful.com.

2. Get a receive-only NWC code — in Rizful, go to Settings → NWC → Receive-only NWC Code and copy the connection string. See full instructions here.

3. Give your LLM or agent the instructions — copy the instructions below and paste them into your AI coding assistant. They contain everything needed to wire up nwc-ruby in Rails, including the Kamal listener role, idempotent handler, and a complete rake task.

# Rizful NWC Integration for Ruby on Rails — Agent Instructions

> Paste this file into your LLM or coding agent to give it everything it needs to add Lightning Bitcoin payments to a Ruby on Rails app using Rizful, Nostr Wallet Connect (NWC), and the `nwc-ruby` gem.

---

## What Is This?

[Rizful.com](https://rizful.com) is a hosted Lightning wallet that exposes a **Nostr Wallet Connect (NWC)** API. NWC lets your Rails app create Bitcoin Lightning invoices and react the moment each one is paid — all via a single connection string and the `nwc-ruby` gem.

**No Lightning node required. No payment processor required. Just a connection string and a gem.**

The `nwc-ruby` gem ([github.com/MegalithicBTC/nwc-ruby](https://github.com/MegalithicBTC/nwc-ruby)) is a production-grade NIP-47 client. It handles the Nostr protocol, encryption (NIP-44 v2 and NIP-04), WebSocket lifecycle, 15-second ping keepalives, 5-minute forced connection recycling, zombie-TCP detection, capped exponential backoff, and clean SIGTERM handling. You call methods.

---

## Prerequisites

1. **Sign up at [Rizful.com](https://rizful.com)** — free, takes 30 seconds.
2. **Generate a receive-only NWC connection string** — in Rizful, open Settings → NWC → _Receive-only NWC Code_. Copy the string (starts with `nostr+walletconnect://`).
3. **Install C build dependencies** — the `rbsecp256k1` transitive dependency compiles `libsecp256k1` from source.

   **macOS:**
   ```sh
   brew install automake openssl libtool pkg-config gmp libffi
   ```

   **Ubuntu / Debian:**
   ```sh
   sudo apt-get update
   sudo apt-get install -y build-essential automake pkg-config libtool libffi-dev libgmp-dev
   ```

   **Alpine:**
   ```sh
   apk add build-base automake autoconf libtool pkgconfig gmp-dev libffi-dev
   ```

   **Docker (add to your Dockerfile BEFORE `bundle install`):**
   ```dockerfile
   RUN apt-get update && apt-get install -y --no-install-recommends \
         build-essential automake pkg-config libtool libffi-dev libgmp-dev \
       && rm -rf /var/lib/apt/lists/*
   ```

4. **Add the gem to the Gemfile:**
   ```ruby
   gem "nwc-ruby"
   ```
   Then run `bundle install`.

5. **Set the connection string as an environment variable** — never commit it:
   ```sh
   export NWC_URL="nostr+walletconnect://...your-rizful-nwc-code..."
   ```

> **Security rule:** Use a _receive-only_ NWC code for any checkout/donation/paywall integration. It can only create invoices and check their status — it **cannot** send funds. Only use a read+write code if your app explicitly needs to send payments (e.g. payouts, tipping bots).

---

## NWC Connection String Format

```
nostr+walletconnect://<wallet-pubkey>?relay=<relay-url>&secret=<client-secret>
```

| Part              | Description                                     |
| ----------------- | ----------------------------------------------- |
| `wallet-pubkey` | Hex pubkey of the Rizful wallet                 |
| `relay`         | Nostr relay URL (e.g. `wss://relay.rizful.com`) |
| `secret`        | Your client keypair secret — keep this private  |

The gem handles all parsing. You just pass the full string to `NwcRuby::Client.from_uri`.

---

## Check Capabilities Before You Build

A connection string's capabilities are set when the wallet issues it. The gem tells you which mode you have:

```ruby
client = NwcRuby::Client.from_uri(ENV["NWC_URL"])
client.read_only?   # => true for a receive-only code
client.read_write?  # => true for a code that can send payments
client.capabilities # => ["get_info", "get_balance", "make_invoice", "lookup_invoice", ...]
```

- **Read-only:** supports `get_info`, `get_balance`, `make_invoice`, `lookup_invoice`, `list_transactions`, notifications. **Cannot** `pay_invoice` or `pay_keysend`.
- **Read+write:** all of the above plus `pay_invoice`, `multi_pay_invoice`, `pay_keysend`, `multi_pay_keysend`.

---

## Core Operations

### 1. Create an Invoice (`make_invoice`)

```ruby
require "nwc_ruby"

client = NwcRuby::Client.from_uri(ENV["NWC_URL"])

# amount is in millisatoshis (msats). 1 sat = 1000 msats.
invoice = client.make_invoice(
  amount: 1_000_000,          # 1000 sats
  description: "Order #1234", # memo shown to payer
  expiry: 600                 # optional: seconds until invoice expires
)

invoice["invoice"]        # BOLT11 string — render as QR code for the customer
invoice["payment_hash"]   # unique id — persist this on the Order
invoice["created_at"]     # unix timestamp
invoice["expires_at"]     # unix timestamp
```

**Parameters:**

- `amount:` — amount in **millisatoshis** (msats). Multiply sats × 1000.
- `description:` — memo string visible to the payer.
- `description_hash:` — optional sha256 of a longer description (LUD-06 style).
- `expiry:` — optional expiry in seconds.
- `metadata:` — optional hash, passed through to the wallet.

**Returns a hash with keys:**

- `"invoice"` — BOLT11 Lightning invoice string. Render as a QR code.
- `"payment_hash"` — 64-char hex. The stable id for this invoice. Persist on your `Invoice` or `Order` row.
- `"type"` — `"incoming"`.
- `"state"` — `"pending"` until paid.
- `"amount"`, `"created_at"`, `"expires_at"`.

---

### 2. Look Up an Invoice (`lookup_invoice`)

Synchronous one-shot check of an invoice's status. Use this when a user hits a "check payment" endpoint, not as a polling loop — prefer notifications (below) for that.

```ruby
status = client.lookup_invoice(payment_hash: invoice["payment_hash"])
paid = !!(status["settled_at"] || status["preimage"])
```

**Settlement fields:**

- `status["settled_at"]` — Unix timestamp when paid (present when paid).
- `status["preimage"]` — 64-char hex proof of payment (present when paid).
- `status["state"]` — transitions `"pending"` → `"settled"`.

---

### 3. Listen for Notifications (Primary Pattern in Ruby)

**This is the recommended way to detect payments in a Rails app.** Unlike Node.js flows that often poll, the `nwc-ruby` gem ships a bulletproof long-running listener. Run it in a dedicated process (a Kamal role, a systemd service, or a rake task) — it blocks forever, reconnects on failure, and cleans up on SIGTERM.

```ruby
client = NwcRuby::Client.from_uri(ENV["NWC_URL"])

client.subscribe_to_notifications do |notification|
  case notification.type
  when "payment_received"
    Invoice.where(payment_hash: notification.payment_hash, state: "pending")
           .update_all(
             state: "paid",
             paid_amount_msats: notification.amount_msats,
             paid_at: Time.at(notification.event.created_at)
           )
  when "payment_sent"
    Payout.where(payment_hash: notification.payment_hash)
          .update_all(state: "settled")
  end
end
# Blocks forever. SIGTERM / SIGINT cause a clean exit.
```

**Under the hood the gem:**

- Subscribes to both kind 23196 (NIP-04) and kind 23197 (NIP-44 v2).
- Deduplicates by `payment_hash` within a single process.
- Sends a WebSocket ping every 15 seconds to defeat idle middleboxes.
- Reconnects with capped exponential backoff.
- Force-recycles the connection every 5 minutes as a belt-and-suspenders check against silently dead TCP streams.

**The `Notification` object exposes:**

| Field                      | Description                              |
| -------------------------- | ---------------------------------------- |
| `#type`                  | `"payment_received"` or `"payment_sent"` |
| `#payment_hash`          | 64-char hex                              |
| `#amount_msats`          | Integer                                  |
| `#data`                  | Full notification hash from the wallet   |
| `#event`                 | Underlying signed Nostr event            |
| `#event.created_at`      | Unix timestamp of the notification       |

---

### 4. Resume After a Restart — Use `since:`

Persist the `created_at` of the last notification you processed. On restart, pass it as `since:` so the listener doesn't replay history but also doesn't miss payments that arrived while the process was down.

```ruby
since = AppState.find_or_create_by(key: "nwc_last_seen").value.to_i
since = Time.now.to_i if since.zero?

client.subscribe_to_notifications(since: since) do |n|
  Invoice.transaction do
    rows = Invoice.where(payment_hash: n.payment_hash, state: "pending")
                  .update_all(
                    state: "paid",
                    paid_amount_msats: n.amount_msats,
                    paid_at: Time.at(n.event.created_at)
                  )
    AppState.where(key: "nwc_last_seen")
            .update_all(value: n.event.created_at)
  end
end
```

---

## Idempotency — Non-Negotiable

During deploys, reconnects, or if you scale the listener to multiple hosts, the **same notification can be delivered more than once**. The gem deduplicates within a single process lifetime, but across restarts or multiple instances you MUST handle duplicates at the database level.

**Required patterns (pick one):**

1. **Unique index on `payment_hash`** — let the database reject duplicate inserts.
2. **Guarded update** — `UPDATE invoices SET state = 'paid' WHERE payment_hash = ? AND state = 'pending'`. This is what the examples above use: if the row is already `paid`, zero rows are updated and the second delivery is a harmless no-op.

**Never** do `balance += amount` in the handler — that double-credits on redelivery. Always transition a specific row from one state to another.

---

## Rails Integration Pattern

### Migration

```ruby
class CreateInvoices < ActiveRecord::Migration[7.1]
  def change
    create_table :invoices do |t|
      t.string :payment_hash, null: false
      t.string :bolt11, null: false
      t.integer :amount_msats, null: false
      t.integer :paid_amount_msats
      t.string :state, null: false, default: "pending" # pending, paid, expired
      t.datetime :paid_at
      t.references :user, foreign_key: true
      t.timestamps
    end
    add_index :invoices, :payment_hash, unique: true
    add_index :invoices, :state
  end

  # Also add an AppState table for the nwc_last_seen cursor:
  # create_table :app_states do |t|
  #   t.string :key, null: false, index: { unique: true }
  #   t.string :value
  #   t.timestamps
  # end
end
```

### Controller / Service that creates an invoice

```ruby
class CheckoutsController < ApplicationController
  def create
    result = NwcRuby::Client.from_uri(ENV.fetch("NWC_URL")).make_invoice(
      amount: params[:amount_sats].to_i * 1_000,
      description: "Order ##{params[:order_id]}"
    )

    invoice = Invoice.create!(
      payment_hash: result["payment_hash"],
      bolt11: result["invoice"],
      amount_msats: result["amount"],
      user: current_user
    )

    render json: {
      payment_hash: invoice.payment_hash,
      bolt11: invoice.bolt11,
      qr_url: "lightning:#{invoice.bolt11}"
    }
  end
end
```

### Rake task for the listener process

```ruby
# lib/tasks/nwc.rake
namespace :nwc do
  desc "Long-running NWC notification listener. Run as a dedicated process."
  task listen_in_app: :environment do
    client = NwcRuby::Client.from_uri(ENV.fetch("NWC_URL"))
    since  = AppState.find_or_create_by(key: "nwc_last_seen").value.to_i
    since  = Time.now.to_i if since.zero?

    client.subscribe_to_notifications(since: since) do |n|
      Invoice.transaction do
        rows = Invoice.where(payment_hash: n.payment_hash, state: "pending")
                      .update_all(
                        state: "paid",
                        paid_amount_msats: n.amount_msats,
                        paid_at: Time.at(n.event.created_at)
                      )
        AppState.where(key: "nwc_last_seen")
                .update_all(value: n.event.created_at)
        # Optional: wake the web container via Postgres LISTEN/NOTIFY, ActionCable, etc.
      end
    end
  end

  desc "One-shot NWC diagnostic: exercises every method your code advertises."
  task test: :environment do
    ok = NwcRuby.test(
      nwc_url:                  ENV.fetch("NWC_URL"),
      pay_to_lightning_address: ENV["PAY_TO_LIGHTNING_ADDRESS"],
      pay_to_satoshis_amount:   Integer(ENV.fetch("PAY_TO_SATOSHIS_AMOUNT", 100))
    )
    exit(ok ? 0 : 1)
  end
end
```

### Kamal deployment

Run the listener as a **Kamal role** (same image as web, different `cmd`), not an accessory. Accessories are for third-party services and are NOT redeployed on `kamal deploy`.

```yaml
# config/deploy.yml
service: myapp
image: ghcr.io/myorg/myapp

servers:
  web:
    hosts: [10.0.0.10]
  nwc_listener:
    hosts: [10.0.0.10]
    cmd: "bundle exec rake nwc:listen_in_app"
    options:
      memory: 512m
```

Docker's `--restart unless-stopped` (Kamal default) + the gem's internal reconnect loop + a SIGTERM trap = crash-only reliability with no systemd, foreman, or process supervisor required.

If you run the listener on multiple hosts, each receives the same notifications independently. That's fine because the handler is idempotent — you get redundancy, not partitioning.

---

## Error Handling

All gem errors inherit from `NwcRuby::Error`.

```ruby
begin
  client.make_invoice(amount: 1_000, description: "tip")
rescue NwcRuby::WalletServiceError => e
  case e.code
  when "NOT_IMPLEMENTED"       then Rails.logger.warn("Method not supported on this NWC code")
  when "UNAUTHORIZED"          then Rails.logger.error("Invalid/expired NWC connection string")
  when "RATE_LIMITED"          then Rails.logger.warn("Backing off and retrying...")
  when "INSUFFICIENT_BALANCE"  then Rails.logger.error("Wallet empty")
  when "QUOTA_EXCEEDED"        then Rails.logger.error("Spend budget exhausted")
  when "PAYMENT_FAILED"        then Rails.logger.error("Routing failed: #{e.message}")
  else Rails.logger.error("Wallet error #{e.code}: #{e.message}")
  end
rescue NwcRuby::TimeoutError
  Rails.logger.warn("Wallet did not respond within 30s")
rescue NwcRuby::UnsupportedMethodError => e
  Rails.logger.error("Your NWC code does not advertise #{e.message}")
end
```

**Error classes:** `InvalidConnectionStringError`, `EncryptionError`, `InvalidSignatureError`, `TransportError`, `TimeoutError`, `UnsupportedMethodError`, `WalletServiceError`.

---

## Diagnostics

The gem ships two methods you can call from IRB, a Rails console, or a rake task.

```ruby
# Tests connection, fetches info, runs all read tests, optional pay test.
NwcRuby.test(
  nwc_url:                  ENV["NWC_URL"],
  pay_to_lightning_address: "you@rizful.com", # optional — only used for read+write codes
  pay_to_satoshis_amount:   10
)

# Subscribes and blocks forever, printing each notification. Run in a separate terminal.
NwcRuby.test_notifications(nwc_url: ENV["NWC_URL"])
```

---

## Key Facts for the Agent

| Fact                       | Value                                                                       |
| -------------------------- | --------------------------------------------------------------------------- |
| Gem name                   | `nwc-ruby`                                                                |
| Require path               | `require "nwc_ruby"`                                                      |
| Entry point                | `NwcRuby::Client.from_uri(ENV["NWC_URL"])`                                |
| Amount unit                | millisatoshis (msats). 1 sat = 1000 msats.                                  |
| Settlement signals         | `"settled_at"` (timestamp) OR `"preimage"` (hex string)                 |
| Notification event kinds   | `23196` (NIP-04), `23197` (NIP-44 v2)                                   |
| Primary pattern            | `subscribe_to_notifications` in a dedicated long-running process          |
| Polling fallback           | `lookup_invoice(payment_hash:)` — one-shot, on-demand only                |
| Receive-only methods       | `make_invoice`, `lookup_invoice`, `list_transactions`, `get_balance`, `get_info` |
| Read+write adds            | `pay_invoice`, `multi_pay_invoice`, `pay_keysend`, `multi_pay_keysend` |
| C build deps (Debian)      | `build-essential automake pkg-config libtool libffi-dev libgmp-dev`       |
| Default request timeout    | 30 seconds                                                                  |
| WebSocket ping interval    | 15 seconds                                                                  |
| Connection recycle         | every 5 minutes                                                             |
| Kamal deployment           | Role (not accessory), `cmd: bundle exec rake nwc:listen_in_app`           |
| Idempotency requirement    | `UPDATE ... WHERE state = 'pending'` OR unique index on `payment_hash`  |
| NIP-47 spec                | https://github.com/nostr-protocol/nips/blob/master/47.md                    |
| Gem repository             | https://github.com/MegalithicBTC/nwc-ruby                                   |

---

## Reference: Client Methods

```ruby
# All methods return a Hash (or Array for multi-* calls) and raise on failure.

client.make_invoice(amount:, description:, description_hash: nil, expiry: nil, metadata: nil)
# => { "invoice" => "lnbc...", "payment_hash" => "...", "type" => "incoming",
#      "state" => "pending", "amount" => ..., "created_at" => ..., "expires_at" => ... }

client.lookup_invoice(payment_hash: "...")  # or invoice: "lnbc..."
# => { ..., "settled_at" => ..., "preimage" => "..." }  (when paid)

client.list_transactions(from: nil, until_ts: nil, limit: nil, offset: nil, unpaid: false, type: nil)
# => { "transactions" => [...] }

client.get_balance   # => { "balance" => <msats> }
client.get_info
# => { "alias" => ..., "color" => ..., "pubkey" => ..., "network" => "mainnet",
#      "block_height" => ..., "methods" => [...], "notifications" => [...] }

# Read+write only:
client.pay_invoice(invoice: "lnbc...", amount: nil)       # => { "preimage" => ..., "fees_paid" => ... }
client.multi_pay_invoice(invoices: [{id:, invoice:, amount:}, ...])
client.pay_keysend(amount:, pubkey:, preimage: nil, tlv_records: nil)
client.sign_message(message: "...")  # => { "message" => ..., "signature" => ... }

# Listener (blocks forever):
client.subscribe_to_notifications(since: nil) { |n| ... }
```

---

## Quick Reference: What to Do When a Payment Arrives

1. In the `subscribe_to_notifications` block, guard on `notification.type == "payment_received"`.
2. Look up the order by `notification.payment_hash`.
3. Transition the row from `pending` to `paid` using an idempotent `UPDATE ... WHERE state = 'pending'`.
4. Persist `notification.event.created_at` as your `nwc_last_seen` cursor (in the same transaction).
5. Notify the web tier if needed (Postgres `LISTEN/NOTIFY`, ActionCable, a background job).
6. Send the confirmation email, unlock the content, ship the product — whatever your app requires.

The instructions above contain the NWC connection string format, C build dependencies for rbsecp256k1, all gem methods, the long-running listener pattern, the idempotent handler, and the Kamal deployment role — everything an AI coding agent needs to wire up Lightning payments in your Rails app.


The Shortest Possible Example

require "nwc_ruby"

client = NwcRuby::Client.from_uri(ENV["NWC_URL"])

# Create an invoice
invoice = client.make_invoice(amount: 1_000, description: "tip")
puts invoice["invoice"]

# Listen for payments, forever, reliably
client.subscribe_to_notifications do |n|
puts "Got paid: #{n.amount_msats} msats for #{n.payment_hash}"
end

That's it. The gem handles the Nostr protocol, encryption, WebSocket lifecycle, heartbeats, zombie-TCP detection, reconnects, and backoff. You call methods.


How It Works

Rizful (and any other NWC-compatible wallet) speaks Nostr Wallet Connect — a standard protocol that lets your app talk to a Lightning wallet via a connection string. The core loop for a checkout is:

  1. Create an invoice — your Rails app calls make_invoice, gets back a BOLT11 invoice string and a payment_hash.
  2. Show the invoice — display it as a QR code or payment link for the customer.
  3. Get notified when it's paid — your app subscribes to payment_received notifications and marks the order as paid the instant it settles.
  4. React — unlock content, send a receipt, ship the product, or whatever your app needs.
Use a receive-only NWC code for checkout flows

A receive-only NWC code can only create invoices and check their status — it cannot send funds. Even if the code is leaked, your balance is safe. Only use a read+write code if your app needs to send payments (e.g. payouts, tipping bots).


Read-only vs read+write — understand this before you build

A connection string's capabilities are set when the wallet issues it and cannot be widened by the client.

Read-only code

A read-only NWC code supports get_info, get_balance, make_invoice, lookup_invoice, list_transactions, and notifications — but not pay_invoice or pay_keysend. It cannot move funds out of the wallet.

Use read-only for: e-commerce checkouts, donation pages, paywall integrations. Your server generates invoices, watches for payment_received notifications, and credits the purchase. Even if your server is fully compromised, the attacker cannot drain your wallet.

Read+write code

A read+write code adds pay_invoice, multi_pay_invoice, pay_keysend, and multi_pay_keysend. Anyone holding it can spend from your wallet up to the budget / rate limits the wallet enforces.

Use read+write for: tipping bots, treasury automation, nostr zap clients, any app that legitimately needs to send Lightning payments. Treat the connection string like a private key. Don't commit it. Rotate it if leaked. Use per-app codes with per-app budgets.

The gem tells you which mode you have:

client = NwcRuby::Client.from_uri(ENV["NWC_URL"])
client.read_only? # => true or false
client.capabilities # => ["get_info", "get_balance", "make_invoice", ...]

Installation

Add to your Gemfile:

gem "nwc-ruby"

Or install directly:

gem install nwc-ruby

The rbsecp256k1 dependency is a C extension that compiles libsecp256k1 from source during gem install. You need a C toolchain and a few libraries available before running bundle install.

macOS:

brew install automake openssl libtool pkg-config gmp libffi

Ubuntu / Debian:

sudo apt-get update
sudo apt-get install -y build-essential automake pkg-config libtool \
libffi-dev libgmp-dev

Alpine:

apk add build-base automake autoconf libtool pkgconfig gmp-dev libffi-dev

Docker (Kamal / production):

RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential automake pkg-config libtool libffi-dev libgmp-dev \
&& rm -rf /var/lib/apt/lists/*

If you see LoadError: cannot load such file -- secp256k1 at runtime, the native extension wasn't compiled. Install the build dependencies above and run gem pristine rbsecp256k1 (or re-run bundle install) to rebuild it.


Create an Invoice

client = NwcRuby::Client.from_uri(ENV["NWC_URL"])

# amount is in millisatoshis (msats). 1 sat = 1000 msats.
invoice = client.make_invoice(
amount: 1_000_000, # 1000 sats
description: "Order #1234"
)

invoice["invoice"] # BOLT11 string — show this to the customer
invoice["payment_hash"] # save this — you'll use it to track the payment

You now have a BOLT11 invoice. Render it as a QR code or a copy-to-clipboard link in your checkout view. Persist the payment_hash on your Order (or whatever your model is called) so you can match incoming notifications back to the order.


Listen for Payments

This is the scenario the gem is most carefully engineered for: a long-running process that credits invoices the instant they're paid.

client = NwcRuby::Client.from_uri(ENV["NWC_URL"])

client.subscribe_to_notifications do |notification|
case notification.type
when "payment_received"
Invoice.find_by(payment_hash: notification.payment_hash)
&.mark_paid!(amount_msats: notification.amount_msats)
when "payment_sent"
Payout.find_by(payment_hash: notification.payment_hash)&.mark_settled!
end
end
# Blocks forever. SIGTERM / SIGINT cause a clean exit.

Under the hood this subscribes to both kind 23196 (NIP-04) and kind 23197 (NIP-44 v2), dedupes by payment_hash, pings every 15 seconds to keep middleboxes from idle-closing the socket, reconnects with capped exponential backoff on failure, and force-recycles the connection every 5 minutes as a belt-and-suspenders check against silently dead TCP streams.

Resuming after a restart

Persist the created_at of the last notification you processed and pass it as since: on restart to avoid replaying history:

since = AppState.get("nwc_last_seen") || Time.now.to_i

client.subscribe_to_notifications(since: since) do |n|
process(n)
AppState.set("nwc_last_seen", n.event.created_at)
end

Deploying on Rails with Kamal

The NWC listener is best deployed as a Kamal role — the same app image as your web container, but with a different cmd. This is the canonical 37signals pattern for a Sidekiq / Solid Queue / cron / listener process. Don't use a Kamal "accessory" for this — accessories are for third-party services (Postgres, Redis) and are not redeployed on kamal deploy.

# config/deploy.yml
service: myapp
image: ghcr.io/myorg/myapp

servers:
web:
hosts: [10.0.0.10]
nwc_listener:
hosts: [10.0.0.10]
cmd: "bundle exec rake nwc:listen_in_app"
options:
memory: 512m
Make your handler idempotent

During deploys, reconnects, or if you scale nwc_listener to multiple hosts, the same payment_received notification can be delivered more than once. The gem deduplicates within a single process lifetime, but across restarts or multiple instances you must handle duplicates at the database level. Use a unique constraint on payment_hash (or an UPDATE ... WHERE state != 'paid' guard) so that processing the same notification twice is a harmless no-op — never double-credit a payment.

Define the listener rake task in your app:

# lib/tasks/nwc.rake
namespace :nwc do
task listen_in_app: :environment do
client = NwcRuby::Client.from_uri(ENV["NWC_URL"])
since = AppState.find_or_create_by(key: "nwc_last_seen").value.to_i
since = Time.now.to_i if since.zero?

client.subscribe_to_notifications(since: since) do |n|
Invoice.transaction do
# Idempotent: only transitions pending → paid, ignores already-paid rows.
rows = Invoice.where(payment_hash: n.payment_hash, state: "pending")
.update_all(
state: "paid",
paid_amount_msats: n.amount_msats,
paid_at: Time.at(n.event.created_at)
)
AppState.where(key: "nwc_last_seen").update_all(value: n.event.created_at)
ActiveRecord::Base.connection.execute("NOTIFY nwc_invoice_paid") if rows > 0
end
end
end
end

Docker's --restart unless-stopped (Kamal's default) plus the gem's internal reconnect loop plus a SIGTERM trap gives you crash-only reliability without systemd, foreman, or any process supervisor.

If you run the listener on multiple hosts, each instance receives the same notifications independently. This is fine as long as the handler is idempotent (as shown above). Running multiple instances gives you redundancy — if one host goes down, the others keep listening — but they do not partition work.


Testing Against a Real Wallet

The gem ships two diagnostic methods. Call them from anywhere — IRB, a Rails console, an RSpec test, or a rake task in your own app.

NwcRuby.test — info, read tests, write test

Parses the connection string, fetches info, runs all read tests (get_info, get_balance, list_transactions, make_invoice, lookup_invoice). If the code is read+write and you provide a Lightning address, it also sends a real payment via pay_invoice. If you provide a Lightning address but the code is read-only, it prints a helpful warning instead of failing.

NwcRuby.test(
nwc_url: ENV["NWC_URL"],
pay_to_lightning_address: "you@rizful.com", # optional — only used if code is read+write
pay_to_satoshis_amount: 10 # default: 100
)
# => true if all checks passed, false otherwise

NwcRuby.test_notifications — listen for notifications

Subscribes to notifications and blocks forever, printing each one as it arrives. Run this in a separate terminal / process. Ctrl-C to stop.

NwcRuby.test_notifications(nwc_url: ENV["NWC_URL"])

From a Rails console

bin/rails c
NwcRuby.test(nwc_url: ENV["NWC_URL"])

Sample output (NwcRuby.test)

NWC Ruby diagnostic

✓ Connection string parsed
✓ Fetched info event (kind 13194)

⚠ This code is READ+WRITE and can allow payments. Be careful with it.

Supported methods:
✓ pay_invoice (mutating)
✓ multi_pay_invoice (mutating)
— pay_keysend
— multi_pay_keysend
✓ make_invoice
✓ lookup_invoice
✓ list_transactions
✓ get_balance
✓ get_info
— sign_message

Notifications: payment_received, payment_sent

✓ Encryption: nip44_v2, nip04 — will use NIP-44 v2

Read tests
✓ get_info (214ms)
✓ get_balance (188ms)
✓ list_transactions (312ms)
✓ make_invoice (1000 msats) (267ms)
✓ lookup_invoice (payment_hash from previous step) (241ms)

Write tests (read+write code detected, Lightning address provided)
✓ pay_invoice (10 sats to you@rizful.com) (1843ms)

All tests passed.

API Reference (Summary)

NwcRuby::Client

MethodReturns
Client.from_uri(uri)Client — parses the nostr+walletconnect:// URI
#info(refresh:)NIP47::Info — cached on first call
#capabilitiesArray<String> — supported method names
#read_only? / #read_write?Boolean

Methods (all raise WalletServiceError on wallet-side errors and TimeoutError after 30 s of silence):

MethodParamsReturns (hash keys)
#make_invoiceamount:, description:, description_hash:, expiry:, metadata:type, state, invoice, payment_hash, amount, created_at, expires_at
#lookup_invoicepayment_hash: or invoice:same as make_invoice, plus settled_at, preimage
#list_transactionsfrom:, until_ts:, limit:, offset:, unpaid:, type:transactions: [...]
#get_balancebalance (msats)
#get_infoalias, color, pubkey, network, block_height, block_hash, methods, notifications
#pay_invoiceinvoice:, amount:preimage, fees_paid (read+write only)
#sign_messagemessage:message, signature

NwcRuby::NIP47::Notification

Field
#type"payment_received" or "payment_sent"
#payment_hashhex
#amount_msatsinteger
#eventthe underlying Event

Errors

All gem errors inherit from NwcRuby::Error: InvalidConnectionStringError, EncryptionError, InvalidSignatureError, TransportError, TimeoutError, UnsupportedMethodError, WalletServiceError (check #code for RATE_LIMITED, NOT_IMPLEMENTED, INSUFFICIENT_BALANCE, PAYMENT_FAILED, NOT_FOUND, and others).


Why NWC + Rizful beats a traditional payment processor

  • No vendor lock-in. NWC is an open standard (NIP-47). Swap Rizful for Alby Hub, Alby Cloud, CoinOs, or your own Lightning node by changing one environment variable. Your Rails code doesn't change.
  • No custodial risk from your payment processor. Funds settle directly to the Lightning wallet you control. Rizful holds the Lightning channels; it doesn't hold your business as a hostage.
  • Fees measured in cents, not percentages. Lightning routing fees are typically well under 1%, often just a few sats.
  • Final settlement in seconds. No chargebacks, no 7-day holds, no "pending" state that resolves next week.
  • Global by default. Any customer with a Lightning wallet can pay — no "supported countries" list, no KYC friction on the buyer's side.