# Įvykiai ir webhooks

> Įvykių tipai, parašo tikrinimas, pakartojimai, paslapties rotacija ir įvykių srautas.

Šaltinis: https://diko.app/api-docs/webhooks

## Du būdai sužinoti apie pokyčius

Kad nereikėtų nuolat tikrinti būsenos, Diko siūlo du būdus:

- **Webhooks:** Diko pati atsiunčia `POST` užklausą jūsų adresu, kai kas nors įvyksta.
- **Įvykių srautas:** `GET /events` leidžia jums pasiimti įvykius, kada patogu. Tai ir atsarginis kelias, jei jūsų webhook endpointas buvo nepasiekiamas.

Abu būdai naudoja tą patį objektą [`event`](https://diko.app/api-docs/zinynas/get-event.md).

> **Pastaba:**
> Webhook endpointus kuriate ir keičiate tik Diko programėlėje, skiltyje **Nustatymai → API → Webhooks** ([`/settings/api/webhooks`](https://studio.diko.app/settings/api/webhooks)). API raktas negali jų kurti, keisti ar ištrinti, todėl nutekėjęs raktas negali nukreipti jūsų įvykių į svetimą adresą.

## Endpointo kūrimas

1. Atidarykite `/settings/api/webhooks` ir pasirinkite **Sukurti endpointą**. Patvirtinkite veiksmą pakartotiniu prisijungimu, jei Diko to paprašys.
2. Įveskite HTTPS adresą ir pasirinkite įvykių tipus (arba visus).
3. Nukopijuokite paslaptį `whsec_...`. **Ji rodoma tik vieną kartą.** Laikykite ją paslapčių saugykloje.
4. Paspauskite **Siųsti bandomąjį įvykį**. Gausite `webhook.test` tik šiam endpointui; jis nepatenka į `GET /events`.

Vienai paskyrai galima turėti daugiausia 10 endpointų. Endpointas gali būti `enabled`, `disabled` arba `failing_disabled` (automatiškai išjungtas dėl nuolatinių nesėkmių, žr. žemiau). Diko rodo paskutinį sėkmingą ir nesėkmingą pristatymą, 30 dienų pristatymų žurnalą su visų bandymų istorija ir leidžia siųsti pristatymą dar kartą.

## Įvykių katalogas

Visų to paties resurso įvykių `data` turi bendrą bazę: `object` ir `id`. Likusius laukus nurodo lentelė.

| Tipas | Kada siunčiamas | Papildomi `data` laukai |
|---|---|---|
| `meeting.ready` | susitikimo apdorojimas baigtas | `status`, `archived`, `minutes_status`, `minutes_version`, `run_id` |
| `meeting.failed` | apdorojimas nepavyko arba nutrūko | `status`, `archived`, `error_code`, `run_id` |
| `meeting.minutes.updated` | protokolas atnaujintas (pergeneravimas, dalyvio pervadinimas, gramatikos patvirtinimas) | `minutes_version` |
| `meeting.updated` | pakeistas pavadinimas arba susitikimas užarchyvuotas | `changed_fields` (`title`, `archived`) |
| `meeting.deleted` | susitikimas ištrintas | – |
| `library.transcript.added` | transkripcija pridėta į biblioteką | `youtube_video_id`, `source_type`, `added_at` |
| `library.transcript.updated` | santrauka tapo `ready` arba `failed` | `changed_fields` (`["summary"]`), `summary_status` |
| `library.transcript.removed` | transkripcija pašalinta iš bibliotekos | – |
| `library.import.succeeded` | importas baigtas | `transcript_id`, `reused` |
| `library.import.failed` | importas nepavyko | `error_code` |
| `library.import.canceled` | importas atšauktas | – |
| `webhook.test` | bandomasis įvykis, siunčiamas tik nurodytam endpointui ir nerodomas `GET /events` | `object` (`webhook_endpoint`), `id` |

`meeting.ready` gali pasikartoti, jei susitikimas apdorojamas iš naujo: tada `run_id` kitoks. Gali atsirasti naujų įvykių tipų, todėl **nežinomus tipus ignoruokite**.

## Pristatymo formatas

Diko siunčia `POST` su JSON turiniu, kuris yra `event` objektas. Turinys **baitas į baitą tas pats** kiekviename bandyme.

```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 laikosi [Standard Webhooks 1.0](https://www.standardwebhooks.com/) specifikacijos. Kiekviena užklausa turi šias antraštes:

| Antraštė | Reikšmė |
|---|---|
| `webhook-id` | įvykio ID (`evt_...`), pastovus visiems bandymams |
| `webhook-timestamp` | Unix sekundės **kiekvieno bandymo pradžioje** |
| `webhook-signature` | `v1,<base64 parašas>`; rotacijos metu du parašai, atskirti tarpu |
| `Content-Type` | `application/json` |
| `User-Agent` | `Diko-Webhooks/1.0 (+https://diko.app/en/api-docs/webhooks)` |

## Parašo tikrinimas

**Visada tikrinkite parašą.** Kitaip bet kas, žinantis jūsų adresą, galėtų siųsti suklastotus įvykius.

1. Perskaitykite **neapdorotą** užklausos turinį (baitus, ne iš naujo sugeneruotą JSON).
2. Patikrinkite `webhook-timestamp`: skirtumas nuo dabartinio laiko turi būti ne didesnis kaip **5 minutės**. Tai saugo nuo seno užklausos pakartojimo.
3. Pasirašymo raktas yra **dekoduoti base64 baitai** tos eilutės dalies, kuri eina po `whsec_`.
4. Pasirašomas tekstas: `{webhook-id}.{webhook-timestamp}.{turinys}`.
5. Parašas yra `base64(HMAC-SHA256(raktas, pasirašomas tekstas))`.
6. Palyginkite su kiekvienu `v1,...` parašu iš `webhook-signature` **pastoviu laiku** (constant-time). Pakanka, kad vienas sutaptų.

**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` yra visa `whsec_...` eilutė, `headers` – nejautrus raidžių dydžiui žemėlapis."""
    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(",")
        # Lyginkite baitus: compare_digest meta TypeError, jei eilutėje yra ne ASCII simbolių.
        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` yra visa "whsec_..." eilutė, `headers` – Node mažosiomis raidėmis rašomos antraštės,
// o `rawBody` – neapdorotas turinys kaip Buffer (kas kita atmetama).
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)
  })
}
```

Naudojimas su FastAPI ir 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)
    # Įdėkite į eilę, pašalinkite dublius pagal event["id"] ir atsakykite greitai.
    return {"ok": True}
```

**Node.js**

```javascript
import express from "express"

const app = express()

// express.raw palieka turinį nepakeistą; express.json() jį sugadintų parašui.
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"))
  // Įdėkite į eilę, pašalinkite dublius pagal event.id ir atsakykite greitai.
  res.sendStatus(200)
})
```

## Pristatymo garantijos

- **Bent kartą (at-least-once).** Tas pats įvykis gali atkeliauti kelis kartus. **Šalinkite dublius** pagal `webhook-id` (tai tas pats kaip `event.id`).
- **Eilės tvarka negarantuojama.** Dėl pakartojimų senesnis įvykis gali atkeliauti vėliau. Be to, Diko siunčia iki dviejų to paties endpointo įvykių vienu metu, todėl net be pakartojimų du įvykiai gali atkeliauti atvirkščiai. Vertinkite `timestamp` arba perskaitykite resurso dabartinę būseną per API. `GET /events` yra vienintelė vieta, kur įvykiai išdėstyti tvarka (pagal užfiksavimą).
- **Atsakykite greitai.** Pristatymas laikomas sėkmingu tik gavus **2xx**. Atsakymo laukiama ne ilgiau kaip 15 sekundžių, todėl įvykį pirmiausia įdėkite į eilę, o sunkų darbą atlikite vėliau.

## Pakartojimai

Kartojami: kiekvienas ne-2xx atsakymas, pasibaigęs skirtasis laikas (15 s) ir tinklo klaida. Iš viso daroma 8 bandymai per maždaug 41 valandą:

| Bandymas | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| Kada | iškart | po 1 min. | po 5 min. | po 30 min. | po 2 val. | po 5 val. | po 10 val. | po 24 val. |

Intervalai keičiami atsitiktine ±20 % paklaida. Kiekvienas bandymas gauna naują `webhook-timestamp`, todėl pakartojimas po 10 minučių vis tiek praeina 5 minučių tikrinimą. Turinys ir `webhook-id` nesikeičia.

- Diko atsižvelgia į antraštę `Retry-After` (iki 1 valandos).
- Diko **neina pagal peradresavimus** (3xx); jie laikomi nesėkme.
- **`410 Gone`** reiškia „šio endpointo daugiau nėra“: Diko išjungia endpointą (`disabled`), atšaukia laukiančius pristatymus ir išsiunčia jums el. laišką.

### Grandinės pertraukiklis ir automatinis išjungimas

Po 5 nesėkmių iš eilės Diko sustabdo siuntimą 1 minutei, o toliau pauzę dvigubina iki 2 valandų. Pasibaigus pauzei išsiunčiamas vienas bandomasis pristatymas. Jei endpointas nepavyksta ilgiau nei **72 valandas**, jis tampa `failing_disabled` ir gaunate el. laišką (jame endpointas įvardijamas tik domenu ir ID; pilnas adresas lieka programėlėje).

Kai endpointą vėl įjungsite, nepavykusius pristatymus galite pakartoti programėlėje arba pasiimti praleistus įvykius iš `GET /events`.

Kai endpointą išjungiate **jūs patys**, jau laukiantys pristatymai lieka laukti ir išsiunčiami jį vėl įjungus. Kai po 72 valandų nesėkmių jį išjungia Diko, laukiantys pristatymai pažymimi kaip nepavykę, kad **Pakartoti nepavykusius** galėtų juos išsiųsti.

### Sulaikyti pristatymai

Jei jūsų API prieiga sustabdyta arba planas nebeapima API, Diko pristatymus **sulaiko**: nieko nesiunčia ir nenaudoja bandymų. Patys įvykiai vis tiek įrašomi. Kol prieiga sustabdyta, pristatymai laukia neišsiųsti. Atšaukus sustabdymą, jie išsiunčiami maždaug per minutę. Jei planas nebeapima API, Diko iš naujo patikrina sulaikytus pristatymus po 15 minučių, o paskui vis rečiau. Laukimas ilgėja kartu su pristatymo amžiumi, iki 6 valandų. Kai planas vėl leidžia API, pristatymai išsiunčiami. Taip sulaikytas ilgiau nei 7 dienas pristatymas baigiasi kaip `failed` su `plan_required`. Atkūrę prieigą, galite jį išsiųsti iš pristatymų žurnalo mygtukais **Siųsti dar kartą** arba **Pakartoti nepavykusius**.

## Paslapties rotacija

Rotuokite paslaptį puslapyje `/settings/api/webhooks` mygtuku **Rotuoti paslaptį** (reikia pakartotinio patvirtinimo). Nauja paslaptis rodoma vieną kartą, o **sena galioja dar 24 valandas**. Per tą laiką kiekvienas pristatymas turi **du parašus** antraštėje `webhook-signature`, atskirtus tarpu. Aukščiau pateiktas kodas tikrina visus, todėl:

1. Sukurkite naują paslaptį.
2. Įdiekite naują paslaptį ten, kur priimate webhooks.
3. Palaukite, kol sena nustos galioti, arba patikrinkite, ar jūsų kodas jau naudoja naują.

## Bandymas, pakartojimas ir žurnalas

Programėlėje galite: siųsti bandomąjį įvykį, peržiūrėti 30 dienų pristatymų žurnalą su kiekvieno bandymo būsena, pakartoti vieną pristatymą mygtuku **Siųsti dar kartą** ir **Pakartoti nepavykusius** už pasirinktą laikotarpį (iki 1 000 pristatymų). Bandomasis įvykis, pakartojimas ir masinis pakartojimas vienam endpointui ribojami iki 6 per minutę.

- **Bandomasis įvykis** (`webhook.test`) gauna vieną bandymą be pakartojimų. Jis pristatomas net išjungtam endpointui, o nesėkmė neįskaitoma į grandinės pertraukiklį (sėkmė jį vis tiek atstato).
- **Pakartojant** pristatymą, kurio 8 bandymai išnaudoti, jis išsiunčiamas dar lygiai vieną kartą; jei nepavyksta, lieka nepavykęs.

## URL taisyklės ir SSRF apsauga

Diko siunčia užklausas iš savo serverių, todėl adresui taikomos griežtos taisyklės:

- tik `https`, prievadas **443**, be vartotojo vardo ir slaptažodžio URL;
- negalima naudoti IP adreso: reikia domeno vardo su tašku;
- ne ilgesnis kaip 2048 simboliai;
- kiekvieno bandymo metu domenas išsprendžiamas iš naujo ir **visi adresai turi būti vieši**. Privatūs, vietiniai (loopback), link-local, multicast ir rezervuoti adresai atmetami, įskaitant į kitus formatus įterptus IPv4 (IPv4-mapped, 6to4, Teredo, NAT64);
- draudžiami hostai `localhost`, `*.local`, `metadata.google.internal` ir apskritai vidiniai serverių vardai (pavyzdžiui, `*.internal`);
- jungtis prisegama prie patikrinto IP, TLS sertifikatas turi būti galiojantis, Diko neina pagal peradresavimus, o atsakymas skaitomas ne daugiau kaip 64 KB.

> **Patarimas:**
> Kuriate lokaliai? Naudokite tunelį (pavyzdžiui, `cloudflared` ar `ngrok`), kuris duoda viešą HTTPS adresą jūsų kompiuteriui.

## Įvykių srautas kaip alternatyva

`GET /events` leidžia pasiimti įvykius patiems. Tai tinka, jei negalite atverti viešo endpointo arba norite patikrinti, ar nepraleidote įvykių po prastovos. Pirmą kartą pradėkite nuo `cursor=latest`, o paskui saugokite paskutinį `next_cursor`. Diko užfiksuoja įvykius tik tada, kai paskyra turi neatšauktą raktą arba įjungtą webhook endpointą. Anksčiau pasibaigę susitikimai įvykių nesukuria, todėl pirmas paleidimas jų neatkuria:

**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")  # laikykite ten, kur jūsų paslauga gali rašyti


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"])  # pakeiskite savo apdorojimu


cursor = load_cursor() or "latest"  # išsaugotas žymeklis arba „nuo dabar“
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)  # idempotentiškai: dublius šalinkite pagal 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" // laikykite ten, kur jūsų paslauga gali rašyti

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) // pakeiskite savo apdorojimu

let cursor: string = loadCursor() ?? "latest" // išsaugotas žymeklis arba „nuo dabar“
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) // dublius šalinkite pagal event.id
  cursor = page.next_cursor
  await saveCursor(cursor)
  if (!page.has_more) await new Promise(resolve => setTimeout(resolve, 10_000))
}
```

Rodomi tik **užfiksuoti** įvykiai, išdėstyti užfiksavimo tvarka, todėl nė vienas nepraleidžiamas. Raktas mato tik tų šeimų įvykius, kuriuos gali skaityti pagal savo [leidimus](https://diko.app/api-docs/leidimai.md).
