> ## Documentation Index
> Fetch the complete documentation index at: https://docs.reedapt.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Hear about each dub as it finishes, and check that the message came from Reedapt.

A webhook endpoint is a URL on your server. When one of your dubs finishes we `POST` an event to it, so you don't have to keep asking.

## Adding an endpoint

Add one in Reedapt under **Developers**, or with the API:

```bash theme={null}
curl https://api.reedapt.com/v1/webhook_endpoints \
  -H "Authorization: Bearer $REEDAPT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/reedapt/webhooks", "description": "Production"}'
```

The answer includes the endpoint's signing `secret` (`whsec_…`). **It is shown only this once.** Store it with your other secrets.

The URL must be public and use `https://`. Every endpoint gets every event for the account, whichever key started the dub.

## Events

| Event | When |
| - | - |
| `dub.completed` | A dub finished. `data.dub` is the dub, as `GET /dubs/{id}` returns it. |
| `dub.failed` | A dub failed or was cancelled. `data.dub.error` says why, and its minutes were returned. |
| `batch.completed` | Every dub of a bulk upload made in the Reedapt app has finished. `data.batch` has the totals and the dubs. |

```json dub.completed theme={null}
{
  "id": "evt_9a3e1c7b5d2f8e0a4c6b1d3f",
  "type": "dub.completed",
  "created_at": "2026-10-28T09:31:03Z",
  "data": {
    "dub": {
      "id": "job_3f9a2c7e1b4d",
      "object": "dub",
      "status": "completed",
      "target_language": "fr",
      "metadata": { "order_id": "1042" },
      "…": "…"
    }
  }
}
```

We may add event types. Ignore types you don't handle, and answer them with `200` too.

## Answering

Answer with any `2xx` within 10 seconds. Do the slow work after answering, for example by putting the event on a queue.

Anything else, or no answer in time, is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 5 hours, then every 10 hours, for about 3 days. An event can occasionally arrive twice, so skip any `id` you've already handled.

## Checking the signature

Every delivery has these headers:

| Header | Value |
| - | - |
| `Reedapt-Signature` | `t=<unix time>,v1=<signature>` |
| `Reedapt-Event` | The event type, such as `dub.completed`. |
| `Reedapt-Delivery` | This delivery's id, as listed under the endpoint's deliveries. |

The signature is the hex HMAC-SHA256 of `<t>.<raw body>`, keyed with the endpoint's secret. Refuse a delivery whose signature doesn't match, or whose time is more than 5 minutes old.

```javascript Node.js (Express) theme={null}
import crypto from "node:crypto";
import express from "express";

const app = express();

export function isFromReedapt(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex");
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
  const given = Buffer.from(parts.v1 ?? "");
  return fresh && given.length === expected.length && crypto.timingSafeEqual(Buffer.from(expected), given);
}

// express.raw keeps the body exactly as it was sent.
app.post("/reedapt/webhooks", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("Reedapt-Signature") ?? "";
  if (!isFromReedapt(req.body.toString("utf8"), header, process.env.REEDAPT_WEBHOOK_SECRET)) {
    return res.sendStatus(400);
  }
  const event = JSON.parse(req.body);
  // Queue event for processing, then answer quickly.
  res.sendStatus(200);
});
```

```python Python (Flask) theme={null}
import hashlib
import hmac
import os
import time

from flask import Flask, abort, request

app = Flask(__name__)


def is_from_reedapt(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    signed = parts.get("t", "").encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    fresh = parts.get("t", "").isdigit() and abs(time.time() - int(parts["t"])) < 300
    return fresh and hmac.compare_digest(expected, parts.get("v1", ""))


@app.post("/reedapt/webhooks")
def reedapt_webhook():
    header = request.headers.get("Reedapt-Signature", "")
    if not is_from_reedapt(request.get_data(), header, os.environ["REEDAPT_WEBHOOK_SECRET"]):
        abort(400)
    event = request.get_json()
    # Queue event for processing, then answer quickly.
    return "", 200
```

> [!WARNING]
>
> Check the signature against the raw body exactly as it arrived. Parsing the JSON and encoding it again changes the bytes, and the signature won't match.

## Testing and troubleshooting

* **Send test event** on the Developers page, or `POST /webhook_endpoints/{id}/test`, sends a `webhook.test` event to the endpoint.
* The endpoint's deliveries, in the Developers page or from `GET /webhook_endpoints/{id}/deliveries`, show each attempt, what your server answered, and when the next try is due.
* While building, use a test key: its dubs are free, and you get real `dub.completed` events.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.