Hakari

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 success in 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.