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
- At-least-once. Failures retry at 1 m, 5 m, 30 m, 2 h, 6 h, then
dead-letter (visible via
GET /v1/webhook-deliveries?status=failed, replayable). Deduplicate by theEnergixer-Delivery-Idheader — replays and retries reuse the same id. - Ordering is not guaranteed across events. Order by the payload's
created_at, not arrival. - Respond
2xxwithin 10 seconds. Redirects are not followed.
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).