PartyFlow

Guide: как подписывать webhook-запросы#

Готовые к копированию snippets на Python, Node.js, Go, Ruby, Bash для HMAC-подписи запросов к PartyFlow webhooks.

Для общего обзора security-модели и выбора схемы — concepts/security.md.


Что подписываем#

У webhook'а есть signing_secret (формат whsec_<base64url>). Его надо:

  1. Сохранить в secrets manager при создании webhook'а. Показывается один раз.
  2. Использовать для вычисления HMAC-SHA256 от signing_string.
  3. Прислать результат в заголовке.

Выбрана одна из двух схем (не обе сразу):

Схема Header с подписью Header с timestamp Формат signing string
Slack X-Slack-Signature: v0=<hex> X-Slack-Request-Timestamp: <unix> v0:<timestamp>:<body>
PartyFlow-native X-PartyFlow-Signature: sha256=<hex> X-PartyFlow-Timestamp: <unix> <timestamp>.<body>

Timestamp — Unix seconds, не milliseconds. Запросы старше 5 минут отвергаются.


Python#

import hmac
import hashlib
import time
import requests
 
 
def send_webhook(url: str, secret: bytes, payload: dict) -> requests.Response:
    import json
    body = json.dumps(payload, separators=(",", ":")).encode()
    ts = str(int(time.time()))
 
    signing_string = ts.encode() + b"." + body
    signature = "sha256=" + hmac.new(secret, signing_string, hashlib.sha256).hexdigest()
 
    return requests.post(
        url,
        data=body,
        headers={
            "Content-Type": "application/json",
            "X-PartyFlow-Timestamp": ts,
            "X-PartyFlow-Signature": signature,
        },
        timeout=10,
    )
 
 
if __name__ == "__main__":
    resp = send_webhook(
        url="https://api.partyflow.ru/api/v1/webhooks/incoming/whk_...",
        secret=b"whsec_...",  # из secrets manager
        payload={"text": "Deployed v2.14.0"},
    )
    resp.raise_for_status()
    print(resp.json())

Ключевой момент: подписываете body до отправки, используя ту же сериализацию. Если передать json= в requests.post(), а подписывать json.dumps(payload), они могут не совпасть (пробелы, порядок ключей). Всегда сериализуйте один раз в bytes, подписывайте и отправляйте через data=.


Node.js#

const crypto = require("crypto");
 
async function sendWebhook(url, secret, payload) {
  const body = JSON.stringify(payload);
  const ts = Math.floor(Date.now() / 1000).toString();
 
  const signature = "sha256=" + crypto
    .createHmac("sha256", secret)
    .update(`${ts}.${body}`)
    .digest("hex");
 
  const resp = await fetch(url, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-PartyFlow-Timestamp": ts,
      "X-PartyFlow-Signature": signature,
    },
    body,
  });
 
  if (!resp.ok) {
    throw new Error(`Webhook failed: HTTP ${resp.status}`);
  }
  return resp.json();
}
 
// Пример
(async () => {
  const result = await sendWebhook(
    "https://api.partyflow.ru/api/v1/webhooks/incoming/whk_...",
    "whsec_...",
    { text: "Deployed v2.14.0" },
  );
  console.log(result);
})();

Go#

package main
 
import (
	"bytes"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"net/http"
	"strconv"
	"time"
)
 
func sendWebhook(url string, secret []byte, payload any) (*http.Response, error) {
	body, err := json.Marshal(payload)
	if err != nil {
		return nil, fmt.Errorf("marshal payload: %w", err)
	}
 
	ts := strconv.FormatInt(time.Now().Unix(), 10)
 
	mac := hmac.New(sha256.New, secret)
	mac.Write([]byte(ts))
	mac.Write([]byte{'.'})
	mac.Write(body)
	signature := "sha256=" + hex.EncodeToString(mac.Sum(nil))
 
	req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
	if err != nil {
		return nil, fmt.Errorf("new request: %w", err)
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("X-PartyFlow-Timestamp", ts)
	req.Header.Set("X-PartyFlow-Signature", signature)
 
	client := &http.Client{Timeout: 10 * time.Second}
	return client.Do(req)
}
 
func main() {
	resp, err := sendWebhook(
		"https://api.partyflow.ru/api/v1/webhooks/incoming/whk_...",
		[]byte("whsec_..."),
		map[string]string{"text": "Deployed v2.14.0"},
	)
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()
	fmt.Println("Status:", resp.Status)
}

Ruby#

require 'json'
require 'net/http'
require 'openssl'
require 'uri'
 
def send_webhook(url, secret, payload)
  body = JSON.generate(payload)
  ts = Time.now.to_i.to_s
 
  signing_string = "#{ts}.#{body}"
  digest = OpenSSL::HMAC.hexdigest('SHA256', secret, signing_string)
  signature = "sha256=#{digest}"
 
  uri = URI.parse(url)
  req = Net::HTTP::Post.new(uri)
  req['Content-Type'] = 'application/json'
  req['X-PartyFlow-Timestamp'] = ts
  req['X-PartyFlow-Signature'] = signature
  req.body = body
 
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = uri.scheme == 'https'
  http.request(req)
end
 
resp = send_webhook(
  'https://api.partyflow.ru/api/v1/webhooks/incoming/whk_...',
  'whsec_...',
  { text: 'Deployed v2.14.0' },
)
raise "HTTP #{resp.code}" unless resp.code == '200'
puts resp.body

Bash#

#!/usr/bin/env bash
set -euo pipefail
 
WEBHOOK_URL="${WEBHOOK_URL:?set WEBHOOK_URL}"
SECRET="${WEBHOOK_SECRET:?set WEBHOOK_SECRET}"
BODY="${1:-{\"text\":\"Hello from bash\"}}"
 
TS=$(date +%s)
SIG="sha256=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')"
 
curl --fail --show-error -X POST "$WEBHOOK_URL" \
  -H 'Content-Type: application/json' \
  -H "X-PartyFlow-Timestamp: $TS" \
  -H "X-PartyFlow-Signature: $SIG" \
  -d "$BODY"

Запуск:

export WEBHOOK_URL='https://api.partyflow.ru/api/v1/webhooks/incoming/whk_...'
export WEBHOOK_SECRET='whsec_...'
./send-webhook.sh '{"text":"Alert from cron job"}'

Slack-совместимая схема#

Нужна, если ваш сервис уже умеет подписывать в стиле Slack (или вы форвардите Slack-запросы).

Python:

signing_string = b"v0:" + ts.encode() + b":" + body
signature = "v0=" + hmac.new(secret, signing_string, hashlib.sha256).hexdigest()
 
headers = {
    "Content-Type": "application/json",
    "X-Slack-Request-Timestamp": ts,
    "X-Slack-Signature": signature,
}

Node.js:

const signature = "v0=" + crypto
  .createHmac("sha256", secret)
  .update(`v0:${ts}:${body}`)
  .digest("hex");
 
const headers = {
  "Content-Type": "application/json",
  "X-Slack-Request-Timestamp": ts,
  "X-Slack-Signature": signature,
};

Проверка на стороне сервера (reference)#

Для учебных/отладочных целей — так endpoint проверяет подпись. Используйте, если пишете собственную прослойку между сервисом и PartyFlow:

import hmac, hashlib, time
 
def verify_partyflow_native(secret: bytes, body: bytes, ts: str, signature: str) -> bool:
    if not ts.isdigit():
        return False
    if abs(int(time.time()) - int(ts)) > 300:  # 5 min window
        return False
    if not signature.startswith("sha256="):
        return False
    expected = hmac.new(secret, ts.encode() + b"." + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature[len("sha256="):])

Важное:

  • hmac.compare_digestconstant-time, иначе возможна timing attack.
  • Timestamp проверяется до HMAC — иначе тратим CPU на устаревшие запросы.
  • Сверяем сырые bytes тела, не re-parsed JSON.

Частые ошибки#

Симптом Причина
HTTP 401 signature invalid Подписываете reserialized JSON, а не bytes тела. Порядок ключей/пробелы меняются — подпись не сходится.
HTTP 401 (все запросы) Часы не синхронизированы (NTP drift). Проверьте date -u против curl -sI https://google.com | grep -i date.
HTTP 401 (периодически) Вы иногда теряете precision на timestamp (сделали Math.round(Date.now() / 1000) в разных местах по-разному).
HTTP 200, но подписи как бы не было Webhook создан с require_signature: false. Endpoint принимает token-only запросы; ваши подписи проверяются, но отсутствие подписи не блокирует.

Verification of outgoing deliveries#

Если вы получаете события от PartyFlow (outgoing webhook на вашу HTTPS-endpoint'у), вам нужно проверять подпись входящих запросов. Схема отличается от incoming — это PartyFlow-native с префиксом версии v1:.

Что приходит#

POST https://your-service.example.com/webhooks/partyflow
Content-Type: application/json
X-PartyFlow-Event: MESSAGE_CREATED
X-PartyFlow-Delivery-Id: 01923f5c-a2c8-7890-b4d0-5a2c8a4b6e0c
X-PartyFlow-Timestamp: 1744934400
X-PartyFlow-Signature: sha256=2c1a9f3e8b7d4a6c5e2f1d0b3a9c8e7f4d5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c
X-PartyFlow-Webhook-Version: 1
 
{"event_id":"...","event_type":"MESSAGE_CREATED",...}

Signing string:

v1:<X-PartyFlow-Timestamp>:<raw-body>

Префикс v1: — это буквальная часть signing string, она попадает в HMAC. Не путайте с v0: из Slack-схемы (incoming).

Python (FastAPI)#

import hmac
import hashlib
import time
from fastapi import FastAPI, Request, HTTPException
 
PARTYFLOW_SECRET = b"whsec_..."  # из secrets manager
RECENT_DELIVERIES: dict[str, float] = {}  # LRU в проде
 
app = FastAPI()
 
 
def verify_partyflow_outgoing(secret: bytes, ts: str, body: bytes, sig_header: str) -> bool:
    if not ts.isdigit():
        return False
    if abs(int(time.time()) - int(ts)) > 300:  # optional — см. "Frozen signature" в security.md
        return False
    if not sig_header.startswith("sha256="):
        return False
    base = b"v1:" + ts.encode() + b":" + body
    expected = hmac.new(secret, base, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, sig_header[len("sha256="):])
 
 
@app.post("/webhooks/partyflow")
async def receive(request: Request):
    body = await request.body()
    ts = request.headers.get("X-PartyFlow-Timestamp", "")
    sig = request.headers.get("X-PartyFlow-Signature", "")
    delivery_id = request.headers.get("X-PartyFlow-Delivery-Id", "")
 
    if not verify_partyflow_outgoing(PARTYFLOW_SECRET, ts, body, sig):
        raise HTTPException(status_code=401, detail="invalid signature")
 
    # Idempotency: если уже обрабатывали этот delivery_id — отвечаем 200 без работы.
    if delivery_id in RECENT_DELIVERIES:
        return {"status": "already_processed"}
    RECENT_DELIVERIES[delivery_id] = time.time()
 
    # Тут ваша бизнес-логика — парсить JSON только после успешной verify.
    import json
    envelope = json.loads(body)
    # ... обработать envelope["event_type"], envelope["data"] ...
    return {"status": "ok"}

Почему timestamp window опциональный: PartyFlow фиксирует подпись перед первой отправкой (см. concepts/security.md), и retry может прийти через час. Идемпотентность по X-PartyFlow-Delivery-Id надёжнее strict timestamp-check'а. Если всё же хотите strict — сделайте окно ≥ 1 часа (больше retry horizon'а).

Node.js (Express)#

const crypto = require("crypto");
const express = require("express");
 
const SECRET = process.env.PARTYFLOW_SIGNING_SECRET; // "whsec_..."
const SEEN = new Map(); // delivery_id -> ts
 
function verifyPartyflowOutgoing(secret, ts, bodyBuf, sigHeader) {
  if (!/^\d+$/.test(ts)) return false;
  if (!sigHeader.startsWith("sha256=")) return false;
  const base = Buffer.concat([Buffer.from(`v1:${ts}:`), bodyBuf]);
  const expected = crypto.createHmac("sha256", secret).update(base).digest("hex");
  const given = sigHeader.slice("sha256=".length);
  try {
    return crypto.timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(given, "hex"));
  } catch {
    return false;
  }
}
 
const app = express();
 
// Важно: получить raw bytes, а не parsed JSON — парсер теряет whitespace и ломает подпись.
app.post(
  "/webhooks/partyflow",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const ts = req.header("X-PartyFlow-Timestamp") || "";
    const sig = req.header("X-PartyFlow-Signature") || "";
    const deliveryId = req.header("X-PartyFlow-Delivery-Id") || "";
 
    if (!verifyPartyflowOutgoing(SECRET, ts, req.body, sig)) {
      return res.status(401).json({ error: "invalid signature" });
    }
 
    if (SEEN.has(deliveryId)) {
      return res.json({ status: "already_processed" });
    }
    SEEN.set(deliveryId, Date.now());
 
    const envelope = JSON.parse(req.body.toString("utf8"));
    // ... обработать envelope ...
    res.json({ status: "ok" });
  },
);
 
app.listen(8080);

Go (net/http)#

package main
 
import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"io"
	"net/http"
	"os"
	"strconv"
	"sync"
	"time"
)
 
var (
	secret    = []byte(os.Getenv("PARTYFLOW_SIGNING_SECRET"))
	seenMu    sync.Mutex
	seenIDs   = make(map[string]time.Time) // LRU в проде
)
 
func verifyPartyFlowOutgoing(ts string, body []byte, sigHeader string) bool {
	if _, err := strconv.ParseInt(ts, 10, 64); err != nil {
		return false
	}
	const prefix = "sha256="
	if len(sigHeader) < len(prefix) || sigHeader[:len(prefix)] != prefix {
		return false
	}
	mac := hmac.New(sha256.New, secret)
	mac.Write([]byte("v1:" + ts + ":"))
	mac.Write(body)
	expected := hex.EncodeToString(mac.Sum(nil))
	given, err := hex.DecodeString(sigHeader[len(prefix):])
	if err != nil {
		return false
	}
	expectedRaw, _ := hex.DecodeString(expected)
	return hmac.Equal(expectedRaw, given)
}
 
func handle(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "body read", http.StatusBadRequest)
		return
	}
	ts := r.Header.Get("X-PartyFlow-Timestamp")
	sig := r.Header.Get("X-PartyFlow-Signature")
	deliveryID := r.Header.Get("X-PartyFlow-Delivery-Id")
 
	if !verifyPartyFlowOutgoing(ts, body, sig) {
		http.Error(w, "invalid signature", http.StatusUnauthorized)
		return
	}
 
	seenMu.Lock()
	if _, dup := seenIDs[deliveryID]; dup {
		seenMu.Unlock()
		_, _ = w.Write([]byte(`{"status":"already_processed"}`))
		return
	}
	seenIDs[deliveryID] = time.Now()
	seenMu.Unlock()
 
	// Тут обработка JSON envelope после успешной verify.
	_, _ = w.Write([]byte(`{"status":"ok"}`))
}
 
func main() {
	http.HandleFunc("/webhooks/partyflow", handle)
	_ = http.ListenAndServe(":8080", nil)
}

Bash (проверка подписи при отладке)#

Для отладки/ручной проверки можно пересчитать подпись из bash:

#!/usr/bin/env bash
# Usage: verify.sh <secret> <timestamp> <body-file> <signature-hex>
SECRET="$1"
TS="$2"
BODY_FILE="$3"
GIVEN="${4#sha256=}"
 
EXPECTED=$(printf 'v1:%s:' "$TS" | cat - "$BODY_FILE" | \
  openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
 
if [ "$EXPECTED" = "$GIVEN" ]; then
  echo "OK"
  exit 0
else
  echo "MISMATCH: expected=$EXPECTED given=$GIVEN"
  exit 1
fi

Частые ошибки при verification outgoing#

Симптом Причина
Подпись не сходится на любом запросе Используете signing string <ts>.<body> (incoming-схема) вместо v1:<ts>:<body>. Префикс v1: обязателен для outgoing.
Подпись не сходится иногда Парсите body как JSON и пересериализуете. Используйте raw bytes.
Одно и то же событие обрабатывается дважды Забыли idempotency по X-PartyFlow-Delivery-Id. Сетевой flake плюс retry — дубликат неминуем.
401 шлётся, но PartyFlow бьёт в dead-letter Если проверка подписи реально провалилась — это не retry'ьте (это не 429/5xx). Лог пишите на свою сторону, чтобы заметить конфиг drift.