Webhooks
With a webhook, Qivuna POSTs each update to your HTTPS endpoint instead of waiting for getUpdates. Every request is signed with your secret; the first one proves you own the endpoint.
setWebhook answers 400 webhooks_disabled and saves nothing; retrying gives the same answer. Receive updates with getUpdates (long polling) for now. This page describes how webhooks will work; the changelog will say when they open. deleteWebhook and getWebhookInfo work as usual (with no webhook set, url is "").Setup#
POST https://bots.qivuna.com/v1/setWebhook
{"url": "https://bot.example.com/qivuna",
"secret_token": "k3Yt1L0ng_RAND0m-secret-of-32-to-128-chars",
"max_connections": 4,
"allowed_updates": ["message", "callback_query", "my_chat_member", "bot_started", "bot_blocked"]}
| Parameter | Rules |
|---|---|
url | https:// only; port 443 or 8443; at most 512 characters; printable ASCII (an international domain in its xn-- form); no user name or password, no fragment. The host must be a public DNS name with at least two labels, or a public IP address. Refused: private and reserved addresses, qivuna.com and its subdomains, localhost and local or test names (.local, .internal, .test, .example, .onion, …) and wildcard-DNS services such as nip.io and sslip.io. Qivuna never resolves your name itself: the egress proxy does, and refuses private addresses there too. |
secret_token | Required. 32–128 characters of A–Z a–z 0–9 _ -. Generate it randomly (for example 32 random bytes in base64url). It is stored encrypted and never shown again; Qivuna uses it only to sign requests. |
max_connections | 1–8, default 4: how many chats are delivered in parallel. |
allowed_updates | As for getUpdates; saved only once the challenge passed. |
encrypt_payload | Encrypted bots: seal each delivery to the bot's key (qbw1). A setWebhook without it turns encryption off. |
While a webhook is set, getUpdates answers 409 conflict. deleteWebhook switches back to polling; waiting updates stay unless you pass drop_pending_updates: true. getWebhookInfo shows the URL, the number of waiting updates and the last error.
Limits: 20 setWebhook calls per bot per hour and 40 per owner; and at most 30 failed challenges per destination host per hour across all bots (a bot that already has a working webhook on that host is not held back by others failing there). When webhooks are unavailable on the platform, setWebhook answers 503 webhook_unavailable: keep polling.
The ownership challenge#
Before anything is saved, setWebhook sends one signed request to the URL and waits up to 10 seconds:
POST /qivuna HTTP/1.1
Content-Type: application/json
X-Qivuna-Bot-Delivery: 01jb2h4c7v3t9m2x5k8q1w6r0z
X-Qivuna-Bot-Timestamp: 1759590000
X-Qivuna-Bot-Signature: v1=5d41c0…
{"type":"url_verification","challenge":"Yk3m…43 characters…"}
It is signed with the new secret (and, when you are replacing a working webhook's secret, also with the current one). Your endpoint must verify the signature like any delivery, then answer status 200 with either the challenge itself as the body, or JSON {"challenge": "…"}. Anything else — another status, a redirect, a timeout — fails with 422 webhook_unverified, whose message says only what happened (“it answered 404”, “…answered 200 without the challenge”, “…could not be reached”), never your response body.
setWebhook. The challenge is never encrypted, even with encrypt_payload.Telling a challenge from an update: a challenge has "type": "url_verification" and no update_id.
What a delivery looks like#
POST /qivuna HTTP/1.1
Content-Type: application/json
User-Agent: Qivuna-Bot-Webhook/1.0
X-Qivuna-Bot-Delivery: 01jb2h7d0s4r8n1c6v9x2p5m3q
X-Qivuna-Bot-Timestamp: 1759590123
X-Qivuna-Bot-Signature: v1=8f3a…
X-Qivuna-Bot-Encryption: qbw1 (only with encrypt_payload)
{"update_id":1044,"message":{…}}
- The body is exactly the update
getUpdateswould have returned (or itsqbw1envelope). X-Qivuna-Bot-Deliveryis a lower-case ULID that stays the same across retries of one update. It is not covered by the v1 signature: de-duplicate on the signedupdate_id, and treat the delivery id as a hint (for logs).- Requests use HTTP/1.1 and
Accept-Encoding: identity, never follow redirects, and give up after 10 seconds. Answer quickly and do slow work afterwards. - Your response body is read up to 64 KB and discarded: you cannot answer with a method call.
Verifying the signature (HMAC v1)#
X-Qivuna-Bot-Signature: v1=<hex HMAC-SHA256(secret_token, timestamp + "." + raw_body)>
The key is your secret_token as UTF-8; the message is the decimal timestamp from X-Qivuna-Bot-Timestamp, a full stop, and the raw request body bytes — before any JSON parsing. For 24 hours after you set a new secret, both signatures are sent, newest first: v1=<new>,v1=<old>. Accept the request if any v1= value matches any secret you hold, compare in constant time, and refuse timestamps more than 300 seconds from your clock (keep NTP running).
Node.js#
import { createHmac, timingSafeEqual } from "node:crypto";
// headers: lower-case names (as node:http gives them); rawBody: Buffer
export function verifyQivuna(headers, rawBody, secrets, nowMs = Date.now()) {
const ts = headers["x-qivuna-bot-timestamp"];
const sig = headers["x-qivuna-bot-signature"];
if (typeof ts !== "string" || !/^[0-9]{1,12}$/.test(ts) || typeof sig !== "string") return false;
if (Math.abs(nowMs / 1000 - Number(ts)) > 300) return false;
const given = sig.split(",").map((s) => s.trim())
.filter((s) => /^v1=[0-9a-f]{64}$/.test(s)).map((s) => Buffer.from(s.slice(3), "hex"));
for (const secret of secrets) {
const mac = createHmac("sha256", secret).update(ts + ".").update(rawBody).digest();
if (given.some((g) => timingSafeEqual(g, mac))) return true;
}
return false;
}
Python#
import hashlib, hmac, re, time
def verify_qivuna(headers, raw_body: bytes, secrets, now=None) -> bool:
ts = headers.get("X-Qivuna-Bot-Timestamp", "")
sig = headers.get("X-Qivuna-Bot-Signature", "")
if not re.fullmatch(r"[0-9]{1,12}", ts) or not sig:
return False
if abs((now if now is not None else time.time()) - int(ts)) > 300:
return False
given = [p.strip()[3:] for p in sig.split(",") if re.fullmatch(r"v1=[0-9a-f]{64}", p.strip())]
for secret in secrets:
mac = hmac.new(secret.encode(), ts.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
if any(hmac.compare_digest(mac, g) for g in given):
return True
return False
PHP#
function qivuna_verify(string $ts, string $sig, string $rawBody, array $secrets): bool
{
if (!preg_match('/^[0-9]{1,12}$/', $ts) || abs(time() - (int) $ts) > 300) {
return false;
}
foreach (explode(',', $sig) as $part) {
$part = trim($part);
if (!preg_match('/^v1=[0-9a-f]{64}$/', $part)) {
continue;
}
foreach ($secrets as $secret) {
if (hash_equals(hash_hmac('sha256', $ts . '.' . $rawBody, $secret), substr($part, 3))) {
return true;
}
}
}
return false;
}
// $rawBody = file_get_contents('php://input');
// $ts = $_SERVER['HTTP_X_QIVUNA_BOT_TIMESTAMP'] ?? ''; $sig = $_SERVER['HTTP_X_QIVUNA_BOT_SIGNATURE'] ?? '';
A minimal endpoint (Node.js, no framework)#
import { createServer } from "node:http";
import { verifyQivuna } from "./verify.mjs";
const SECRETS = [process.env.QIVUNA_WEBHOOK_SECRET];
const seen = new Set(); // use your database in production; keep ids for a day
createServer(async (req, res) => {
if (req.method !== "POST" || req.url !== "/qivuna") return res.writeHead(404).end();
const chunks = []; let size = 0;
for await (const c of req) { size += c.length; if (size > 1 << 20) return res.writeHead(413).end(); chunks.push(c); }
const raw = Buffer.concat(chunks);
if (!verifyQivuna(req.headers, raw, SECRETS)) return res.writeHead(401).end();
const body = JSON.parse(raw.toString("utf8"));
if (body.type === "url_verification") {
return res.writeHead(200, { "content-type": "text/plain" }).end(body.challenge);
}
if (seen.has(body.update_id)) return res.writeHead(200).end(); // a repeat
seen.add(body.update_id);
res.writeHead(200).end(); // answer first …
handleUpdate(body).catch(console.error); // … then work (or put it on your own queue)
}).listen(8080, "127.0.0.1"); // TLS in front: a reverse proxy on 443
Answering 200 before the work is done means a crash loses that update; to be safe, write it to your own durable queue before answering. The SDKs include ready webhook handlers (Node.js http, Express, Fastify; Python WSGI and ASGI) that do the checks in the right order, de-duplicate, and answer 503 for an update still in progress so it is retried.
Retries and outcomes#
| Your endpoint answers | What happens |
|---|---|
| Any 2xx | Delivered. The update is done. |
| 5xx, 408, 429, a timeout, a connection or TLS error | Retried after 2 s, 10 s, 30 s, 2 min, 10 min, 30 min, then every hour, each ±20 %. A 429's Retry-After (seconds or an HTTP date, at most 1 hour) is honoured. |
| 410 Gone | Rejected, and the webhook is switched off at once. |
| Any other 4xx, any 3xx (redirects are never followed), 1xx | Rejected: that update is not retried. |
| No answer for 24 hours | The update expires (dead), like any update older than 24 hours. |
While your endpoint is failing, Qivuna slows down: one request at a time for the whole bot, and a pause between failed rounds that grows on the same schedule. A dead endpoint gets about as many requests as one update's retries — roughly 30 a day — however many chats are waiting. The first success restores normal speed and retries everything that was waiting.
Ordering is kept: within a chat, an update waits until the one before it was delivered, rejected or expired.
When a webhook is switched off#
Qivuna switches a webhook off, tells the owner in QivBot, and leaves the waiting updates for getUpdates when:
- the endpoint answers 410;
- deliveries have failed continuously for 24 hours with evidence that the endpoint itself is at fault (an HTTP status of its own, or a failure after the connection reached it) — and only while Qivuna's egress is demonstrably working and no platform-wide failure is going on;
- deliveries have failed for 72 hours without such evidence, under the same conditions;
- the URL is no longer allowed by the rules above;
- the owner switches it off in QivBot, or the Qivuna team does.
An outage on Qivuna's side never switches anybody's webhook off: deliveries wait and are not counted as failures. getWebhookInfo keeps last_error_message after a disable so you can see why, for example “Disabled: the webhook answered 410 Gone”, “Disabled after 24 hours of failed deliveries” or “Wrong response from the webhook: 503”. These messages are our own wording and never contain your response body or URL. Call setWebhook again to switch it back on.
Encrypted payloads (qbw1)#
An encrypted bot can ask for every delivery to be sealed to its active key, so that whatever terminates TLS in front of your endpoint (a CDN, a load balancer) sees only ciphertext. With encrypt_payload: true the body is:
{"qbw1":{"eph":"<base64, 32 bytes>","nonce":"<base64, 12 bytes>","ct":"<base64 ciphertext ‖ tag>"}}
shared = X25519(eph_private, bot_x25519_public)
key = HKDF-SHA256(ikm = shared, salt = eph_public ‖ bot_x25519_public,
info = "Qivuna.Bot.v1.webhook", length = 32)
ct‖tag = AES-256-GCM(key, nonce, utf8(update JSON),
aad = utf8("Qivuna.Bot.v1.webhook|" ‖ delivery_id ‖ "|" ‖ timestamp))
- Every attempt is sealed afresh (a new ephemeral key and that attempt's timestamp). The delivery id and timestamp from the headers are bound into the AEAD.
- The HMAC signature covers the encrypted body. Check the signature and the timestamp first, then open; once you configured encryption, refuse plaintext bodies.
- Without an active key (revoked, or the bot changed owners) nothing is sent and nothing counts as a failure: deliveries wait (up to the usual 24 hours) with the last error “Waiting: encrypt_payload needs an active bot key”.
- Inner
encrypted_messageenvelopes stay end-to-end encrypted to the bot as usual;qbw1is an extra layer for everything else in the update.
The SDKs implement all of this (encryptedPayload: true / encrypted_payload=True). Test vectors: qbw1 and webhook_hmac in the vectors file.
Where deliveries come from#
All webhook traffic will leave Qivuna through a dedicated egress proxy, never from the app servers. Its IPv4 and IPv6 addresses will be listed here when webhooks open.
Allow-list them if your firewall needs to, but always verify the signature: an address is not proof of origin.