# Guide to Vimeo Webhooks: Features and Best Practices

Vimeo app webhooks tell your application when something happens to the videos and live events your users manage through it: an upload finishes transcoding, a video is deleted, a transcript is ready, a live stream starts or ends, or someone registers for a gated event. Instead of polling the Vimeo API for status changes, your app gets a short notification with the URIs to fetch.

This guide covers how Vimeo webhooks are created and signed, the events and their payloads, the rate limits that apply to deliveries, and the practices that keep a Vimeo receiver reliable.

## What are Vimeo webhooks?

Vimeo webhooks belong to a Vimeo developer app. There is no dashboard screen for them: you create, list, update and delete them through the Vimeo API. Each webhook has a `webhook_url`, a single `webhook_type` (the event), an `is_enabled` flag and an optional `secret`. An app can have only one webhook of a given event type, so subscribing to five events means five webhooks.

A webhook fires when a user who has authorised your app, with the `webhooks` scope, performs the action: uploading a video, deleting one, starting a live event and so on. Vimeo then POSTs a small JSON body naming the event and the URIs of the affected resources.

If the webhook has a `secret`, Vimeo adds an `X-Webhook-Signature` header: an HMAC-SHA256 of the raw payload, keyed with that secret. Without a secret, Vimeo sends no signature at all.

## Vimeo webhook features

| Feature | Details |
| --- | --- |
| Configuration | Vimeo API only: `POST /apps/{app_id}/webhooks` |
| Subscription | One webhook per event type per app |
| Payload | `{ webhook_type, data, timestamp }`, with resource URIs in `data` |
| Signature header | `X-Webhook-Signature`, only when the webhook has a `secret` |
| Scheme | HMAC-SHA256, hex, no prefix |
| Signed content | The raw request body, nothing else |
| Secret | The `secret` you set on the webhook through the API |
| Rate limit | 25 to 2,500 requests per webhook per minute, by the app owner's plan |
| Rate limit headers | `X-Webhook-RateLimit-Limit`, `X-Webhook-RateLimit-Remaining`, `X-Webhook-RateLimit-Reset` |
| Replay window | None. The body's `timestamp` is not checked by Vimeo |

Vimeo doesn't document a retry policy, a response timeout, the status codes it treats as success, a delivery ID or source IPs for webhooks.

## Common events

Vimeo documents 17 event types. The name you subscribe with is also the `webhook_type` value in each delivery.

| Area | Events | Fires when |
| --- | --- | --- |
| Videos | `video-created`, `video-deleted` | A user creates or deletes a video |
| Transcoding | `video-transcode-playable`, `video-transcode-fully-playable`, `video-transcode-complete` | A video is ready to play, is playable in all its resolutions, or has finished transcoding |
| Upload problems | `video-upload-failed` | An upload fails |
| Processing | `transcript-status-complete`, `automatic-thumbnail-available`, `content-scan-completed` | A transcript completes or fails, a generated thumbnail is ready, or a content scan of a version finishes |
| Live events | `live-event-started`, `live-event-updated`, `live-event-ended`, `live-event-archive-available`, `live-event-clip-created` | A live event goes live, is edited or ends, its archive is ready, or a clip is created from it |
| Registrations | `registration-created`, `registration-updated`, `registration-deleted` | A registrant is added to, changed on or removed from a registration-gated resource |

Here is Vimeo's `video-transcode-complete` example:

```json
{
  "webhook_type": "video-transcode-complete",
  "data": {
    "clip_uri": "/videos/890352789",
    "video_uri": "/videos/890352789",
    "version_uri": "/videos/890352789/versions/1"
  },
  "timestamp": 1644961452
}

```

* `webhook_type` is the event. Route on it.
* `data` holds URIs, not full resources. Call the API (for example `GET /videos/890352789`) for titles, files or status.
* `timestamp` is the Unix time in seconds when the event happened.
* The fields inside `data` vary by event. `video-upload-failed` carries `video_uri`, `error_type` (for example `invalid-video-file`) and `failure_cause` (for example `remote_unauthorized`). `transcript-status-complete` carries `transcript_uri`, `text_track_uri`, `transcript_status` and `video_uri`. Registration events carry `resource_uri`, `registrant_uuid`, `email` and `sequence_number`.

Registration payloads include the registrant's email address, so treat them as personal data in logs and queues.

## Setting up Vimeo webhooks

Before you start, you need:

* A Vimeo app at [developer.vimeo.com/apps](https://developer.vimeo.com/apps) with the `CAPABILITY_WEBHOOK_SCOPE` capability, which lets its access tokens use the `webhooks` scope.
* An access token for that app with the `public`, `private`, `edit`, `delete` and `webhooks` scopes.

Then:

1. Generate a random secret. Vimeo recommends at least 64 bytes, for example `openssl rand -hex 64`.
2. Create a webhook for each event type you need, setting `secret` in the same request. Send the version 3.4 `Accept` header with every call:
  
  
  ```bash
  curl -X POST "https://api.vimeo.com/apps/$APP_ID/webhooks" \
    -H "Authorization: bearer $ACCESS_TOKEN" \
    -H "Content-Type: application/json" \
    -H "Accept: application/vnd.vimeo.*+json;version=3.4" \
    -d '{
      "is_enabled": true,
      "webhook_url": "https://example.com/webhooks/vimeo",
      "webhook_type": "video-transcode-complete",
      "secret": "<your secret>"
    }'
  
  ```
  
  
3. Store the secret server-side, for example as `VIMEO_WEBHOOK_SECRET`. All of your app's webhooks can share it.
4. To add or change a secret on an existing webhook, `PATCH /apps/{app_id}/webhooks/{webhook_id}` with a new `secret`. To read it back, call `GET /apps/{app_id}/webhooks/{webhook_id}?fields=secret`.

**Set a secret on every webhook through the Vimeo API.** The `secret` field is optional, and a webhook created without one sends no `X-Webhook-Signature` header, so its deliveries fail verification in your handler and in a Hookdeck Vimeo source.

If you receive Vimeo webhooks through Hookdeck, use the Hookdeck source URL as each webhook's `webhook_url` and paste the same secret into the Vimeo source.

Vimeo has no test-delivery button. Trigger a real event, such as uploading and deleting a short test video, to see your first delivery.

## Securing Vimeo webhooks

Compute an HMAC-SHA256 of the raw request body with the secret as the key, hex-encode it, and compare it with `X-Webhook-Signature` in constant time.

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

function verifyVimeoSignature(rawBody, signatureHeader, secret) {
  if (!signatureHeader || !secret) return false;
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  const received = Buffer.from(signatureHeader.trim().toLowerCase(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

app.post('/webhooks/vimeo', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.VIMEO_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyVimeoSignature(req.body, req.get('x-webhook-signature'), secret)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Route on event.webhook_type, then process asynchronously.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac

def verify_vimeo_signature(raw_body: bytes, signature_header, secret: str) -> bool:
    if not signature_header or not secret:
        return False
    expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature_header.strip().lower().encode("utf-8"), expected.encode("utf-8"))

# In FastAPI: raw_body = await request.body()
# verify_vimeo_signature(raw_body, request.headers.get("x-webhook-signature"),
#                        os.environ["VIMEO_WEBHOOK_SECRET"])

```

Vimeo's docs give a one-line PHP check, `hash_equals(hash_hmac('sha256', $payload, $secret), $_SERVER['X-Webhook-Signature'])`, which is where the hex encoding comes from. In a real PHP app the header arrives as `$_SERVER['HTTP_X_WEBHOOK_SIGNATURE']`, so copy the logic rather than the array key.

To check your verifier, use the secret `vimeo-guide-test-secret` and this exact body:

```
{"webhook_type":"video-deleted","data":{"video_uri":"/videos/890352789"},"timestamp":1761400000}

```

The `X-Webhook-Signature` value should be:

```
13e0972d56e5fcb17e1fbcada8bb7a0f63500f6e865f351319079824ad1a36d8

```

> **Make Vimeo webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `X-Webhook-Signature` header on every delivery, acknowledges Vimeo straight away, and holds each video and live event until your service has processed it.

## Vimeo webhook limitations and pain points

### Signing is optional

**The Problem:** Deliveries arrive with no signature, so anything could have sent them.

**Why It Happens:** `secret` is an optional field on the create request. Leave it out and Vimeo signs nothing.

**Workarounds:**

* Always set `secret` when creating a webhook, and `PATCH` one onto any older webhooks.
* Reject requests without `X-Webhook-Signature` rather than treating a missing header as a pass.

**How Hookdeck Can Help:** A Hookdeck Vimeo source with the secret configured marks unsigned or mis-signed deliveries as failed verification, so they never reach your service.

### No documented retries

**The Problem:** You can't tell whether a delivery that hit a deploy or an outage will ever come back.

**Why It Happens:** Vimeo's webhook docs describe rate limits and error codes for the management API, but say nothing about retrying failed deliveries, timeouts or which status codes count as success.

**Workarounds:**

* Return a `2xx` as soon as the signature is verified and the event is stored.
* After any outage, reconcile through the API, for example by checking the transcode status of recently uploaded videos.

**How Hookdeck Can Help:** Hookdeck accepts each delivery as soon as it arrives and stores it, then delivers to your service with [retries](/docs/retries) you configure, so an outage on your side doesn't depend on Vimeo's unknown retry behaviour.

### Per-webhook rate limits that drop requests

**The Problem:** During a bulk upload, some notifications never arrive.

**Why It Happens:** Each webhook is limited to a number of requests per minute set by the app owner's plan: 25 on Free, 125 on Starter, 250 on Standard, 750 on Advanced and 2,500 on Enterprise. Once a webhook hits its limit, Vimeo says it no longer sends requests to that URL until the quota resets. It doesn't say the skipped events are queued.

**Workarounds:**

* Watch `X-Webhook-RateLimit-Remaining` on incoming deliveries and alert when it gets low.
* After a burst, use the API to fill in anything that may have been skipped.
* Spread large imports over time, or move the app owner to a plan with a higher limit.

**How Hookdeck Can Help:** Hookdeck can't raise Vimeo's limit, but it logs every delivery that does arrive, including its rate limit headers, so gaps are easy to spot and investigate.

### Thin payloads and no delivery ID

**The Problem:** Every event needs a follow-up API call, and duplicates are hard to spot.

**Why It Happens:** Payloads carry URIs rather than resources, and Vimeo sends no delivery ID or event ID. Only the body is signed, and Vimeo doesn't check `timestamp`.

**Workarounds:**

* Fetch the resource from the API before acting, which also confirms its current state.
* Build an idempotency key from `webhook_type`, the main URI in `data` and `timestamp`.
* Mind your own API rate limits when every webhook triggers a fetch.

**How Hookdeck Can Help:** Hookdeck's [deduplication](/docs/deduplication) can drop repeated deliveries based on the body fields you choose, and its delivery rate limits smooth out the follow-up API calls your service makes.

## Best practices

### Verify against the raw body

The signature covers the exact bytes. Use `express.raw({ type: '*/*' })` in Express, `await request.text()` in a Next.js route handler and `await request.body()` in FastAPI, and parse only after verification.

### Manage webhooks as code

Because Vimeo webhooks only exist through the API, keep a script or infrastructure code that lists what should exist, compares it with `GET /apps/{app_id}/webhooks`, and creates, updates or disables webhooks to match. It is easy to lose track of one-webhook-per-type subscriptions otherwise.

### Treat events as hints

Use each event to decide what to fetch, not as the source of truth. Vimeo doesn't promise delivery order, and the transcoding events for one video can fire close together, so read the video's current state from the API before updating your records.

### Acknowledge fast and process asynchronously

Verify, store, return `200`, and do the work afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks) and our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

## Conclusion

Vimeo app webhooks are created through the API, one per event type, and are only signed when you give them a `secret`. Set one on every webhook, verify the hex HMAC-SHA256 of the raw body in `X-Webhook-Signature`, fetch the resources behind the URIs before acting, and keep an eye on the per-webhook rate limit during busy periods.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks every Vimeo signature, and holds each event until your service has processed it.

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