LNURL-pay Setup#

Path A delivery uses your existing LNURL-pay endpoint. This doc lists the exact thresholds the Satogram service checks against your /.well-known/lnurlp/<user> response, plus a reference callback handler.

What the service checks#

These are the exact checks the Satogram service runs against your /.well-known/lnurlp/<user> response. If any check fails, your user is silently skipped for that campaign.

Field Requirement Why
commentAllowed Must be > 0. Recommend >= 256. The service rejects recipients with commentAllowed == 0 outright. The message is "📨 Satogram: <user_message>" UTF-8 encoded, where the prefix is already 16 bytes, and user messages can run well over 100 chars. 256 is a safe default; 512 leaves headroom.
minSendable Must be <= amt_per_satogram_msat. With min 1 sat (1000 msat) you’ll always qualify. The service’s typical per-satogram amount is 5 sats (5000 msat) but campaigns can configure lower. Setting minSendable to 1000 msat covers every realistic case.
maxSendable Must be >= amt_per_satogram_msat. Same reason in reverse, high enough to cover whatever the sender chose. Most wallets already set this to something large like 100M sats.
callback Standard LNURL-pay callback. Must return a BOLT-11 whose amount_msat exactly matches the ?amount= parameter. The service decodes the invoice and rejects it if the amount mismatches. This is a defense against the callback handing back an unexpected amount.

That’s it. There is no Satogram-specific LNURL extension or custom field your endpoint needs.

Example: a compliant LNURL-pay response#

JSON
{
  "tag": "payRequest",
  "callback": "https://yourwallet.com/lnurlp/callback/alice",
  "minSendable": 1000,
  "maxSendable": 100000000000,
  "metadata": "[[\"text/identifier\",\"alice@yourwallet.com\"],[\"text/plain\",\"Pay to alice\"]]",
  "commentAllowed": 512
}

Example: the callback handler (Go, LND)#

The service GETs your callback with ?amount=<msat>&comment=<urlencoded>. You generate an invoice with that amount, stash a row keyed by payment hash (so you can credit the right user when it settles), and return {"pr": "<bolt11>"}.

Show callback handler
Go
package lnurl

import (
    "context"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "net/http"
    "strconv"

    "github.com/lightningnetwork/lnd/lnrpc"
)

// LNURLPayCallback handles GET /lnurlp/callback/{user}?amount=<msat>&comment=<text>
func (s *Server) LNURLPayCallback(w http.ResponseWriter, r *http.Request) {
    username := r.PathValue("user")

    amtMsat, err := strconv.ParseInt(r.URL.Query().Get("amount"), 10, 64)
    if err != nil || amtMsat < 1000 {
        http.Error(w, `{"status":"ERROR","reason":"bad amount"}`, http.StatusBadRequest)
        return
    }
    comment := r.URL.Query().Get("comment") // already URL-decoded by net/http

    user, err := s.users.LookupByUsername(r.Context(), username)
    if err != nil {
        http.Error(w, `{"status":"ERROR","reason":"unknown user"}`, http.StatusNotFound)
        return
    }

    // LUD-06: description hash must equal sha256 of the metadata string you
    // served from /.well-known/lnurlp/<user>. Keep the metadata bytes
    // identical between both endpoints.
    descHash := s.metadataHashFor(username) // [32]byte

    inv, err := s.lnd.AddInvoice(r.Context(), &lnrpc.Invoice{
        ValueMsat:       amtMsat,
        DescriptionHash: descHash[:],
        Expiry:          600,
    })
    if err != nil {
        http.Error(w, `{"status":"ERROR","reason":"invoice failure"}`, http.StatusInternalServerError)
        return
    }

    // Persist the row BEFORE returning the invoice so a settlement event
    // never arrives before we know who to credit.
    if err := s.db.RecordPendingLnurlPayment(r.Context(), PendingPayment{
        PaymentHash: hex.EncodeToString(inv.RHash),
        UserID:      user.ID,
        AmountMsat:  amtMsat,
        Comment:     comment,
    }); err != nil {
        http.Error(w, `{"status":"ERROR","reason":"db failure"}`, http.StatusInternalServerError)
        return
    }

    json.NewEncoder(w).Encode(map[string]any{
        "pr":     inv.PaymentRequest,
        "routes": []any{},
    })
}

func (s *Server) metadataHashFor(username string) [32]byte {
    // Compute sha256 of the exact metadata JSON string returned from
    // /.well-known/lnurlp/<username>. Cache by username if you like.
    panic("implement: sha256 of the metadata string")
}

A few notes on this code:

  • The comment parameter is already URL-decoded by net/http when you read it via Query().Get. Don’t decode again.
  • You can either persist the comment here (simple) or read it from the settled HTLC’s TLV 34349334 later (see Detecting payments). Persisting it lets you credit and display the message even if the sender used a wallet that didn’t propagate destination custom records; reading it from the HTLC is more truthful but only works when the message actually arrives over the wire. For Satograms specifically, the TLV will be present, so reading from the HTLC is fine.
  • LND populates RHash on the response from AddInvoice as raw 32 bytes. Store it hex-encoded so it’s easy to compare against Invoice.RHash later.

Other implementations#

The exact same callback pattern applies on Core Lightning (lightning-cli invoice), LDK (channel_manager.create_inbound_payment + invoice signer), and Eclair (POST /createinvoice). The shape of the JSON response on /.well-known/lnurlp/<user> is identical. The only thing that changes is which RPC you call to add the invoice and how you read payment_hash off the response.

For Eclair specifically, since custom TLV exposure on settled invoices is limited, the recommended pattern is to always persist {payment_hash → user_id, comment} at callback time, see the Eclair section in Detecting payments.