Wolvy DOCS

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.

Scope webhooks:manage Header Wolvy-Signature: t=…,v1=… HMAC-SHA256 · hex
On this page

How webhooks work#

  1. You register an https URLand pick the events you want. Wolvy returns a signing secret, once.
  2. Something happensBackground jobs notice changes every minute, so a delivery usually leaves within a minute or two.
  3. Wolvy posts a signed JSON eventYour server checks the signature and the timestamp, answers 2xx fast, and does the work afterwards.
  4. 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"
    ]
  }'
201 · response
{
  "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#

EventSent 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
video.created · data
{
  "id": "a1b2c3d4e5f60718293a",
  "object": "video",
  "status": "processing"
}
video.ready — example data
video.ready · data
{
  "id": "a1b2c3d4e5f60718293a",
  "object": "video",
  "status": "ready"
}
video.failed — example data
video.failed · data
{
  "id": "a1b2c3d4e5f60718293a",
  "object": "video",
  "status": "failed",
  "error": "download failed: source returned HTTP 404"
}
video.deleted — example data
video.deleted · data
{
  "id": "a1b2c3d4e5f60718293a",
  "object": "video"
}
caption.ready — example data
caption.ready · data
{
  "id": "a1b2c3d4e5f60718293a",
  "object": "video",
  "captions": [
    {
      "label": "English",
      "language_code": "en"
    }
  ]
}
security.event — example data
security.event · data
{
  "id": 20977,
  "object": "security_event",
  "event_type": "drm_device_revoked",
  "severity": "critical",
  "video_id": "a1b2c3d4e5f60718293a",
  "viewer_id": "user-8813"
}
payment.paid — example data
payment.paid · data
{
  "id": 5512,
  "object": "payment",
  "amount": 129,
  "plan_type": "fixed",
  "billing_cycle": "monthly"
}
webhook.test — example data
webhook.test · 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.

HeaderValue
Wolvy-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>
Wolvy-Event-TypeThe event type, e.g. video.ready
Wolvy-Delivery-IdInteger id of this delivery — matches List deliveries
Content-Typeapplication/json
User-AgentWolvy-Webhooks/1.0
Body
{
  "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#

  1. Read the raw bodyThe exact bytes, before any JSON parsing. Frameworks that parse for you must be told not to on this route.
  2. Split the headerWolvy-Signature into t and v1.
  3. Reject stale deliveriesIf t is more than 5 minutes from your clock, refuse it — a captured delivery could be replayed.
  4. Compute and compareHMAC-SHA256(key = your whsec_ secret, message = t + "." + raw body) as lowercase hex, compared with v1 in 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)
});

Test vector

secretwhsec_56b87e856166601d9414d8e3b96f78fe26cbda605a38676a
t1790000000
raw body{"id":"evt_3f9a1c07b2e84d5a6c10","type":"video.ready","created_at":"2026-09-21T14:13:10+00:00","data":{"id":"a1b2c3d4e5f60718293a","object":"video","status":"ready"}}
v1e43bfc1bae59608bba1a11c2eef01cde3b04a7794673edf8365eafd94cd8e2aa
headerWolvy-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.

SIGNATURE_LAB.EXE

Wolvy sends

Returned once when you create the endpoint.
Headers
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
Raw body
{"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 t and v1
  • 2. Timestamp within 5 minutes2 s old
  • 3. HMAC-SHA256(secret, t + "." + raw body) equals v1
    e43bfc1bae59608bba1a11c2eef01cde3b04a7794673edf8365eafd94cd8e2aa
Verified

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.failed events with different ids. Make handlers idempotent: record the event id, and make the action itself safe to repeat.
  • Don’t rely on order. Deliveries are independent; if a video.ready arrives after you have seen video.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;
  }
}

Retries & auto-disable#

AttemptWhenIf it fails
1As soon as the event is picked upRetry in about 1 minute
2+1 minuteRetry in about 5 minutes
3+5 minutesRetry in about 30 minutes
4+30 minutesRetry in about 2 hours
5+2 hoursDelivery 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
  }'

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"

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.