QivunaDevelopers

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.

Not available yet Webhooks are not open on the platform yet. 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"]}
ParameterRules
urlhttps:// 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_tokenRequired. 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_connections1–8, default 4: how many chats are delivered in parallel.
allowed_updatesAs for getUpdates; saved only once the challenge passed.
encrypt_payloadEncrypted 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.

Configure your endpoint with the new secret before calling 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":{…}}

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 answersWhat happens
Any 2xxDelivered. The update is done.
5xx, 408, 429, a timeout, a connection or TLS errorRetried 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 GoneRejected, and the webhook is switched off at once.
Any other 4xx, any 3xx (redirects are never followed), 1xxRejected: that update is not retried.
No answer for 24 hoursThe 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:

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

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.