Подпись и повторы
Проверка HMAC-подписи, повторные доставки, идемпотентность.
Вебхук приходит из интернета, и убедиться, что его отправил DoneBy, а не кто-то, кто узнал адрес, — задача вашего обработчика. Для этого каждая доставка подписана секретом адреса, а повторы устроены так, чтобы одно событие можно было безопасно обработать один раз.
Заголовок подписи
X-DoneBy-Signature: t=1758707412,v1=3b9f0c7e1d4a26f58c0e9b7a1f2d3c4b5a69788f7e6d5c4b3a2918070f6e5d4c| Часть | Значение |
|---|---|
t | Время отправки — Unix-время в секундах. |
v1 | HMAC-SHA256 в hex от строки t.тело. Ключ — секрет адреса. Во время перевыпуска секрета v1 может быть два. |
Подписывается строка из значения t, точки и сырого тела запроса — байт в байт, как оно пришло: 1758707412.{"id":"evt_6Rk2Pw",...}.
Как проверить подпись
- Возьмите сырое тело запроса — до разбора JSON.
- Разберите заголовок
X-DoneBy-Signature: значениеtи все значенияv1. - Проверьте время: если
tотличается от текущего больше чем на 5 минут, отклоните запрос. Это защита от повторной отправки перехваченного запроса. - Посчитайте HMAC-SHA256 от
t + "." + телос секретом адреса. - Сравните результат с каждым
v1функцией сравнения за постоянное время. Совпала хотя бы одна подпись — запрос настоящий. - Не совпала — ответьте
401и не обрабатывайте событие.
Node.js
import crypto from 'node:crypto';
const TOLERANCE_SEC = 300;
export function verifySignature(rawBody, header, secret, now = Date.now()) {
const pairs = String(header || '').split(',').map((p) => p.trim().split('='));
const t = Number((pairs.find(([k]) => k === 't') || [])[1]);
const sigs = pairs.filter(([k]) => k === 'v1').map(([, v]) => v);
if (!t || sigs.length === 0) return false;
if (Math.abs(now / 1000 - t) > TOLERANCE_SEC) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest();
return sigs.some((s) => {
const got = Buffer.from(s, 'hex');
return got.length === expected.length && crypto.timingSafeEqual(got, expected);
});
}import express from 'express';
import { verifySignature } from './verify.js';
const app = express();
app.post('/hooks/doneby', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString('utf8');
const header = req.get('X-DoneBy-Signature');
if (!verifySignature(raw, header, process.env.DONEBY_WEBHOOK_SECRET)) {
return res.status(401).end();
}
const event = JSON.parse(raw);
res.status(200).end();
queue.add(event);
});Python
import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
SECRET = os.environ['DONEBY_WEBHOOK_SECRET'].encode()
TOLERANCE_SEC = 300
app = Flask(__name__)
def verify_signature(raw: bytes, header: str) -> bool:
pairs = [p.strip().split('=', 1) for p in header.split(',') if '=' in p]
ts = next((v for k, v in pairs if k == 't'), '')
sigs = [v for k, v in pairs if k == 'v1']
if not ts.isdigit() or not sigs:
return False
if abs(time.time() - int(ts)) > TOLERANCE_SEC:
return False
message = ts.encode() + b'.' + raw
expected = hmac.new(SECRET, message, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s) for s in sigs)
@app.post('/hooks/doneby')
def doneby_hook():
raw = request.get_data()
if not verify_signature(raw, request.headers.get('X-DoneBy-Signature', '')):
abort(401)
event = request.get_json()
queue.put(event)
return '', 200Важно
Не сравнивайте подписи обычным равенством строк. Такое сравнение останавливается на первом несовпадающем символе, и по времени ответа подпись можно подобрать. crypto.timingSafeEqual и hmac.compare_digest сравнивают за постоянное время.
Совет
Чаще всего подпись «не сходится», потому что фреймворк уже разобрал JSON и подписывается JSON.stringify(req.body) — с другими пробелами и порядком ключей. В Express подключайте для этого маршрута express.raw(), во Flask берите request.get_data(). Второй частый случай — часы сервера отстают: включите синхронизацию времени (NTP).
Перевыпуск секрета
Если секрет мог утечь, откройте Настройки → Вебхуки, выберите адрес и нажмите «Перевыпустить секрет». Новый секрет действует сразу, старый — ещё 24 часа. В это время заголовок содержит две подписи: v1=<новая>,v1=<старая>. Успейте обновить секрет на своём сервере за это время — примеры выше проверяют обе подписи и переживут замену без потерянных событий.
Повторные доставки
Если доставка не удалась — не 2xx за 10 секунд, — DoneBy повторяет её по расписанию. Каждая пауза отсчитывается от предыдущей попытки:
| Попытка | Пауза перед ней | С первой попытки |
|---|---|---|
| 1 | сразу | — |
| 2 | 1 мин | 1 мин |
| 3 | 5 мин | 6 мин |
| 4 | 30 мин | 36 мин |
| 5 | 2 ч | 2 ч 36 мин |
| 6 | 12 ч | 14 ч 36 мин |
Всего 6 попыток, последняя — примерно через 14 ч 36 мин после первой. Каждая повторная доставка несёт то же тело и тот же id события, но новую метку t и новую подпись.
Идемпотентность обработчика
Одно событие может прийти дважды. Например, вы обработали его, но ответили позже чем через 10 секунд — для DoneBy это неудача, и через минуту придёт повтор. Поэтому:
- Сохраняйте
idобработанных событий и пропускайте повторы. Хранить их достаточно сутки — все повторы одного события укладываются в 14 ч 36 мин. - Ставьте уникальный индекс на
idсобытия в своей базе — это надёжнее проверки в коде, особенно если обработчиков несколько. - Делайте действия повторяемыми: «поставить статус Done» безопаснее, чем «увеличить счётчик на 1».
export async function handleEvent(event, db) {
const res = await db.query(
`INSERT INTO doneby_events (id, type, received_at)
VALUES ($1, $2, now())
ON CONFLICT (id) DO NOTHING`,
[event.id, event.type]
);
if (res.rowCount === 0) return;
await applyEvent(event);
}Отключение адреса
Если за 3 суток подряд на адрес не прошло ни одной успешной доставки, DoneBy отключает его и отправляет письмо владельцу и администраторам пространства: какой адрес отключён и какие ошибки были последними. События, которые произошли, пока адрес был отключён, не копятся и не досылаются.
Чтобы включить адрес, откройте Настройки → Вебхуки и нажмите «Включить». Пропущенные изменения заберите через API: GET /tasks?updated_since=<время отключения> — см. список задач.
Совет
На время коротких работ на своём сервере адрес отключать не нужно: повторы перекрывают простой примерно до 14 ч 36 мин. Если простой дольше, после восстановления дотяните изменения через updated_since.
Чек-лист обработчика
- Подпись проверяется по сырому телу, сравнение — за постоянное время.
- Метка
tне старше 5 минут, часы сервера синхронизируются. - Ответ
2xxуходит сразу, обработка — в очереди. - Дубли отсекаются по
idсобытия. - Секрет лежит в переменной окружения или хранилище секретов, а не в коде.
- Есть план на случай отключения адреса: досинхронизация через
updated_since.