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.
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.
Something wrong or unclear on this page? Email [email protected] — include the page name.