PartyFlow

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'а → IntegrationsOutgoing WebhooksNew 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, который:

  1. Проверяет подпись.
  2. Дедуплицирует по X-PartyFlow-Delivery-Id.
  3. Отвечает 200 быстро.
  4. Реальную обработку выносит в 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>.

Три главных правила:

  1. Используйте raw body, как оно пришло. Если вы делаете json.loads(body), потом json.dumps(payload) и подписываете — получите другой hex (пробелы, порядок ключей).
  2. Constant-time compare (hmac.compare_digest в Python, crypto.timingSafeEqual в Node). Обычное == уязвимо к timing attacks.
  3. Проверяйте 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#

  1. Idempotency обязательна. Не полагайтесь на "retry бывают редко" — они бывают.
  2. Store signing secret securely (Vault, K8s Secret, AWS Secrets Manager). Не коммитьте в код.
  3. Логируйте delivery_id + event_id на входе и на выходе — это ключ к любой диагностике.
  4. Alert на consecutive_failures ≥ 10. Auto-disable при 100 — но если alerting на 10, вы починитесь до disabled'а.
  5. Отвечайте быстро. Выносите тяжёлую обработку в background-queue, отвечайте 200 в пределах 1 сек.
  6. Ротация secret'а — используйте admin "rotate signing secret" раз в квартал. После ротации старые запросы, уже запланированные к доставке, всё равно пройдут верификацию (подпись фиксируется перед первой отправкой), но новые события придут с новым secret.

Связанное#