18 KiB
NIP-15
Nostr Marketplace
draft
optional
Based on Diagon-Alley.
Implemented in NostrMarket and Plebeian Market.
Terms
merchant
- seller of products with NOSTR key-paircustomer
- buyer of products with NOSTR key-pairproduct
- item for sale by themerchant
stall
- list of products controlled bymerchant
(amerchant
can have multiple stalls)marketplace
- clientside software for searchingstalls
and purchasingproducts
Nostr Marketplace Clients
Merchant admin
Where the merchant
creates, updates and deletes stalls
and products
, as well as where they manage sales, payments and communication with customers
.
The merchant
admin software can be purely clientside, but for convenience
and uptime, implementations will likely have a server client listening for NOSTR events.
Marketplace
Marketplace
software should be entirely clientside, either as a stand-alone app, or as a purely frontend webpage. A customer
subscribes to different merchant NOSTR public keys, and those merchants
stalls
and products
become listed and searchable. The marketplace client is like any other ecommerce site, with basket and checkout. Marketplaces
may also wish to include a customer
support area for direct message communication with merchants
.
Merchant
publishing/updating products (event)
A merchant can publish these events:
Kind | Description | |
---|---|---|
0 |
set_meta |
The merchant description (similar with any nostr public key). |
30017 |
set_stall |
Create or update a stall. |
30018 |
set_product |
Create or update a product. |
4 |
direct_message |
Communicate with the customer. The messages can be plain-text or JSON. |
5 |
delete |
Delete a product or a stall. |
Event 30017
: Create or update a stall.
Event Content
{
"id": <string, id generated by the merchant. Sequential IDs (`0`, `1`, `2`...) are discouraged>,
"name": <string, stall name>,
"description": <string (optional), stall description>,
"currency": <string, currency used>,
"shipping": [
{
"id": <string, id of the shipping zone, generated by the merchant>,
"name": <string (optional), zone name>,
"cost": <float, base cost for shipping. The currency is defined at the stall level>,
"regions": [<string, regions included in this zone>]
}
]
}
Fields that are not self-explanatory:
shipping
:- an array with possible shipping zones for this stall.
- the customer MUST choose exactly one of those shipping zones.
- shipping to different zones can have different costs. For some goods (digital for example) the cost can be zero.
- the
id
is an internal value used by the merchant. This value must be sent back as the customer selection. - each shipping zone contains the base cost for orders made to that shipping zone, but a specific shipping cost per product can also be specified if the shipping cost for that product is higher than what's specified by the base cost.
Event Tags
{
"tags": [["d", <string, id of stall]],
// other fields...
}
- the
d
tag is required, its value MUST be the same as the stallid
.
Event 30018
: Create or update a product
Event Content
{
"id": <string, id generated by the merchant (sequential ids are discouraged)>,
"stall_id": <string, id of the stall to which this product belong to>,
"name": <string, product name>,
"description": <string (optional), product description>,
"images": <[string], array of image URLs, optional>,
"currency": <string, currency used>,
"price": <float, cost of product>,
"quantity": <int or null, available items>,
"specs": [
[<string, spec key>, <string, spec value>]
],
"shipping": [
{
"id": <string, id of the shipping zone (must match one of the zones defined for the stall)>,
"cost": <float, extra cost for shipping. The currency is defined at the stall level>
}
]
}
Fields that are not self-explanatory:
-
quantity
can be null in the case of items with unlimited availability, like digital items, or services -
specs
:- an optional array of key pair values. It allows for the Customer UI to present product specifications in a structure mode. It also allows comparison between products
- eg:
[["operating_system", "Android 12.0"], ["screen_size", "6.4 inches"], ["connector_type", "USB Type C"]]
Open: better to move
spec
in thetags
section of the event? -
shipping
:- an optional array of extra costs to be used per shipping zone, only for products that require special shipping costs to be added to the base shipping cost defined in the stall
- the
id
should match the id of the shipping zone, as defined in theshipping
field of the stall - to calculate the total cost of shipping for an order, the user will choose a shipping option during checkout, and then the client must consider this costs:
- the
base cost from the stall
for the chosen shipping option - the result of multiplying the product units by the
shipping costs specified in the product
, if any.
- the
Event Tags
"tags": [
["d", <string, id of product],
["t", <string (optional), product category],
["t", <string (optional), product category],
// other fields...
],
...
- the
d
tag is required, its value MUST be the same as the productid
. - the
t
tag is as searchable tag, it represents different categories that the product can be part of (food
,fruits
). Multiplet
tags can be present.
Checkout events
All checkout events are sent as JSON strings using NIP-04.
The merchant
and the customer
can exchange JSON messages that represent different actions. Each JSON
message MUST
have a type
field indicating the what the JSON represents. Possible types:
Message Type | Sent By | Description |
---|---|---|
0 | Customer | New Order |
1 | Merchant | Payment Request |
2 | Merchant | Order Status Update |
Step 1: customer
order (event)
The below JSON goes in content of NIP-04.
{
"id": <string, id generated by the customer>,
"type": 0,
"name": <string (optional), ???>,
"address": <string (optional), for physical goods an address should be provided>,
"message": <string (optional), message for merchant>,
"contact": {
"nostr": <32-bytes hex of a pubkey>,
"phone": <string (optional), if the customer wants to be contacted by phone>,
"email": <string (optional), if the customer wants to be contacted by email>
},
"items": [
{
"product_id": <string, id of the product>,
"quantity": <int, how many products the customer is ordering>
}
],
"shipping_id": <string, id of the shipping zone>
}
Open: is contact.nostr
required?
Step 2: merchant
request payment (event)
Sent back from the merchant for payment. Any payment option is valid that the merchant can check.
The below JSON goes in content
of NIP-04.
payment_options
/type
include:
url
URL to a payment page, stripe, paypal, btcpayserver, etcbtc
onchain bitcoin addressln
bitcoin lightning invoicelnurl
bitcoin lnurl-pay
{
"id": <string, id of the order>,
"type": 1,
"message": <string, message to customer, optional>,
"payment_options": [
{
"type": <string, option type>,
"link": <string, url, btc address, ln invoice, etc>
},
{
"type": <string, option type>,
"link": <string, url, btc address, ln invoice, etc>
},
{
"type": <string, option type>,
"link": <string, url, btc address, ln invoice, etc>
}
]
}
Step 3: merchant
verify payment/shipped (event)
Once payment has been received and processed.
The below JSON goes in content
of NIP-04.
{
"id": <string, id of the order>,
"type": 2,
"message": <string, message to customer>,
"paid": <bool: has received payment>,
"shipped": <bool: has been shipped>,
}
Merchant Delegation
In the Merchant Delegation paradigm, a different Nostr account effectively controls all commerce-related events on behalf of the Merchant. The delegated account creates every Stall and Product, receives all Checkout events, and handles checkout-related communication with the Customer.
Clients implementing Merchant Delegation automatically re-target non-checkout events to the Merchant Root Account (aka Delegator).
By using Merchant Delegation, the Merchant's Root Account remains free to engage solely in social graph activities (ie. posting short-form / long-form content creation and engagement, non-checkout communications with Customers, etc), without the checkout-related events spamming their inbox.
The Merchant signs a Merchant Delegate Token, which is placed in a tag on every event signed by the delegate account. Clients parse the tag, verify the signature is valid, and cause non-checkout events (ie. follows, zaps, direct messages, etc.) to target the Merchant Root Account (aka Delegator) automatically.
As an extra layer of validation, a NIP-05 identifier may be included. When an identifier is present, clients will verify its validity, and reject delegation if the Npub is not properly identified. This provides the ability to invalidate the delegation, if need be.
To use Merchant Delegation Tags:
-
Clients MUST treat events with a
merchant-delegation
tag in the following manner:- Validate the
delegation token
signature - If
retarget_while<
and/orretarget_while>
is present in the conditions query string, only perform event re-targeting within the given time window - If
nip_05_identifier
is present in the conditions query string, only perform re-targeting if the Npub of the event's creator is verifiable as-per the NIP-05 spec
- Validate the
-
Clients SHOULD display a relevant visual indicator that a Stall and/or Product's Checkout process is handled by a Merchant Delegate.
Merchant Delegation tags are described as follows:
[
"merchant-delegation",
<pubkey of the delegator>,
<conditions query string>,
<delegation token: 64-byte Schnorr signature of the sha256 hash of the delegation string>
]
The merchant-delegation token should be a 64-byte Schnorr signature of the sha256 hash of the following string:
**nostr:delegation:<pubkey of publisher (delegatee)>:<conditions query string>
Conditions Query String
The following fields and operators are supported in the above query string:
Fields:
-
retarget_while
- Operators:
<${TIMESTAMP}
- clients must only redirect event targets for events created before the specified timestamp>${TIMESTAMP}
- clients must only redirect event targets for events created after the specified timestamp
- Operators:
-
nip_05_identifier
- Operators:
<${TIMESTAMP}
- clients must only redirect event targets for events created before the specified timestamp>${TIMESTAMP}
- clients must only redirect event targets for events created after the specified timestamp
- Operators:
In order to create a single condition, you must use a supported field and operator. Multiple conditions can be used in a single query string, including on the same field. Conditions must be combined with &
.
For example, the following condition strings are valid:
retarget_while<1675721813&nip_05_identifier=service-bot@my-awesome-store.com
retarget_while>1675721813%&nip_05_identifier=service-bot@my-awesome-store.com
retarget_while>1674777689&created_at<1675721813&&nip_05_identifier=service-bot@my-awesome-store.com
*Note: This is closely inspired by the NIP-26: Delegated Event Signing tag specification. However, the NIP-26 implementation is NOT chosen for this use case. Delegating event signing ability as per NIP-26 would be superfluous because the Merchant Delegate will not possess the ability to decrypt incoming order messages from Customers.
Customize Marketplace
Create a customized user experience using the naddr
from NIP-19. The use of naddr
enables easy sharing of marketplace events while incorporating a rich set of metadata. This metadata can include relays, merchant profiles, and more. Subsequently, it allows merchants to be grouped into a market, empowering the market creator to configure the marketplace's user interface and user experience, and share that marketplace. This customization can encompass elements such as market name, description, logo, banner, themes, and even color schemes, offering a tailored and unique marketplace experience.
Event 30019
: Create or update marketplace UI/UX
Event Content
{
"name": <string (optional), market name>,
"about": <string (optional), market description>,
"ui": {
"picture": <string (optional), market logo image URL>,
"banner": <string (optional), market logo banner URL>,
"theme": <string (optional), market theme>,
"darkMode": <bool, true/false>
},
"merchants": [array of pubkeys (optional)],
// other fields...
}
This event leverages naddr to enable comprehensive customization and sharing of marketplace configurations, fostering a unique and engaging marketplace environment.
Auctions
Event 30020
: Create or update a product sold as an auction
Event Content:
{
"id": <String, UUID generated by the merchant. Sequential IDs (`0`, `1`, `2`...) are discouraged>,
"stall_id": <String, UUID of the stall to which this product belong to>,
"name": <String, product name>,
"description": <String (optional), product description>,
"images": <[String], array of image URLs, optional>,
"starting_bid": <int>,
"start_date": <int (optional) UNIX timestamp, date the auction started / will start>,
"duration": <int, number of seconds the auction will run for, excluding eventual time extensions that might happen>,
"specs": [
[<String, spec key>, <String, spec value>]
],
"shipping": [
{
"id": <String, UUID of the shipping zone. Must match one of the zones defined for the stall>,
"cost": <float, extra cost for shipping. The currency is defined at the stall level>
}
]
}
Note
Items sold as an auction are very similar in structure to fixed-price items, with some important differences worth noting.
-
The
start_date
can be set to a date in the future if the auction is scheduled to start on that date, or can be omitted if the start date is unknown/hidden. If the start date is not specified, the auction will have to be edited later to set an actual date. -
The auction runs for an initial number of seconds after the
start_date
, specified byduration
.
Event 1021
: Bid
{
"content": <int, amount of sats>,
"tags": [["e", <event ID of the auction to bid on>]],
// other fields...
}
Bids are simply events of kind 1021
with a content
field specifying the amount, in the currency of the auction. Bids must reference an auction.
Note
Auctions can be edited as many times as desired (they are "addressable events") by the author - even after the start_date, but they cannot be edited after they have received the first bid! This is enforced by the fact that bids reference the event ID of the auction (rather than the product UUID), which changes with every new version of the auctioned product. So a bid is always attached to one "version". Editing the auction after a bid would result in the new product losing the bid!
Event 1022
: Bid confirmation
Event Content:
{
"status": <String, "accepted" | "rejected" | "pending" | "winner">,
"message": <String (optional)>,
"duration_extended": <int (optional), number of seconds>
}
Event Tags:
"tags": [["e" <event ID of the bid being confirmed>], ["e", <event ID of the auction>]],
Bids should be confirmed by the merchant before being considered as valid by other clients. So clients should subscribe to bid confirmation events (kind 1022
) for every auction that they follow, in addition to the actual bids and should check that the pubkey of the bid confirmation matches the pubkey of the merchant (in addition to checking the signature).
The content
field is a JSON which includes at least a status
. winner
is how the winning bid is replied to after the auction ends and the winning bid is picked by the merchant.
The reasons for which a bid can be marked as rejected
or pending
are up to the merchant's implementation and configuration - they could be anything from basic validation errors (amount too low) to the bidder being blacklisted or to the bidder lacking sufficient trust, which could lead to the bid being marked as pending
until sufficient verification is performed. The difference between the two is that pending
bids might get approved after additional steps are taken by the bidder, whereas rejected
bids can not be later approved.
An additional message
field can appear in the content
JSON to give further context as of why a bid is rejected
or pending
.
Another thing that can happen is - if bids happen very close to the end date of the auction - for the merchant to decide to extend the auction duration for a few more minutes. This is done by passing a duration_extended
field as part of a bid confirmation, which would contain a number of seconds by which the initial duration is extended. So the actual end date of an auction is always start_date + duration + (SUM(c.duration_extended) FOR c in all confirmations
.
Customer support events
Customer support is handled over whatever communication method was specified. If communicating via nostr, NIP-04 is used.
Additional
Standard data models can be found here