# Receive webhooks when a job ends

Have Snipri post each job to your server when it ends: set the endpoint, read the event, check its signature, answer in time and replace the secret.

- Page: https://www.snipri.com/docs/receive-webhooks
- By [Cihan Düzgören](https://www.snipri.com/about) · November 2, 2026 · 5 min read
- Section: API, in [the Snipri docs](https://www.snipri.com/docs)

Rather than read a job until it ends, your server can be told. When a job made through the API completes or fails, Snipri posts it to an address you choose, with its clips and the links to download them.

## Set the endpoint

1. Open the account menu and choose **API**.
2. Under **Webhook**, type the address your server receives at in **Endpoint URL**.
3. Press **Save**.

The address has to meet these rules:

- It starts with https:// and uses the standard port, 443.
- It is at a public address, with a full domain name.
- It carries no user name or password, and is at most 2048 characters long.
- Redirects are not followed, so save the address that answers.

Saving an address makes the account's signing secret, if it has none yet. To stop the deliveries, clear the field and press **Remove**. Under the field, the screen says how the last delivery went: the status your server answered with, or that it could not be reached.

A job can also name an address of its own as `webhookUrl` in the request that makes it, and its events go there in place of the account's. [Set clip options in an API request](https://www.snipri.com/docs/api-clip-options) lists the field.

## The events

| Event | When it is sent |
| --- | --- |
| `job.completed` | A job made through the API finished, with its clips, or with none when nothing was worth clipping. |
| `job.failed` | A job made through the API stopped before it gave clips. |

Only jobs made with a key are posted. A job started in the Studio is not, even on an account with an endpoint.

## What a delivery carries

Each event is a POST with a JSON body. Its `data` is the job as reading it would give, with its clips and fresh links to them, so there is nothing to fetch before you can use it.

Body:

```json
{
  "type": "job.completed",
  "timestamp": "2026-11-02T18:24:12.000Z",
  "data": {
    "id": "cmhj2b9xk0003l708s4f1z9qa",
    "status": "completed",
    "name": "episode-12",
    "origin": "api",
    "source": {
      "type": "url",
      "url": "https://example.com/episode-12.mp4"
    },
    "durationSec": 3542.5,
    "videos": 1,
    "error": null,
    "createdAt": "2026-11-02T18:00:00.000Z",
    "completedAt": "2026-11-02T18:24:10.000Z",
    "clips": [
      {
        "id": "cmhj2kq4e0007l708u2m3c8vd",
        "index": 0,
        "part": 0,
        "title": "Launch week, honestly",
        "hook": "Nobody tells you this about launch week",
        "reason": "A clear question and a complete answer, and the last line lands without anything said before it.",
        "scores": {
          "hook": 8,
          "payoff": 8,
          "standalone": 9,
          "arc": 7,
          "quotability": 7
        },
        "startSec": 312.4,
        "endSec": 349.9,
        "durationSec": 37.5,
        "videoUrl": "https://...",
        "thumbnailUrl": "https://...",
        "urlsExpireAt": "2026-11-02T19:24:12.000Z"
      }
    ]
  }
}
```

| Header | What it carries |
| --- | --- |
| `webhook-id` | The event's id, the same on every attempt to deliver it. Use it to drop a repeat. |
| `webhook-timestamp` | When this attempt was signed, in seconds since 1 January 1970, UTC. |
| `webhook-signature` | v1, a comma and the signature in base64. While a replaced secret still signs, there are two, with a space between them. |
| `content-type` | application/json |
| `user-agent` | Snipri-Webhooks/1.0 (+https://www.snipri.com) |

## Check the signature

Every delivery is signed in the [Standard Webhooks](https://www.standardwebhooks.com/) format, so a library made for it can check one with your signing secret. To find the secret, press **Show secret** under **Signing secret** on the API screen, and keep it on your server as you keep a key. To check a delivery yourself:

1. Take the body exactly as it arrived, before any parsing. Parsed and written out again, it is no longer the text that was signed.
2. Join the `webhook-id` header, a dot, the `webhook-timestamp` header, another dot and the body.
3. Sign that with HMAC-SHA256, keyed with the part of the secret after `whsec_`, decoded from base64.
4. Compare the result, in base64, with each entry of the `webhook-signature` header after its `v1` and comma, in constant time. One match is enough.
5. Refuse a delivery whose timestamp is more than a few minutes from your own clock, so an old one sent again cannot pass.

Check a delivery in Node.js:

```js
import { createHmac, timingSafeEqual } from "node:crypto";

// secret: the signing secret from the API screen, whsec_...
// headers: the request's headers, with lowercase names
// body: the request's raw body, exactly as it arrived
export function verifySnipriWebhook(secret, headers, body, toleranceSeconds = 300) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  if (!id || !timestamp) return false;
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!(age <= toleranceSeconds)) return false;
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest();
  return String(headers["webhook-signature"] ?? "").split(" ").some((entry) => {
    const [version, signature] = entry.split(",");
    if (version !== "v1" || !signature) return false;
    const given = Buffer.from(signature, "base64");
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}
```

Use it in an Express route:

```js
const { SNIPRI_WEBHOOK_SECRET } = process.env;

app.post("/snipri-webhook", express.raw({ type: "application/json" }), (req, res) => {
  const body = req.body.toString("utf8");
  if (!verifySnipriWebhook(SNIPRI_WEBHOOK_SECRET, req.headers, body)) {
    return res.sendStatus(400);
  }
  const event = JSON.parse(body);
  if (event.type === "job.completed") {
    // event.data is the job, with its clips
  }
  res.sendStatus(204);
});
```

## Answer in time

Answer with any 2xx status within 10 seconds. Any other answer, or none in that time, is tried again up to 3 more times. Each attempt is signed afresh and carries the job as it is then, with fresh clip links.

Answer first and do the work after: a server that downloads the clips before it answers can run out of time and be sent the same event again. The `webhook-id` is the same on every attempt, so keep the ids you have handled and drop a repeat.

> **Note:** Now and then a job that went quiet is failed, and `job.failed` is sent, before its clips arrive after all. Then `job.completed` follows for the same job. Treat `job.completed` as the last word on a job.

## Replace the secret

1. Under **Signing secret**, press **Replace secret**, then **Replace it now** to confirm.
2. Copy the new secret from the screen and put it on your server.

For 24 hours after the secret is replaced, every delivery is signed with the new secret and the old one, two entries in webhook-signature, so your server can switch over whenever it suits. Replacing it again in that time drops the oldest.

The screen shows until when the previous secret keeps signing.

Previous guide: [Read jobs and clips through the API](https://www.snipri.com/docs/read-jobs-and-clips)

Next guide: [API errors and limits](https://www.snipri.com/docs/api-errors-and-limits)

Still stuck? Write to [hello@snipri.com](mailto:hello@snipri.com) with the name of the video and what the screen said.
