# Guide to Gumlet Webhooks: Features and Best Practices

Gumlet webhooks tell your application when video work finishes in the background. Uploading and transcoding a video takes time, and so does starting a live stream, so instead of polling the Gumlet API you can have Gumlet call your endpoint when a video is ready to embed, when processing fails, when a live session ends, or when a viewer submits a lead form in the player.

This guide covers how Gumlet webhooks are configured, the events and payloads they send, how to authenticate them with the Secret Token, how retries work, and the practices that keep a Gumlet receiver reliable.

## What are Gumlet webhooks?

A Gumlet webhook has a URL, a trigger list and an optional Secret Token. The trigger list holds the events you want, and the webhook applies to the video sources or workspaces you choose. When one of those events happens, Gumlet sends a JSON `POST` whose `type` field names the event.

Gumlet doesn't sign webhook requests. If you set a Secret Token, Gumlet sends it back as plain text in the `x-gumlet-token` header "with each request", and your endpoint compares it with the token you stored. If you don't set one, the header still arrives but holds an empty string, so there is nothing to check.

## Gumlet webhook features

| Feature | Details |
| --- | --- |
| Configuration | Dashboard (**Developer > Webhooks**) or the webhooks API |
| Method | `POST` with a JSON body |
| Event field | `type` in the body |
| Scope | The video sources or workspaces you select for the webhook |
| Authentication | Optional Secret Token, sent as plain text in `x-gumlet-token`. No signature |
| Success | Any `2xx` |
| Timeout | 25 seconds |
| Retries | Every half hour for 24 hours after a non-`2xx`, a timeout or a connection error |
| Redirects | Followed, up to 5 |
| Source IPs | Not published |

## Common events

| Area | `type` | Fires when |
| --- | --- | --- |
| Video processing | `video.status.created`, `video.status.downloaded`, `video.status.stream_ready`, `video.status.processed`, `video.status.ready` | An asset is created, its source file is downloaded, it can stream at one resolution, it is processed, and it is ready to embed |
| Video problems and changes | `video.status.errored`, `video.status.deleted`, `video.status.repackaged` | Processing fails, the asset is deleted, or a subtitle or audio stream is added |
| Live video | `live.video.status.created`, `preparing`, `ready`, `connected`, `active`, `disconnected`, `complete` | A live session moves through its lifecycle (each value is prefixed `live.video.status.`) |
| Uploads and edits | `event.video.uploaded`, `event.video.updated` | A video is uploaded, or its title, tags, description or privacy change |
| Player | `event.embed.viewed`, `event.embed.cta_clicked`, `event.embed.form_submitted` | A video is viewed for the first time, a call to action is clicked, or a lead form is submitted |
| Playlists, comments and channels | `event.playlist.created`, `event.playlist.asset`, `event.playlist.deleted`, `event.comment.all`, `event.channel.member_joined` | A playlist changes, a comment is added, replied to or resolved, or someone joins a channel |

Gumlet's two lists don't quite agree. The webhooks guide includes `video.status.processed`, which the API reference's event list doesn't, and the API reference adds `video.status.optimized`, `event.video.analytics` and `event.image.analytics`, which the guide doesn't. Handle all of them and acknowledge anything you don't recognise.

Status bodies carry the asset's `status`, its `input` and `output`, and timestamps in milliseconds. Here is Gumlet's `live.video.status.complete` example, trimmed:

```json
{
  "type": "live.video.status.complete",
  "status": "complete",
  "live_asset_id": "68b671c1373a93b5103e1f9a",
  "input": {
    "live_video_source_id": "68678418fc386bd77fd5c689",
    "resolution": ["360p", "480p", "720p", "1080p"],
    "title": "Live stream at 04:25:38 on 2nd September"
  },
  "output": {
    "playback_url": "https://video.gumlet.io/68678418fc386bd77fd5c689/68b671c1373a93b5103e1f9a/master.m3u8",
    "embed_url": "https://play.gumlet.io/embed/live/68b671c1373a93b5103e1f9a",
    "duration": 0
  },
  "created_at": 1756787138405,
  "updated_at": 1756787392403,
  "recording_asset_id": "68b671c1373a93b5103e1f9c",
  "vod_collection_id": "68678419fc386bd77fd5c69c"
}

```

The `event.*` product events are different: Gumlet describes their body as "the originating event payload" with `type` set, and doesn't document their fields. Log a few before you depend on them.

If you create a video with `process_low_resolution_input` set to `true` and the input is smaller than Gumlet's minimum frame size, the webhook also carries a `warning` object with the code `WRN_LOW_FRAME_SIZE`, telling you that transformations were not applied.

## Setting up Gumlet webhooks

1. Open [Developer > Webhooks](https://dash.gumlet.com/developer/webhooks) in the Gumlet dashboard.
2. Enter your HTTPS endpoint URL.
3. Choose the triggers you want and the video sources they apply to.
4. Enter a **Secret Token**. Use a long random value, for example the output of `openssl rand -hex 32`, and store it as `GUMLET_WEBHOOK_SECRET`.
5. Save.

You can also create webhooks through Gumlet's webhooks API with the same settings.

**With Hookdeck.** Set a Secret Token on the Gumlet webhook. Webhooks without one send nothing to verify. Create a Hookdeck source with the **Gumlet** type, paste the same value into **Secret Token**, and use the source URL as the webhook URL in Gumlet. Hookdeck compares it with the `x-gumlet-token` header on each request. The source accepts `POST` only, which is the method Gumlet uses.

## Securing Gumlet webhooks

Read `x-gumlet-token`, reject it if it's missing or empty, and compare it with your stored token in constant time before you parse the body.

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

const SECRET = process.env.GUMLET_WEBHOOK_SECRET;

function safeEqual(received, expected) {
  const a = Buffer.from(received, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  // timingSafeEqual throws on unequal lengths, so check first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

app.post('/webhooks/gumlet', express.raw({ type: '*/*' }), (req, res) => {
  const received = req.get('x-gumlet-token');
  // An empty header means the webhook has no Secret Token: reject it.
  if (!SECRET || !received || !safeEqual(received, SECRET)) {
    return res.sendStatus(401);
  }

  let event;
  try {
    event = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }

  // Route on event.type, queue the work, then respond.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hmac
import os

SECRET = os.environ.get("GUMLET_WEBHOOK_SECRET", "")

def verify_gumlet_request(headers) -> bool:
    if not SECRET:
        return False  # fail closed when unconfigured
    received = headers.get("x-gumlet-token")  # Starlette and Flask headers are case-insensitive
    if not received:
        return False  # missing, or empty because no Secret Token is set
    # Compare bytes: compare_digest raises TypeError on a non-ASCII str.
    return hmac.compare_digest(received.encode("utf-8"), SECRET.encode("utf-8"))

```

A static token authenticates the sender, not the body. It can't detect a modified payload, and with no signed timestamp a captured request can be replayed and still pass. Use HTTPS, a long random token, and change it in Gumlet and in your receiver together if it might have leaked.

> **Make Gumlet webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks `x-gumlet-token` on every request, stores each event durably, and delivers it to your service with retries you control.

## Gumlet webhook limitations and pain points

### The Secret Token is optional

**The Problem:** Your receiver accepts requests from anyone who knows the URL.

**Why It Happens:** The Secret Token is optional, and a webhook without one sends `x-gumlet-token` as an empty string. A check like `received === secret` passes when both are empty.

**Workarounds:**

* Always set a Secret Token, and fail closed if your stored value is empty.
* Treat an empty header the same as a missing one.

**How Hookdeck Can Help:** Hookdeck's Gumlet source requires a Secret Token, so it never accepts the empty header. Hookdeck answers Gumlet with a `200` either way, then records a request without the right token as failed verification, so it never reaches your destination.

### Half-hour retries

**The Problem:** A short outage on your side delays a "video ready" notification by 30 minutes or more.

**Why It Happens:** Gumlet retries a failed delivery every half hour for 24 hours. A timeout counts as a failure, and the timeout is 25 seconds.

**Workarounds:**

* Respond with `200` as soon as the token checks out, and process in the background.
* For anything user-facing, fall back to the Gumlet API if you haven't heard about a video within your expected processing time.

**How Hookdeck Can Help:** Hookdeck accepts each delivery as soon as it arrives, so Gumlet doesn't wait for your service or schedule a retry. Hookdeck then delivers to your service with [retries](/docs/retries) on your own schedule, and lets you replay any stored event.

### Undocumented product-event bodies

**The Problem:** Your handler breaks on an `event.*` webhook because a field isn't where you expected.

**Why It Happens:** Gumlet documents the body of the video and live status webhooks, but for product events it only says the body is the originating payload with `type` set.

**Workarounds:**

* Capture real `event.*` deliveries before you write handlers for them.
* Parse defensively and log anything you can't handle.

**How Hookdeck Can Help:** Hookdeck stores every request with its headers and body, so you can see the real shape of each event, and [transformations](/docs/transformations) can normalise bodies before they reach your service.

## Best practices

### Route on `type`

Every body carries `type`. Switch on it, and acknowledge unknown values with `200` so a new event never turns into a stream of retries.

### Act on `video.status.ready`, not earlier statuses

`video.status.stream_ready` fires as soon as a single resolution can stream. Wait for `video.status.ready` before you publish an embed, and treat `video.status.errored` as a terminal failure to surface to users.

### Make handlers idempotent

Retries can deliver an event more than once. Key on `type` plus the asset ID (`asset_id` or `live_asset_id`) and `updated_at`, and skip events you have already handled. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Don't rely on redirects

Gumlet follows up to five redirects, but each one adds latency inside the 25-second timeout. Register the final URL.

### Acknowledge fast

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

## Conclusion

Gumlet sends a JSON `POST` for video, live video and product events, with the event name in `type` and an optional Secret Token in `x-gumlet-token` rather than a signature. Always set the token and reject an empty header, compare it in constant time, route on `type`, and respond within 25 seconds, because every failure costs a half-hour wait.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks the token, answers Gumlet at once, and keeps every event until your service has processed it.

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