# Satogram Wallet Provider Integration Guide (Full) This file is the complete integration guide for custodial Lightning wallet providers who want their users to receive Satograms. All sections are concatenated below. The HTML version of these docs lives at https://satogram.xyz/integrate/. For a shorter structured index, see https://satogram.xyz/llms.txt. Satogram is a Lightning Network broadcast messaging service. Senders pay upfront to have a short message + sats delivered to thousands of lightning addresses across the network. Custodial wallets whose users have lightning addresses on their domain receive these payments via two paths: per-recipient LNURL-pay (default), or a batch keysend path for opted-in high-volume providers. ================================================================================ Section: https://satogram.xyz/integrate/business ================================================================================ # Why Integrate Satograms ## The opportunity Satograms are message-attached sats that get broadcast to many lightning-address recipients in a single campaign. Senders pay upfront; the service fans the payment out across the Lightning Network. If your users have lightning addresses on your domain (e.g. `alice@yourwallet.com`), they will already receive Satograms through the LNURL-pay flow you have today, no integration on your part beyond what LUD-12 already requires. Integration unlocks two things: **(1)** display the sender's message to your user, turning a "5 sats received" notification into "Bob sent you 5 sats with this note", and **(2)** if your volume justifies it, opting into the batch keysend path which lets you take an explicit cut of every Satogram destined for your users. ## What's in it for you ### 1. The cut (batch keysend mode) This is the headline number. When you opt into batch keysends: - The Satogram service sends one keysend to your node that covers N of your users at once. - The keysend's `amount_msat` is `N × per_satogram_amount × 1000`. - **You decide how much of that you credit to users and how much you keep as a delivery fee.** The protocol does not enforce a split. There is no operator-side share. A worked example. A Satogram campaign at 5 sats per recipient hits 10,000 lightning addresses across the network. 1,200 of those are on your wallet: | Mode | Inbound HTLCs | Sats received | Your cut | Credited to users | |------|--------------|----------------|----------|--------------------| | LNURL-pay (no opt-in) | 1,200 | 6,000 | none | 6,000 | | Batch keysend (opt-in) | ~24 | 6,000 | **rate × 6,000** | (1 − rate) × 6,000 | Multiply by campaigns per month and the cut compounds. The cut works for one specific reason: Satograms are unsolicited inbound messages, not user-requested payments. Charging a delivery fee on incoming-spam-with-sats is a legitimately better revenue source than charging your users on payments they explicitly accepted. Your rate is set per-wallet during onboarding with the operator. The example code in this guide leaves the rate as a configurable constant; replace it with the rate from your operator agreement. ### 2. Free inbound activity for your users Every Satogram campaign hits every known lightning address on the network. If you have N users with addresses on your domain, you receive N inbound payments per campaign with zero marketing spend. Users see activity in their wallets. Engagement goes up. Inbound activity is one of the strongest retention signals in any consumer fintech. ### 3. Stickier lightning addresses Lightning addresses are sticky identities, a user with `alice@yourwallet.com` has a reason to keep the handle live, which means keeping the account live. Inbound Satograms reinforce this: the more sats land in their handle, the higher the switching cost. ### 4. Brand listing The Satogram service maintains internal lists of supported wallets. Being on that list is free distribution. Senders see your domain in their UI; recipients see "delivered to your wallet" in their notifications. ## What does it cost? Honest accounting: - **Engineering effort:** roughly 1-3 dev-days for Path A (LNURL-pay configuration check + reading two TLVs off settled invoices). Add 2-5 more dev-days for Path B (batch keysend acceptance + recipient-list parsing + accounting policy for the cut). - **Inbound liquidity:** for batch keysends you receive in chunks of up to `50 × per_satogram` sats per payment. At 5 sats per recipient that's 250 sats per HTLC, trivial. For most providers this volume is well below normal channel-capacity headroom; you don't need to provision specifically for Satograms. - **Routing fees:** zero. You're the receiver. - **Ongoing ops:** your existing invoice subscriber plus ~50 lines of TLV-parsing code. No new infrastructure. - **Integration fee / token / escrow:** none beyond the negotiated per-Satogram delivery fee. Opt-in is a one-line config change on the operator's side (your domain + node pubkey added to their opt-in list). ## Who's already integrated? A leading Lightning custodial wallet is already live on the batch keysend path; the design was built around their integration pattern. Adopting this pattern means following a well-trodden path, not pioneering. ## What you're agreeing to (and what you're not) What you're agreeing to: - Crediting users who appear in TLV `6789998212` of an incoming Satogram payment, on a reasonable best-effort basis. - Keeping `commentAllowed > 0` on your LNURL-pay endpoint so per-recipient delivery works. - If opting into batch keysend: keeping `accept-keysend=true` (LND default) and your node reachable. What you're **not** agreeing to: - No SLA. If your node is down, the operator's service tolerates the failures and moves on. - No mandated fee rate. Your cut is your business decision. - No mandated UX. How you surface the message to users (notification, transaction history, in-app message inbox) is up to you. ## How to get started 1. Read [How it works](/integrate/docs/how-it-works) for the technical model. 10-minute read. 2. Verify your LNURL-pay endpoint meets [the requirements](/integrate/docs/lnurl-pay-setup). For many wallets this is a one-line config bump on `commentAllowed`. 3. Add TLV reading to your existing invoice subscriber: [Detecting payments](/integrate/docs/detecting-payments). 4. Test on regtest: [Testing](/integrate/docs/testing). 5. If you want the cut: [Opt into batch keysends](/integrate/docs/batch-keysend-opt-in). Realistic timeline from kickoff to first Satogram credited in production: 1-2 weeks. ================================================================================ Section: https://satogram.xyz/integrate/docs/how-it-works ================================================================================ # How It Works This doc explains the data model and protocol. The next docs cover wiring it up. ## The two delivery paths The Satogram service routes a payment to a custodial user through one of two paths. ### Path A: per-recipient LNURL-pay (default) For every lightning address the service knows about, it runs the standard LNURL-pay flow: 1. `GET https://yourwallet.com/.well-known/lnurlp/alice` and parses the response. 2. Validates `commentAllowed`, `minSendable`, `maxSendable` (see [LNURL-pay setup](/integrate/docs/lnurl-pay-setup)). If any check fails, the recipient is skipped silently for that campaign. 3. `GET ?amount=&comment=` and parses the returned BOLT-11. 4. Validates the invoice's msat amount equals what was requested. Then pays it via `SendPaymentV2`. When the service pays the BOLT-11, it attaches two TLVs as **destination custom records** so they land in the recipient's HTLC alongside the payment: | TLV | Contents | |-----|----------| | `34349334` | `"📨 Satogram: " + ` (UTF-8 bytes) | | `6789998212` | The recipient's lightning address, e.g. `alice@yourwallet.com` | This is why your subscriber sees both TLVs even though the invoice itself is a normal BOLT-11. The Lightning final-hop onion carries them through. ### Path B: batch keysend (opt-in) If your wallet's domain is on the operator's opt-in list (your domain mapped to your node's pubkey), the service bundles up to 50 recipients on your domain into a **single keysend** to your node: - Keysend `value_msat` = `num_recipients × amt_per_satogram × 1000` - TLV `34349334` = the message (same `"📨 Satogram: …"` prefix as above) - TLV `6789998212` = comma-separated list of recipient addresses, e.g. `alice@yourwallet.com,bob@yourwallet.com,carol@yourwallet.com` - TLV `5482373484` = keysend preimage (your LND validates this and only accepts the HTLC if it hashes to the payment hash) Batches are flushed when they hit 50 entries or ~800 bytes of address payload, whichever comes first. Batching saves on routing fees and reduces the number of inbound HTLCs your node has to settle. It's also the path on which you can take a custodial cut, see [Why integrate](/integrate/business) and [Batch keysend opt-in](/integrate/docs/batch-keysend-opt-in). ## What you'll see on the wire A settled LNURL-pay Satogram (Path A) looks like this from `lncli lookupinvoice `: ```json { "memo": "alice@yourwallet.com", "r_hash": "20b1c195...", "value": 5, "value_msat": 5000, "settled": true, "is_keysend": false, "amt_paid_sat": 5, "htlcs": [ { "amt_msat": 5000, "custom_records": { "34349334": "f09f93a8205361746f6772616d3a20676d2066726f6d20746162636f6e6621", "6789998212": "616c69636540796f757277616c6c65742e636f6d" } } ] } ``` A batched keysend Satogram (Path B) for 3 recipients at 5 sats each: ```json { "r_hash": "20b1c195...", "value": 15, "settled": true, "is_keysend": true, "amt_paid_sat": 15, "htlcs": [ { "amt_msat": 15000, "custom_records": { "34349334": "f09f93a8205361746f6772616d3a20676d2066726f6d20746162636f6e6621", "5482373484": "<32-byte preimage>", "6789998212": "616c6963654079...,626f624079...,6361726f6c4079..." } } ] } ``` Decode hex on `34349334` and you get `"📨 Satogram: gm from tabconf!"`. Decode `6789998212` for the recipient list. ## What your code needs to do The same four things in every implementation: 1. Subscribe to settled incoming payments on your node. 2. Extract TLV `34349334` (message) and TLV `6789998212` (recipient or comma-separated recipients) from the final-hop HTLC. 3. Split TLV `6789998212` on the comma character. For Path A this gives one address; for Path B, up to 50. 4. Credit each recipient with their share of `amount_msat` (minus your custodial cut if you've opted into Path B), idempotent on `(payment_hash, recipient)`. What changes between implementations is **how you get at the TLVs**. The next docs cover the LNURL-pay callback (which generates the invoice) and the subscriber (which reads the TLVs on settlement). ## Minimums and amounts Two minimums matter: - **LNURL-pay (Path A):** the service sends `amt_per_satogram` sats per recipient (typically 5 sats, but configurable per campaign). Your `minSendable` on the LNURL-pay endpoint is the floor, set it low (1000 msat / 1 sat) to ensure you qualify for every campaign. - **Batch keysend (Path B):** the service enforces a minimum of **10 sats per recipient** when paying lightning-address-style recipients in batches. Below that the batch rounds up. If your wallet has a credit floor higher than 10 sats, you'll need a policy for dust, see [Batch keysend opt-in](/integrate/docs/batch-keysend-opt-in). ## TLV registry | TLV | Origin | Meaning | |-----|--------|---------| | `34349334` | [satoshis.stream TLV registry](https://github.com/satoshisstream/satoshis.stream/blob/main/TLV_registry.md) | Keysend message (UTF-8) | | `5482373484` | [BOLT-04 / LND keysend](https://github.com/lightning/blips/blob/master/blip-0003.md) | Keysend preimage | | `6789998212` | Satogram (this project) | Recipient identifier(s) for custodial credit lookup | ## Reference examples - Working reference subscriber (LND, Go): `examples/lnd/main.go` ================================================================================ Section: https://satogram.xyz/integrate/docs/lnurl-pay-setup ================================================================================ # 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/` response, plus a reference callback handler. ## What the service checks These are the exact checks the Satogram service runs against your `/.well-known/lnurlp/` 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: "` 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=&comment=`. 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": ""}`.
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=&comment= 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/. 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/. 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](/integrate/docs/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/` 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](/integrate/docs/detecting-payments). ================================================================================ Section: https://satogram.xyz/integrate/docs/detecting-payments ================================================================================ # Detecting Incoming Satograms The job is the same on every implementation: 1. Subscribe to settled incoming payments on your node. 2. Extract TLV `34349334` (message) and TLV `6789998212` (recipient or comma-separated recipients) from the final-hop HTLC. 3. For batch keysends, split TLV `6789998212` on the comma character. For LNURL-pay (Path A) it's always one address. 4. Credit each recipient with their share of `amount_msat`, idempotent on `(payment_hash, recipient)`. What changes per implementation is **how you get at the TLVs**. Below are working subscribers for LND, Core Lightning, LDK, and Eclair. ## LND (Go) You almost certainly already have a goroutine subscribed to `SubscribeInvoices`. The Satogram-specific additions are: (a) recognize the custom records, (b) handle two cases (single recipient vs. batched), and (c) credit accordingly. The full reference is at `examples/lnd/main.go`. Below is a more thorough version that handles both delivery paths.
Show full LND subscriber ```go package satogram import ( "context" "encoding/hex" "errors" "fmt" "io" "strings" "github.com/lightningnetwork/lnd/lnrpc" ) const ( TLVMessage uint64 = 34349334 // utf-8 message TLVRecipients uint64 = 6789998212 // single address OR comma-separated list TLVKeysend uint64 = 5482373484 // keysend preimage ) // CustodialFeeBps is your delivery fee in basis points. The actual rate is // negotiated with the operator per wallet; this is just a placeholder. const CustodialFeeBps = int64(1000) type SettledSatogram struct { PaymentHash string AmtPaidSat int64 Message string Recipients []string // 1 entry for LNURL-pay, N for batch keysend IsKeysend bool } func (s *Service) WatchInvoices(ctx context.Context) error { // Start from current tip so we don't replay history. For production, // persist the last add_index and resume from it on restart. info, err := s.lnd.ListInvoices(ctx, &lnrpc.ListInvoiceRequest{ Reversed: true, NumMaxInvoices: 1, }) if err != nil { return fmt.Errorf("listing invoices: %w", err) } addIndex := info.LastIndexOffset sub, err := s.lnd.SubscribeInvoices(ctx, &lnrpc.InvoiceSubscription{ AddIndex: addIndex, }) if err != nil { return fmt.Errorf("subscribing: %w", err) } for { inv, err := sub.Recv() if errors.Is(err, io.EOF) { return nil } if err != nil { // In production: detect connection drops and resubscribe from // the last addIndex you saw. return fmt.Errorf("recv: %w", err) } if inv.State != lnrpc.Invoice_SETTLED { continue } s.handleSettled(ctx, inv) } } func (s *Service) handleSettled(ctx context.Context, inv *lnrpc.Invoice) { sg, ok := ExtractSatogram(inv) if !ok { // Not a Satogram. Could be a regular incoming payment. s.handleRegularInvoice(ctx, inv) return } // Split the paid amount across recipients minus your fee. credits := SplitAmount(sg.AmtPaidSat, sg.Recipients, CustodialFeeBps) for _, c := range credits { if err := s.users.CreditByLightningAddress(ctx, c.Address, c.Sats, CreditMeta{ Source: "satogram", PaymentHash: sg.PaymentHash, Message: sg.Message, }); err != nil { s.log.Errorf("credit failed: addr=%s sats=%d err=%v", c.Address, c.Sats, err) continue } s.log.Infof("satogram: credited %s with %d sats (msg=%q)", c.Address, c.Sats, truncate(sg.Message, 60)) } } func ExtractSatogram(inv *lnrpc.Invoice) (SettledSatogram, bool) { if len(inv.Htlcs) == 0 { return SettledSatogram{}, false } // Custom records live on the final-hop HTLC. For a keysend or any // single-HTLC payment, take the first. For MPP, scan them. var rawMsg, rawRecipients []byte for _, h := range inv.Htlcs { if h.CustomRecords == nil { continue } if v, ok := h.CustomRecords[TLVMessage]; ok && rawMsg == nil { rawMsg = v } if v, ok := h.CustomRecords[TLVRecipients]; ok && rawRecipients == nil { rawRecipients = v } } if rawRecipients == nil { return SettledSatogram{}, false } addresses := splitAndTrim(string(rawRecipients), ",") if len(addresses) == 0 { return SettledSatogram{}, false } return SettledSatogram{ PaymentHash: hex.EncodeToString(inv.RHash), AmtPaidSat: inv.AmtPaidSat, Message: string(rawMsg), // safe to be empty Recipients: addresses, IsKeysend: inv.IsKeysend, }, true } type Credit struct { Address string Sats int64 } // SplitAmount takes a custodial cut (in bps) off the top and divides the // remainder evenly across recipients. The remainder of integer division // is kept by the custodian. func SplitAmount(totalSats int64, recipients []string, feeBps int64) []Credit { if len(recipients) == 0 || totalSats <= 0 { return nil } cut := totalSats * feeBps / 10000 net := totalSats - cut per := net / int64(len(recipients)) out := make([]Credit, 0, len(recipients)) for _, r := range recipients { out = append(out, Credit{Address: r, Sats: per}) } return out } func splitAndTrim(s, sep string) []string { parts := strings.Split(s, sep) out := make([]string, 0, len(parts)) for _, p := range parts { if p = strings.TrimSpace(p); p != "" { out = append(out, p) } } return out } func truncate(s string, n int) string { if len(s) <= n { return s } return s[:n] + "..." } ```
## Core Lightning (Python plugin) Core Lightning exposes incoming TLVs through two complementary hooks: - `htlc_accepted` fires for every incoming HTLC **before** it settles. The full onion payload (including any destination custom records the sender attached) is available here as a TLV stream. - `invoice_payment` fires once the invoice is fully paid. The pattern below uses both: stash TLVs by payment hash in `htlc_accepted`, then credit on `invoice_payment` so you never credit a user for an HTLC that ends up failing. Keysend-delivered Satograms (Path B) also flow through `invoice_payment` because CLN's keysend plugin auto-creates an invoice on the fly. This builds on the existing helloworld plugin at `examples/cln-plugin/python-plugin/helloworld.py`, which already shows the TLV-parsing mechanics.
Show CLN plugin ```python #!/usr/bin/env python3 import json import threading from binascii import unhexlify from pyln.client import Plugin from pyln.proto.onion import TlvPayload TLV_MESSAGE = 34349334 TLV_RECIPIENTS = 6789998212 plugin = Plugin() # In-memory stash: payment_hash -> {"message": str, "recipients": [str]}. # Replace with a persistent KV store (sqlite, plugin datastore) in production. _pending = {} _pending_lock = threading.Lock() def _parse_satogram_tlvs(onion): """Return (message, [recipient_addresses]) or (None, None) if not a Satogram.""" payload_hex = onion.get("payload") if not payload_hex: return None, None try: tlv = TlvPayload.from_bytes(unhexlify(payload_hex), skip_length=False) except Exception as e: plugin.log(f"failed to parse onion payload: {e}") return None, None message, recipients_raw = None, None for field in tlv.fields: # field.typenum may come back as int or str depending on pyln version. t = int(field.typenum) if str(field.typenum).isdigit() else field.typenum val = field.value if isinstance(field.value, (bytes, bytearray)) else str(field.value).encode() if t == TLV_MESSAGE: try: message = val.decode("utf-8") except UnicodeDecodeError: message = val.hex() elif t == TLV_RECIPIENTS: try: recipients_raw = val.decode("utf-8") except UnicodeDecodeError: recipients_raw = None if recipients_raw is None: return None, None recipients = [r.strip() for r in recipients_raw.split(",") if r.strip()] if not recipients: return None, None return message, recipients @plugin.hook("htlc_accepted") def on_htlc_accepted(onion, htlc, plugin, **kwargs): message, recipients = _parse_satogram_tlvs(onion) if recipients: payment_hash = htlc.get("payment_hash") with _pending_lock: _pending[payment_hash] = {"message": message or "", "recipients": recipients} plugin.log(f"satogram TLVs stashed for payment_hash={payment_hash} recipients={len(recipients)}") # Always let the HTLC continue; CLN handles settlement. return {"result": "continue"} @plugin.hook("invoice_payment") def on_invoice_payment(payment, plugin, **kwargs): payment_hash = payment.get("payment_hash") or payment.get("label") # `msat` arrives as a string like "5000msat" in some versions. msat_raw = payment.get("msat") or payment.get("amount_msat") or "0msat" msat = int(str(msat_raw).replace("msat", "").strip() or 0) amt_sat = msat // 1000 with _pending_lock: sg = _pending.pop(payment_hash, None) if sg is None: # Not a Satogram, regular payment. Fall through to your normal credit path. return {"result": "continue"} credit_satogram(payment_hash, amt_sat, sg["message"], sg["recipients"]) return {"result": "continue"} # Replace with your custodial credit path. CUSTODIAL_FEE_BPS = 1000 # placeholder; actual rate is negotiated with the operator per wallet def credit_satogram(payment_hash, amt_sat, message, recipients): cut = amt_sat * CUSTODIAL_FEE_BPS // 10000 net = amt_sat - cut per = net // len(recipients) for addr in recipients: # TODO: your real DB call, idempotent on payment_hash + addr. plugin.log(f"satogram credit: payment_hash={payment_hash} addr={addr} sats={per} msg={message[:60]!r}") @plugin.init() def init(options, configuration, plugin, **kwargs): plugin.log("satogram custodial plugin loaded") plugin.run() ```
Notes on this plugin: - **Why both hooks?** `htlc_accepted` is the only place CLN exposes the raw onion TLV stream. `invoice_payment` is the only place that's guaranteed to fire **after** the HTLC actually settled. Stashing in the first and crediting in the second avoids both "credit then HTLC fails" bugs and "TLVs unavailable" bugs. - **Multi-part payments:** If an MPP arrives, `htlc_accepted` fires once per part, all with the same `payment_hash`. The stash will overwrite on each, which is fine because all parts of an MPP carry the same final-hop TLVs. - **Restart durability:** The dict-based stash above loses state across plugin restarts. For production, use CLN's `datastore`/`listdatastore` JSON-RPC commands (or sqlite via `pyln`) so an HTLC accepted just before restart still credits after restart. - **Keysend:** Make sure CLN's keysend plugin is loaded (it ships in mainline CLN and is enabled by default) and that you haven't set `disable-mpp` for incoming. For batch keysend (Path B), CLN's keysend plugin generates the invoice on the fly, so `invoice_payment` fires with the auto-generated invoice label. To install: drop the file into your CLN plugin directory and start with `lightningd --plugin=/path/to/satogram.py`, or `lightning-cli plugin start /path/to/satogram.py` on a running node. ## LDK (Rust) LDK is a library; the integration point is your event handler. The flow: 1. `Event::PaymentClaimable` arrives with `onion_fields.custom_tlvs` populated for any TLVs the sender attached. The HTLC is held; you must claim it to settle. 2. Call `channel_manager.claim_funds(preimage)` to settle. 3. `Event::PaymentClaimed` fires when the HTLC is fully settled. Credit users here. Stashing between the two events is what makes credits idempotent and crash-safe. Claim is asynchronous, and a node restart between claim and claimed should not lose the recipient list.
Show LDK event handler ```rust use std::collections::HashMap; use std::sync::{Arc, Mutex}; use lightning::events::{Event, EventHandler, PaymentPurpose}; use lightning::ln::PaymentHash; use lightning::ln::channelmanager::ChannelManager; const TLV_MESSAGE: u64 = 34349334; const TLV_RECIPIENTS: u64 = 6789998212; const CUSTODIAL_FEE_BPS: u64 = 1000; // placeholder; actual rate is negotiated with the operator per wallet #[derive(Clone)] struct SatogramData { message: String, recipients: Vec, } pub struct SatogramHandler> { channel_manager: CM, pending: Arc>>, // ... your DB handle, logger, etc. } impl> SatogramHandler { fn extract_satogram(custom_tlvs: &[(u64, Vec)]) -> Option { let mut message = String::new(); let mut recipients_raw: Option = None; for (t, v) in custom_tlvs { match *t { TLV_MESSAGE => { message = String::from_utf8_lossy(v).into_owned(); } TLV_RECIPIENTS => { recipients_raw = Some(String::from_utf8_lossy(v).into_owned()); } _ => {} } } let csv = recipients_raw?; let recipients: Vec = csv .split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect(); if recipients.is_empty() { return None; } Some(SatogramData { message, recipients }) } fn credit_satogram(&self, payment_hash: &PaymentHash, amount_msat: u64, sg: &SatogramData) { let amt_sat = amount_msat / 1000; let cut = amt_sat * CUSTODIAL_FEE_BPS / 10000; let net = amt_sat - cut; let per = net / sg.recipients.len() as u64; for addr in &sg.recipients { // TODO: your real credit call, keyed idempotently on (payment_hash, addr). log::info!( "satogram credit: payment_hash={} addr={} sats={} msg={:?}", hex::encode(payment_hash.0), addr, per, &sg.message.chars().take(60).collect::(), ); } } } impl> EventHandler for SatogramHandler { fn handle_event(&self, event: Event) { match event { Event::PaymentClaimable { payment_hash, amount_msat, purpose, onion_fields, .. } => { // Extract preimage from the purpose so we can claim. let preimage = match &purpose { PaymentPurpose::Bolt11InvoicePayment { payment_preimage: Some(p), .. } => *p, PaymentPurpose::SpontaneousPayment(p) => *p, _ => { log::warn!("payment claimable without preimage; skipping"); return; } }; if let Some(of) = onion_fields.as_ref() { if let Some(sg) = Self::extract_satogram(of.custom_tlvs()) { self.pending.lock().unwrap().insert(payment_hash.0, sg); } } self.channel_manager.as_ref().claim_funds(preimage); } Event::PaymentClaimed { payment_hash, amount_msat, .. } => { let sg = self.pending.lock().unwrap().remove(&payment_hash.0); if let Some(sg) = sg { self.credit_satogram(&payment_hash, amount_msat, &sg); } // Else: not a Satogram, fall through to your normal credit path. } _ => { // Hand other events to your existing handler. } } } } ```
Notes: - `onion_fields.custom_tlvs()` (the accessor on `RecipientOnionFields`) returns the slice of `(u64, Vec)` pairs the sender attached. It's `Some` whenever the receiving node parsed a final-hop TLV stream, which it always does for both LNURL-pay payments (where the Satogram service attaches them via `DestCustomRecords`) and keysend payments. - For **batch keysend (Path B)** to work on LDK, your node must signal keysend support: set the appropriate feature bit via `UserConfig`/`ChannelConfig` and ensure your keysend acceptance logic doesn't reject TLV `6789998212`. By default LDK accepts unknown even-typed TLVs in the keysend payload. `6789998212` is even, so this works out of the box, but verify with a test send. - The `pending` map should be persisted (sled, sqlite, your existing payment DB) for production. A node restart between `PaymentClaimable` and `PaymentClaimed` would otherwise drop the recipient list. If you're using **LDK Node** (the high-level wrapper) instead of `lightning` directly, you receive simpler `Event::PaymentReceived` events that don't currently expose `custom_tlvs` as cleanly. You have two options: (a) fall through to the lower-level `ChannelManager` event stream alongside LDK Node, or (b) for LNURL-pay only, rely on the DB-at-callback pattern from the [LNURL-pay setup](/integrate/docs/lnurl-pay-setup) doc: store user+message keyed by payment hash when serving the callback, then look it up on settlement. ## Eclair (REST + WebSocket) Eclair has historically had thinner exposure of final-hop custom TLVs to the application layer compared to LND/CLN/LDK. The practical recommendation depends on which delivery path you care about: - **LNURL-pay (Path A) works on any Eclair version.** You control the callback handler, so you persist `{payment_hash → user_id, message}` at invoice-generation time and look it up when the WebSocket reports the payment received. The TLVs the sender attaches are nice-to-have but not required. - **Batch keysend (Path B) is harder on Eclair.** You'd need an Eclair plugin (Scala/Kotlin) to inspect the final-hop TLV stream, or you can skip opt-in to batch keysends and rely entirely on Path A. The example below shows the Path A flow. Your LNURL-pay callback (Node/Express style; adapt to your stack):
Show callback handler ```javascript // POST/GET /lnurlp/callback/:user?amount=&comment= app.get('/lnurlp/callback/:user', async (req, res) => { const username = req.params.user; const amountMsat = parseInt(req.query.amount, 10); const comment = req.query.comment || ''; if (!Number.isFinite(amountMsat) || amountMsat < 1000) { return res.status(400).json({ status: 'ERROR', reason: 'bad amount' }); } const user = await db.lookupUserByUsername(username); if (!user) { return res.status(404).json({ status: 'ERROR', reason: 'unknown user' }); } // Eclair createinvoice. descriptionHash must be sha256 of the metadata // string served from /.well-known/lnurlp/. const descriptionHash = metadataHashFor(username); // 32-byte hex const eclairResp = await fetch(`${ECLAIR_URL}/createinvoice`, { method: 'POST', headers: { Authorization: 'Basic ' + Buffer.from(':' + ECLAIR_PASSWORD).toString('base64'), 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ descriptionHash, amountMsat: String(amountMsat), expireIn: '600', }), }); const invoice = await eclairResp.json(); // invoice.serialized is the BOLT-11; invoice.paymentHash is hex. await db.recordPendingLnurlPayment({ paymentHash: invoice.paymentHash, userId: user.id, amountMsat, comment, }); res.json({ pr: invoice.serialized, routes: [] }); }); ```
The WebSocket subscriber that credits on settlement:
Show WebSocket subscriber ```javascript const WebSocket = require('ws'); const ws = new WebSocket(`${ECLAIR_WS_URL}/ws`, { headers: { Authorization: 'Basic ' + Buffer.from(':' + ECLAIR_PASSWORD).toString('base64'), }, }); ws.on('message', async (raw) => { let event; try { event = JSON.parse(raw); } catch (_) { return; } if (event.type !== 'payment-received') return; const paymentHash = event.paymentHash; const totalMsat = (event.parts || []).reduce( (sum, p) => sum + (p.amount || 0), 0 ); const pending = await db.takePendingLnurlPayment(paymentHash); if (!pending) return; // not a tracked LNURL-pay invoice, skip // For LNURL-pay Satograms, the "comment" we stored at callback time IS // the Satogram message (including the "📨 Satogram: " prefix the sender // service attaches before URL-encoding it). const message = pending.comment; const recipients = [pending.userId]; // Path A is always 1 recipient await creditSatogram(paymentHash, Math.floor(totalMsat / 1000), message, recipients); }); async function creditSatogram(paymentHash, amtSat, message, recipients) { const feeBps = 1000n; // placeholder; actual rate is negotiated with the operator per wallet const total = BigInt(amtSat); const cut = (total * feeBps) / 10000n; const net = total - cut; const per = net / BigInt(recipients.length); for (const userId of recipients) { await db.creditUser({ userId, sats: Number(per), paymentHash, source: 'satogram', memo: message, }); } } ```
If you want batch-keysend support on Eclair, the path is: 1. Set `eclair.features.keysend = optional` in your `eclair.conf` so the node advertises keysend support. 2. Write an Eclair plugin (Scala or Kotlin via the `eclair-plugin-api`) that subscribes to `Sphinx.DecryptedPacket` events and extracts TLVs `34349334` and `6789998212` from the final-hop payload, dispatching them to your credit layer via a local IPC/HTTP call. 3. Coordinate with the Satogram operator to add your domain and pubkey to their opt-in list (see [Batch keysend opt-in](/integrate/docs/batch-keysend-opt-in)). The plugin approach is well-trodden but Scala-specific; if your team isn't comfortable with it, sticking to Path A is the pragmatic choice and you'll still receive every Satogram destined for your users. ================================================================================ Section: https://satogram.xyz/integrate/docs/testing ================================================================================ # End-to-End Testing You don't need to wait for a real Satogram campaign to verify your integration. A two-node regtest setup is enough to exercise both delivery paths against your subscriber. The recipe below uses [Polar](https://lightningpolar.com/) (a GUI for spinning up local Lightning networks) with two LND nodes; adapt the sender side to any LND you control. ## 1. Topology Spin up a Polar network with: - **`sender`**: any LND node. This stands in for the production Satogram service. - **`receiver`**: the LND node your custodial wallet is integrated with. Run your `SubscribeInvoices` subscriber (or your CLN plugin, LDK event handler, Eclair WebSocket consumer) pointed at this node. - Open a channel from `sender` → `receiver` with enough capacity for your tests (100k sats is plenty). Mine 6 blocks in Polar so the channel goes active. If your production stack is CLN, LDK, or Eclair, the simplest setup is still LND-as-sender + your-stack-as-receiver. The TLV format on the wire is identical; nothing about the sender side cares what implementation the receiver runs. ## 2. Hex-encode the TLV values Both LND CLI and CLN CLI take TLV values as hex. Two helpers you'll reuse: ```sh # Message body: what the Satogram service writes to TLV 34349334 MSG_HEX=$(printf '📨 Satogram: gm from tabconf!' | xxd -p -c 0) # Recipient list: what the service writes to TLV 6789998212. # For Path A use a single address; for Path B use comma-separated. ADDR_HEX_PATH_A=$(printf 'alice@yourwallet.com' | xxd -p -c 0) ADDR_HEX_PATH_B=$(printf 'alice@yourwallet.com,bob@yourwallet.com,carol@yourwallet.com' | xxd -p -c 0) ``` `printf` (not `echo`) avoids the trailing newline that would otherwise corrupt the TLV bytes. ## 3. Test Path A: LNURL-pay invoice settlement This simulates the case where your LNURL-pay callback returned a BOLT-11 and the Satogram service paid it with destination custom records attached. On `receiver`, generate an invoice the way your callback handler would: ```sh lncli -n regtest --rpcserver=receiver:10009 addinvoice \ --amt_msat=5000 \ --memo="alice@yourwallet.com" \ --expiry=600 # Copy the "payment_request" and "r_hash" from the response. ``` On `sender`, pay it and attach the two TLVs: ```sh PAY_REQ='' lncli -n regtest --rpcserver=sender:10009 sendpayment \ --pay_req="${PAY_REQ}" \ --data "34349334=${MSG_HEX},6789998212=${ADDR_HEX_PATH_A}" \ --force ``` If your subscriber is wired up correctly, you should see something like: ``` satogram: credited alice@yourwallet.com with 4 sats (msg="📨 Satogram: gm from tabconf!") ``` (4 sats, not 5: the placeholder custodial fee in the example code takes 1 sat off the top. With a zero fee you'd see the full 5 sats credited; with a different rate, adjust accordingly.) ## 4. Test Path B: batch keysend This simulates the operator opting your domain into batch mode and routing three recipients to your node in one keysend. Grab `receiver`'s pubkey: ```sh RECEIVER_PK=$(lncli -n regtest --rpcserver=receiver:10009 getinfo | jq -r '.identity_pubkey') ``` Fire the keysend with three comma-separated recipients. The amount is `recipients * per_satogram`, for 3 recipients at 10 sats each (the service's minimum for batched lightning addresses), use 30 sats: ```sh lncli -n regtest --rpcserver=sender:10009 sendpayment \ --keysend \ --dest="${RECEIVER_PK}" \ --amt=30 \ --data "34349334=${MSG_HEX},6789998212=${ADDR_HEX_PATH_B}" \ --force ``` LND automatically attaches the keysend preimage (TLV `5482373484`), so you don't need to provide it. Expected subscriber output (with the example code's placeholder fee): three credits of ~9 sats each (`30 sats received, minus your fee, then split three ways`). ## 5. Verify on the receiver If your subscriber isn't logging what you expect, inspect the raw invoice directly: ```sh # Most recent invoice + its custom records lncli -n regtest --rpcserver=receiver:10009 listinvoices \ --max_invoices=1 --reversed | \ jq '.invoices[].htlcs[].custom_records' ``` Sample output for a Path B payment: ```json { "34349334": "f09f93a8205361746f6772616d3a20676d2066726f6d20746162636f6e6621", "5482373484": "<32-byte preimage>", "6789998212": "616c69636540796f757277616c6c65742e636f6d2c626f6240796f757277616c6c65742e636f6d2c6361726f6c40796f757277616c6c65742e636f6d" } ``` Decode the message and recipient list back to text: ```sh # Decode the message TLV lncli -n regtest --rpcserver=receiver:10009 listinvoices \ --max_invoices=1 --reversed | \ jq -r '.invoices[].htlcs[].custom_records["34349334"]' | \ xxd -r -p # Decode the recipients TLV lncli -n regtest --rpcserver=receiver:10009 listinvoices \ --max_invoices=1 --reversed | \ jq -r '.invoices[].htlcs[].custom_records["6789998212"]' | \ xxd -r -p ``` ## 6. Cross-implementation receiver checks If your `receiver` is **CLN** instead of LND, swap the inspection commands: ```sh # Most recent paid invoice lightning-cli --network=regtest listinvoices | jq '.invoices[-1]' # Tail the plugin logs for your stash + credit lines lightning-cli --network=regtest plugin list # Look at lightningd's log file directly for plugin.log() output ``` If `receiver` is **LDK**, point your `EventHandler` test harness at the regtest sender. Most LDK integrators run an `ldk-node-cli` (or their own equivalent), use whatever invoice-add / event-tail commands your harness exposes. If `receiver` is **Eclair**, generate the invoice via the REST API instead of `lncli addinvoice`: ```sh curl -u :${ECLAIR_PASSWORD} -X POST ${ECLAIR_URL}/createinvoice \ -d amountMsat=5000 -d descriptionHash= -d expireIn=600 ``` Then watch your WebSocket consumer for the `payment-received` event after the sender pays. ## 7. Negative test: rejection paths Before going live, also verify your code **doesn't** credit when it shouldn't: ```sh # 1. Payment with no TLVs at all, must not credit anyone. lncli -n regtest --rpcserver=sender:10009 sendpayment \ --pay_req="${PAY_REQ}" --force # 2. Payment with TLV 6789998212 set to an unknown user, must not credit. UNKNOWN_HEX=$(printf 'ghost@yourwallet.com' | xxd -p -c 0) lncli -n regtest --rpcserver=sender:10009 sendpayment \ --pay_req="${PAY_REQ}" \ --data "34349334=${MSG_HEX},6789998212=${UNKNOWN_HEX}" --force # 3. Batch with one known + one unknown, must credit only the known. MIXED_HEX=$(printf 'alice@yourwallet.com,ghost@yourwallet.com' | xxd -p -c 0) lncli -n regtest --rpcserver=sender:10009 sendpayment \ --keysend --dest="${RECEIVER_PK}" --amt=20 \ --data "34349334=${MSG_HEX},6789998212=${MIXED_HEX}" --force ``` The behavior of case (2) and case (3) is a policy choice (refund, drop, or operator-bucket), but whichever you pick, it should be deliberate and tested. ================================================================================ Section: https://satogram.xyz/integrate/docs/batch-keysend-opt-in ================================================================================ # Batch Keysend Opt-In Opting your wallet into batch keysend mode is what unlocks the custodial cut described in [Why integrate](/integrate/business). This doc covers what changes operationally and how to onboard. ## What it does Without opt-in, the Satogram service pays your users one-by-one through your LNURL-pay endpoint. You receive one inbound payment per user per campaign. With opt-in, the service bundles up to 50 of your users into a single keysend to your node, with the recipient list packed into TLV `6789998212` as comma-separated lightning addresses. You credit each user from the recipient list and keep the remainder as your fee. The protocol does not enforce a fee split. Your rate is set per-wallet during onboarding with the operator. The example code in this guide leaves the fee as a configurable constant; replace it with your negotiated rate. ## Technical requirements Before requesting opt-in, confirm: | Requirement | Why | |-------------|-----| | Your node accepts keysend payments | LND: `accept-keysend=true` in `lnd.conf` (this is the default but verify). CLN: keysend plugin loaded (default). LDK: keysend feature bit signaled. Eclair: `eclair.features.keysend = optional`. | | Your subscriber reads TLV `6789998212` and splits on `,` | The same code path handles single-address (Path A) and CSV (Path B). See [Detecting payments](/integrate/docs/detecting-payments). | | Your subscriber tolerates unknown recipients in the CSV | A batch may include an address that doesn't exist in your user table (a recently-deleted user, a typo, etc.). Your code must drop those credits, not throw. See test (3) in [Testing](/integrate/docs/testing). | | You have a documented policy for the unknown-recipient case | Refund the operator? Drop the sats? Park them in an operator-bucket account? Pick one and write it down. | | Your node has enough inbound liquidity headroom | Each batch HTLC is at most `50 × per_satogram` sats. At 5 sats per recipient that's 250 sats per HTLC, well within normal channel capacity. The volume question is "how many batches per day", usually well below any threshold you'd need to provision for. | ## Minimums The Satogram service enforces a **minimum payment of 10 sats** when paying lightning-address-style recipients in a batch. Below 10 sats the batch rounds up to 10. This means: - If your wallet credits with sat precision: you'll receive at least 10 sats per recipient in a batch and credit at least `10 - fee_cut` sats. (For example, a small fee leaves a single-digit-sat credit per recipient.) - If your wallet has a higher credit floor (e.g. 100 sats): your policy choices are (a) skip recipients whose per-recipient share is below your floor and keep the dust as additional fee, or (b) accumulate sub-floor credits until they cross the floor. ## Onboarding steps The operator maintains the opt-in list of supported wallets internally. To get added, send the Satogram operator: 1. **Your wallet's domain.** Example: `yourwallet.com`. This is the suffix the service uses to match lightning addresses to your domain. 2. **The LND/CLN/LDK/Eclair node pubkey** that should receive batched keysends. Use a node with healthy inbound liquidity from the operator's node, or peer with them directly so you don't depend on third-party routing for high-volume delivery. 3. **Confirmation that your invoice subscriber handles TLV `6789998212` as a comma-separated list** and credits recipients individually. 4. **Confirmation that you accept keysends** on the configured node. 5. **Your unknown-recipient policy** (refund / drop / operator-bucket) so the operator knows what to expect if a campaign references a stale user. 6. (Optional) **Your fee rate**, if you want it documented somewhere. The operator doesn't need this to enable opt-in, but some integrators prefer to publish it. There is no commercial agreement, integration fee, or SLA. The operator adds your row to the config and the next campaign starts batching to you. ## Reverting opt-in To stop receiving batches, ask the operator to remove your row. Your users continue receiving Satograms via the LNURL-pay path. Nothing else changes on your side; your subscriber should already handle Path A. ## What about routing fees and capacity? You're receiving, so routing fees are paid by the sender. The relevant capacity question is **inbound**: can the operator's node push you `N × per_satogram` sats in a single HTLC? For typical campaigns (`per_satogram=5 sats`, batches up to 50 recipients), each HTLC is up to 250 sats. This is a small amount and routes easily through almost any path. The only failure mode worth thinking about is if every channel between the operator's node and yours has insufficient remote-side balance at HTLC time, in which case the batch falls back to per-recipient LNURL-pay payments for that campaign. No data is lost; users still get credited via Path A. For high-volume providers, peering directly with the operator's node is the way to make this entirely deterministic. ================================================================================ Section: https://satogram.xyz/integrate/docs/checklist ================================================================================ # Operational Checklist Run through this before flipping your integration on for production users. ## LNURL-pay endpoint - [ ] `/.well-known/lnurlp/` returns `commentAllowed >= 256`, `minSendable <= 1000`, and a callback URL. - [ ] Callback handler returns an invoice whose msat amount exactly matches the `?amount=` parameter (use msat-precision fields like LND `ValueMsat` / CLN `amount_msat` / Eclair `amountMsat`, not the rounded-sat variants). - [ ] Description hash on the invoice matches sha256 of the metadata string from the lnurlp endpoint (LUD-06 compliance, most LNURL-pay parsers verify this). - [ ] Your endpoint is not rate-limiting the Satogram operator's IPs aggressively. The service can hit `/.well-known/lnurlp/` in tight succession during a campaign. Skipped users get no payment. ## Invoice subscriber - [ ] Your invoice subscriber persists a resumable cursor and resumes on restart (LND: `add_index`; CLN: `lastpay_index`; LDK: persisted event handler; Eclair: WebSocket reconnect plus a `getreceivedinfo` sweep of the recent window). - [ ] Your subscriber reads TLV `6789998212` and splits on `,` so it handles both single-address (Path A) and batch (Path B) payments. - [ ] Your subscriber tolerates unknown recipients in a CSV (a typo, deleted user, etc.) without throwing. - [ ] Credit writes are idempotent on `(payment_hash, recipient)` so a subscription replay after restart doesn't double-credit. ## User experience - [ ] You have a path to display the message (TLV `34349334`) to the user in your app: notification copy, transaction history, in-app inbox. - [ ] The message field is treated as untrusted UTF-8: escape for HTML rendering, cap displayed length, consider stripping unsafe Unicode (RTL overrides, zero-width chars). There is no verified sender, text inside the message that reads `from alice@example.com` is just text the sender typed. - [ ] Your accounting layer is happy with credits as small as 1 sat (Path A), or you have a documented dust threshold (Path B). ## Testing - [ ] You have run the end-to-end regtest recipe in [Testing](/integrate/docs/testing) and seen both Path A and Path B credit your test user. - [ ] You have run the negative tests (no TLVs, unknown user, mixed batch) and verified your code's behavior matches your written policy. - [ ] You have a test recipient in production that you can pay end-to-end to verify the live flow. From an LND sender: `lncli sendpayment --keysend --dest --data 34349334=,6789998212= --amt 5`. From a CLN sender: `lightning-cli keysend 5000 '[{"type":6789998212,"value":""}]'`. ## Batch keysend (only if opting in) - [ ] Your node accepts keysend (LND `accept-keysend=true` / CLN keysend plugin loaded / LDK feature bit set / Eclair `features.keysend = optional`). - [ ] You have a written fee policy (the percentage you keep) and your code reflects it. - [ ] You have a written unknown-recipient policy (refund / drop / operator-bucket) and your code reflects it. - [ ] You have sent the operator your domain, node pubkey, and confirmations from [Batch keysend opt-in](/integrate/docs/batch-keysend-opt-in). ## Reference examples - Working reference subscriber (LND, Go): `examples/lnd/main.go` - Example CLN plugin showing TLV parsing: `examples/cln-plugin/python-plugin/helloworld.py`