Payments & Webhooks
Two ways to charge for things: Telegram Stars (native, no provider, instant) and the crypto-checkout pattern (create a charge, receive a verified webhook, unlock access â works for both NOWPayments and Apirone).
API keys and IPN/callback secrets live in global.* variables (set in the Variables panel) â never inline. The webhook secret must be a global.*/shared.* reference; the compiler rejects plaintext.
Telegram Stars (XTR) â native paymentsâ
For digital goods, Telegram Stars is the simplest checkout there is: no payment provider, no API key, no crypto wallet, no KYC. You send a Stars invoice, Telegram runs the checkout, and you get two events back â one to validate the order, one to fulfil it.
The lifecycle has three steps:
send.invoice(...)withcurrency: "XTR"â thepricesamount is the number of Stars. Telegram shows its native Stars checkout.on pre_checkoutâ fires just before the charge clears (Telegram gives ~10s). The runtime auto-approves so the charge proceeds; use this flow to log or sanity-check the order. Readpre_checkout.*(invoice_payload,total_amount,currency,id).on successful_paymentâ the charge cleared. Grant the goods here and store thepayment.telegram_payment_charge_id(you need it for refunds). Readpayment.*(total_amount,invoice_payload,telegram_payment_charge_id,currency).
The invoice_payload is your routing key â encode whatever you need (a product key, plan id) so the payment flow knows what was bought. Price always comes from your own catalog, never from a button, so a stale button can never charge the wrong amount.
// 1) Send a Stars invoice. payload carries the product key.
flow buy on callback "buy:" {
set { flow.key = str.split(callback.data, ":")[1] }
set { flow.prod = array.find(shared.products, "key", flow.key) }
when types.isNil(flow.prod) {
send.answer_callback(text: "That product is no longer available.", show_alert: true)
stop
}
send.invoice(
flow.prod.name,
flow.prod.desc,
flow.key, // invoice payload
[{ label: flow.prod.name, amount: flow.prod.price }], // amount = Stars
currency: "XTR"
)
}
// 2) Validate the order before the charge clears.
flow precheck on pre_checkout {
set { flow.prod = array.find(shared.products, "key", pre_checkout.invoice_payload) }
when types.isNil(flow.prod) {
debug.log(`pre_checkout REJECT: unknown product '${pre_checkout.invoice_payload}'`)
} else {
when flow.prod.price != pre_checkout.total_amount {
debug.log("pre_checkout MISMATCH: amount differs from catalog")
}
}
}
// 3) Fulfil on payment â grant the product, store the charge id for refunds.
flow paid on successful_payment {
set { flow.prod = array.find(shared.products, "key", payment.invoice_payload) }
set { flow.pname = types.isNil(flow.prod) ? payment.invoice_payload : flow.prod.name }
set {
user.owned << [{
key: payment.invoice_payload,
name: flow.pname,
price: payment.total_amount,
charge_id: payment.telegram_payment_charge_id,
at: time.timestamp()
}]
}
send.text(
`â
Payment received â â${payment.total_amount}\n\nYou now own ${flow.pname}.\n\nCharge id (keep for support):\n\`${payment.telegram_payment_charge_id}\``,
parse_mode: "Markdown"
)
}
Refundsâ
Always store the telegram_payment_charge_id from on successful_payment â it's the only way to refund a Stars purchase. A typical owner-only /refund command looks up the sale by charge id, marks it refunded in your ledger, and notifies the buyer:
flow refund on command "/refund" {
when user.id != global.admin_id {
send.text("â Owner only.")
} else {
set { flow.cid = str.trim(message.args) }
set { flow.sale = array.find(shared.sales, "charge_id", flow.cid) }
when types.isNil(flow.sale) {
send.text("â No sale found for that charge id.")
} else {
set {
shared.sales = array.reject(shared.sales, "charge_id", flow.cid)
shared.sales << [{
buyer: flow.sale.buyer, key: flow.sale.key, amount: flow.sale.amount,
charge_id: flow.sale.charge_id, at: flow.sale.at, refunded: true
}]
}
send.text(`â
Marked \`${flow.cid}\` refunded (â${flow.sale.amount}).`, parse_mode: "Markdown")
send.text(`âŠī¸ Your purchase was refunded to your Stars balance.`, chat_id: flow.sale.buyer)
}
}
}
See the stars-shop blueprint for a complete data-driven Stars store with a paginated catalog, a per-user purchase library, owner analytics, and refund tooling. Stars vs. crypto: use Stars for digital goods (instant, zero setup); use NOWPayments / Apirone (below) when you need fiat-priced or on-chain crypto payments â see the nowpayments_shop blueprint.
The shape of a crypto checkoutâ
- The buyer triggers a purchase (command or button).
- You create a charge with the payment library, encoding routing info (the buyer's chat id) into
order_id. - The provider gives you a deposit address; you show it to the buyer.
- The buyer pays. The provider sends an HTTP webhook to your bot.
- Your webhook flow re-verifies the payment, parses the routing info, unlocks access, and acknowledges.
Because a webhook flow has no chat in scope, step 2's encoding is what lets step 5 know who to deliver to.
NOWPayments: HMAC-SHA512, sorted JSONâ
NOWPayments signs IPNs with HMAC-SHA512 over a JSON body whose keys are recursively sorted. Declare the webhook with exactly those options:
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,
price_amount: Number, actually_paid: Number
} {
// Trust-but-verify: re-check the status against the API.
let { finished, status, 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 }) // ack so the provider stops retrying
stop
}
when !finished {
respond.json({ ok: true })
stop
}
// Route via order_id ("<uid>-<plan>").
set { flow.buyer_uid = types.int(str.split(order_id, "-")[0]) }
set { flow.plan = str.split(order_id, "-")[1] }
send.text(`â
Payment confirmed â ${flow.plan} unlocked!`, chat_id: flow.buyer_uid)
respond.json({ ok: true })
}
Apirone: verify by data echoâ
Apirone callbacks aren't HMAC-signed. They echo back the data object you supplied when creating the address. Use verify: data to compare a secret field, and respond with the literal *ok* so Apirone stops retrying:
flow payment_cb on webhook("payment_cb",
verify: data,
secret: global.apirone_secret,
data_field: "data.secret",
methods: [post]
) with body: {
value: Number, confirmations: Number,
data: { chat_id: Number, order_id: String, secret: String }
} {
respond.text("*ok*")
let { confirmed } = await apirone.handle_callback(
account: global.wallet_id,
address: "",
reported_value: request.body.value,
confirmations: request.body.confirmations,
order_id: request.body.data.order_id,
chat_id: request.body.data.chat_id
) on error { stop }
when confirmed {
send.text(`â
Received ${request.body.value} sat!`, chat_id: request.body.data.chat_id)
}
}
Trust but verifyâ
Never deliver goods on the raw webhook body alone â an attacker who learns your endpoint could forge a "paid" notification. Always:
- Verify the signature/secret (
verify: hmacorverify: data). - Re-check status via the API (
handle_ipn/handle_callback). - Deliver only on a confirmed/finished status.
Idempotencyâ
Webhooks can be delivered more than once. Make unlocking idempotent â drop any stale record before adding the new one, so a duplicate doesn't double-credit:
set {
shared.paid_uids = array.reject(shared.paid_uids, "uid", flow.buyer_uid)
shared.paid_uids << [{ uid: flow.buyer_uid }]
}
Signing your own outgoing requestsâ
When you call an API that requires a signed request, build the signature with crypto.hmacSha256 (secret from global.*):
set { flow.sig = crypto.hmacSha256(flow.payload, global.api_secret) }
res = http.post(
url: "https://api.example.com/order",
headers: { "X-Signature": flow.sig },
body: flow.payload
) on error { send.text("Request failed.") stop }