Chapter 15 - Commerce
15
Nostr Implementation Possibility - Hex № 15

Nostr Marketplace

Stalls, products, checkout messages, and auctions for building an ecommerce marketplace on top of relays.

Deprecated

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.

Deprecated

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 roles
ClientUsed byJob
Merchant adminmerchantCreate, 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.
MarketplacecustomerBrowse 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.
Merchant admin client Relays Marketplace customer client 30017 / 30018 REQ kinds 30017, 30018
Fig. 15-1 — Shop architecture. The merchant admin publishes stall and product events. Any marketplace client can subscribe and build a storefront from them.

2 Roles and Vocabulary

This chapter uses five terms. Learn them before reading the event formats.

NIP-15 terms
TermMeaning
merchantSeller of products. Identified by a Nostr keypair.
customerBuyer of products. Identified by a Nostr keypair.
productOne item for sale, listed by a merchant.
stallA list of products controlled by one merchant. A merchant may run several stalls.
marketplaceClient-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:

Merchant event kinds
KindNameDescription
0set_metaThe merchant's profile metadata. Same kind-0 event every Nostr user has.
30017set_stallCreate or update a stall. Addressable event.
30018set_productCreate or update a product. Addressable event.
4direct_messageTalk to the customer. Content is plain text or a checkout JSON message. See §6.
5deleteDelete 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.

Event content - kind 30017
{
  "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>]
    }
  ]
}
Stall content fields
FieldTypeRule
idstringMerchant-chosen. Sequential ids are discouraged.
namestringStall name shown to customers.
descriptionstringOptional. Free text.
currencystringApplies to the stall and to every product in it.
shippingarray of zone objectsPossible shipping zones. The customer must pick exactly one at checkout.
shipping[].idstringInternal value chosen by the merchant. The customer's checkout order must send this id back unchanged.
shipping[].costfloatBase cost for shipping to this zone. Use zero for goods with no shipping cost, such as digital items.
shipping[].regionsarray of stringsThe regions this zone covers. Different zones may cost different amounts.
Event tags - kind 30017
{
  "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.

Event content - kind 30018
{
  "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>
    }
  ]
}
Product content fields
FieldTypeRule
idstringMerchant-chosen. Sequential ids are discouraged.
stall_idstringMust equal the id of an existing stall event by the same merchant.
imagesarray of stringsOptional. Image URLs.
pricefloatCost of one unit, in the stall's currency.
quantityint or nullUnits in stock. Use null for unlimited items, such as digital goods or services.
specsarray of [key, value] pairsOptional. Structured specifications so a marketplace client can display and compare products, e.g. [["operating_system", "Android 12.0"], ["screen_size", "6.4 inches"]].
shippingarray of zone-cost objectsOptional. Extra shipping cost on top of the stall's base cost, for products that cost more to ship.
shipping[].idstringMust match one of the stall's shipping zone ids.
shipping[].costfloatExtra 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.

Event tags - kind 30018
{
  "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.

Tech Note

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.

Checkout message types
TypeSent byMeaning
0CustomerNew order.
1MerchantPayment request.
2MerchantOrder status update.

Type 0 - Customer Order

The customer sends this first, listing chosen products, quantities, and a shipping zone.

Checkout message - type 0, encrypted content
{
  "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.

Checkout message - type 1, encrypted content
{
  "id":      <string, order id this replies to>
  "type":    1
  "message": <string, optional>
  "payment_options": [
    { "type": <string, option type>, "link": <string, url, address, or invoice> }
  ]
}
Payment option types
TypeMeaning
urlLink to a payment page: Stripe, PayPal, BTCPay Server, and similar.
btcAn on-chain bitcoin address.
lnA bitcoin Lightning invoice.
lnurlA 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.

Checkout message - type 2, encrypted content
{
  "id":      <string, order id this replies to>
  "type":    2
  "message": <string, note for the customer>
  "paid":    <bool, payment received>
  "shipped": <bool, order shipped>
}
Customer Merchant kind 4 - type 0 - New Order kind 4 - type 1 - Payment Request Customer pays via chosen option - btc, ln, lnurl, or url - off relay kind 4 - type 2 - Status: paid=true, shipped=false kind 4 - type 2 - Status: paid=true, shipped=true All four messages are NIP-04 encrypted kind-4 events between the two pubkeys.
Fig. 15-2 — Checkout flow. Customer and merchant trade four encrypted messages: order, payment request, then two status updates.
Tech Note

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.

Event content - kind 30019
{
  "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

Event content - kind 30020
{
  "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.

Warning!

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

Event - kind 1021
{
  "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.

Event content and tags - kind 1022
{
  "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>]
  ]
}
Bid confirmation status values
StatusMeaning
acceptedThe bid is valid and stands.
pendingNot yet approved. May still be approved after more steps, such as extra verification of the bidder.
rejectedRefused. Cannot later be approved. The optional message field may explain why.
winnerSent 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.

Bidder Relay Merchant Watchers 1021 bid 1022 confirm
Fig. 15-3 — Auction bidding. Bids and confirmations are both regular events that flow through the same relays as any other Nostr event.

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.

Session transcript - wss://relay.example.com
-- 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

Tech Note

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 unrecommended and 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: null means unlimited availability, for digital items and services.
  • 20232023-09-08Added the description field 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

AuthorArc (benarc), based on Diagon-Alley by lnbits
First commit2023-04-13 — “NIP-15 Nostr marketplace (#330)”
Statusdraft unrecommended — replaced by NIP-99
Source15.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 naddr used to share a customized marketplace.