Skip to main content

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).

danger

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:

  1. send.invoice(...) with currency: "XTR" — the prices amount is the number of Stars. Telegram shows its native Stars checkout.
  2. 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. Read pre_checkout.* (invoice_payload, total_amount, currency, id).
  3. on successful_payment — the charge cleared. Grant the goods here and store the payment.telegram_payment_charge_id (you need it for refunds). Read payment.* (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)
}
}
}
tip

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​

  1. The buyer triggers a purchase (command or button).
  2. You create a charge with the payment library, encoding routing info (the buyer's chat id) into order_id.
  3. The provider gives you a deposit address; you show it to the buyer.
  4. The buyer pays. The provider sends an HTTP webhook to your bot.
  5. 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:

  1. Verify the signature/secret (verify: hmac or verify: data).
  2. Re-check status via the API (handle_ipn / handle_callback).
  3. 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 }

See also​