Set up
An admin adds a webhook in the board's settings, admin → Integrácie → Webhooky: the URL (HTTPS), the events (none = all) and whether bug reports are included (bugs are private; send them only to systems your team controls). Ozvi shows the signing secret once; store it in your receiver's configuration. The test button sends a sample item.created with "test": true.
Chat formats (Slack, Microsoft Teams, Discord) post a readable message instead of JSON and are not signed; everything below is about the JSON format.
Events
| Event | When |
|---|---|
item.created | A new idea, or a new bug report (bugs only for hooks with “include bugs”). Posts waiting for moderation are sent once approved. |
item.status_changed | The stage changed. previousStatus, and proof (PRs, release, preview) when an item reaches fixed or shipped. |
comment.created | A public comment (internal notes and comments waiting for approval are never sent). |
item.merged | A duplicate was merged; mergedInto has the original's number and title. |
item.linked | Linked to a GitHub issue; link has ref and url. |
item.tags_changed | Public tags changed; tagsChange has previous, added, removed (team-only tags never leave Ozvi). |
changelog.published | An update was published; entry has title, body, url, version, the shipped ideas and the AI label. |
Payload
One JSON object per delivery. Text fields come from your users: treat them as untrusted (see user content and provenance). bodyText is the same text without Markdown, handy for chat or e-mail.
{
"event": "item.status_changed",
"project": { "slug": "acme", "name": "Acme" },
"item": {
"number": 128,
"kind": "bug",
"title": "Invoice PDF has the wrong VAT rate",
"body": "After the update the PDF shows **20 %**…",
"bodyText": "After the update the PDF shows 20 %…",
"status": "fixed",
"fixedInVersion": "2.14.1",
"votes": 0,
"url": "https://ozvi.io/acme/i/128",
"createdAt": "2026-11-02T09:14:03.120Z",
"tags": ["invoices"],
"author": { "name": "Jana", "trust": "sso_customer" },
"source": "widget"
},
"actor": { "name": "Peter" },
"previousStatus": "in_progress",
"proof": { "prs": [{ "url": "https://github.com/acme/app/pull/45", "title": "Fix VAT", "ref": "acme/app#45" }], "release": { "tag": "v2.14.1", "url": null }, "preview": null },
"at": "2026-11-08T10:01:22.481Z"
}Headers
| Header | Value |
|---|---|
webhook-id | Unique per delivery, the same on every retry (msg_…). Use it to ignore duplicates. |
webhook-timestamp | Unix seconds when the delivery was signed. |
webhook-signature | v1,<base64>: HMAC-SHA256 of id.timestamp.body. May hold several space-separated signatures. |
ozvi-event | The event name, as in the body. |
ozvi-delivery | The delivery id without the msg_ prefix. |
ozvi-signature | The original signature, kept for receivers built before Standard Webhooks (see below). |
Verify signatures
Always verify on the raw body, before parsing JSON, compare in constant time, and reject timestamps older than five minutes (replays).
The secret Ozvi shows you is the base64url form of 32 random bytes. Standard Webhooks libraries expect the same bytes as whsec_ + standard base64; convert once:
node -e 'console.log("whsec_" + Buffer.from(process.argv[1], "base64url").toString("base64"))' "<secret from Ozvi>"import { Webhook } from "standardwebhooks";
const wh = new Webhook(process.env.OZVI_WHSEC); // the whsec_… form
const event = wh.verify(rawBody, req.headers); // throws on a bad signature or an old timestampOr without a library, in a few lines:
Node.js
import crypto from "node:crypto";
import express from "express";
const key = Buffer.from(process.env.OZVI_WEBHOOK_SECRET, "base64url"); // the secret as shown in Ozvi
function verify(raw, h) {
const id = h["webhook-id"], ts = h["webhook-timestamp"], sigs = h["webhook-signature"] ?? "";
if (!id || !ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = crypto.createHmac("sha256", key).update(`${id}.${ts}.${raw}`).digest();
return sigs.split(" ").some((part) => {
const [v, sig] = part.split(",");
const got = Buffer.from(sig ?? "", "base64");
return v === "v1" && got.length === expected.length && crypto.timingSafeEqual(got, expected);
});
}
const app = express();
app.post("/ozvi", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.body.toString("utf8"), req.headers)) return res.sendStatus(401);
const event = JSON.parse(req.body);
// … handle event.event, dedupe by req.headers["webhook-id"]
res.sendStatus(204);
});
app.listen(3000);Python
import base64, hashlib, hmac, os, time
from flask import Flask, abort, request
KEY = base64.urlsafe_b64decode(os.environ["OZVI_WEBHOOK_SECRET"] + "==") # the secret as shown in Ozvi
def verify(raw: bytes, h) -> bool:
msg_id, ts, sigs = h.get("webhook-id"), h.get("webhook-timestamp"), h.get("webhook-signature", "")
if not msg_id or not ts or abs(time.time() - int(ts)) > 300:
return False
expected = hmac.new(KEY, f"{msg_id}.{ts}.".encode() + raw, hashlib.sha256).digest()
for part in sigs.split(" "):
version, _, sig = part.partition(",")
if version == "v1" and hmac.compare_digest(base64.b64decode(sig), expected):
return True
return False
app = Flask(__name__)
@app.post("/ozvi")
def ozvi():
if not verify(request.get_data(), request.headers):
abort(401)
event = request.get_json()
# … handle event["event"]
return "", 204PHP
<?php
$key = base64_decode(strtr(getenv('OZVI_WEBHOOK_SECRET'), '-_', '+/')); // the secret as shown in Ozvi
$raw = file_get_contents('php://input');
$id = $_SERVER['HTTP_WEBHOOK_ID'] ?? '';
$ts = $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '';
$sigs = $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '';
$ok = false;
if ($id !== '' && ctype_digit($ts) && abs(time() - (int) $ts) <= 300) {
$expected = hash_hmac('sha256', "$id.$ts.$raw", $key, true);
foreach (explode(' ', $sigs) as $part) {
[$version, $sig] = array_pad(explode(',', $part, 2), 2, '');
if ($version === 'v1' && hash_equals($expected, base64_decode($sig))) { $ok = true; break; }
}
}
if (!$ok) { http_response_code(401); exit; }
$event = json_decode($raw, true);
// … handle $event['event']
http_response_code(204);Retries and ordering
- Answer with any
2xxwithin 10 seconds. Do the slow work afterwards (a queue), not before answering. - Anything else is retried: Ozvi tries six times in all, after 1, 4, 9, 16 and 25 minutes. Redirects are not followed.
- The order of deliveries is not guaranteed; use
atand the item's current state (GET /items/{number}) when order matters. - The same delivery can arrive twice (a retry after a timeout): dedupe by
webhook-id. - Delivery history (30 days) and failures are in admin → Integrácie.
Ozvi-Signature
Receivers built before Standard Webhooks can keep using Ozvi-Signature: t=<unix>,v1=<hex>: the hex HMAC-SHA256 of <t>.<body>, keyed with the secret string exactly as shown in Ozvi (not decoded). Both headers are sent on every JSON delivery.
Moving to Standard Webhooks
webhook-* headers whenever it suits you.