Guide · needs your code
Webhooks
Instead of polling, let Wolvy post to your server when something happens — a video finishes encoding, fails, gets a caption, or trips a security check. Every delivery is signed; verifying that signature is the one piece of code you must get right.
Updated View as Markdown
On this page
How webhooks work#
- You register an https URLand pick the events you want. Wolvy returns a signing secret, once.
- Something happensBackground jobs notice changes every minute, so a delivery usually leaves within a minute or two.
- Wolvy posts a signed JSON eventYour server checks the signature and the timestamp, answers
2xxfast, and does the work afterwards. - No 2xx? Wolvy retriesup to five attempts over about two and a half hours, then marks the delivery failed.
Create an endpoint#
curl -X POST "https://api.wolvy.net/v1/webhook-endpoints" \
-H "Authorization: Bearer $WOLVY_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 5b6f1c2e-9a0d-4f3e-8c71-2d4b6a9e0f13" \
-d '{
"url": "https://example.com/hooks/wolvy",
"events": [
"video.ready",
"video.failed"
]
}' import { randomUUID } from "node:crypto";
const res = await fetch("https://api.wolvy.net/v1/webhook-endpoints", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.WOLVY_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": randomUUID(), // reuse the same key when you retry
},
body: JSON.stringify({
url: "https://example.com/hooks/wolvy",
events: ["video.ready", "video.failed"],
}),
});
const endpoint = await res.json();
if (!res.ok) throw new Error(`${res.status} ${endpoint.error.code}`); import os
import uuid
import requests
res = requests.post(
"https://api.wolvy.net/v1/webhook-endpoints",
headers={
"Authorization": f"Bearer {os.environ['WOLVY_API_KEY']}",
"Idempotency-Key": str(uuid.uuid4()), # reuse the same key when you retry
},
json={
"url": "https://example.com/hooks/wolvy",
"events": ["video.ready", "video.failed"],
},
timeout=30,
)
endpoint = res.json()
if not res.ok:
raise RuntimeError(f"{res.status_code} {endpoint['error']['code']}") <?php
$ch = curl_init('https://api.wolvy.net/v1/webhook-endpoints');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('WOLVY_API_KEY'),
'Content-Type: application/json',
'Idempotency-Key: ' . bin2hex(random_bytes(16)), // reuse the same key when you retry
],
CURLOPT_POSTFIELDS => json_encode([
'url' => 'https://example.com/hooks/wolvy',
'events' => ['video.ready', 'video.failed'],
]),
]);
$endpoint = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
throw new RuntimeException("$status {$endpoint['error']['code']}");
} {
"id": 3,
"object": "webhook_endpoint",
"url": "https://example.com/hooks/wolvy",
"events": ["video.ready", "video.failed"],
"is_active": true,
"failure_count": 0,
"created_at": "2026-09-15T12:00:00+00:00",
"updated_at": "2026-09-15T12:00:00+00:00",
"secret": "whsec_56b87e856166601d9414d8e3b96f78fe26cbda605a38676a"
} - The URL must be https on port 443 or 8443 and resolve to public addresses. It is re-checked before every delivery.
- Redirects are not followed. Register the final URL, including any trailing slash your framework insists on.
- Endpoints created here and in the dashboard are the same records; manage them from either.
Events#
| Event | Sent when |
|---|---|
video.created | Wolvy finished downloading your source and handed it to encoding. |
video.ready | Every resolution is encoded. The video is fully available. |
video.failed | Ingest or encoding failed. Ingest failures carry error. One video can produce this event twice — dedupe. |
video.deleted | A video was deleted through the API. Dashboard deletions do not emit it. |
caption.ready | The caption list changed — added, replaced, or removed (including the last one: captions: []). |
security.event | A new security event was recorded. |
payment.paid | A payment settled. |
usage.threshold_reachedRESERVED | Reserved. You can subscribe, but it is not sent yet — watch danger_zone on Get usage instead. |
webhook.test | Only from Send a test delivery. Always delivered; not subscribable. |
video.created — example data
{
"id": "a1b2c3d4e5f60718293a",
"object": "video",
"status": "processing"
} video.ready — example data
{
"id": "a1b2c3d4e5f60718293a",
"object": "video",
"status": "ready"
} video.failed — example data
{
"id": "a1b2c3d4e5f60718293a",
"object": "video",
"status": "failed",
"error": "download failed: source returned HTTP 404"
} video.deleted — example data
{
"id": "a1b2c3d4e5f60718293a",
"object": "video"
} caption.ready — example data
{
"id": "a1b2c3d4e5f60718293a",
"object": "video",
"captions": [
{
"label": "English",
"language_code": "en"
}
]
} security.event — example data
{
"id": 20977,
"object": "security_event",
"event_type": "drm_device_revoked",
"severity": "critical",
"video_id": "a1b2c3d4e5f60718293a",
"viewer_id": "user-8813"
} payment.paid — example data
{
"id": 5512,
"object": "payment",
"amount": 129,
"plan_type": "fixed",
"billing_cycle": "monthly"
} webhook.test — example data
{
"object": "test",
"message": "If you can verify this signature, your endpoint is configured correctly."
} The delivery#
Each delivery is an HTTP POST with a JSON body. The envelope is the same for every event; only type and data change.
| Header | Value |
|---|---|
Wolvy-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
Wolvy-Event-Type | The event type, e.g. video.ready |
Wolvy-Delivery-Id | Integer id of this delivery — matches List deliveries |
Content-Type | application/json |
User-Agent | Wolvy-Webhooks/1.0 |
{
"id": "evt_3f9a1c07b2e84d5a6c10",
"type": "video.ready",
"created_at": "2026-09-21T14:13:10+00:00",
"data": {
"id": "a1b2c3d4e5f60718293a",
"object": "video",
"status": "ready"
}
} id identifies the event for de-duplication. On the wire the body is compact, with no spaces — the example is pretty-printed for reading; the exact bytes are in the test vector. That is why you verify the raw body, never a re-serialised copy.
Verify the signature#
- Read the raw bodyThe exact bytes, before any JSON parsing. Frameworks that parse for you must be told not to on this route.
- Split the header
Wolvy-Signatureintotandv1. - Reject stale deliveriesIf
tis more than 5 minutes from your clock, refuse it — a captured delivery could be replayed. - Compute and compare
HMAC-SHA256(key = your whsec_ secret, message = t + "." + raw body)as lowercase hex, compared withv1in constant time.
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.WOLVY_WEBHOOK_SECRET; // whsec_…
export function verifyWolvySignature(rawBody, header, secret, now = Math.floor(Date.now() / 1000)) {
let t = "", v1 = "";
for (const part of String(header ?? "").split(",")) {
const i = part.indexOf("=");
if (i === -1) continue;
const key = part.slice(0, i).trim();
const value = part.slice(i + 1).trim();
if (key === "t") t = value;
if (key === "v1") v1 = value;
}
if (!/^\d+$/.test(t) || !v1 || Math.abs(now - Number(t)) > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex");
return expected.length === v1.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}
// express.raw keeps the exact bytes — express.json() would re-serialise them.
app.post("/hooks/wolvy", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyWolvySignature(req.body, req.get("Wolvy-Signature"), SECRET)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body); // parse only after verifying
res.sendStatus(200); // acknowledge within 15 s…
handleEvent(event); // …then do the work (dedupe on event.id)
}); <?php
function wolvy_verify_signature(string $raw, string $header, string $secret, ?int $now = null): bool
{
$t = $v1 = '';
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
if ($key === 't') { $t = $value; }
if ($key === 'v1') { $v1 = $value; }
}
if (!ctype_digit($t) || $v1 === '' || abs(($now ?? time()) - (int) $t) > 300) {
return false;
}
return hash_equals(hash_hmac('sha256', $t . '.' . $raw, $secret), $v1);
}
$raw = file_get_contents('php://input'); // the raw body, before json_decode
$header = $_SERVER['HTTP_WOLVY_SIGNATURE'] ?? '';
if (!wolvy_verify_signature($raw, $header, getenv('WOLVY_WEBHOOK_SECRET'))) {
http_response_code(400);
exit;
}
$event = json_decode($raw, true);
http_response_code(200);
// Queue the work — dedupe on $event['id'] — rather than doing it here. import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["WOLVY_WEBHOOK_SECRET"] # whsec_…
def verify_wolvy_signature(raw, header, secret, now=None):
parts = dict(p.strip().split("=", 1) for p in (header or "").split(",") if "=" in p)
t, v1 = parts.get("t", ""), parts.get("v1", "")
if not (t.isascii() and t.isdigit()) or not v1:
return False
if abs((int(time.time()) if now is None else now) - int(t)) > 300:
return False
expected = hmac.new(secret.encode(), t.encode() + b"." + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
@app.post("/hooks/wolvy")
def wolvy_webhook():
if not verify_wolvy_signature(request.get_data(), request.headers.get("Wolvy-Signature", ""), SECRET):
abort(400)
event = request.get_json() # parse only after verifying
# enqueue the work, deduplicated on event["id"]
return "", 200 package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
)
type WolvyEvent struct {
ID string `json:"id"`
Type string `json:"type"`
CreatedAt string `json:"created_at"`
Data json.RawMessage `json:"data"`
}
func verifyWolvySignature(raw []byte, header, secret string, now time.Time) bool {
var t, v1 string
for _, part := range strings.Split(header, ",") {
key, value, ok := strings.Cut(strings.TrimSpace(part), "=")
if !ok {
continue
}
switch key {
case "t":
t = value
case "v1":
v1 = value
}
}
ts, err := strconv.ParseInt(t, 10, 64)
if err != nil || v1 == "" {
return false
}
if age := now.Unix() - ts; age > 300 || age < -300 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(t + "."))
mac.Write(raw)
return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(v1))
}
func wolvyWebhook(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil || !verifyWolvySignature(raw, r.Header.Get("Wolvy-Signature"), os.Getenv("WOLVY_WEBHOOK_SECRET"), time.Now()) {
http.Error(w, "invalid signature", http.StatusBadRequest)
return
}
var event WolvyEvent
if err := json.Unmarshal(raw, &event); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
go handleEvent(event) // your code: dedupe on event.ID, then act on event.Type
}
func handleEvent(event WolvyEvent) { log.Printf("wolvy %s %s", event.ID, event.Type) }
func main() {
http.HandleFunc("/hooks/wolvy", wolvyWebhook)
log.Fatal(http.ListenAndServe(":8080", nil))
} Test vector
| secret | whsec_56b87e856166601d9414d8e3b96f78fe26cbda605a38676a |
| t | 1790000000 |
| raw body | {"id":"evt_3f9a1c07b2e84d5a6c10","type":"video.ready","created_at":"2026-09-21T14:13:10+00:00","data":{"id":"a1b2c3d4e5f60718293a","object":"video","status":"ready"}} |
| v1 | e43bfc1bae59608bba1a11c2eef01cde3b04a7794673edf8365eafd94cd8e2aa |
| header | Wolvy-Signature: t=1790000000,v1=e43bfc1bae59608bba1a11c2eef01cde3b04a7794673edf8365eafd94cd8e2aa |
Pass now = 1790000100 to your verifier to check the vector without the 5-minute window getting in the way.
Signature lab#
Sign a delivery the way Wolvy does, then break your receiver in the four ways real integrations break and see which check catches each one.
Wolvy sends
Content-Type: application/json User-Agent: Wolvy-Webhooks/1.0 Wolvy-Event-Type: video.ready Wolvy-Delivery-Id: 1841 Wolvy-Signature: t=1790000000,v1=e43bfc1bae59608bba1a11c2eef01cde3b04a7794673edf8365eafd94cd8e2aa
{"id":"evt_3f9a1c07b2e84d5a6c10","type":"video.ready","created_at":"2026-09-21T14:13:10+00:00","data":{"id":"a1b2c3d4e5f60718293a","object":"video","status":"ready"}}Your server checks
Break the receiver:
- ✓1. Parse the header — found
tandv1 - ✓2. Timestamp within 5 minutes — 2 s old
- ✓3. HMAC-SHA256(secret, t + "." + raw body) equals v1
e43bfc1bae59608bba1a11c2eef01cde3b04a7794673edf8365eafd94cd8e2aa
All three checks pass. Parse the JSON now, respond 2xx, then do the work.
Respond fast, dedupe#
- Answer within 15 seconds. Verify, parse, respond
200, then do slow work on a queue. A timeout counts as a failure. - Expect duplicates. Retries after a lost response, and some events can arrive twice — a failed download produces two
video.failedevents with different ids. Make handlers idempotent: record the eventid, and make the action itself safe to repeat. - Don’t rely on order. Deliveries are independent; if a
video.readyarrives after you have seenvideo.deleted, re-read the video before acting. - Fetch, don’t trust, when it matters. For decisions such as publishing a lesson, confirm with
GET /v1/videos/{id}.
// processed_events(event_id TEXT PRIMARY KEY, received_at TIMESTAMP)
async function handleEvent(event) {
const inserted = await db.query(
"INSERT INTO processed_events (event_id, received_at) VALUES ($1, now()) ON CONFLICT DO NOTHING",
[event.id],
);
if (inserted.rowCount === 0) return; // seen before — a retry or a duplicate
switch (event.type) {
case "video.ready":
await markLessonPublished(event.data.id);
break;
case "video.failed":
await alertContentTeam(event.data.id, event.data.error ?? "encoding failed");
break;
}
} <?php
// processed_events(event_id VARCHAR(40) PRIMARY KEY, received_at DATETIME)
function handle_wolvy_event(PDO $db, array $event): void
{
$insert = $db->prepare('INSERT IGNORE INTO processed_events (event_id, received_at) VALUES (?, NOW())');
$insert->execute([$event['id']]);
if ($insert->rowCount() === 0) {
return; // seen before — a retry or a duplicate
}
switch ($event['type']) {
case 'video.ready':
mark_lesson_published($event['data']['id']);
break;
case 'video.failed':
alert_content_team($event['data']['id'], $event['data']['error'] ?? 'encoding failed');
break;
}
} Retries & auto-disable#
| Attempt | When | If it fails |
|---|---|---|
| 1 | As soon as the event is picked up | Retry in about 1 minute |
| 2 | +1 minute | Retry in about 5 minutes |
| 3 | +5 minutes | Retry in about 30 minutes |
| 4 | +30 minutes | Retry in about 2 hours |
| 5 | +2 hours | Delivery marked failed |
A failure is anything other than a 2xx within 15 seconds — including a redirect, a TLS error or an address that no longer resolves publicly.
curl -X PATCH "https://api.wolvy.net/v1/webhook-endpoints/3" \
-H "Authorization: Bearer $WOLVY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"is_active": true
}' const res = await fetch("https://api.wolvy.net/v1/webhook-endpoints/3", {
method: "PATCH",
headers: {
Authorization: `Bearer ${process.env.WOLVY_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
is_active: true,
}),
});
const endpoint = await res.json();
if (!res.ok) throw new Error(`${res.status} ${endpoint.error.code}`); import os
import requests
res = requests.patch(
"https://api.wolvy.net/v1/webhook-endpoints/3",
headers={"Authorization": f"Bearer {os.environ['WOLVY_API_KEY']}"},
json={"is_active": True},
timeout=30,
)
endpoint = res.json()
if not res.ok:
raise RuntimeError(f"{res.status_code} {endpoint['error']['code']}") <?php
$ch = curl_init('https://api.wolvy.net/v1/webhook-endpoints/3');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'PATCH',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('WOLVY_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'is_active' => true,
]),
]);
$endpoint = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
throw new RuntimeException("$status {$endpoint['error']['code']}");
} Delivery history is kept for 30 days (delivered) and 90 days (failed). Deleting an endpoint deletes its history too.
Testing & debugging#
Send a test event
Queue a webhook.test delivery — always sent, whatever the endpoint subscribes to — and check your verification end to end without waiting for a video to encode.
curl -X POST "https://api.wolvy.net/v1/webhook-endpoints/3/test" \
-H "Authorization: Bearer $WOLVY_API_KEY" \
-H "Idempotency-Key: 5b6f1c2e-9a0d-4f3e-8c71-2d4b6a9e0f13" import { randomUUID } from "node:crypto";
const res = await fetch("https://api.wolvy.net/v1/webhook-endpoints/3/test", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.WOLVY_API_KEY}`,
"Idempotency-Key": randomUUID(), // reuse the same key when you retry
},
});
const delivery = await res.json();
if (!res.ok) throw new Error(`${res.status} ${delivery.error.code}`); import os
import uuid
import requests
res = requests.post(
"https://api.wolvy.net/v1/webhook-endpoints/3/test",
headers={
"Authorization": f"Bearer {os.environ['WOLVY_API_KEY']}",
"Idempotency-Key": str(uuid.uuid4()), # reuse the same key when you retry
},
timeout=30,
)
delivery = res.json()
if not res.ok:
raise RuntimeError(f"{res.status_code} {delivery['error']['code']}") <?php
$ch = curl_init('https://api.wolvy.net/v1/webhook-endpoints/3/test');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('WOLVY_API_KEY'),
'Idempotency-Key: ' . bin2hex(random_bytes(16)), // reuse the same key when you retry
],
CURLOPT_POSTFIELDS => '',
]);
$delivery = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
throw new RuntimeException("$status {$delivery['error']['code']}");
} See what happened
GET /v1/webhook-deliveries?status=failed lists each delivery’s attempts, the HTTP status your server returned and the next retry time — the first place to look when events seem to go missing.
Developing locally
Wolvy only delivers to public https URLs, so localhost cannot receive events. Expose your local server through a tunnel (for example cloudflared or ngrok), register the tunnel’s https URL as a separate endpoint with its own secret, and delete it when you are done.
Common questions#
How do I verify the Wolvy-Signature header?
Split it into t and v1, recompute HMAC-SHA256(secret, t + "." + rawBody) as lowercase hex, and compare with a constant-time function. Reject a timestamp more than five minutes old. There is ready-made code in four languages.
Why does my signature check always fail?
Almost always because you are hashing a re-serialised body. Sign the exact bytes that arrived — req.rawBody, the body read before any JSON parser, php://input — not the object your framework handed you. The signature lab reproduces each failure.
What happens when my endpoint is down?
Each event gets five attempts, roughly 1 minute, 5 minutes, 30 minutes and 2 hours apart, and is then marked failed. Twenty consecutive exhausted deliveries switch the endpoint off; fix the receiver, switch it back on, and pending deliveries resume.
Can the same event arrive twice?
Yes. A lost response causes a retry, and a failed download produces two video.failed events with different ids. Record each event id you have processed and make the action itself safe to repeat.
How fast must my endpoint answer?
Within 15 seconds, with any 2xx. Verify the signature, queue the work and respond — anything slower counts as a failed delivery, as does a redirect, a TLS error or a host that stops resolving publicly.
Something wrong or unclear on this page? Email contact@wolvy.net — include the page name.