# Webhooks

> Receive signed Meetingize events when transcripts, AI reports and documents are ready, verify the HMAC-SHA256 signature in Python, Node.js or the shell, and handle retries.

Webhooks notify your server when something happens in Meetingize, so you do not have to poll. Each delivery is an
HTTPS `POST` with a JSON event, signed with a secret only you and Meetingize know.

## Register an endpoint

In the web app: **Settings > Developers > Add an endpoint**. Or through the API with the `webhooks` scope:

```bash
curl -s -X POST "$MEETINGIZE_API/webhook_endpoints" \
  -H "Authorization: Bearer $MEETINGIZE_API_KEY" -H "Content-Type: application/json" \
  -d '{"url": "https://hooks.example.com/meetingize", "events": ["transcription.completed", "document.generated"]}'
```

The response contains the signing `secret` (`whsec_...`), shown only once. URLs must use `https` and resolve to a
public address (private and loopback addresses are refused). To test locally, expose your endpoint with a tunnel
(for example ngrok or Cloudflare Tunnel).

## Events

| Type | When | `data.object` |
|---|---|---|
| `transcription.completed` | the diarized transcript is available | job (+ `meeting`) |
| `transcription.failed` | the transcription failed | job with `error` |
| `processing.completed` | the AI meeting report is ready | job (+ `meeting`) |
| `processing.failed` | the AI report failed after a successful transcription | job with `error` |
| `document.generated` | a document generation succeeded | job (+ `document`) |
| `document.failed` | a document generation failed | job with `error` |
| `usage.threshold_reached` | API usage of the month reached 50, 80 or 100 % of the wallet, or 80 or 100 % of the spend cap | `usage_alert` |
| `ping` | "send a test" button or `POST /webhook_endpoints/{id}/test` | `{ "message": "..." }` |

Subscribe to `"*"` to receive every event except `ping`.

```json
{
  "id": "evt_7d1f...",
  "object": "event",
  "type": "transcription.completed",
  "created": "2026-10-11T14:03:22Z",
  "api_version": "2026-10-01",
  "livemode": false,
  "data": {
    "object": {
      "id": "job_9b0c...", "object": "job", "kind": "transcription", "status": "running",
      "stage": "processing", "meeting_id": "1b9e...", "metadata": { "crm_id": "42" },
      "meeting": { "id": "1b9e...", "object": "meeting", "title": "RDV client", "status": "transcribed" }
    }
  }
}
```

## Headers

```http
Content-Type: application/json
User-Agent: Meetingize-Webhooks/1.0
Meetingize-Event-Id: evt_7d1f...
Meetingize-Event-Type: transcription.completed
Meetingize-Delivery-Id: whd_...
Meetingize-Signature: t=1760191402,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

## Verify the signature

`v1` is the hex HMAC-SHA256 of `"{t}.{raw_body}"` with your full secret (`whsec_...`) as key. To verify:

1. read the **raw** request body (before any JSON parsing);
2. recompute the HMAC and compare it in constant time with each `v1=` value of the header;
3. refuse the event if `t` is more than 5 minutes away from now (replay protection);
4. deduplicate on `Meetingize-Event-Id` (deliveries are at least once).

During a secret rotation the old secret stays valid for 24 hours and the header carries two `v1=` values.

### Python

```python

def verify_meetingize_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    items = [p.split("=", 1) for p in header.split(",") if "=" in p]
    timestamps = [v for k, v in items if k == "t"]
    signatures = [v for k, v in items if k == "v1"]
    if not timestamps or not signatures:
        return False
    t = int(timestamps[0])
    if abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, s) for s in signatures)

# Flask example
from flask import Flask, request, abort
app = Flask(__name__)

@app.post("/meetingize")
def meetingize_webhook():
    if not verify_meetingize_signature(request.get_data(), request.headers.get("Meetingize-Signature", ""),
                                       secret="whsec_..."):
        abort(400)
    event = request.get_json()
    # enqueue work, return fast
    return "", 204
```

### Node.js

```js

function verifyMeetingizeSignature(rawBody, header, secret, toleranceSec = 300) {
  const parts = (header || '').split(',').map((p) => p.split('='));
  const t = Number((parts.find(([k]) => k === 't') || [])[1]);
  const sigs = parts.filter(([k]) => k === 'v1').map(([, v]) => v);
  if (!t || !sigs.length || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest('hex');
  return sigs.some((s) => s.length === expected.length
    && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}

const app = express();
// Keep the raw body: verification must run on the exact bytes received
app.post('/meetingize', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verifyMeetingizeSignature(req.body, req.get('Meetingize-Signature'), process.env.MEETINGIZE_WEBHOOK_SECRET)) {
    return res.sendStatus(400);
  }
  const event = JSON.parse(req.body.toString('utf8'));
  res.sendStatus(204);
});
```

### Shell (debugging)

```bash
BODY_FILE=event.json            # raw body as received
HEADER='t=1760191402,v1=5257a8...'
SECRET='whsec_...'
T=$(echo "$HEADER" | tr ',' '\n' | sed -n 's/^t=//p')
EXPECTED=$( { printf '%s.' "$T"; cat "$BODY_FILE"; } | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
echo "$HEADER" | tr ',' '\n' | grep -q "^v1=$EXPECTED$" && echo "valid" || echo "INVALID"
```

The [`meetingize` CLI](../../cli/meetingize/) also has `meetingize webhooks verify`.

## Delivery, retries and disabling

- Your endpoint must answer `2xx` within 10 seconds. Redirects are not followed. Do the work asynchronously and
  answer fast.
- Failed deliveries are retried after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h and 24 h (8 attempts in total), then
  marked `failed`.
- After 15 consecutive failed attempts the endpoint is **disabled** (`disabled_reason: "too_many_failures"`) and
  the owner is emailed. Re-enable it with `PATCH /v1/webhook_endpoints/{id}` `{"enabled": true}` or from the web
  app.
- The delivery log (status, HTTP code, first 1 KB of your response, duration, next retry) is visible in the web
  app and with `GET /v1/webhook_endpoints/{id}/deliveries`, kept 30 days.
- Ordering is not guaranteed. Use `created` and the job status, not the arrival order.

## Rotate the secret

`POST /v1/webhook_endpoints/{id}/rotate_secret` returns a new secret. Both secrets sign deliveries for 24 hours,
which gives you time to deploy the new one.
