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.

Below is a subscriber 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 on batch keysends, in basis points.
// This is a placeholder; replace it with the rate set during onboarding.
const CustodialFeeBps = int64(1000)

type SettledSatogram struct {
    PaymentHash string
    AmtPaidMsat 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. The delivery fee applies only
    // to batch keysends (Path B), never to LNURL-pay (Path A).
    feeBps := int64(0)
    if sg.IsKeysend {
        feeBps = CustodialFeeBps
    }
    credits := SplitAmount(sg.AmtPaidMsat, sg.Recipients, feeBps)

    for _, c := range credits {
        if err := s.users.CreditByLightningAddress(ctx, c.Address, c.Msat, CreditMeta{
            Source:      "satogram",
            PaymentHash: sg.PaymentHash,
            Message:     sg.Message,
        }); err != nil {
            s.log.Errorf("credit failed: addr=%s msat=%d err=%v", c.Address, c.Msat, err)
            continue
        }
        s.log.Infof("satogram: credited %s with %d msat (msg=%q)", c.Address, c.Msat, 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),
        AmtPaidMsat: inv.AmtPaidMsat,
        Message:     string(rawMsg), // safe to be empty
        Recipients:  addresses,
        IsKeysend:   inv.IsKeysend,
    }, true
}

type Credit struct {
    Address string
    Msat    int64
}

// SplitAmount takes a custodial cut (in bps) off the top and divides the
// remainder evenly across recipients. It works in msat because at 1 sat per
// recipient a whole-sat split rounds every credit down to 0 after any fee.
// The remainder of integer division is kept by the custodian.
func SplitAmount(totalMsat int64, recipients []string, feeBps int64) []Credit {
    if len(recipients) == 0 || totalMsat <= 0 {
        return nil
    }
    cut := totalMsat * feeBps / 10000
    net := totalMsat - cut
    per := net / int64(len(recipients))
    out := make([]Credit, 0, len(recipients))
    for _, r := range recipients {
        out = append(out, Credit{Address: r, Msat: 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.

Show CLN plugin
Python
#!/usr/bin/env python3
import hashlib
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):
    # The hook payload has no payment_hash, so derive it from the preimage.
    payment_hash = hashlib.sha256(bytes.fromhex(payment["preimage"])).hexdigest()
    # CLN's keysend plugin labels the invoices it creates "keysend-<time>".
    is_keysend = payment["label"].startswith("keysend-")
    # `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)

    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, msat, sg["message"], sg["recipients"], is_keysend)
    return {"result": "continue"}


# Replace with your custodial credit path.
CUSTODIAL_FEE_BPS = 1000  # placeholder; replace it with the rate set during onboarding

def credit_satogram(payment_hash, msat, message, recipients, is_keysend):
    # The delivery fee applies only to batch keysends (Path B), never to LNURL-pay (Path A).
    # Split in msat, since a whole-sat split of a 1 sat share rounds to 0 after any fee.
    fee_bps = CUSTODIAL_FEE_BPS if is_keysend else 0
    cut = msat * fee_bps // 10000
    net = msat - cut
    per_msat = 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} msat={per_msat} 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.
  • Even TLVs: Both Satogram TLVs have even type numbers, and CLN rejects HTLC onions with unknown even fields by default. Add accept-htlc-tlv-type=34349334 and accept-htlc-tlv-type=6789998212 to your config (CLN v23.08+). Without them, CLN rejects Path A payments, and its keysend plugin strips both TLVs from batch keysends, so those settle with no one to credit.
  • 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 with a keysend- label, which is how the plugin above tells Path B from Path A.

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_with_known_custom_tlvs(preimage) to settle. Plain claim_funds fails any payment that carries custom TLVs with even type numbers, and both Satogram TLVs are even.
  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::ops::Deref;
use std::sync::{Arc, Mutex};

use lightning::events::{Event, EventHandler, PaymentPurpose, ReplayEvent};
use lightning::ln::channelmanager::AChannelManager;
use lightning::types::payment::PaymentHash;

const TLV_MESSAGE: u64 = 34349334;
const TLV_RECIPIENTS: u64 = 6789998212;
const CUSTODIAL_FEE_BPS: u64 = 1000; // placeholder; replace it with the rate set during onboarding

#[derive(Clone)]
struct SatogramData {
    message: String,
    recipients: Vec<String>,
    is_keysend: bool,
}

pub struct SatogramHandler<CM: Deref>
where
    CM::Target: AChannelManager,
{
    channel_manager: CM,
    pending: Arc<Mutex<HashMap<[u8; 32], SatogramData>>>,
    // ... your DB handle, logger, etc.
}

impl<CM: Deref> SatogramHandler<CM>
where
    CM::Target: AChannelManager,
{
    fn extract_satogram(custom_tlvs: &[(u64, Vec<u8>)], is_keysend: bool) -> Option<SatogramData> {
        let mut message = String::new();
        let mut recipients_raw: Option<String> = 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<String> = csv
            .split(',')
            .map(|s| s.trim().to_string())
            .filter(|s| !s.is_empty())
            .collect();
        if recipients.is_empty() {
            return None;
        }
        Some(SatogramData { message, recipients, is_keysend })
    }

    fn credit_satogram(&self, payment_hash: &PaymentHash, amount_msat: u64, sg: &SatogramData) {
        // The delivery fee applies only to batch keysends (Path B), never to LNURL-pay (Path A).
        // Split in msat, since a whole-sat split of a 1 sat share rounds to 0 after any fee.
        let fee_bps = if sg.is_keysend { CUSTODIAL_FEE_BPS } else { 0 };
        let cut = amount_msat * fee_bps / 10000;
        let net = amount_msat - cut;
        let per_msat = 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={} msat={} msg={:?}",
                hex::encode(payment_hash.0),
                addr,
                per_msat,
                &sg.message.chars().take(60).collect::<String>(),
            );
        }
    }
}

impl<CM: Deref> EventHandler for SatogramHandler<CM>
where
    CM::Target: AChannelManager,
{
    fn handle_event(&self, event: Event) -> Result<(), ReplayEvent> {
        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 Ok(());
                    }
                };

                let custom_tlvs: &[(u64, Vec<u8>)] = onion_fields
                    .as_ref()
                    .map(|of| of.custom_tlvs().as_slice())
                    .unwrap_or(&[]);

                // claim_funds fails any payment with an even custom TLV, and
                // both Satogram TLVs are even. Claim with the known-TLV variant,
                // but only after checking every even TLV is one we understand.
                let all_even_known = custom_tlvs
                    .iter()
                    .all(|(t, _)| t % 2 == 1 || *t == TLV_MESSAGE || *t == TLV_RECIPIENTS);
                if !all_even_known {
                    self.channel_manager.get_cm().fail_htlc_backwards(&payment_hash);
                    return Ok(());
                }

                let is_keysend = matches!(purpose, PaymentPurpose::SpontaneousPayment(_));
                if let Some(sg) = Self::extract_satogram(custom_tlvs, is_keysend) {
                    self.pending.lock().unwrap().insert(payment_hash.0, sg);
                }

                self.channel_manager.get_cm().claim_funds_with_known_custom_tlvs(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.
            }
        }
        Ok(())
    }
}

Notes:

  • onion_fields.custom_tlvs() (the accessor on RecipientOnionFields) returns the slice of (u64, Vec<u8>) 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.
  • LDK advertises keysend support by default, so batch keysend needs no extra config.
  • Both Satogram TLVs (34349334 and 6789998212) have even type numbers, and LDK treats even custom TLVs as unknown. claim_funds fails such a payment on both delivery paths, which is why the handler claims with claim_funds_with_known_custom_tlvs after checking every even TLV. 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 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=<msat>&comment=<text>
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/<user>.
  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);
});

// No delivery fee on Path A. The fee applies only to batch keysends (Path B).
async function creditSatogram(paymentHash, amtSat, message, recipients) {
  const per = BigInt(amtSat) / 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).

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.