Preskočiť na obsah

Dokumentácia pre vývojárov je v angličtine, rovnako ako API.

Webhooks

Ozvi tells your systems when something happens on a board: a new bug, a status change, a published update. JSON deliveries are signed following Standard Webhooks, so any of its libraries can verify them.

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

EventWhen
item.createdA new idea, or a new bug report (bugs only for hooks with “include bugs”). Posts waiting for moderation are sent once approved.
item.status_changedThe stage changed. previousStatus, and proof (PRs, release, preview) when an item reaches fixed or shipped.
comment.createdA public comment (internal notes and comments waiting for approval are never sent).
item.mergedA duplicate was merged; mergedInto has the original's number and title.
item.linkedLinked to a GitHub issue; link has ref and url.
item.tags_changedPublic tags changed; tagsChange has previous, added, removed (team-only tags never leave Ozvi).
changelog.publishedAn 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.

item.status_changed
{
  "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

HeaderValue
webhook-idUnique per delivery, the same on every retry (msg_…). Use it to ignore duplicates.
webhook-timestampUnix seconds when the delivery was signed.
webhook-signaturev1,<base64>: HMAC-SHA256 of id.timestamp.body. May hold several space-separated signatures.
ozvi-eventThe event name, as in the body.
ozvi-deliveryThe delivery id without the msg_ prefix.
ozvi-signatureThe 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:

Your secret → whsec_ form
node -e 'console.log("whsec_" + Buffer.from(process.argv[1], "base64url").toString("base64"))' "<secret from Ozvi>"
With the official library (npm i standardwebhooks)
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 timestamp

Or without a library, in a few lines:

Node.js

verify.mjs (Express)
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

verify.py (Flask)
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 "", 204

PHP

ozvi.php
<?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 2xx within 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 at and 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

Nothing to change in Ozvi: switch your receiver to the webhook-* headers whenever it suits you.