# Events and webhooks

> Event types, signature verification, retries, secret rotation and the event stream.

Source: https://diko.app/en/api-docs/webhooks

## Two ways to learn about changes

So you never have to poll for status, Diko offers two ways:

- **Webhooks:** Diko sends a `POST` request to your address when something happens.
- **The event stream:** `GET /events` lets you pick up events whenever it suits you. It is also the fallback when your webhooks were unreachable.

Both use the same [`event`](https://diko.app/en/api-docs/reference/get-event.md) object.

> **Note:**
> You create and change webhook endpoints only in the Diko app, under **Nustatymai → API → Webhooks** ([`/settings/api/webhooks`](https://studio.diko.app/settings/api/webhooks)). An API key cannot create, change or delete them, so a leaked key cannot redirect your events to a foreign address.

## Creating an endpoint

1. Open `/settings/api/webhooks` and choose **Sukurti endpointą** (Create endpoint). Confirm the action by signing in again if Diko asks for it.
2. Enter an HTTPS address and choose the event types (or all of them).
3. Copy the secret `whsec_...`. **It is shown only once.** Keep it in a secrets store.
4. Press **Siųsti bandomąjį įvykį** (Send test event). You get a `webhook.test` event for this endpoint only; it does not appear in `GET /events`.

You can have at most 10 endpoints per account. An endpoint can be `enabled`, `disabled` or `failing_disabled` (switched off automatically after persistent failures, see below). Diko shows the last successful and failed delivery, a 30-day delivery log with the history of every attempt, and lets you send a delivery again.

## Event catalog

The `data` of all events about the same resource has a common base: `object` and `id`. The table lists the other fields.

| Type | When it is sent | Extra `data` fields |
|---|---|---|
| `meeting.ready` | meeting processing finished | `status`, `archived`, `minutes_status`, `minutes_version`, `run_id` |
| `meeting.failed` | processing failed or was interrupted | `status`, `archived`, `error_code`, `run_id` |
| `meeting.minutes.updated` | minutes updated (regeneration, speaker rename, grammar approval) | `minutes_version` |
| `meeting.updated` | the title changed or the meeting was archived | `changed_fields` (`title`, `archived`) |
| `meeting.deleted` | the meeting was deleted | – |
| `library.transcript.added` | a transcript was added to the library | `youtube_video_id`, `source_type`, `added_at` |
| `library.transcript.updated` | the summary became `ready` or `failed` | `changed_fields` (`["summary"]`), `summary_status` |
| `library.transcript.removed` | a transcript was removed from the library | – |
| `library.import.succeeded` | an import finished | `transcript_id`, `reused` |
| `library.import.failed` | an import failed | `error_code` |
| `library.import.canceled` | an import was canceled | – |
| `webhook.test` | a test event, sent only to the named endpoint and not shown in `GET /events` | `object` (`webhook_endpoint`), `id` |

`meeting.ready` can repeat when a meeting is processed again: then the `run_id` differs. New event types may appear, so **ignore types you do not know**.

## Delivery format

Diko sends a `POST` with a JSON body that is the `event` object. The body is **byte for byte the same** on every attempt.

```json
{
  "id": "evt_3kQ9mXw0aB7nZp1RtYc4Ld",
  "object": "event",
  "type": "meeting.ready",
  "timestamp": "2026-10-11T09:30:00.000Z",
  "data": {
    "object": "meeting",
    "id": "mtg_0aZ1bY2cX3dW4eV5fU6gT7",
    "status": "ready",
    "archived": false,
    "minutes_status": "ready",
    "minutes_version": 2,
    "run_id": "5b0e8f1a-2c3d-4e5f-8a9b-1c2d3e4f5a6b"
  }
}
```

Diko follows the [Standard Webhooks 1.0](https://www.standardwebhooks.com/) specification. Every request has these headers:

| Header | Value |
|---|---|
| `webhook-id` | the event ID (`evt_...`), constant across attempts |
| `webhook-timestamp` | Unix seconds at the **start of each attempt** |
| `webhook-signature` | `v1,<base64 signature>`; during a rotation two signatures separated by a space |
| `Content-Type` | `application/json` |
| `User-Agent` | `Diko-Webhooks/1.0 (+https://diko.app/en/api-docs/webhooks)` |

## Verifying the signature

**Always verify the signature.** Otherwise anyone who knows your address could send forged events.

1. Read the **raw** request body (the bytes, not re-serialised JSON).
2. Check `webhook-timestamp`: the difference from the current time must be at most **5 minutes**. This protects against replaying an old request.
3. The secret key is the **decoded base64 bytes** of the part after `whsec_`.
4. The signed content is `{webhook-id}.{webhook-timestamp}.{body}`.
5. The signature is `base64(HMAC-SHA256(key, signed content))`.
6. Compare it with every `v1,...` signature in `webhook-signature` using a **constant-time** comparison. One match is enough.

**Python**

```python
import base64
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 5 * 60


def verify_webhook(secret: str, headers, raw_body: bytes) -> bool:
    """`secret` is the whole `whsec_...` string, `headers` a case-insensitive mapping."""
    try:
        message_id = headers["webhook-id"]
        timestamp = headers["webhook-timestamp"]
        signatures = headers["webhook-signature"]
        if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
            return False
    except (KeyError, ValueError):
        return False

    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{message_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()

    for candidate in signatures.split(" "):
        version, _, signature = candidate.partition(",")
        # Compare bytes: compare_digest raises TypeError for a str with non-ASCII characters.
        if version == "v1" and hmac.compare_digest(signature.encode(), expected.encode()):
            return True
    return False
```

**Node.js**

```javascript
import crypto from "node:crypto"

const TOLERANCE_SECONDS = 5 * 60

// `secret` is the whole "whsec_..." string, `headers` are Node's lower-case headers and
// `rawBody` is the raw body as a Buffer (anything else is rejected).
export function verifyWebhook(secret, headers, rawBody) {
  const id = headers["webhook-id"]
  const timestamp = headers["webhook-timestamp"]
  const signatures = headers["webhook-signature"]
  if (!id || !timestamp || !signatures || !Buffer.isBuffer(rawBody)) return false
  const sentAt = Number(timestamp)
  if (!Number.isFinite(sentAt) || Math.abs(Date.now() / 1000 - sentAt) > TOLERANCE_SECONDS) return false

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64")
  const expected = crypto.createHmac("sha256", key).update(`${id}.${timestamp}.`).update(rawBody).digest()

  return signatures.split(" ").some(candidate => {
    const [version, signature] = candidate.split(",")
    if (version !== "v1" || !signature) return false
    const received = Buffer.from(signature, "base64")
    return received.length === expected.length && crypto.timingSafeEqual(received, expected)
  })
}
```

Using it with FastAPI and Express:

**Python**

```python
import json
import os

from fastapi import FastAPI, HTTPException, Request

app = FastAPI()


@app.post("/diko")
async def diko_webhook(request: Request):
    raw_body = await request.body()
    if not verify_webhook(os.environ["DIKO_WEBHOOK_SECRET"], request.headers, raw_body):
        raise HTTPException(status_code=400, detail="invalid signature")
    event = json.loads(raw_body)
    # Queue it, remove duplicates by event["id"] and answer quickly.
    return {"ok": True}
```

**Node.js**

```javascript
import express from "express"

const app = express()

// express.raw leaves the body untouched; express.json() would break the signature.
app.post("/diko", express.raw({ type: "application/json" }), (req, res) => {
  if (!verifyWebhook(process.env.DIKO_WEBHOOK_SECRET, req.headers, req.body)) return res.sendStatus(400)
  const event = JSON.parse(req.body.toString("utf8"))
  // Queue it, remove duplicates by event.id and answer quickly.
  res.sendStatus(200)
})
```

## Delivery guarantees

- **At least once.** The same event can arrive several times. **Remove duplicates** by `webhook-id` (it equals `event.id`).
- **Order is not guaranteed.** Because of retries an older event can arrive later, and Diko sends up to two events of one endpoint at the same time, so even without retries two events can arrive in the other order. Look at `timestamp` or read the current state of the resource through the API. `GET /events` is the one place where events are ordered (by commit).
- **Answer quickly.** A delivery counts as successful only on a **2xx** response. The timeout is 15 seconds, so queue the event first and do the heavy work later.

## Retries

Every non-2xx response, timeout (15 s) and network error is retried. In total there are 8 attempts over about 41 hours:

| Attempt | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| When | at once | after 1 min | after 5 min | after 30 min | after 2 h | after 5 h | after 10 h | after 24 h |

Intervals vary by ±20 % random jitter. Every attempt gets a new `webhook-timestamp`, so a retry after 10 minutes still passes the 5-minute check. The body and `webhook-id` do not change.

- The `Retry-After` header is honoured (up to 1 hour).
- Redirects (3xx) are **not followed** and count as a failure.
- **`410 Gone`** means "this endpoint is gone": Diko disables the endpoint (`disabled`), cancels pending deliveries and e-mails you.

### Circuit breaker and automatic disabling

After 5 consecutive failures Diko pauses delivery for 1 minute and doubles the pause up to 2 hours. When a pause ends, one probe delivery is sent. If an endpoint keeps failing for more than **72 hours**, it becomes `failing_disabled` and you get an e-mail (it names the endpoint by host and ID only; the full address stays in the app).

After you enable the endpoint again, you can replay the failed deliveries in the app or pick up the missed events from `GET /events`.

When **you** disable an endpoint, deliveries that are already waiting stay waiting and go out after you enable it again. When Diko disables it after 72 hours of failures, the waiting deliveries are marked failed so that **Pakartoti nepavykusius** (Replay failed) can send them.

### Held deliveries

If your API access is suspended, or the plan no longer includes the API, Diko **holds** your deliveries: nothing is sent and no attempt is used up. The events themselves are still recorded. While access is suspended, deliveries wait unsent and go out within about a minute after the suspension is lifted. If the plan no longer includes the API, Diko looks at held deliveries again after 15 minutes, then less often (the wait grows with the delivery's age, up to 6 hours), and sends them as soon as the plan allows it again. A delivery held that way for more than 7 days ends as `failed` with `plan_required`. Once access is back, you can send it from the delivery log with **Siųsti dar kartą** (Send again) or **Pakartoti nepavykusius** (Replay failed).

## Secret rotation

Rotate the secret on `/settings/api/webhooks` with **Rotuoti paslaptį** (Rotate secret); this needs re-authentication. The new secret is shown once and **the old one stays valid for another 24 hours**. During that time every delivery carries **two signatures** in `webhook-signature`, separated by a space. The code above checks all of them, so:

1. Create the new secret.
2. Deploy the new secret to your receiver.
3. Wait for the old one to expire, or check that your code already uses the new one.

## Testing, replay and the log

In the app you can send a test event, view the 30-day delivery log with the status of every attempt, replay a single delivery with **Siųsti dar kartą** (Send again) and use **Pakartoti nepavykusius** (Replay failed) for a chosen period (up to 1,000 deliveries). Test events, replays and bulk replays are limited to 6 per minute per endpoint.

- A **test event** (`webhook.test`) gets a single attempt and no retries. It is delivered even to a disabled endpoint, and a failure does not count towards the circuit breaker (a success still clears it).
- **Replaying** a delivery whose 8 attempts are used up sends it exactly once more; if that fails it stays failed.

## URL rules and SSRF protection

Diko sends requests from its own servers, so the address is subject to strict rules:

- `https` only, port **443**, no user name or password in the URL;
- no IP addresses: a domain name with a dot is required;
- at most 2048 characters;
- on every attempt the domain is resolved again and **every address must be public**. Private, loopback, link-local, multicast and reserved addresses are rejected, including IPv4 embedded in other formats (IPv4-mapped, 6to4, Teredo, NAT64);
- the hosts `localhost`, `*.local`, `metadata.google.internal` and internal host names in general (for example `*.internal`) are forbidden;
- the connection is pinned to the verified IP, the TLS certificate must be valid, redirects are not followed and the response is read up to 64 KB at most.

> **Tip:**
> Building locally? Use a tunnel (for example `cloudflared` or `ngrok`) that gives your machine a public HTTPS address.

## The event stream as a fallback

`GET /events` lets you pick up events yourself. It suits you if you cannot expose a public endpoint or want to check that you missed nothing after downtime. Start with `cursor=latest` the first time, then store the last `next_cursor`. Diko records events only while the account has a key that is not revoked or an enabled webhook endpoint. Meetings that finished before that produce no events, so a first run does not back-fill them:

**Python**

```python
import os
import time
from pathlib import Path

import requests

BASE = "https://api.diko.app/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['DIKO_API_KEY']}"}
CURSOR_FILE = Path("diko-events.cursor")  # keep it where your service can write


def load_cursor():
    return CURSOR_FILE.read_text().strip() if CURSOR_FILE.exists() else None


def save_cursor(cursor):
    CURSOR_FILE.write_text(cursor)


def handle(event):
    print(event["type"], event["id"])  # replace with your own processing


cursor = load_cursor() or "latest"  # a stored cursor, or "from now"
while True:
    response = requests.get(
        f"{BASE}/events",
        headers=HEADERS,
        params={"cursor": cursor, "limit": 100, "types": ["meeting.ready"]},
        timeout=30,
    )
    response.raise_for_status()
    page = response.json()
    for event in page["data"]:
        handle(event)  # idempotently: remove duplicates by event["id"]
    cursor = page["next_cursor"]
    save_cursor(cursor)
    if not page["has_more"]:
        time.sleep(10)
```

**TypeScript**

```typescript
import { existsSync, readFileSync, writeFileSync } from "node:fs"

const BASE = "https://api.diko.app/api/v1"
const headers = { Authorization: `Bearer ${process.env.DIKO_API_KEY}` }
const CURSOR_FILE = "diko-events.cursor" // keep it where your service can write

const loadCursor = () => (existsSync(CURSOR_FILE) ? readFileSync(CURSOR_FILE, "utf8").trim() : null)
const saveCursor = (cursor: string) => writeFileSync(CURSOR_FILE, cursor)
const handle = async (event: { id: string; type: string }) => console.log(event.type, event.id) // replace with your own processing

let cursor: string = loadCursor() ?? "latest" // a stored cursor, or "from now"
while (true) {
  const query = new URLSearchParams({ cursor, limit: "100" })
  query.append("types", "meeting.ready")
  const response = await fetch(`${BASE}/events?${query}`, { headers })
  if (!response.ok) throw new Error(`${response.status} ${await response.text()}`)
  const page: { data: { id: string; type: string }[]; next_cursor: string; has_more: boolean } = await response.json()
  for (const event of page.data) await handle(event) // remove duplicates by event.id
  cursor = page.next_cursor
  await saveCursor(cursor)
  if (!page.has_more) await new Promise(resolve => setTimeout(resolve, 10_000))
}
```

Only **committed** events are shown, in commit order, so none is skipped. A key sees only the events of the families it can read under its [scopes](https://diko.app/en/api-docs/scopes.md).
