Skip to main content

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

OptionValuesNotes
verifyhmac (default), token, data, noneVerification mode
secretglobal.* / shared.* refRequired for hmac/token/data. Inline plaintext is rejected.
headerStringSignature/token header (default X-Signature / X-Webhook-Token)
sig_algosha256 (default), sha512HMAC hash (hmac only)
sig_payloadraw (default), sorted_jsonWhat gets signed (hmac only)
sig_encodinghex (default), base64Signature encoding (hmac only)
data_fielddotted pathBody field to compare for verify: data (default data.secret)
methods[post], [get, post], …Allowed HTTP methods
bindexpressionTies the session to a key across callbacks
with body: { … }type schemaMakes request.body.* typed

Verification modes

ModeHow it works
hmacHMAC over the body, compared in constant time. Configure sig_algo / sig_payload / sig_encoding. NOWPayments uses sha512 + sorted_json.
tokenA shared secret compared in constant time via a header or ?token= query param.
dataA secret echoed back in a JSON body field (data_field) is compared to your secret. Used by Apirone.
noneNo verification — dev/public only.
danger

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:

FieldTypeNotes
request.methodStringHTTP verb (uppercase)
request.pathStringFull request path
request.paramsMapPath params from the /* tail
request.queryMapURL query parameters
request.headersMapLowercased header keys
request.bodydeclared schema / AnyTyped when with body is present
request.originStringUNTRUSTED — never use for auth
warning

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
VariantContent-TypeParams
respond.texttext/plainbody!, status, content_type
respond.jsonapplication/jsonbody!, status
note

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]) }

See also