# Guide to Wistia Webhooks: Features and Best Practices

[Wistia](https://wistia.com) is a video hosting and marketing platform for businesses, with a customisable player, lead capture forms, calls to action and live events. Its webhooks report two kinds of activity: what happens to your media (uploads, processing, transcripts, translations, deletions) and what viewers do with it (plays, watch progress, form submissions and clicks). Marketing teams use them to push video leads into a CRM, score engagement, and publish videos as soon as they're ready.

This guide covers configuring a Wistia webhook and its Secret Key, the batched `events` array, verifying `X-Wistia-Signature`, and handling at-least-once, unordered delivery with a 10-second timeout.

## What are Wistia webhooks?

Wistia webhooks are configured under **Account Settings > Webhooks**. A webhook has a POST URL, a Secret Key that you choose and the event types to send. Wistia doesn't generate the key for you and has no other authentication on the request, so the signature is the only proof a delivery came from Wistia.

Deliveries are always JSON with a `User-Agent` of `Wistia-Webhooks/{VERSION}`. Wistia batches events that happen close together into one request, so the body holds an `events` array that may contain several events of different types.

## Wistia webhook features

| Feature | Details |
| --- | --- |
| Signature | hex HMAC-SHA256 |
| Signature header | `x-wistia-signature` |
| Body | JSON |
| Event routing | Body field `events[].type` |

## Events and payloads

| Event | Triggered When | Common Use Cases |
| --- | --- | --- |
| `media.created` | A media is created | Track new uploads |
| `media.processing` | Processing is started | Show a "processing" state |
| `media.ready` | Processing is complete and all video and image assets are encoded | Publish the video, notify the uploader |
| `media.failed` | Processing failed | Alert and retry the upload |
| `media.updated` | A media is replaced or its title, description or thumbnail changes | Sync metadata (see `previous_attributes`) |
| `media.transcript_updated` | New captions are generated (automated or human) | Fetch the transcript for `transcript.language_code` |
| `media.transcript_failed` | Transcript generation failed | Alert; see `transcript.reason` |
| `media.deleted` | A media is deleted | Remove it from your catalogue |
| `media.translation_created` | A translation is created for a media | Store the translated transcript |
| `media.localization_created` | A localization is created for a media | Publish the localized version |
| `media.localization_failed` | A localization failed | Alert and retry |
| `viewing_session.play` | A viewer plays a media | Engagement analytics |
| `viewing_session.percent_watched` | A viewer reaches a watch-percentage milestone | Progress tracking, lead scoring |
| `viewing_session.turnstile.converted` | A viewer submits a Wistia form | Send the lead to a CRM |
| `viewing_session.call_to_action.converted` | A viewer clicks a call to action | Conversion tracking |
| `viewing_session.annotation.converted` | A viewer clicks an annotation link | Conversion tracking |
| `live_event.registration` | Someone registers for a live event | Add the registrant to your system |

`viewing_session.play` fires the first time someone presses play during a visit to a page, or when the video autoplays, so it counts visits that played rather than every press of the button. `percent_watched` fires once at each of four milestones. `turnstile.converted` is Wistia's name for a Wistia Forms submission and can include the viewer's name as well as their email.

### Event Payload Structure

The body is `{ "hook": { "uuid": ... }, "events": [ ... ] }`. `hook.uuid` identifies the message. Each item in `events` has:

* `uuid`, the event's own ID and the key to deduplicate on
* `type`, from the table above
* `generated_at`, an ISO-8601 UTC timestamp to order by
* `metadata.account_id`
* `payload`, with objects that depend on the type: `media` and `project` for media events (plus `previous_attributes`, `transcript` or `localization` where relevant), `visitor`, `viewing_session` and `percent_watched` for viewing events, and `live_event` and `registration` for sign-ups

`media.duration` is in seconds. Several of the examples in Wistia's docs (`media.created`, `media.processing`, `media.updated`, `media.transcript_updated` and `media.deleted`) contain trailing commas and aren't valid JSON, so copy field names from them but take test fixtures from real deliveries.

### Published payload example

This is the `live_event.registration` sample from the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/wistia/latest/live_event.registration.json), taken from Wistia's documentation:

```json
{
  "hook": {
    "uuid": "bb812395-8d23-4285-9278-9ca014209ae7"
  },
  "events": [
    {
      "uuid": "09e7b27100b3a000",
      "type": "live_event.registration",
      "payload": {
        "live_event": {
          "id": "abc123xyz",
          "name": "Product Launch Webinar",
          "url": "https://company.wistia.com/live/abc123xyz",
          "starts_at": "2024-04-15T14:00:00Z"
        },
        "registration": {
          "visitor_key": "v20150227_c651bc81-8ec8-445b-b8a0-878d19278d35",
          "registered_at": "2024-03-31T13:59:22Z",
          "fields": {
            "first_name": "Lenny",
            "last_name": "Lavigne",
            "email": "lenny@wistia.com",
            "company": "Wistia",
            "job_title": "Video Producer",
            "phone": "+1-555-123-4567",
            "country": "United States"
          }
        },
        "location": {
          "country": "United States",
          "region": "Massachusetts",
          "city": "Cambridge"
        },
        "utm": {
          "campaign": "spring_launch",
          "medium": "email",
          "source": "newsletter"
        },
        "referrer_url": "https://wistia.com/blog/product-update"
      },
      "metadata": {
        "account_id": "8lq25o0p9c"
      },
      "generated_at": "2024-03-31T13:59:22Z"
    }
  ]
}

```

## Setting up Wistia webhooks

### Prerequisites

* A Wistia account with access to Account Settings
* A public endpoint URL (use `npx hookdeck-cli listen 3000 wistia --path /webhooks/wistia` for local development)

### Register Your Endpoint and Set the Secret Key

1. Open **Account Settings** and go to **Webhooks**.
2. Paste your endpoint into **POST URL**.
3. Type a long random **Secret Key** and store the same value as `WEBHOOK_SECRET`.
4. Tick the events you want (see [overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/wistia-webhooks/references/overview.md)).
5. Click **Submit**.

Wistia's settings table says you "should be using a sha256" signature, which hints that older webhooks may have been set up differently. If verification fails on a long-standing webhook, re-save it with a new Secret Key.

### Test a delivery

Start a local receiver and forward requests with the [Hookdeck CLI](/docs/cli):

```bash
npx hookdeck-cli listen 3000 wistia --path /webhooks/wistia

```

Point the webhook at the CLI URL with `media.created`, `media.ready` and `viewing_session.play` selected. Upload a short video, wait for it to process, then play it from an embed. You'll see how events are batched and what the `media` and `visitor` objects look like for your account.

## Securing Wistia webhooks

`X-Wistia-Signature` is the hex HMAC-SHA256 of the raw POST body, keyed with your Secret Key. Wistia's Ruby sample is `OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), secret_key, payload_body)`. Verify the whole body once, then process each event in it. There's no timestamp in the signature, so rely on event `uuid` values to spot repeats. See the [webhooks documentation](https://docs.wistia.com/docs/webhooks).

### Node.js verification

```javascript
const crypto = require('node:crypto');

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const signature = headers['x-wistia-signature'];
  if (typeof signature !== 'string') return false;
  const key = Buffer.from(secret, 'utf8');
  const message = rawBody;
  const candidates = [signature];
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    if (!/^[0-9a-fA-F]{64}$/.test(candidate)) return false;
    const received = Buffer.from(candidate, 'hex');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

The handler loops over the batch, skips any event `uuid` it has seen, and keeps going if one event fails so the rest of the batch isn't lost. It returns `503` if anything failed; since processed events are recorded, a resend only repeats the failed ones.

```javascript
const express = require('express');

function createWistiaApp({ hasEvent, recordEvent, handlers }) {
  const app = express();
  app.post('/webhooks/wistia', express.raw({ type: '*/*' }), async (req, res) => {
    const secret = process.env.WEBHOOK_SECRET;
    if (!secret) return res.sendStatus(500);
    if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);
    let parsed;
    try {
      parsed = JSON.parse(req.body.toString('utf8'));
    } catch {
      return res.sendStatus(400);
    }
    if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return res.sendStatus(400);
    const { events } = parsed;
    if (!Array.isArray(events) || events.some((event) => !event || typeof event !== 'object' || !event.uuid)) {
      return res.sendStatus(400);
    }
    let failures = 0;
    for (const event of events) {
      try {
        if (await hasEvent(event.uuid)) continue;
        const handler = handlers[event.type];
        if (handler) await handler(event.payload, event.generated_at, event.metadata);
        else console.log('Unhandled Wistia event', event.type);
        await recordEvent(event.uuid);
      } catch {
        failures += 1;
      }
    }
    return failures ? res.sendStatus(503) : res.sendStatus(200);
  });
  return app;
}

```

Keep each handler fast. The whole batch has to finish within 10 seconds, so queue anything that calls another API.

With a Hookdeck Wistia source in front, Hookdeck verifies the hex HMAC of the body against your Secret Key and replies `200` straight away. A delivery with a bad signature is recorded as a failed verification and stops at Hookdeck. Hookdeck forwards the batch as Wistia sent it, so your handler still loops over `events`.

> **Turn video engagement into reliable data.** [Hookdeck Event Gateway](/event-gateway) verifies Wistia's signature, responds inside the 10-second timeout and keeps each batch for retry and replay to your CRM sync.

## Wistia webhook limitations and pain points

### No documented retry schedule

**The Problem:** You can't tell whether a failed delivery will be retried or for how long.

**Why It Happens:** Wistia documents at-least-once delivery and a 10-second timeout, and says a message might be resent after a timeout, but gives no retry schedule.

**Workarounds:**

* Respond quickly and process from a queue.
* Reconcile media status through Wistia's API after an outage.

**How Hookdeck Can Help:** Hookdeck accepts each delivery immediately and retries your service on a schedule you control.

### Batches mix event types

**The Problem:** A single request can carry, say, a `media.updated` and several `viewing_session.play` events, which complicates routing.

**Why It Happens:** Wistia combines events that occur close together into one POST.

**Workarounds:**

* Always loop over `events` and dispatch each item by its own `type`.
* Track success per event, not per request.

**How Hookdeck Can Help:** Hookdeck records the full batch, so you can inspect and replay exactly what Wistia sent. A [transformation](/docs/transformations) can reshape the body before it reaches your service.

### Unordered and repeated events

**The Problem:** `media.processing` can arrive after `media.ready`, and the same event can arrive twice.

**Why It Happens:** Wistia doesn't guarantee order and may resend a message, for example after a timeout.

**Workarounds:**

* Deduplicate on event `uuid`.
* Compare `generated_at` before overwriting a media's stored state.

**How Hookdeck Can Help:** Hookdeck keeps every delivery with its arrival time, which helps when you need to trace an out-of-order sequence.

### Unreliable docs examples

**The Problem:** Fixtures copied from the docs fail to parse.

**Why It Happens:** Five of the media examples have trailing commas.

**Workarounds:**

* Build fixtures from real deliveries in a test account.

**How Hookdeck Can Help:** Real request bodies are stored in Hookdeck and can be copied straight into tests.

## Best practices

### Order media state by generated_at

Store the `generated_at` of the last event applied to each media and ignore older ones, so a late `media.processing` can't undo `media.ready`.

### Use previous_attributes on updates

`media.updated` includes `previous_attributes`, which tells you what changed. Sync only those fields rather than rewriting the whole record.

### Route leads from conversions

Send `turnstile.converted` and `live_event.registration` to your CRM, and use `percent_watched` milestones to score how engaged each lead is.

### Protect viewer data

Conversion and registration events include names, emails and locations. Keep them out of logs and apply your usual retention rules.

## Conclusion

Wistia's webhooks are well suited to both media pipelines and marketing automation, with per-event UUIDs, timestamps and a straightforward signature. The receiver has to handle batches of mixed events, out-of-order and repeated delivery, a 10-second limit, and an unpublished retry policy.

[Hookdeck Event Gateway](https://hookdeck.com) adds stored deliveries, retries and replay to Wistia's at-least-once model.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Wistia webhooks reliably in minutes.