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
{
"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
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
commentparameter is already URL-decoded bynet/httpwhen you read it viaQuery().Get. Don’t decode again. - You can either persist the
commenthere (simple) or read it from the settled HTLC’s TLV34349334later (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
RHashon the response fromAddInvoiceas raw 32 bytes. Store it hex-encoded so it’s easy to compare againstInvoice.RHashlater.
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.