Webhooks
Hakari POSTs JSON to your URL when things happen in your project — streams go live, VODs finish processing, recordings complete. Each delivery is HMAC-SHA256 signed so you can verify it came from us.
Configure
Dashboard → Webhooks → + New webhook:
- URL — your HTTPS endpoint. Plain
http://is accepted only for local development. - Description — optional label for the dashboard.
- Events — tick the ones you care about. See the event catalog below.
On create you'll see the webhook's secret — a 32-hex-char string used to sign every delivery. Keep it private.
Delivery shape
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: hakari-webhook/1.0
X-Hakari-Event: stream.live
X-Hakari-Delivery: 66bfee0a9a3d2e0012f3a1b8
X-Hakari-Signature: sha256=<64 hex chars>
{
"id": "66bfee0a9a3d2e0012f3a1b8",
"event": "stream.live",
"createdAt": "2026-04-21T14:32:18.420Z",
"data": {
"id": "66bfec...",
"streamKey": "hkr_abc123",
"status": "live",
"projectId": "66bf1a...",
"vodEnabled": true,
"name": "Launch webinar",
"createdAt": "2026-04-21T14:10:02.000Z"
}
}
Response handling:
- 2xx — success. Delivery marked
successin the dashboard. - Any other status / network error / timeout (10s) — retried. See Retry policy.
Verify the signature
The signature is HMAC-SHA256 of the raw request body using your webhook's secret. Compare with constant-time equality — don't use ==, it leaks timing.
Node.js
import crypto from "crypto";
import express from "express";
const app = express();
// Preserve the raw body for HMAC computation. JSON.stringify is NOT
// byte-identical to what was signed — parse/restringify changes key order,
// whitespace, unicode escapes. Always HMAC over the wire bytes.
app.use(express.raw({ type: "application/json" }));
app.post("/hakari-webhook", (req, res) => {
const received = (req.get("x-hakari-signature") || "").replace(/^sha256=/, "");
const expected = crypto
.createHmac("sha256", process.env.HAKARI_WEBHOOK_SECRET)
.update(req.body)
.digest("hex");
const valid =
received.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(received, "hex"), Buffer.from(expected, "hex"));
if (!valid) return res.status(401).end();
const event = JSON.parse(req.body.toString("utf8"));
switch (event.event) {
case "stream.live": /* ... */ break;
case "stream.ended": /* ... */ break;
case "vod.complete": /* ... */ break;
}
res.json({ ok: true });
});
Python (Flask)
import hmac, hashlib
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = b"<your webhook secret>"
@app.post("/hakari-webhook")
def hook():
received = request.headers.get("X-Hakari-Signature", "").removeprefix("sha256=")
expected = hmac.new(SECRET, request.get_data(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(received, expected):
abort(401)
event = request.get_json()
# ... dispatch on event["event"]
return {"ok": True}
Go
func verify(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
received := strings.TrimPrefix(r.Header.Get("X-Hakari-Signature"), "sha256=")
mac := hmac.New(sha256.New, []byte(os.Getenv("HAKARI_WEBHOOK_SECRET")))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(received), []byte(expected)) {
http.Error(w, "unauthorized", 401)
return
}
// parse + dispatch body
}
Retry policy
If the delivery is not a 2xx, or your endpoint times out (10s), we retry:
| Attempt | Delay from previous | | --- | --- | | 1 | 0s | | 2 | 5s | | 3 | 30s | | 4 | 2m | | 5 | 10m |
After the 5th failure the delivery is marked failed and we stop trying. A webhook's consecutiveFailures counter surfaces in the dashboard — if it gets high, your URL is probably down or misconfigured.
Every attempt is a fresh HTTP request — don't treat duplicate X-Hakari-Delivery as a bug, it's the same event being retried. Make your handler idempotent on that id.
Events
Stream events
| Event | Fires when |
| --- | --- |
| stream.created | A stream is created. |
| stream.live | The first push opens a session. |
| stream.ended | The push disconnects (or the stream is killed). |
| stream.disabled | The stream is disabled — either manually, via credit kill, or by inactivity auto-stop. Payload includes reason. |
VOD events
| Event | Fires when |
| --- | --- |
| vod.uploaded | The source file has landed in storage and passes validation. |
| vod.processing | Transcode starts (a job picks up the upload). |
| vod.complete | All renditions are uploaded, master playlist published, thumbnail extracted. data.playbackUrl is ready. |
| vod.failed | Transcode errored. data.error has the message. |
| vod.cancelled | You hit Cancel on the VOD. |
All payloads share the envelope shown above; the data shape depends on the event. Full schemas in the API reference.
Replay / debug
Dashboard → Webhooks → Recent deliveries shows the last 50 attempts with event, status, HTTP response code, and any error. Useful for spot-checking signature verification during integration.