Chapter 9
Zaps
Tip anyone, anywhere, in seconds: how Lightning payments and Nostr events shake hands, and what a zap receipt really proves.
Let's follow one tip from a single tap, through a wallet server, Lightning and the relays, to the ⚡ counter under a post.
Imagine a street musician. You like the song, so you drop a coin in their hat. Everybody nearby sees the coin land, and the musician smiles. Simple.
On the internet, that “coin in the hat” has always been awkward: card fees eat tiny tips, payment
sites need accounts, and the platform takes a cut. Nostr’s answer is the
Here’s the twist that makes zaps interesting: relays never touch the money. A relay only
stores
The four players
- The sender’s app: the Nostr client you’re tapping ⚡ in, connected to your Lightning wallet.
- The recipient’s wallet server: the company or self-hosted box that receives Lightning
payments for the recipient. It speaks
, a simple web protocol for “how do I pay you?”.LNURL LNURL: A set of HTTP conventions (the LUD specs) that let a wallet ask a server for a fresh Lightning invoice. - The Lightning network: where the money actually moves.
- Relays: where the zap gets announced, so every app can show it.
Press play (or step with the arrows) and watch Alice zap Erin’s ostrich drawing. Each step shows exactly what travels between the players.
Four players, ten steps. Step through how a tip travels from the sender's app, through the recipient's wallet server and the Lightning network, and back onto Nostr relays as a receipt.
1. Find the Lightning address. The app reads Erin's profile (kind 0). Its lud16 field holds a Lightning address that looks like an email: erin@wallet.alpha.example.
Text version
- 1. Find the Lightning address. The app reads Erin's profile (kind 0). Its lud16 field holds a Lightning address that looks like an email: erin@wallet.alpha.example.
- 2. Knock on the wallet's door. The address turns into a plain web URL. The app asks that wallet server: “how do I pay Erin?”
- 3. The wallet says “I speak Nostr”. The answer includes allowsNostr: true and a nostrPubkey: the key this wallet will use to sign receipts. Remember it — we check it later!
- 4. Write and sign a zap request. Alice's app writes a little signed note: who gets paid, how much, which post, a comment, and which relays should hear about it.
- 5. Hand the request to the wallet. The zap request is NOT posted to relays. It rides along in a web request straight to the wallet server's callback URL.
- 6. Get a Lightning invoice. The server checks the request and returns an invoice for 2,100 sats. The zap request is baked into the invoice's description, so the two are tied together.
- 7. Pay!. Alice's wallet pays the invoice over the Lightning network. Money moves in seconds, without touching any relay.
- 8. The payment settles. The wallet server sees its invoice paid. The secret preimage it reveals is Lightning's “paid” stamp.
- 9. The wallet publishes a zap receipt. The wallet server signs a zap receipt with its nostrPubkey and sends it to the relays listed in the request.
- 10. Everyone sees the zap. Apps load receipts from relays, check them, and add them up. Erin's post now shows ⚡ 2,100 sats.
1. Find the Lightning address
The app reads Erin's profile (kind 0). Its lud16 field holds a Lightning address that looks like an email: erin@wallet.alpha.example.
What travels on this step
{ "name": "erin" ,"display_name": "Erin" ,"about": "Artist. Drawing one weird animal per day. Zaps keep me caffeinated." ,"nip05": "erin@alpha.example" ,"lud16": "erin@wallet.alpha.example" } Step 1: finding the hat
To tip someone you need to know where their hat is. On Nostr, it’s in their profile: the
erin@wallet.alpha.example, but it’s really a shortcut for a web URL. Try it:
Lightning address → wallet URL
A lud16 Lightning address is just a short way to write a web URL. Type one to see the URL an app would fetch.
https://wallet.alpha.example/.well-known/lnurlp/erinThe app fetches that URL and the wallet server replies with its payment details. Two fields in the
reply matter for zaps: allowsNostr: true (“yes, I understand zaps”) and nostrPubkey, the
Under the hood: Lightning addresses and LNURL Show details
LUD-16 → LUD-06. A Lightning address name@domain maps to
https://domain/.well-known/lnurlp/name (LUD-16).
The response is an LNURL-pay (LUD-06) document;
{
"callback": "https://wallet.alpha.example/lnurlp/erin/callback",
"minSendable": 1000,
"maxSendable": 100000000000,
"metadata": "[[\"text/plain\",\"Zap Erin\"],[\"text/identifier\",\"erin@wallet.alpha.example\"]]",
"tag": "payRequest",
"allowsNostr": true,
"nostrPubkey": "044b2cc412cc89149bc4f38a5067db6034dbaafe20321721a5414bb7237176f4"
}Amounts are in millisatoshis (1 sat = 1000 msat). nostr-tools does the lookup for you:
import { nip57 } from "nostr-tools";
// metadataEvent = the recipient's kind 0
const callback = await nip57.getZapEndpoint(metadataEvent); // null if !allowsNostrSteps 2–6: the paperwork
Before paying, Alice’s app writes a zap request: a tiny note that says “I, Alice, want to
zap Erin 2,100 sats for this post, with the comment ‘Love the ostrich! ⚡’. Please announce it on
these relays.” Alice
Surprise: this note is not posted to relays. The app hands it straight to Erin’s wallet server
in a web request. The wallet server checks it, then creates a Lightning
Under the hood: the zap request Show details
The zap request (kind 9734). Required tags: p (recipient) and relays (where the receipt
should go). Recommended but optional: amount (msat, as a string) and lnurl. Optional: e (zapped event),
a (zapped addressable event), k (its kind). content is an optional comment.
{
"kind": 9734,
"content": "Love the ostrich! ⚡",
"tags": [
["relays", "wss://relay.alpha.example", "wss://relay.gamma.example"],
["amount", "2100000"],
["p", "c71750007e42443e5ca8c1ea00babed4e8c78a9cb45dd6db3733d918e0b55deb"],
["e", "def4b79eea11b4d6c0caa6da27dedc1ff5fc274a3378c64193f21aa05125d356"]
],
"pubkey": "e550c6e92808f358382b7b8c14345344f5ef9a299529834d1c26d57b6ace44cc",
"created_at": 1735684800,
"id": "24539a56…",
"sig": "b5beae9e…"
}It is sent with an HTTP GET to the callback, URI-encoded in the nostr query parameter:
<callback>?amount=2100000&nostr=%7B%22kind%22%3A9734…&lnurl=<lnurl>.
The server must check: valid signature, tags present, exactly one p, zero or one e, a
relays tag, amount equal to the query amount, at most one P. It replies { "pr": "<bolt11>" }:
a description-hash invoice whose description is the zap request JSON.
import { finalizeEvent, nip57 } from "nostr-tools";
const template = nip57.makeZapRequest({
event: erinsPost, // or { pubkey } for a profile zap
amount: 2_100_000, // msat
comment: "Love the ostrich! ⚡",
relays: ["wss://relay.alpha.example"],
});
const zapRequest = finalizeEvent(template, aliceSecretKey);
const url = `${callback}?amount=2100000&nostr=${encodeURIComponent(JSON.stringify(zapRequest))}`;
const { pr: invoice } = await (await fetch(url)).json();Steps 7–10: money moves, then the announcement
Alice’s wallet pays the invoice over Lightning. Relays don’t see this at all; it’s pure
Lightning. When the payment arrives, Erin’s wallet server writes a zap receipt (kind 9735),
signs it with its nostrPubkey, and publishes it to the relays Alice listed. Every app that loads
Erin’s post finds the receipt, checks it, and adds ⚡ 2,100 to the counter.
Under the hood: the zap receipt Show details
The zap receipt (kind 9735) is signed by the wallet server, not by Erin or Alice.
Tags: p (recipient), optional P (sender), e/a/k copied from the request, bolt11 (the
invoice), description (the zap request JSON, verbatim) and optional preimage.
created_at should be the invoice’s paid-at time.
{
"kind": 9735,
"pubkey": "044b2cc412cc89149bc4f38a5067db6034dbaafe20321721a5414bb7237176f4",
"content": "",
"tags": [
["p", "c71750007e42443e5ca8c1ea00babed4e8c78a9cb45dd6db3733d918e0b55deb"],
["P", "e550c6e92808f358382b7b8c14345344f5ef9a299529834d1c26d57b6ace44cc"],
["e", "def4b79eea11b4d6c0caa6da27dedc1ff5fc274a3378c64193f21aa05125d356"],
["bolt11", "lnbc21000n1p…"],
["description", "{\"kind\":9734,\"content\":\"Love the ostrich! ⚡\",…}"],
["preimage", "0e03fe78…"]
],
"created_at": 1735684802
}To show zaps on a post, subscribe with a NIP-01 filter:
["REQ", "zaps", { "kinds": [9735], "#e": ["def4b79eea11b4d6c0caa6da27dedc1ff5fc274a3378c64193f21aa05125d356"] }]lnbc21000n is the amount: 21000 × n (nano-BTC, 0.1 sat) = 2,100 sats.
nip57.getSatoshisAmountFromBolt11(invoice) decodes it for you.
Zaps are public: the receipt, with the zap request inside it, shows who zapped whom, how much, and the comment. Think “coin in the hat on a busy street”, not “private bank transfer”.
Can you trust a zap?
Here’s the honest part. Anyone can publish a kind 9735 event. It’s just an event. So before an app adds a zap to the counter it checks the receipt:
- Is the receipt’s signature valid?
- Was it signed by the recipient’s wallet server, the
nostrPubkeywe saw in step 1? - Does it contain a real, signed zap request?
- Does the amount in the invoice match the amount Alice asked for?
These checks catch tampering and impostors. But look closely at the last scenario below: a dishonest wallet server can sign a perfect-looking receipt for an invoice nobody paid. Every check passes!
Can you trust this receipt?
Anyone can publish a kind 9735 event. Pick a scenario and watch the checks an app runs before it counts a zap.
Checks
- Receipt signature is valid — passed
- Signed by the wallet's nostrPubkey — passed
- Embedded zap request is a validly signed kind 9734 — passed
- Receipt and request name the same recipient — passed
- Invoice amount matches the requested amount — passed
The receipt being checked
{ "id": "7682a16d40733fd532c600fc001391fdb208a9a6037f88d1f1cad2f1694ac347" ,"pubkey": "044b2cc412cc89149bc4f38a5067db6034dbaafe20321721a5414bb7237176f4" ,"created_at": 1735684802 ,"kind": 9735 , "tags": [ [ ] , [ ] , [ ] , [ ] , [ ] , [ ] ] ,"content": "" ,"sig": "2e584640f02b15def129e281551368b07bfcd0531c652a0f15a294fa1823bf6925eda9dc2e5c23c14ceab38865e14deb2e9ba682ab62722856a706f79cb35434" } Honest wallet: 5 of 5 checks passed. Counts as a zap ✓
That’s the trust model in one sentence: a zap receipt is the recipient’s wallet provider saying “this was paid.” It proves someone requested an invoice, and the wallet vouches for the rest. Usually that’s fine: the wallet is chosen by the recipient, and lying would only fake their own income. Still, it’s why zap counts are social signals, not accounting records. The only people who know for sure a payment happened are the payer (who holds the preimage) and the recipient (whose balance went up).
Under the hood: validating receipts Show details
Client-side validation (NIP-57, Appendix F):
- the receipt
pubkeyMUST equal the recipient’s LNURLnostrPubkey; - the
bolt11invoice amount MUST equal the request’samounttag (if present); - the request’s
lnurltag SHOULD match the recipient’s lnurl.
Appendix E adds that SHA256(description) should match the invoice’s description hash, which
glues the invoice to the exact zap request.
NIP-57 says it plainly: a zap receipt is “not a proof of payment”; it only shows that
someone fetched an invoice. A preimage tag doesn’t help either: the wallet that created the
invoice knows the preimage before anyone pays.
Related: events can carry zap tags to split zaps between several recipients (NIP-57
Appendix G), and NIP-47 (Nostr
Wallet Connect) lets apps talk to your wallet so zapping is one tap.
import { nip57 } from "nostr-tools";
// Throws away receipts that fail the basics before you count them.
const request = JSON.parse(receipt.tags.find((t) => t[0] === "description")?.[1] ?? "null");
const err = nip57.validateZapRequest(JSON.stringify(request)); // null when the request is OK
const okSigner = receipt.pubkey === lnurlPayResponse.nostrPubkey;
const sats = nip57.getSatoshisAmountFromBolt11(receipt.tags.find((t) => t[0] === "bolt11")?.[1] ?? "");Everything on this page is demo data: fake wallets, fake invoices, no real sats. Nostr School never moves money.
Quick check
Takeaways
- A zap is a Lightning payment plus two Nostr events: a zap request (kind 9734) signed by the sender, and a zap receipt (kind 9735) signed by the recipient's wallet server.
- Your Lightning address lives in the lud16 field of your kind 0 profile and is just a short way to write a web URL (LUD-16).
- The zap request travels over HTTP to the wallet server, never to relays; only the receipt is published to relays.
- Apps check a receipt's signature, its signer (the wallet's nostrPubkey) and the invoice amount before counting it.
- A zap receipt is the wallet server's word, not a proof of payment: you trust the recipient's wallet provider.