1 Overview
NIP-15 defines a full ecommerce protocol. A merchant lists a shop as a set of Nostr events. A customer browses that shop, places an order, pays, and gets shipping updates, all through signed events and encrypted direct messages.
The design is based on Diagon-Alley, an early lnbits shop plugin. Two open-source projects implement it: NostrMarket and Plebeian Market.
A merchant publishes a stall: a named list of products with a currency and shipping zones. Inside a stall, the merchant publishes individual products for sale. A marketplace client subscribes to one or more merchant public keys, lists their stalls and products, and gives the customer a basket and a checkout flow, much like an ordinary online shop.
NIP-15 is unrecommended. The spec itself calls it too complicated. Use NIP-99, Classified Listings, instead for new work. Read this chapter to understand deployed marketplaces built on NIP-15, not to build new ones.
Two Kinds of Client
NIP-15 describes two separate pieces of software:
| Client | Used by | Job |
|---|---|---|
| Merchant admin | merchant | Create, update, and delete stalls and products. Manage sales, payments, and messages with customers. Can be client-only, but a always-on server component helps catch orders while the merchant is offline. |
| Marketplace | customer | Browse stalls and products from one or more merchants. Hold a basket. Run checkout. Entirely client-side: a stand-alone app or a plain front-end webpage. |
2 Roles and Vocabulary
This chapter uses five terms. Learn them before reading the event formats.
| Term | Meaning |
|---|---|
| merchant | Seller of products. Identified by a Nostr keypair. |
| customer | Buyer of products. Identified by a Nostr keypair. |
| product | One item for sale, listed by a merchant. |
| stall | A list of products controlled by one merchant. A merchant may run several stalls. |
| marketplace | Client-side software that searches stalls and lets a customer buy products. |
3 Merchant Events
A merchant publishes five kinds of event. Each one plays a distinct role in the shop:
| Kind | Name | Description |
|---|---|---|
| 0 | set_meta | The merchant's profile metadata. Same kind-0 event every Nostr user has. |
| 30017 | set_stall | Create or update a stall. Addressable event. |
| 30018 | set_product | Create or update a product. Addressable event. |
| 4 | direct_message | Talk to the customer. Content is plain text or a checkout JSON message. See §6. |
| 5 | delete | Delete a product or a stall. The regular NIP-09 deletion request. |
Kinds 30017 and 30018 sit in the addressable range. A relay keeps only the latest event for a given merchant pubkey, kind, and d tag value. Publishing a new version with the same d tag replaces the old one.
4 Stall Event (kind 30017)
A stall groups products under one name, one currency, and one set of shipping zones.
{
"id": <string, merchant-generated stall id. Avoid sequential ids like "0", "1", "2">
"name": <string, stall name>
"description": <string, optional, stall description>
"currency": <string, currency used by this stall>
"shipping": [
{
"id": <string, merchant-generated shipping zone id>
"name": <string, optional, zone name>
"cost": <float, base shipping cost in the stall currency>
"regions": [<string, region names covered by this zone>]
}
]
}
| Field | Type | Rule |
|---|---|---|
| id | string | Merchant-chosen. Sequential ids are discouraged. |
| name | string | Stall name shown to customers. |
| description | string | Optional. Free text. |
| currency | string | Applies to the stall and to every product in it. |
| shipping | array of zone objects | Possible shipping zones. The customer must pick exactly one at checkout. |
| shipping[].id | string | Internal value chosen by the merchant. The customer's checkout order must send this id back unchanged. |
| shipping[].cost | float | Base cost for shipping to this zone. Use zero for goods with no shipping cost, such as digital items. |
| shipping[].regions | array of strings | The regions this zone covers. Different zones may cost different amounts. |
{
"tags": [["d", <string, stall id>]]
}
The d tag is required. Its value must equal the stall's id field. This is what makes the event addressable: a later 30017 event with the same d value replaces this one.
5 Product Event (kind 30018)
A product belongs to exactly one stall and carries its own price, quantity, and specs.
{
"id": <string, merchant-generated product id. Sequential ids discouraged>
"stall_id": <string, id of the parent stall>
"name": <string, product name>
"description": <string, optional, product description>
"images": <array of image URLs, optional>
"currency": <string, currency used>
"price": <float, cost of one unit>
"quantity": <int or null, units available>
"specs": [
[<string, spec key>, <string, spec value>]
]
"shipping": [
{
"id": <string, must match a shipping zone id from the stall>
"cost": <float, extra shipping cost for this product>
}
]
}
| Field | Type | Rule |
|---|---|---|
| id | string | Merchant-chosen. Sequential ids are discouraged. |
| stall_id | string | Must equal the id of an existing stall event by the same merchant. |
| images | array of strings | Optional. Image URLs. |
| price | float | Cost of one unit, in the stall's currency. |
| quantity | int or null | Units in stock. Use null for unlimited items, such as digital goods or services. |
| specs | array of [key, value] pairs | Optional. Structured specifications so a marketplace client can display and compare products, e.g. [["operating_system", "Android 12.0"], ["screen_size", "6.4 inches"]]. |
| shipping | array of zone-cost objects | Optional. Extra shipping cost on top of the stall's base cost, for products that cost more to ship. |
| shipping[].id | string | Must match one of the stall's shipping zone ids. |
| shipping[].cost | float | Extra cost, added per unit ordered. |
To total the shipping cost for an order, add two numbers: the stall's base cost for the zone the customer chose, plus the product's per-unit shipping cost from this table, multiplied by the units ordered.
{
"tags": [
["d", <string, product id>],
["t", <string, optional, product category>],
["t", <string, optional, another product category>]
]
}
The d tag is required. Its value must equal the product's id field. The t tag is a searchable category, such as food or fruits. A product may carry several t tags.
The source spec leaves two questions open and never resolves them. First, it asks whether specs should move into the tags array instead of staying in content, so relays could index it. Second, the customer order message (§6) has a name field marked only "???" in the source, with no stated purpose. Treat both as unresolved; do not assume behavior beyond what is written here.
6 Checkout Messages
Checkout runs as a private conversation. The merchant and customer exchange kind-4 events, encrypted with NIP-04. Every checkout message is a JSON object placed in the encrypted content field. Every message carries a type field that says what it represents.
| Type | Sent by | Meaning |
|---|---|---|
| 0 | Customer | New order. |
| 1 | Merchant | Payment request. |
| 2 | Merchant | Order status update. |
Type 0 - Customer Order
The customer sends this first, listing chosen products, quantities, and a shipping zone.
{
"id": <string, customer-generated order id>
"type": 0
"name": <string, optional>
"address": <string, optional, required in practice for physical goods>
"message": <string, optional, note for the merchant>
"contact": {
"nostr": <32-byte hex pubkey>
"phone": <string, optional>
"email": <string, optional>
}
"items": [
{ "product_id": <string>, "quantity": <int> }
]
"shipping_id": <string, id of the chosen shipping zone>
}
Type 1 - Merchant Payment Request
The merchant replies with one or more ways to pay. The customer picks one and pays outside Nostr.
{
"id": <string, order id this replies to>
"type": 1
"message": <string, optional>
"payment_options": [
{ "type": <string, option type>, "link": <string, url, address, or invoice> }
]
}
| Type | Meaning |
|---|---|
| url | Link to a payment page: Stripe, PayPal, BTCPay Server, and similar. |
| btc | An on-chain bitcoin address. |
| ln | A bitcoin Lightning invoice. |
| lnurl | A bitcoin lnurl-pay string. |
Any payment method the merchant can verify is valid. The spec does not limit this list to the four types above.
Type 2 - Merchant Status Update
The merchant sends this once payment is confirmed, and again when the order ships.
{
"id": <string, order id this replies to>
"type": 2
"message": <string, note for the customer>
"paid": <bool, payment received>
"shipped": <bool, order shipped>
}
Checkout messages are defined only as NIP-04 encrypted content. NIP-04 is itself deprecated in favor of NIP-17. The source spec was never updated to reflect this, so a compliant NIP-15 checkout still leaks sender, receiver, and timing metadata to every relay involved, on top of NIP-15's own complexity problems.
7 Marketplace Customization (kind 30019)
A market operator can group several merchants under one branded storefront, and share it as a single NIP-19 naddr string. The naddr carries the event coordinates plus extra metadata, such as recommended relays.
{
"name": <string, optional, market name>
"about": <string, optional, market description>
"ui": {
"picture": <string, optional, logo image URL>
"banner": <string, optional, banner image URL>
"theme": <string, optional, theme name>
"darkMode": <bool>
}
"merchants": [<array of pubkeys, optional>]
}
A marketplace client reads this event to theme itself: market name, description, logo, banner, and which merchant pubkeys to list.
8 Auctions
NIP-15 also supports selling a product at auction instead of a fixed price. An auction listing looks like a product, with bidding fields instead of a price.
Event 30020 - Auction Product
{
"id": <string, UUID, merchant-generated. Sequential ids discouraged>
"stall_id": <string, UUID of the parent stall>
"name": <string, product name>
"description": <string, optional>
"images": <array of image URLs, optional>
"starting_bid": <int>
"start_date": <int, optional, unix timestamp the auction starts>
"duration": <int, seconds the auction runs, before extensions>
"specs": [[<string, spec key>, <string, spec value>]]
"shipping": [
{ "id": <string, UUID, must match a stall shipping zone>, "cost": <float> }
]
}
Fields work the same as a fixed-price product, with two additions. start_date may be left out when the start date is unknown or hidden; the merchant edits the event later to set it. duration counts seconds from start_date, not counting any later extension.
An auction is an addressable event, so the merchant can edit it freely before the first bid arrives. After the first bid, do not edit it again. A bid references the auction's event id, not its stall or product UUID. Editing the auction creates a new event id, and every bid made against the old id becomes orphaned.
Event 1021 - Bid
{
"content": <int, bid amount, in sats>
"tags": [["e", <event id of the kind-30020 auction>]]
}
A bid is a regular event. Its content is the bid amount as a plain integer, in the currency of the auction. Every bid must reference exactly one auction with an e tag.
Event 1022 - Bid Confirmation
A bid only counts once the merchant confirms it. A marketplace client should treat unconfirmed bids as not yet valid.
{
"content": {
"status": <string, one of "accepted", "rejected", "pending", "winner">
"message": <string, optional>
"duration_extended": <int, optional, seconds added to the auction>
}
"tags": [
["e", <event id of the bid being confirmed>],
["e", <event id of the auction>]
]
}
| Status | Meaning |
|---|---|
| accepted | The bid is valid and stands. |
| pending | Not yet approved. May still be approved after more steps, such as extra verification of the bidder. |
| rejected | Refused. Cannot later be approved. The optional message field may explain why. |
| winner | Sent to the winning bid once the auction ends and the merchant picks a winner. |
A marketplace client should subscribe to kind 1022 for every auction it follows, alongside the kind 1021 bids. It must also check that the confirmation's pubkey matches the merchant's pubkey, in addition to checking the event signature.
A merchant may extend an auction close to its end date by sending a confirmation with duration_extended. The real end time is always start_date + duration, plus the sum of every duration_extended value sent across all confirmations for that auction.
9 Customer Support
Support conversations after a sale use whatever channel the merchant listed. If that channel is Nostr, use NIP-04 encrypted messages, the same as checkout.
10 Worked Example
A merchant lists a stall and a product, then a customer buys it. Orange lines are published by the sender shown in the comment. Checkout content is shown decrypted for clarity; on the wire it is NIP-04 ciphertext.
-- Merchant publishes a stall -- → ["EVENT", {"id":"a11f02…","pubkey":"merch0f7…","kind":30017, "tags":[["d","stall-fruit-01"]], "content":"{\"id\":\"stall-fruit-01\",\"name\":\"Fresh Fruit Stand\", \"currency\":\"sat\", \"shipping\":[{\"id\":\"zone-eu\",\"name\":\"Europe\",\"cost\":1500, \"regions\":[\"EU\"]}]}", "sig":"9a2c1e…"}] ← ["OK", "a11f02…", true, ""] -- Merchant publishes a product in that stall -- → ["EVENT", {"id":"b220d4…","pubkey":"merch0f7…","kind":30018, "tags":[["d","prod-apples-01"],["t","food"],["t","fruits"]], "content":"{\"id\":\"prod-apples-01\",\"stall_id\":\"stall-fruit-01\", \"name\":\"Apples, 1kg\",\"currency\":\"sat\",\"price\":900, \"quantity\":40,\"shipping\":[{\"id\":\"zone-eu\",\"cost\":0}]}", "sig":"7fd310…"}] ← ["OK", "b220d4…", true, ""] -- Customer orders 2kg of apples, via a NIP-04 kind-4 DM to the merchant -- -- plaintext content before encryption: -- {"id":"ord-77x","type":0,"address":"12 Market Row", "contact":{"nostr":"cust99a…"}, "items":[{"product_id":"prod-apples-01","quantity":2}], "shipping_id":"zone-eu"} → ["EVENT", {"id":"c33ab1…","pubkey":"cust99a…","kind":4, "tags":[["p","merch0f7…"]],"content":"Bq7f==?iv=Nn3xR7==","sig":"1e88ac…"}] ← ["OK", "c33ab1…", true, ""] -- Merchant replies with a payment request -- -- plaintext: {"id":"ord-77x","type":1,"payment_options": -- [{"type":"ln","link":"lnbc18u1p3xyz…"}]} → ["EVENT", {"id":"d4412c…","pubkey":"merch0f7…","kind":4, "tags":[["p","cust99a…"],["e","c33ab1…"]], "content":"u3f9skA2==?iv=q8x2Lp0zK1==","sig":"3d8e2f…"}] ← ["OK", "d4412c…", true, ""] -- Merchant confirms payment, then later confirms shipping -- -- plaintext: {"id":"ord-77x","type":2,"paid":true,"shipped":false} → ["EVENT", {"id":"e551fd…","pubkey":"merch0f7…","kind":4, "tags":[["p","cust99a…"],["e","c33ab1…"]], "content":"z9y1Nn3xR7==?iv=A2q8x2Lp==","sig":"6b0a4d…"}] ← ["OK", "e551fd…", true, ""] -- plaintext: {"id":"ord-77x","type":2,"paid":true,"shipped":true} → ["EVENT", {"id":"f66a0e…","pubkey":"merch0f7…","kind":4, "tags":[["p","cust99a…"],["e","c33ab1…"]], "content":"Lp0zK1u3f9sk==?iv=x2R7z9y1==","sig":"a19c7e…"}] ← ["OK", "f66a0e…", true, ""]
11 Version History
The number 15 was used twice. The first NIP-15, "End of Stored Events Notice" by Semisol, was drafted 2022-05-26 and merged into NIP-01 on 2023-04-09 (that merge is the origin of the EOSE message defined in NIP-01 §5). Four days later, on 2023-04-13, the freed number 15 was reused for this unrelated Nostr Marketplace spec. The two documents share no content. The history below covers only the Marketplace spec.
Notable changes to the Marketplace spec, newest first:
- 20262026-07-26Reworded the deprecation notice to name NIP-99 explicitly.
- 20262026-05-31Marked the spec
unrecommendedand added the deprecation warning pointing to NIP-99. - 20242024-08-20Renamed "parameterized replaceable event" to "addressable event" throughout, matching the NIP-01 terminology change.
- 20242024-01-09Added the entire Auctions section: event 30020, kind 1021 bids, and kind 1022 bid confirmations (PR #859).
- 20232023-10-31Clarified that
quantity: nullmeans unlimited availability, for digital items and services. - 20232023-09-08Added the
descriptionfield to the kind 30019 marketplace customization event (PR #768). - 20232023-08-15Renamed shipping zone "countries" to "regions" (PR #465).
- 20232023-08-10Added optional per-product shipping cost, on top of the stall's base cost (ktecho).
- 20232023-11-15Repo-wide cleanup removed the per-file author badges from the spec text.
- Origin2023-04-13Arc (benarc), with Andrew Camilleri and Vlad Stan, published Nostr Marketplace, based on the lnbits Diagon-Alley plugin (PR #330), reusing the number freed four days earlier.
Original Proposal - Credits
| Author | Arc (benarc), based on Diagon-Alley by lnbits |
| First commit | 2023-04-13 — “NIP-15 Nostr marketplace (#330)” |
| Status | draft unrecommended — replaced by NIP-99 |
| Source | 15.md on GitHub - full change history |
12 See Also
- NIP-99 — Classified Listings. The recommended replacement for this spec.
- NIP-01 — addressable events, the storage class used by kinds 30017, 30018, 30019, and 30020.
- NIP-04 — encrypted direct messages, used for all checkout and support communication. Itself deprecated.
- NIP-17 — private direct messages, the recommended replacement for NIP-04.
- NIP-09 — event deletion, used for kind 5 to remove a stall or product.
- NIP-19 — bech32 encodings, source of the
naddrused to share a customized marketplace.