Guide: Получать outgoing webhook'и за 5 минут
Minimal путь "поднять receiver, который принимает события из PartyFlow": создать подписку, ответить 200, проверить подпись, обеспечить идемпотентность. За полным описанием payload'ов — reference/outgoing-webhooks.md.
Симметричный гайд для кнопок — guides/bot-interactive-quickstart.md.
Что получаем
PartyFlow шлёт POST на ваш HTTPS endpoint при событиях в канале (сообщение создано/обновлено/удалено, реакция, канал создан/заархивирован). Receiver отвечает 2xx, в противном случае PartyFlow повторит доставку по exponential-расписанию.
[PartyFlow event] ──POST /webhooks/partyflow──▶ [your service] ──200──▶ done
+ X-PartyFlow-Signature
+ X-PartyFlow-Delivery-IdПонадобится: admin-доступ к space в PartyFlow, публичный HTTPS endpoint (для dev — ngrok/cloudflared), Python 3.10+.
Шаг 1. Создать подписку в UI
Admin space'а → Integrations → Outgoing Webhooks → New subscription:
| Поле | Пример |
|---|---|
| Name | audit-pipeline |
| URL | https://your-service.example.com/webhooks/partyflow |
| Event types | MESSAGE_CREATED, MESSAGE_UPDATED |
| Channels | пусто = все каналы space'а |
| Trigger | all (или mention / keywords — см. concepts/webhooks.md) |
После Create — диалог с signing secret. Показывается один раз, сохраните в secrets manager.
export PARTYFLOW_SIGNING_SECRET='<тот-самый-secret>'Важно:
- URL должен быть HTTPS (PartyFlow отказывается слать на plain HTTP).
- URL должен быть публичным — SSRF-фильтр отвергает RFC 1918, loopback, link-local адреса.
- Для локальной разработки используйте ngrok/cloudflared.
Шаг 2. Minimal receiver
FastAPI-endpoint, который:
- Проверяет подпись.
- Дедуплицирует по
X-PartyFlow-Delivery-Id. - Отвечает
200быстро. - Реальную обработку выносит в background (если она > 1 сек).
"""
PartyFlow outgoing webhook receiver.
"""
import hashlib
import hmac
import json
import os
import time
from collections import OrderedDict
from fastapi import BackgroundTasks, FastAPI, Header, HTTPException, Request
SIGNING_SECRET = os.environ["PARTYFLOW_SIGNING_SECRET"].encode()
app = FastAPI()
# In-memory LRU для idempotency. В production — Redis SET EX 3600 или
# колонка в БД с unique constraint по (delivery_id).
_seen: "OrderedDict[str, float]" = OrderedDict()
_SEEN_MAX = 10_000
_SEEN_TTL_SEC = 3600
def verify(secret: bytes, timestamp: str, body: bytes, sig_header: str) -> bool:
# Anti-replay: окно 5 минут.
if not timestamp.isdigit():
return False
if abs(int(time.time()) - int(timestamp)) > 300:
return False
if not sig_header.startswith("sha256="):
return False
base = f"v1:{timestamp}:".encode() + body
expected = hmac.new(secret, base, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig_header[len("sha256="):])
def already_handled(delivery_id: str) -> bool:
now = time.time()
# Purge expired entries.
while _seen and next(iter(_seen.values())) + _SEEN_TTL_SEC < now:
_seen.popitem(last=False)
if delivery_id in _seen:
return True
_seen[delivery_id] = now
if len(_seen) > _SEEN_MAX:
_seen.popitem(last=False)
return False
def process_event(event_type: str, payload: dict) -> None:
"""Ваша бизнес-логика. Выполняется после того, как мы ответили 200.
Держите её idempotent: при retry может быть запущена повторно."""
print(f"[{event_type}] {json.dumps(payload, ensure_ascii=False)[:500]}")
@app.post("/webhooks/partyflow")
async def receive(
request: Request,
background_tasks: BackgroundTasks,
x_partyflow_signature: str = Header(default=""),
x_partyflow_timestamp: str = Header(default=""),
x_partyflow_delivery_id: str = Header(default=""),
x_partyflow_event: str = Header(default=""),
):
body = await request.body()
# 1. Verify signature BEFORE any business logic.
if not verify(SIGNING_SECRET, x_partyflow_timestamp, body, x_partyflow_signature):
raise HTTPException(401, "invalid signature")
# 2. Idempotency check.
if not x_partyflow_delivery_id:
raise HTTPException(400, "missing X-PartyFlow-Delivery-Id")
if already_handled(x_partyflow_delivery_id):
# Duplicate — acknowledge with 200, skip re-processing.
return {"ok": True, "duplicate": True}
# 3. Parse and dispatch in background. Return 200 fast.
payload = json.loads(body)
background_tasks.add_task(process_event, x_partyflow_event, payload)
return {"ok": True}Запустить:
pip install fastapi uvicorn
export PARTYFLOW_SIGNING_SECRET='<secret-из-шага-1>'
uvicorn receiver:app --host 0.0.0.0 --port 8080Для локального тестирования:
ngrok http 8080
# → копируйте https-URL в PartyFlow subscription.Шаг 3. Верификация подписи (детали)
Signing string — v1:{timestamp}:{raw_body}. Ключ — signing_secret из шага 1. Алгоритм — HMAC-SHA256, результат — hex в заголовке X-PartyFlow-Signature: sha256=<hex>.
Три главных правила:
- Используйте raw body, как оно пришло. Если вы делаете
json.loads(body), потомjson.dumps(payload)и подписываете — получите другой hex (пробелы, порядок ключей). - Constant-time compare (
hmac.compare_digestв Python,crypto.timingSafeEqualв Node). Обычное==уязвимо к timing attacks. - Проверяйте timestamp window (5 минут) до HMAC — экономит CPU на replay attempts.
Полные snippets для Node.js / Go / Ruby / Bash — guides/verify-signatures.md → Verification of outgoing deliveries.
Шаг 4. Идемпотентность по X-PartyFlow-Delivery-Id
PartyFlow может прислать один и тот же delivery_id несколько раз — если ваш ответ потерялся (TCP timeout, 5xx, network glitch). delivery_id стабилен на все retry одной доставки.
Что делать:
- Сохраните
delivery_idв БД с unique constraint, либо в Redis с TTL ≥ retry-window (текущий дефолт — ~11 минут; безопасный запас — 1 час). - При получении: если
delivery_idуже есть — просто отвечайте200, не повторяйте side effects. - Пример выше использует in-memory LRU — только для dev. В production используйте persistent store (переживает рестарт).
Пример с Redis:
import redis
r = redis.Redis()
def already_handled(delivery_id: str) -> bool:
# SET NX — atomic. Возвращает False если ключ уже был.
return not r.set(f"pf:delivery:{delivery_id}", "1", nx=True, ex=3600)Шаг 5. Ответ 200 + graceful retry handling
Рекомендуемое поведение:
| Ситуация | Код | Что сделает PartyFlow |
|---|---|---|
| Успех, событие обработано (или duplicate) | 200 |
Доставка считается успешной, consecutive_failures сбрасывается. |
| Transient (БД недоступна, внешний сервис вниз) | 503 |
Retry по exponential schedule. При возможности верните Retry-After: <сек>. |
| Rate limit на вашей стороне | 429 + Retry-After: <сек> |
Retry после указанного времени (clamp [1s, 1h]). |
| Невалидный payload для вас (редко — формат envelope стабилен) | 400 / 422 |
Dead-letter немедленно. PartyFlow не будет ретраить. |
Важно: любой 4xx (кроме 408 и 429) — immediate dead-letter. Не возвращайте 400 для transient — используйте 503.
Таймаут: отвечайте < 1 сек как target. Если не успеваете — выносите обработку в background (как в примере выше), отвечайте 200 сразу.
Retry schedule (кратко)
Exponential backoff: 5s → 10s → 20s → 40s → 80s → 160s → 320s с ±20% jitter перед dead-letter. Всего ~8 попыток, полное retry-window — ~10 минут 35 секунд. Детали и производные значения (что делать если Retry-After > 1 часа, когда срабатывает circuit breaker) — reference/outgoing-webhooks.md → Retry schedule.
Auto-disable
Если подписка получает 100 подряд dead-letter'ов — PartyFlow её автоматически отключает. Admin увидит уведомление в UI. Включить обратно — вручную в admin UI. События, случившиеся пока disabled, не буферизуются.
Troubleshooting
| Симптом | Причина | Решение |
|---|---|---|
401 invalid signature на стороне receiver |
Неправильный secret, парсите JSON до проверки подписи, non-constant-time compare, rotated secret. | Используйте raw body (await request.body()), не re-serialize. Проверьте, что PARTYFLOW_SIGNING_SECRET совпадает со значением, выданным при Create (или после rotate). |
invalid timestamp / timestamp window |
Clock skew > 5 минут. | Установите NTP на хосте receiver. |
| PartyFlow пишет в UI, что subscription auto-disabled | 100 подряд failed доставок. Обычно — прод endpoint упал, а вы не заметили. | Починить receiver, activate обратно в admin UI. Стройте alerting на consecutive_failures метрику subscription'а (видна в admin UI). |
| Получаем дубли событий в своей БД | Receiver не проверяет X-PartyFlow-Delivery-Id. |
Добавить idempotency (Шаг 4). |
| Receiver получает retry через 10 минут, но мы уже обработали | Ожидаемо. Retry приходит если 2xx не пришёл вовремя. Отвечайте 200 + игнор по delivery_id. |
См. Шаг 4. |
| Получаем одно событие на нескольких подписках | Ожидаемо. Одно событие → N доставок, по одной на подписку. event_id у всех одинаковый, delivery_id — разный. |
Если у вас две подписки в одну БД — дедуплицируйте по event_id, не по delivery_id. |
| PartyFlow не присылает событие, которое должен | Триггер (mention / keywords) не совпал, канал не в channel_ids, event type не в event_types. |
Проверьте trigger config в admin UI. Посмотрите delivery log в admin UI — видно, какие события прилетели к подписке. |
context.messages пустой, ожидали контекст |
Подписка создана без include_context=true, либо context_error=true (transient). |
Включите в subscription settings. |
Best practices
- Idempotency обязательна. Не полагайтесь на "retry бывают редко" — они бывают.
- Store signing secret securely (Vault, K8s Secret, AWS Secrets Manager). Не коммитьте в код.
- Логируйте
delivery_id+event_idна входе и на выходе — это ключ к любой диагностике. - Alert на
consecutive_failures≥ 10. Auto-disable при 100 — но если alerting на 10, вы починитесь до disabled'а. - Отвечайте быстро. Выносите тяжёлую обработку в background-queue, отвечайте
200в пределах 1 сек. - Ротация secret'а — используйте admin "rotate signing secret" раз в квартал. После ротации старые запросы, уже запланированные к доставке, всё равно пройдут верификацию (подпись фиксируется перед первой отправкой), но новые события придут с новым secret.
Связанное
- reference/outgoing-webhooks.md — полный справочник: event types, payload, retry schedule, context enrichment.
- concepts/webhooks.md — жизненный цикл, trigger modes (
all/mention/keywords). - guides/verify-signatures.md — snippets на Python/Node/Go/Ruby/Bash.
- concepts/security.md — best practices для secrets, ротация.
- concepts/rate-limits.md — per-plan лимиты, auto-disable пороги.