Webhooks — delivery and verification

Set a webhook_url (and note your whsec_ secret) via PATCH /v1/domain/config, and Energixer POSTs you events instead of making you poll:

Event When
video.ready Processing finished; payload contains the full video object incl. playback URLs
video.failed Retries exhausted, or processing timed out (failure_reason: "processing_timeout")
video.expired The upload never completed within an hour
video.cancelled Cancelled or deleted while in flight

The payload's data.video is byte-identical in shape to GET /v1/videos/:id — one serializer, no surprises.

Delivery semantics

Verify the signature (always)

Every delivery carries:

Energixer-Signature: t=1722550000,v1=<hex hmac-sha256>
Energixer-Delivery-Id: <uuid>

The signed string is `${t}.${rawBody}` with your whsec_ secret. Verify with a timing-safe compare and reject timestamps older than ~5 minutes. During the hour after a secret rotation, deliveries carry two v1= values (new first) — accept either.

TypeScript — this exact file is unit-tested against the real signer; copy it whole:

// Energixer webhook verification — the exact snippet published in the docs
// (ADR-11 imports this file verbatim; ADR-08's tests run it against the real
// signer). Standalone: Node 18+, no dependencies.
//
// Usage:
//   const ok = verifyEnergixerWebhook(
//     process.env.ENERGIXER_WEBHOOK_SECRET,   // whsec_...
//     req.headers['energixer-signature'],     // "t=<unix>,v1=<hex>[,v1=<hex>]"
//     rawBody,                                // the EXACT request body bytes
//   );
//
// Always verify against the raw body string — re-serializing parsed JSON
// breaks the signature.

import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyEnergixerWebhook(
  secret: string,
  signatureHeader: string,
  rawBody: string,
  toleranceSeconds = 300,
): boolean {
  const parts = signatureHeader.split(',').map((part) => part.trim());
  const timestampPart = parts.find((part) => part.startsWith('t='));
  if (!timestampPart) return false;
  const timestamp = Number(timestampPart.slice(2));
  if (!Number.isInteger(timestamp)) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSeconds) {
    return false;
  }
  const expected = createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');
  const expectedBuf = Buffer.from(expected);
  return parts
    .filter((part) => part.startsWith('v1='))
    .some((part) => {
      const candidate = Buffer.from(part.slice(3));
      return (
        candidate.length === expectedBuf.length &&
        timingSafeEqual(candidate, expectedBuf)
      );
    });
}

Python — also tested against the real signer:

# Energixer webhook verification — the exact snippet published in the docs
# (tested against the real signer in CI). Python 3.9+, stdlib only.
#
# Usage:
#     ok = verify_energixer_webhook(
#         secret=os.environ["ENERGIXER_WEBHOOK_SECRET"],   # whsec_...
#         signature_header=request.headers["Energixer-Signature"],
#         raw_body=request.get_data(as_text=True),          # EXACT body bytes
#     )
#
# Always verify against the raw body — re-serializing parsed JSON breaks the
# signature.

import hashlib
import hmac
import time


def verify_energixer_webhook(
    secret: str,
    signature_header: str,
    raw_body: str,
    tolerance_seconds: int = 300,
) -> bool:
    parts = [p.strip() for p in signature_header.split(",")]
    timestamp_part = next((p for p in parts if p.startswith("t=")), None)
    if timestamp_part is None:
        return False
    try:
        timestamp = int(timestamp_part[2:])
    except ValueError:
        return False
    if abs(int(time.time()) - timestamp) > tolerance_seconds:
        return False
    expected = hmac.new(
        secret.encode(), f"{timestamp}.{raw_body}".encode(), hashlib.sha256
    ).hexdigest()
    return any(
        hmac.compare_digest(p[3:], expected) for p in parts if p.startswith("v1=")
    )

Sign the raw body bytes you received — do not re-serialize the JSON first (key order changes break signatures).