Webhooks
A webhook trigger starts a flow when an external system sends an HTTP request to your bot's webhook URL. This is how you receive payment confirmations and third-party callbacks.
The webhook URL
Each webhook trigger gets a URL. The read-only bot.webhook_base gives you the base (with a trailing slash); append the trigger name to build the full URL you register with the external service:
// bot.webhook_base → https://<domain>/webhook/<botID>/trigger/
let { address } = await apirone.new_address(
callback_url: `${bot.webhook_base}payment_cb`, // → …/trigger/payment_cb
/* … */
)
Trigger forms
Bare (no verification — development or public endpoints only):
flow ping on webhook "health" {
respond.text("ok")
}
Full (verification + typed body):
flow payment_received on webhook("apirone",
verify: data,
secret: global.apirone_secret,
data_field: "data.secret",
methods: [post]
) with body: {
value: Number,
input_address: String,
data: { chat_id: Number, order_id: String, secret: String }
} {
respond.text("*ok*")
send.text(`Payment of ${request.body.value} received!`, chat_id: request.body.data.chat_id)
}
Options
| Option | Values | Notes |
|---|---|---|
verify | hmac (default), token, data, none | Verification mode |
secret | global.* / shared.* ref | Required for hmac/token/data. Inline plaintext is rejected. |
header | String | Signature/token header (default X-Signature / X-Webhook-Token) |
sig_algo | sha256 (default), sha512 | HMAC hash (hmac only) |
sig_payload | raw (default), sorted_json | What gets signed (hmac only) |
sig_encoding | hex (default), base64 | Signature encoding (hmac only) |
data_field | dotted path | Body field to compare for verify: data (default data.secret) |
methods | [post], [get, post], … | Allowed HTTP methods |
bind | expression | Ties the session to a key across callbacks |
with body: { … } | type schema | Makes request.body.* typed |
Verification modes
| Mode | How it works |
|---|---|
hmac | HMAC over the body, compared in constant time. Configure sig_algo / sig_payload / sig_encoding. NOWPayments uses sha512 + sorted_json. |
token | A shared secret compared in constant time via a header or ?token= query param. |
data | A secret echoed back in a JSON body field (data_field) is compared to your secret. Used by Apirone. |
none | No verification — dev/public only. |
The secret must be a global.* or shared.* variable reference — the compiler rejects inline plaintext. Every mode is fail-closed: a missing secret rejects the request with 401. Bodies are capped at 1 MiB and there's a 30s execution timeout.
The request scope
A typed request object is injected into every webhook flow:
| Field | Type | Notes |
|---|---|---|
request.method | String | HTTP verb (uppercase) |
request.path | String | Full request path |
request.params | Map | Path params from the /* tail |
request.query | Map | URL query parameters |
request.headers | Map | Lowercased header keys |
request.body | declared schema / Any | Typed when with body is present |
request.origin | String | UNTRUSTED — never use for auth |
request.origin is derived from X-Forwarded-For, which is spoofable. Never use it for authentication — rely on verify.
respond.*
By default a webhook flow returns {"ok": true} with status 200. Override it with respond.* (control flow continues afterward, so you can ack the provider and then send a Telegram message):
respond.text("*ok*") // text/plain
respond.json({ status: "received", id: request.body.id }) // application/json
respond.text("Not Found", status: 404) // custom status
| Variant | Content-Type | Params |
|---|---|---|
respond.text | text/plain | body!, status, content_type |
respond.json | application/json | body!, status |
Apirone callbacks retry forever unless the webhook returns exactly *ok* as text/plain. Always respond.text("*ok*") in an Apirone callback flow.
No implicit chat
Webhook flows have no Telegram chat in scope. Extract the target chat_id from the payload and pass it explicitly to every send.*:
send.text("Payment confirmed", chat_id: request.body.data.chat_id)
A common pattern is to encode routing into the order id when creating a charge, then parse it back in the webhook (NOWPayments echoes order_id but no arbitrary data):
set { flow.buyer_uid = types.int(str.split(request.body.order_id, "-")[0]) }