Guide: как подписывать webhook-запросы
Готовые к копированию snippets на Python, Node.js, Go, Ruby, Bash для HMAC-подписи запросов к PartyFlow webhooks.
Для общего обзора security-модели и выбора схемы — concepts/security.md.
Что подписываем
У webhook'а есть signing_secret (формат whsec_<base64url>). Его надо:
- Сохранить в secrets manager при создании webhook'а. Показывается один раз.
- Использовать для вычисления HMAC-SHA256 от
signing_string. - Прислать результат в заголовке.
Выбрана одна из двух схем (не обе сразу):
| Схема | 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.bodyBash
#!/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_digest— constant-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. |