# Webhooks

> Receive video.ready and other events from Wolvy. Verify the Wolvy-Signature HMAC over the raw body, handle duplicates, and understand retries and auto-disable.

Source: https://docs.wolvy.net/webhooks/ · Wolvy Developer API v1 · updated 2026-11-12

_Guide · needs your code_

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

## How webhooks work

1. **You register an https URL**and pick the events you want. Wolvy returns a signing secret, once.
2. **Something happens**Background jobs notice changes every minute, so a delivery usually leaves within a minute or two.
3. **Wolvy posts a signed JSON event**Your server checks the signature and the timestamp, answers `2xx` fast, and does the work afterwards.
4. **No 2xx? Wolvy retries**up to five attempts over about two and a half hours, then marks the delivery failed.

## Create an endpoint

```bash
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"
    ]
  }'
```

_Also on the page in Node.js, Python, PHP._

_201 · response_

```json
{
  "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"
}
```

> **Security — The secret is shown once**
> Store `secret` (`whsec_` + 48 hex characters) straight away — the API never returns it again. Each endpoint has its own. Keep it server-side, like an API key.

- 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](https://docs.wolvy.net/api/#list-security-events) was recorded. |
| `payment.paid` | A payment settled. |
| `usage.threshold_reached` (required) | Reserved. You can subscribe, but it is not sent yet — watch `danger_zone` on [Get usage](https://docs.wolvy.net/api/#get-usage) instead. |
| `webhook.test` | Only from [Send a test delivery](https://docs.wolvy.net/api/#test-webhook-endpoint). Always delivered; not subscribable. |

`video.created` — example `data`

_video.created · data_

```json
{
  "id": "a1b2c3d4e5f60718293a",
  "object": "video",
  "status": "processing"
}
```

`video.ready` — example `data`

_video.ready · data_

```json
{
  "id": "a1b2c3d4e5f60718293a",
  "object": "video",
  "status": "ready"
}
```

`video.failed` — example `data`

_video.failed · data_

```json
{
  "id": "a1b2c3d4e5f60718293a",
  "object": "video",
  "status": "failed",
  "error": "download failed: source returned HTTP 404"
}
```

`video.deleted` — example `data`

_video.deleted · data_

```json
{
  "id": "a1b2c3d4e5f60718293a",
  "object": "video"
}
```

`caption.ready` — example `data`

_caption.ready · data_

```json
{
  "id": "a1b2c3d4e5f60718293a",
  "object": "video",
  "captions": [
    {
      "label": "English",
      "language_code": "en"
    }
  ]
}
```

`security.event` — example `data`

_security.event · data_

```json
{
  "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_

```json
{
  "id": 5512,
  "object": "payment",
  "amount": 129,
  "plan_type": "fixed",
  "billing_cycle": "monthly"
}
```

`webhook.test` — example `data`

_webhook.test · data_

```json
{
  "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](https://docs.wolvy.net/api/#list-webhook-deliveries) |
| `Content-Type` | `application/json` |
| `User-Agent` | `Wolvy-Webhooks/1.0` |

_Body_

```json
{
  "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](https://docs.wolvy.net/webhooks/#verify). That is why you verify the raw body, never a re-serialised copy.

## Verify the signature

1. **Read the raw body**The exact bytes, before any JSON parsing. Frameworks that parse for you must be told not to on this route.
2. **Split the header**`Wolvy-Signature` into `t` and `v1`.
3. **Reject stale deliveries**If `t` is more than 5 minutes from your clock, refuse it — a captured delivery could be replayed.
4. **Compute and compare**`HMAC-SHA256(key = your whsec_ secret, message = t + "." + raw body)` as lowercase hex, compared with `v1` in constant time.

```js
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)
});
```

_Also on the page in PHP, Python, Go._

> **Careful — Raw body, per framework**
> - **Express** — `express.raw({ type: "application/json" })` on the webhook route; `express.json()` destroys the bytes.
> - **Next.js route handlers** — `await request.text()`, then `JSON.parse` after verifying.
> - **Laravel** — `$request->getContent()` and `$request->header('Wolvy-Signature')`; exclude the route from CSRF protection.
> - **Django** — `request.body`, with `@csrf_exempt`. **Flask** — `request.get_data()`.

### 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.

_[Interactive: Signature Lab — runs on https://docs.wolvy.net/webhooks/]_

## 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}`](https://docs.wolvy.net/api/#get-video).

```js
// 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;
  }
}
```

_Also on the page in PHP._

## 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.

> **Important — 20 failed deliveries in a row disable the endpoint**
> When 20 consecutive deliveries use up all their attempts, the endpoint is switched off (`is_active: false`) and nothing more is sent. Any successful delivery resets the count. Fix the receiver, then switch it back on — this also resets the count and resumes pending deliveries:

```bash
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
  }'
```

_Also on the page in Node.js, Python, PHP._

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.

```bash
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"
```

_Also on the page in Node.js, Python, PHP._

### See what happened

[`GET /v1/webhook-deliveries?status=failed`](https://docs.wolvy.net/api/#list-webhook-deliveries) 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](https://docs.wolvy.net/webhooks/#verify).

### 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](https://docs.wolvy.net/webhooks/#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](mailto:contact@wolvy.net?subject=Docs%3A%20Webhooks) — include the page name.
