Build Nostr marketplace applications using NIP-15 stalls, products, auctions, and NIP-69 P2P orders. Use when creating e-commerce on Nostr, building product listings, setting up stalls with shipping zones, implementing auction bidding systems, constructing checkout flows with NIP-04 DMs, creating P2P trading orders, or generating marketplace UI configurations. Covers kind:30017 stalls, kind:30018 products, kind:30020 auctions, kind:1021 bids, kind:1022 bid confirmations, kind:30019 marketplace UI, and kind:38383 P2P orders.
Construct correct Nostr marketplace events for commerce applications. This skill handles the non-obvious parts: stall/product relationships, shipping cost calculations, auction lifecycle rules, checkout message sequencing via NIP-04, and P2P order tag structures from NIP-69.
Do NOT use when:
| Intent | Kind(s) | NIP |
|---|---|---|
| Create/update a merchant stall | 30017 | NIP-15 |
| List a product for sale | 30018 | NIP-15 |
| Configure marketplace UI | 30019 | NIP-15 |
| Create an auction listing | 30020 | NIP-15 |
| Place a bid on an auction | 1021 | NIP-15 |
| Confirm/reject a bid | 1022 | NIP-15 |
| Checkout (order/pay/status) | NIP-04 DMs | NIP-15 |
| P2P buy/sell order | 38383 | NIP-69 |
The stall is the merchant's store. Products belong to stalls. Shipping zones are defined at the stall level with base costs.
{
"kind": 30017,
"tags": [["d", "my-stall-001"]],
"content": "{\"id\":\"my-stall-001\",\"name\":\"Satoshi's Electronics\",\"description\":\"Quality electronics for sats\",\"currency\":\"USD\",\"shipping\":[{\"id\":\"us-domestic\",\"name\":\"US Domestic\",\"cost\":5.99,\"regions\":[\"US\"]},{\"id\":\"worldwide\",\"name\":\"Worldwide\",\"cost\":15.00,\"regions\":[\"EU\",\"AS\",\"SA\"]}]}"
}
Rules:
d tag value MUST equal the id in content JSONid should be a UUID or descriptive slug — sequential IDs (0, 1, 2) are
discouragedcurrency is a string (e.g., "USD", "BTC", "EUR")shipping array defines zones; customer must choose exactly one zone at
checkoutcost in the stall's currencyProducts belong to a stall via stall_id. They can add per-product shipping
costs on top of the stall's base shipping cost.
{
"kind": 30018,
"tags": [
["d", "product-xyz-789"],
["t", "electronics"],
["t", "gadgets"]
],
"content": "{\"id\":\"product-xyz-789\",\"stall_id\":\"my-stall-001\",\"name\":\"Lightning Node Kit\",\"description\":\"Pre-configured Bitcoin node\",\"images\":[\"https://example.com/node.jpg\"],\"currency\":\"USD\",\"price\":299.99,\"quantity\":25,\"specs\":[[\"processor\",\"ARM Cortex-A72\"],[\"storage\",\"1TB SSD\"],[\"connectivity\",\"Ethernet + WiFi\"]],\"shipping\":[{\"id\":\"us-domestic\",\"cost\":10.00},{\"id\":\"worldwide\",\"cost\":25.00}]}"
}
Rules:
d tag value MUST equal the id in content JSONstall_id MUST reference an existing stall's idquantity: integer for limited stock, null for unlimited (digital
goods/services)t tags are searchable categories — use multiple for discoverabilityspecs is an array of [key, value] pairs for structured displayshipping[].id MUST match a zone id from the parent stallCustom marketplace configuration grouping merchants together:
{
"kind": 30019,
"tags": [["d", "my-marketplace"]],
"content": "{\"name\":\"Bitcoin Bazaar\",\"about\":\"A curated marketplace for Bitcoin products\",\"ui\":{\"picture\":\"https://example.com/logo.png\",\"banner\":\"https://example.com/banner.jpg\",\"theme\":\"dark\",\"darkMode\":true},\"merchants\":[\"pubkey1hex...\",\"pubkey2hex...\"]}"
}
Auctions are similar to products but with bidding mechanics:
{
"kind": 30020,
"tags": [["d", "auction-rare-item-001"]],
"content": "{\"id\":\"auction-rare-item-001\",\"stall_id\":\"my-stall-001\",\"name\":\"Signed Hal Finney Print\",\"description\":\"Limited edition signed print\",\"images\":[\"https://example.com/print.jpg\"],\"starting_bid\":50000,\"start_date\":1719391096,\"duration\":86400,\"specs\":[[\"condition\",\"mint\"],[\"authentication\",\"verified\"]],\"shipping\":[{\"id\":\"worldwide\",\"cost\":20.00}]}"
}
Rules:
starting_bid is in the stall's currency (integer)start_date is a Unix timestamp; omit if start date is unknownduration is seconds the auction runs (excluding extensions)start_date + duration + SUM(all duration_extended from confirmations){
"kind": 1021,
"content": "75000",
"tags": [["e", "<auction-event-id>"]]
}
Rules:
content is the bid amount as a string (in the auction's currency)e tag references the event ID of the auction, not the product UUIDSent by the merchant to validate bids:
{
"kind": 1022,
"content": "{\"status\":\"accepted\",\"message\":\"Bid received and validated\",\"duration_extended\":300}",
"tags": [
["e", "<bid-event-id>"],
["e", "<auction-event-id>"]
]
}
Status values: accepted, rejected, pending, winner
winner is sent to the winning bid after auction endsduration_extended (optional): seconds added to auction durationCheckout uses encrypted direct messages. See references/checkout-flow.md for full details.
Step 1 — Customer sends order (type:0):
{
"id": "order-uuid-123",
"type": 0,
"name": "Alice",
"address": "123 Bitcoin St, Miami, FL",
"message": "Please ship ASAP",
"contact": {
"nostr": "<customer-pubkey-hex>",
"phone": null,
"email": "[email protected]"
},
"items": [
{ "product_id": "product-xyz-789", "quantity": 2 }
],
"shipping_id": "us-domestic"
}
Step 2 — Merchant sends payment request (type:1):
{
"id": "order-uuid-123",
"type": 1,
"message": "Payment required within 15 minutes",
"payment_options": [
{ "type": "ln", "link": "lnbc..." },
{ "type": "btc", "link": "bc1q..." },
{ "type": "url", "link": "https://pay.example.com/order-123" }
]
}
Step 3 — Merchant sends status update (type:2):
{
"id": "order-uuid-123",
"type": 2,
"message": "Payment confirmed, shipping tomorrow",
"paid": true,
"shipped": false
}
For peer-to-peer bitcoin trading. See references/marketplace-events.md for full tag reference.
{
"kind": 38383,
"tags": [
["d", "order-uuid-456"],
["k", "sell"],
["f", "USD"],
["s", "pending"],
["amt", "100000"],
["fa", "50"],
["pm", "zelle", "cashapp"],
["premium", "3"],
["network", "mainnet"],
["layer", "lightning"],
["name", "SatoshiTrader"],
["bond", "1000"],
["expires_at", "1719391096"],
["expiration", "1719995896"],
["y", "my-platform"],
["z", "order"]
],
"content": ""
}
Required tags: d, k, f, s, amt, fa, pm, premium, network,
layer, expires_at, expiration, y, z
Status flow: pending → in-progress → success | canceled | expired
d tag matches content id (for kinds 30017, 30018, 30020)stall_id references a valid stallquantity is integer or null (not string, not undefined)starting_bid is an integere tag references auction event ID (not product UUID)e tags (bid + auction)type values (0, 1, 2)z tag set to "order"f tag uses ISO 4217 currency codek tag is "buy" or "sell"Total shipping for an order:
base_cost = stall.shipping[chosen_zone].cost
per_product = SUM(product.shipping[chosen_zone].cost × quantity) for each item
total_shipping = base_cost + per_product
Example: Stall base shipping $5.99, buying 2 units of a product with $10 extra shipping per unit → total shipping = $5.99 + (2 × $10) = $25.99.
| Mistake | Why It Breaks | Fix |
|---|---|---|
d tag doesn't match content id | Relay can't address the event correctly | Always keep d tag and content id identical |
Sequential IDs (0, 1, 2) | Collision risk across merchants | Use UUIDs or descriptive slugs |
| Editing auction after bids received | Bids reference event ID which changes on edit | Never edit auctions that have bids |
| Bid references product UUID instead of event ID | Bid won't be associated with the auction | Use the auction's Nostr event ID in the e tag |
Missing z tag on P2P orders | Clients can't identify the event as an order | Always include ["z", "order"] |
Using buy/sell without ISO 4217 f tag | Currency is ambiguous | Use standard codes: USD, EUR, BRL, VES |
| Product shipping zone ID doesn't match stall | Shipping calculation breaks | Product shipping IDs must exist in parent stall |
quantity as string "10" instead of int 10 | Type mismatch in clients | Use integer or null, never string |
Checkout type as string "0" instead of int 0 | Message routing fails | Use integer type values: 0, 1, 2 |
Bid confirmation missing auction e tag | Can't link confirmation to auction | Include both bid and auction e tags |
| Event | Kind | Category | Key Fields |
|---|---|---|---|
| Stall | 30017 | Addressable | id, name, currency, shipping zones |
| Product | 30018 | Addressable | id, stall_id, price, quantity, specs, shipping |
| Marketplace UI | 30019 | Addressable | name, ui config, merchant pubkeys |
| Auction | 30020 | Addressable | id, stall_id, starting_bid, start_date, duration |
| Bid | 1021 | Regular | amount in content, e tag → auction event ID |
| Bid Confirmation | 1022 | Regular | status, e tags → bid + auction |
| P2P Order | 38383 | Addressable | k (buy/sell), f (currency), s (status), amt, fa |
IDs must be consistent — The d tag and the id field inside content
JSON must always match. This is how addressable events are located.
Shipping is hierarchical — Stalls define base shipping zones and costs. Products add per-unit costs on top. The customer picks one zone at checkout.
Auctions are immutable after bids — Because bids reference the event ID (not a UUID), editing an auction creates a new event and orphans existing bids. Design auctions carefully before publishing.
Checkout is sequential — Order (type:0) → Payment request (type:1) → Status update (type:2). Each message references the same order ID. All messages are NIP-04 encrypted DMs.
P2P orders are self-contained — All trade parameters live in tags, not
content. The z tag must be "order" for client discovery. Status
transitions follow: pending → in-progress → success/canceled/expired.