Webhooky

Přehled

BriefGate posílá na váš endpoint podepsané POST požadavky, kdykoli se se sběrem podkladů něco stane. Webhooky jsou nejlepší způsob, jak reagovat na aktivitu klienta v reálném čase — jsou efektivnější než dotazování get_intake_status ve smyčce a fungují i ve chvíli, kdy váš agent nic nedělá nebo spí mezi úkoly.

Každý požadavek je podepsaný, takže si ověříte, že přišel od BriefGate a nikdo s ním nemanipuloval.

Registrace endpointu

POST /v1/webhooks
json
{
  "url": "https://yourapp.com/webhooks/briefgate",
  "events": ["item.submitted", "intake.completed"]
}

Odebírat můžete libovolnou podmnožinu ze sedmi dostupných událostí. Pro odběr všech předejte všech sedm názvů.

bash
curl -X POST https://api.briefgate.dev/v1/webhooks \
  -H "Authorization: Bearer bg_live_xxxxx" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://yourapp.com/webhooks/briefgate","events":["item.submitted","intake.completed","intake.stalled"]}'

Odpověď:

json
{
  "id": "whe_a1b2c3d4e5f6",
  "url": "https://yourapp.com/webhooks/briefgate",
  "events": ["item.submitted", "intake.completed", "intake.stalled"],
  "format": "raw",
  "active": true,
  "created_at": "2026-07-18T09:11:00Z",
  "secret": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}

Hodnota secret se zobrazí právě jednou. Uložte si ji bezpečně — bez ní neověříte podpisy.

Nepovinné pole format určuje, jakou podobu bude mít tělo požadavku. Výchozí je raw, tedy podepsaná obálka BriefGate popsaná níže. Nastavením na slack nebo discord se místo toho pošle hotová zpráva do chatu — viz Slack a Discord.

Správa endpointů

Vypište všechny endpointy zaregistrované na účtu:

GET /v1/webhooks
json
{
  "webhooks": [
    {
      "id": "whe_a1b2c3d4e5f6",
      "url": "https://yourapp.com/webhooks/briefgate",
      "events": ["item.submitted", "intake.completed", "intake.stalled"],
      "format": "raw",
      "active": true,
      "created_at": "2026-07-18T09:11:00Z"
    }
  ]
}

Hodnota secret se tady nikdy nevrací — jen při vytvoření. U endpointu s formátem slack nebo discord se url vrací jen jako origin (viz Co v chatovém upozornění nikdy není).

Odstranění endpointu:

DELETE /v1/webhooks/:id

Vrací 204 No Content. Nic dalšího se pak na něj neposílá a znovu ho aktivovat nejde — zaregistrujte nový.

Vytvoření endpointu, jeho smazání, opakování jednotlivého doručení a odeslání testovací události jsou vyhrazené vlastníkovi účtu. Kterýkoli přihlášený člen týmu si může endpointy vypsat a prohlédnout historii doručení, ale na jejich změnu potřebuje roli vlastníka. API klíč je přihlašovací údaj samotného účtu, takže tohle omezení platí jen pro přihlášení přes prohlížeč.

Ověření podpisu

Každý požadavek nese hlavičku:

X-BriefGate-Signature: t=1721131200,v1=abc123...

Spolu s ní jedou ještě dvě další hlavičky, byť ani jedna není součástí podpisu: X-BriefGate-Event nese název události, takže se podle ní dá směrovat ještě před parsováním JSONu, a User-Agent: BriefGate-Webhook/1.0 identifikuje požadavek jako náš.

Postup ověření:

  1. Z hlavičky vytáhněte t a v1.
  2. Požadavek odmítněte, pokud |teď - t| > 300 sekund (brání útoku přehráním).
  3. Spočítejte HMAC-SHA256(secret, "${t}.${rawBody}"), kde rawBody je surové tělo požadavku jako řetězec.
  4. Porovnejte přes timingSafeEqual — na porovnání podpisu nikdy nepoužívejte ===.

Kompletní ověřovací funkce pro Node.js:

javascript
import { createHmac, timingSafeEqual } from 'crypto';

function verifyWebhook(rawBody, secret, signatureHeader) {
  const parts = Object.fromEntries(
    signatureHeader.split(',').map(p => p.split('=', 2))
  );
  const t = parts['t'];
  const v1 = parts['v1'];
  if (!t || !v1) throw new Error('Missing signature components');

  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) {
    throw new Error('Timestamp out of tolerance window');
  }

  const expected = createHmac('sha256', secret)
    .update(`${t}.${rawBody}`)
    .digest('hex');

  if (!timingSafeEqual(Buffer.from(v1), Buffer.from(expected))) {
    throw new Error('Signature mismatch');
  }
}

verifyWebhook volejte dřív, než s obsahem cokoli uděláte. Když vyhodí výjimku, vraťte HTTP 400 a požadavek zahoďte.

Události

item.submitted

Spustí se, když klient v portálu odešle jednu položku.

json
{
  "event": "item.submitted",
  "intake_id": "in_01J3K...",
  "project_name": "Bella Cucina Website",
  "item_key": "logo",
  "item_label": "Logo firmy",
  "item_type": "image",
  "file_count": 1,
  "timestamp": 1784212920
}

file_count je přítomné jen u položek typu file, image a file_list. timestamp je v sekundách Unixového času a je to hodnota, přes kterou se počítá podpis. E-mail klienta se schválně neposílá — chatový kanál má obvykle víc čtenářů než samotný sběr podkladů.

intake.completed

Spustí se, jakmile jsou odeslané všechny povinné položky (nepovinné dokončení neblokují).

json
{
  "event": "intake.completed",
  "intake_id": "in_01J3K...",
  "project_name": "Bella Cucina Website",
  "timestamp": 1784372060
}

Odpovědi samotné si vyzvedněte přes get_intake_results.

client.viewed

Spustí se, když klient otevře svůj portál.

json
{
  "event": "client.viewed",
  "intake_id": "in_01J3K...",
  "slug": "8f3kqmr2",
  "project_name": "Bella Cucina Website"
}

Žádná IP adresa se neposílá. Zobrazení se odstraňují duplicitně na serveru, takže klient, který stránku obnoví, událost nespustí znovu.

chase.bounced

Spustí se, když poskytovatel e-mailu nahlásí u upomínkového e-mailu odraz nebo stížnost na spam. Jestli BriefGate na adresu přestane posílat, záleží na tom, co nahlásil — viz still_chasing níže a Zpracování odrazů.

json
{
  "event": "chase.bounced",
  "intake_id": "in_01J3K...",
  "chase_id": "chs_01J4M...",
  "provider_message_id": "01J4M8Z...",
  "event_type": "bounced",
  "recipient": "owner@bellacucina.cz",
  "still_chasing": true,
  "bounce_type": "Transient",
  "delivered_before": true,
  "timestamp": 1784361600
}

event_type je to, co nahlásil poskytovatel e-mailu — bounced, nebo complained. recipient je adresa, na které nastal odraz. still_chasing říká, jestli sběr podkladů má ještě další příjemce, které upomínky dál osloví (true), nebo jestli to byl poslední, takže se pro tento sběr podkladů další upomínky už zrušily (false).

bounce_type je klasifikace od poskytovatele (Permanent, Transient, Undetermined, nebo null u stížnosti). delivered_before je true, když poskytovatel u téže zprávy už dřív potvrdil doručení — jde o opožděné hlášení zpoza serveru příjemce, ne o mrtvou adresu. Trvalý odraz nebo stížnost adresu vyřadí vždy; odraz po doručení nikdy; dočasný odraz bez doručení ji vyřadí až třetím v řadě.

intake.stalled

Spustí se, když sběr podkladů vyčerpá povolený počet upomínek — odešlo se jich max_reminders (výchozí 3) a klient stále nedokončil. Upomínkový engine zbývající upomínky zruší a sběr podkladů předá zpátky vám.

json
{
  "event": "intake.stalled",
  "intake_id": "in_01J3K...",
  "project_name": "Bella Cucina Website",
  "missing_items": ["logo", "hero_copy", "wp_admin"],
  "timestamp": 1784707200
}

missing_items obsahuje klíče povinných položek, které pořád čekají na klienta.

Když dorazí intake.stalled, zvažte oslovení klienta jinou cestou.

intake.overdue

Spustí se jen jednou — v okamžiku, kdy periodická kontrola poprvé zjistí, že sběr podkladů má za sebou termín dokončení a alespoň jedna povinná položka pořád chybí. Pro stejný sběr podkladů se už nespustí podruhé.

json
{
  "event": "intake.overdue",
  "intake_id": "in_01J3K...",
  "project_name": "Bella Cucina Website",
  "due_date": "2026-07-01T00:00:00.000Z",
  "outstanding_items": 3,
  "timestamp": 1784707200
}

outstanding_items počítá povinné položky, které jsou pořád ve stavu pending nebo needs_revision — waivnuté položky se nepočítají. Tahle událost se spouští nezávisle na automatickém plánu upomínek i na tom, jestli má účet zapnuté e-mailové upozornění pro vlastníka — je to jediná událost určená vám, vývojáři, ne jako připomínka klientovi.

intake.archived

Spustí se, když POST /v1/intakes/:id/archive uzavře sběr podkladů — z libovolného stavu, včetně toho, který nikdy nebyl odeslán.

json
{
  "event": "intake.archived",
  "intake_id": "in_01J3K...",
  "project_name": "Bella Cucina Website",
  "timestamp": 1784707200
}

Sběr podkladů a vše na něm (položky, soubory, historie upomínek) dál existuje — tohle není událost o smazání, a pro DELETE /v1/intakes/:id dnes žádný odpovídající webhook intake.deleted neexistuje.

Slack a Discord

Abyste dostávali události z BriefGate, nemusíte provozovat vlastní server. Nasměrujte webhook na příchozí webhook Slacku nebo Discordu a upozornění dorazí do kanálu, který tým stejně sleduje.

Je to tatáž cesta doručení jako u kteréhokoli jiného webhooku — stejné události, stejné opakování pokusů, stejný log. Mění se jen tělo požadavku, protože Slack i Discord přijímají vlastní formát zprávy a na cokoli jiného odpovídají chybou 400.

Slack

Ve Slacku vytvořte příchozí webhook (Your apps → vaše aplikace → Incoming Webhooks → Add New Webhook to Workspace), vyberte kanál a zkopírujte URL. Pak:

bash
curl -X POST https://api.briefgate.dev/v1/webhooks \
  -H "Authorization: Bearer bg_live_xxxxx" \
  -H "Content-Type: application/json" \
  -d '{
        "url": "https://hooks.slack.com/services/T0000/B0000/xxxxxxxx",
        "events": ["intake.completed", "intake.stalled", "chase.bounced"],
        "format": "slack"
      }'

Do kanálu chodí prosté anglické věty:

The client submitted "logo" for Bella Cucina Website.
Intake: in_01J3K...

Věta klienta nikdy nejmenuje — obsah události item.submitted schválně neobsahuje e-mail klienta (viz výše), takže formátovač pro chat vždy sáhne po náhradě "The client"; s client_email u téhle události nepočítejte nikdy.

Příchozí webhook Slacku posílá vždy do kanálu, pro který byl vytvořen, a pod jménem a ikonou nastavenými u dané Slack aplikace. To je pravidlo Slacku, ne naše: příchozí webhook neumí přepsat kanál, jméno ani ikonu a neumí poslat přímou zprávu. Když chcete upozorňovat každého vývojáře soukromě, ať má každý svůj vlastní webhook vytvořený proti DM, nebo odebírejte kanál na projekt.

Discord

Otevřete nastavení kanálu → Integrations → Webhooks → New Webhook, zkopírujte URL a zaregistrujte ji s "format": "discord".

bash
curl -X POST https://api.briefgate.dev/v1/webhooks \
  -H "Authorization: Bearer bg_live_xxxxx" \
  -H "Content-Type: application/json" \
  -d '{
        "url": "https://discord.com/api/webhooks/123456/xxxxxxxx",
        "events": ["intake.completed"],
        "format": "discord"
      }'

Zmínky ve zprávě jsou neaktivní: název projektu nebo klient obsahující @everyone se doručí jako text a nikoho neupozorní.

Co v chatovém upozornění nikdy není

Odkaz do portálu se neposílá. Je to přístupový odkaz do portálu vašeho klienta a kanál má obvykle víc čtenářů než samotný sběr podkladů. Každé upozornění nese ID sběru podkladů, což stačí k dohledání přes API a nikomu to nic neotevírá.

Ze stejného důvodu GET /v1/webhooks vrací u Slacku a Discordu jen origin adresy (https://hooks.slack.com/…). Cesta v takové URL je přihlašovací údaj — kdo ji má, může do kanálu psát — takže se s ní zachází jako s podpisovým tajemstvím a znovu se nezobrazuje. Adresa formátu raw míří na váš vlastní server, a tak se vrací celá.

Požadavky na Slack a Discord se stále podepisují hlavičkou X-BriefGate-Signature. Ani jedna služba ji nekontroluje; nic to nestojí a drží to jednu cestu kódu pro všechny formáty.

Kdy který

Chatové upozornění je pro člověka, který chce vědět. Formát raw je pro agenta, který bude jednat — nese celý obsah, jde ověřit a je to to, co má spouštět intake.completed, když dalším krokem je kód. Zaregistrovat obojí je běžné: jeden endpoint na formát, každý s odběrem těch událostí, které mu dávají smysl.

Opakování pokusů

Když váš endpoint nevrátí HTTP 2xx, BriefGate to zkusí znovu s exponenciálně rostoucí prodlevou:

Pokus Odstup od předchozího
1 (okamžitě) —
2 1 minuta
3 5 minut
4 30 minut
5 2 hodiny
6 6 hodin

Po šesti neúspěšných pokusech se doručení vzdá a v logu se označí jako failed. Přesměrování se nenásledují, takže 301 nebo 302 se počítá jako neúspěch — registrujte rovnou cílovou adresu.

Vrácením HTTP 410 Gone endpoint ze své strany deaktivujete. Dané doručení se označí jako neúspěšné bez dalších pokusů a nic dalšího se na endpoint neodešle, dokud ho znovu nezaregistrujete.

Historie doručení — posledních 100 pokusů, od nejnovějšího:

GET /v1/webhooks/:id/deliveries
json
{
  "deliveries": [
    {
      "id": "whd_9f8e7d6c5b4a",
      "event": "intake.completed",
      "status": "failed",
      "attempts": 6,
      "response_code": 500,
      "error": "HTTP 500",
      "next_retry_at": null,
      "created_at": "2026-07-18T09:11:00Z",
      "delivered_at": null
    }
  ]
}

Odeslaný obsah se tady nevrací. Tenhle pohled je od toho, abyste zjistili, proč doručení selhalo, a v obsahu může být cokoli, co klient nahrál.

Jednotlivé doručení ručně zopakujete přes:

POST /v1/webhooks/:id/deliveries/:deliveryId/retry

Tohle doručení zařadí do fronty na nejbližší plánovaný běh a pošle ho stejnou cestou jako každé jiné — se stejnou SSRF kontrolou, s nově spočítaným podpisem, zalogované jako každý jiný pokus. Funguje na libovolném doručení, které ještě neuspělo (u toho, co už proběhlo, i u endpointu, který jste mezitím vypnuli, dostanete 409). Počet pokusů se nevynuluje: attempts pokračuje tam, kde skončilo, takže doručení, které už vyčerpalo všech 6 pokusů, dostane přesně jeden další, ne novou sadu od začátku — pokud chcete víc, nejdřív opravte endpoint. Odpověď:

json
{
  "delivery": {
    "id": "whd_9f8e7d6c5b4a",
    "event": "intake.completed",
    "status": "pending",
    "attempts": 6,
    "response_code": 500,
    "error": null,
    "next_retry_at": "2026-07-18T09:12:00Z",
    "created_at": "2026-07-18T09:11:00Z",
    "delivered_at": null
  }
}

Testování

Na endpoint můžete poslat testovací doručení, aniž byste zakládali skutečný sběr podkladů:

POST /v1/webhooks/:id/test

Bez těla požadavku — vybrat si, kterou událost testovací doručení předstírá, nejde. BriefGate vždy pošle pevně daný, vymyšlený obsah pro item.submitted:

json
{
  "event": "item.submitted",
  "intake_id": "in_test",
  "item_key": "test",
  "message": "This is a test delivery from BriefGate.",
  "timestamp": 1784212920
}

Tvar je schválně minimalistický a neodpovídá poli po poli skutečnému obsahu item.submitted (chybí project_name, item_label, item_type) — slouží k ověření, že je endpoint dostupný a že funguje ověřování podpisu, ne k procvičení parsování obsahu.

Doručení se zařadí do fronty a odešle na pozadí, podepsané stejně jako skutečné, a objeví se v logu doručení jako každý jiný pokus. Požadavek se vrátí okamžitě:

json
{
  "queued": true,
  "delivery_id": "whd_9f8e7d6c5b4a"
}

Doporučení

Nejdřív ověřte podpis. Nezpracovávejte obsah dřív, než zavoláte ověřovací funkci. Požadavek bez platného podpisu okamžitě zahoďte.

Počítejte s idempotencí. Webhook může ve výjimečných případech dorazit vícekrát (opakování kvůli síti, restart infrastruktury). Kontrolujte kombinaci intake_id + event, ať tutéž událost nezpracujete dvakrát.

Surové tělo zalogujte před parsováním. Když váš JSON parser spadne, budete ho chtít mít k dispozici pro ladění. Uložte ho ještě před JSON.parse.

Odpovídejte rychle. Na vrácení odpovědi 2xx má endpoint 10 sekund, pak se požadavek přeruší a počítá jako neúspěšný pokus. Pokud handler dělá něco náročného (volá externí API, sestavuje soubory), potvrďte příjem hned a zbytek odbavte na pozadí.

Nespoléhejte na pořadí doručení. Události item.submitted mohou dorazit před intake.completed i po ní. Handler navrhněte tak, aby zvládl události v libovolném pořadí.