Tutorial: E-Commerce Bot
A crypto storefront: browse a paginated catalog, add to a cart, check out with NOWPayments, and confirm via a verified webhook. Based on the ecommerce.botgami and nowpayments_shop.botgami blueprints.
Keep your NOWPayments API key and IPN secret in global.* variables (set in the Variables panel). Never hardcode them.
What you'll learnโ
- Named menus with dynamic ranges and typed payloads
- Catalog pagination with a
pageparam - Drill-down cart and checkout flow
- Confirm/cancel pattern using a parameterized menu
- Crypto charge creation and verified webhook confirmation
Step 1: Stateโ
import nowpayments from "nowpayments"
bot ShopBot {
meta { name: "Crypto Shop" slug: "ecommerce-bot" icon: "๐" }
global nowpayments_api_key: String = ""
global nowpayments_ipn_secret: String = ""
shared products: Array<{ id: String, name: String, price: Number, stock: Number, category: String }> = [
{ id: "coffee", name: "โ Coffee", price: 5.0, stock: 100, category: "drinks" },
{ id: "mug", name: "๐ต Mug", price: 12.0, stock: 50, category: "merch" },
{ id: "tshirt", name: "๐ T-Shirt", price: 25.0, stock: 30, category: "merch" }
]
user cart: Array<{ id: String, name: String, price: Number }> = []
user last_payment_id: Number = 0
}
Step 2: Catalog menu with paginationโ
A page: Number param slices the product list. The prev/next buttons carry the page forward automatically โ no extra state needed.
menu catalogMenu(page: Number) {
text: `๐ Products (page ${page + 1})`
for p in array.slice(shared.products, page * 4, page * 4 + 4) {
text `${p.name} โ $${p.price}` -> addToCart(pid: p.id, name: p.name, price: p.price)
row
}
text "โฌ
Prev" -> prevPage(page: page - 1)
text "Next โก" -> nextPage(page: page + 1)
row
submenu "๐ Cart" -> cartMenu
}
flow shop on command "/shop" {
send.text("Browse our catalog:", reply_markup: catalogMenu(0))
}
subflow prevPage(page: Number) {
set { flow.safe = math.max(page, 0) }
edit.text(message: callback.message, text: "Catalog:", reply_markup: catalogMenu(flow.safe))
}
subflow nextPage(page: Number) {
set { flow.max = math.floor((util.len(shared.products) - 1) / 4) }
set { flow.safe = math.min(page, flow.max) }
edit.text(message: callback.message, text: "Catalog:", reply_markup: catalogMenu(flow.safe))
}
Step 3: Add to cart (typed payload)โ
The pid, name, and price arrive as typed values โ no string parsing needed.
subflow addToCart(pid: String, name: String, price: Number) {
set { user.cart << [{ id: pid, name: name, price: price }] }
send.answer_callback(text: `Added ${name}!`)
}
Step 4: Cart menu with checkout drill-downโ
menu cartMenu {
text: "๐ Cart"
for item in user.cart {
text `${item.name} โ $${item.price}` -> removeItem(id: item.id)
row
}
submenu "๐ณ Checkout" -> checkoutMenu
back "โฌ
Browse"
}
menu checkoutMenu {
text: "๐ณ Confirm checkout?"
text "โ
Pay now" -> startCheckout()
text "โ Cancel" -> cancelCheckout()
}
Step 5: Checkout handlerโ
subflow startCheckout() {
when util.len(user.cart) == 0 {
send.text("Your cart is empty.")
stop
}
set { flow.total = 0 }
foreach item in user.cart {
set { flow.total += item.price }
}
set { flow.order_id = `${user.id}-order` }
let { payment_id, pay_address, pay_amount, pay_currency } = await nowpayments.create_payment(
api_key: global.nowpayments_api_key,
price_amount: flow.total,
price_currency: "usd",
pay_currency: "btc",
order_id: flow.order_id,
order_description: "Shop order",
ipn_callback_url: `${bot.webhook_base}nowpayments`
) on error {
send.text("โ Couldn't start checkout. Try again shortly.")
stop
}
set { user.last_payment_id = payment_id }
send.text(`๐งพ Send *${pay_amount} ${pay_currency}* to:\n\`${pay_address}\`\n\nYour order confirms automatically.`, parse_mode: "Markdown")
}
subflow cancelCheckout() {
edit.text(message: callback.message, text: "Checkout cancelled.", reply_markup: cartMenu)
}
Step 6: Confirm via webhook + adjust stockโ
The IPN re-verifies, then (on finished) clears the buyer's cart and decrements stock. Make it idempotent.
flow nowpayments_ipn on webhook("nowpayments",
verify: hmac, sig_algo: sha512, sig_payload: sorted_json,
header: "x-nowpayments-sig", secret: global.nowpayments_ipn_secret,
methods: [post], bind: request.body.order_id
) with body: { payment_id: Number, payment_status: String, order_id: String } {
let { finished, order_id } = await nowpayments.handle_ipn(
api_key: global.nowpayments_api_key,
payment_id: request.body.payment_id,
reported_status: request.body.payment_status
) on error {
respond.json({ ok: true })
stop
}
when !finished {
respond.json({ ok: true })
stop
}
set { flow.buyer_uid = types.int(str.split(order_id, "-")[0]) }
send.text("โ
Payment received โ your order is on its way! ๐ฆ", chat_id: flow.buyer_uid)
respond.json({ ok: true })
}
Decrementing per-product stock from the webhook means rebuilding the shared.products array (no in-place element mutation). Read the Cross-user data upsert pattern.