Обработчик вебхука выполняется ровно один раз — сколько бы раз провайдер его ни прислал
Провайдер повторит доставку, если не получил ответ вовремя. Моргнула сеть, перезапустился сервер, обработчик отработал 31 секунду вместо 30 — событие придёт ещё раз. И ещё раз.
Без защиты это двойное начисление: оплаченный дважды месяц в учебном центре, два заказа в магазине, расхождение в кассе, которое потом разбирают вручную по выписке.
Провайдер решает, сколько раз доставить событие. Вы решаете, сколько раз оно изменит ваши данные. Это два разных числа, и библиотека нужна ровно для того, чтобы они не совпали.
use Ecomdev\WebhookIdempotency\IdempotencyGuard;
use Ecomdev\WebhookIdempotency\RequestKey;
use Ecomdev\WebhookIdempotency\Store\PdoStore;
$guard = new IdempotencyGuard(new PdoStore($pdo));
$outcome = $guard->run(
new RequestKey('payme', $payload['params']['id']),
function () use ($pdo, $payload): string {
// Всё, что меняет данные, живёт внутри замыкания.
markOrderPaid($pdo, $payload['params']['account']['order_id']);
return json_encode(['result' => ['state' => 2]]);
}
);
http_response_code(200);
echo $outcome->response; // при повторе — тот же ответ, обработчик не запускалсяПолный пример эндпоинта с проверкой подписи и транзакцией —
examples/payment-webhook.php.
%%{init: {'theme':'base','themeVariables':{'fontFamily':'ui-sans-serif,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif','fontSize':'15px','textColor':'#1e1b4b','lineColor':'#4338ca','primaryColor':'#e0e7ff','primaryTextColor':'#1e1b4b','primaryBorderColor':'#4338ca','secondaryColor':'#ede9fe','tertiaryColor':'#f5f3ff','mainBkg':'#c7d2fe','nodeBorder':'#4338ca','nodeTextColor':'#1e1b4b','edgeLabelBackground':'#e0e7ff','attributeBackgroundColorOdd':'#eef2ff','attributeBackgroundColorEven':'#e0e7ff','noteBkgColor':'#fef3c7','noteTextColor':'#1a1a1a','noteBorderColor':'#b45309','clusterBkg':'#f5f3ff','clusterBorder':'#4338ca','labelBoxBkgColor':'#e0e7ff','labelBoxBorderColor':'#4338ca','labelTextColor':'#1e1b4b','actorBkg':'#c7d2fe','actorBorder':'#4338ca','actorTextColor':'#1e1b4b','actorLineColor':'#4338ca','signalColor':'#4338ca','signalTextColor':'#1e1b4b','sequenceNumberColor':'#ffffff','activationBkgColor':'#ddd6fe','activationBorderColor':'#4338ca','transitionColor':'#4338ca','transitionLabelColor':'#1e1b4b','stateBkg':'#c7d2fe','stateLabelColor':'#1e1b4b','altBackground':'#f5f3ff','compositeBackground':'#f5f3ff','compositeBorder':'#4338ca','compositeTitleBackground':'#e0e7ff','specialStateColor':'#4338ca','innerEndBackground':'#4338ca'}}}%%
stateDiagram-v2
[*] --> processing: INSERT прошёл, доставка захвачена
processing --> completed: обработчик вернул результат
processing --> failed: обработчик бросил исключение
failed --> processing: следующая доставка пробует снова
processing --> processing: захват протух, воркер умер
completed --> [*]: повтор получает сохранённый ответ
note right of completed
Из completed выхода назад нет.
Опоздавший вебхук не откатит
уже подтверждённый платёж.
end note
Состояние движется только вперёд. Переход completed → processing невозможен
ни при каких входящих данных — это правило проще всего нарушить тем, что
обработчик просто присваивает пришедший статус.
%%{init: {'theme':'base','themeVariables':{'fontFamily':'ui-sans-serif,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif','fontSize':'15px','textColor':'#1e1b4b','lineColor':'#4338ca','primaryColor':'#e0e7ff','primaryTextColor':'#1e1b4b','primaryBorderColor':'#4338ca','secondaryColor':'#ede9fe','tertiaryColor':'#f5f3ff','mainBkg':'#c7d2fe','nodeBorder':'#4338ca','nodeTextColor':'#1e1b4b','edgeLabelBackground':'#e0e7ff','attributeBackgroundColorOdd':'#eef2ff','attributeBackgroundColorEven':'#e0e7ff','noteBkgColor':'#fef3c7','noteTextColor':'#1a1a1a','noteBorderColor':'#b45309','clusterBkg':'#f5f3ff','clusterBorder':'#4338ca','labelBoxBkgColor':'#e0e7ff','labelBoxBorderColor':'#4338ca','labelTextColor':'#1e1b4b','actorBkg':'#c7d2fe','actorBorder':'#4338ca','actorTextColor':'#1e1b4b','actorLineColor':'#4338ca','signalColor':'#4338ca','signalTextColor':'#1e1b4b','sequenceNumberColor':'#ffffff','activationBkgColor':'#ddd6fe','activationBorderColor':'#4338ca','transitionColor':'#4338ca','transitionLabelColor':'#1e1b4b','stateBkg':'#c7d2fe','stateLabelColor':'#1e1b4b','altBackground':'#f5f3ff','compositeBackground':'#f5f3ff','compositeBorder':'#4338ca','compositeTitleBackground':'#e0e7ff','specialStateColor':'#4338ca','innerEndBackground':'#4338ca'}}}%%
sequenceDiagram
autonumber
participant P as Провайдер
participant G as IdempotencyGuard
participant DB as Таблица квитанций
participant H as Ваш обработчик
P->>G: callback (id = A)
G->>DB: INSERT (provider, A)
DB-->>G: вставлено
G->>H: выполнить
H-->>G: ответ
G->>DB: status = completed, сохранить ответ
G-->>P: 200 OK
Note over P,G: ответ потерялся в сети
P->>G: тот же callback (id = A)
G->>DB: INSERT (provider, A)
DB-->>G: нарушение UNIQUE
G->>DB: SELECT квитанцию
DB-->>G: completed + сохранённый ответ
G-->>P: 200 OK, обработчик не запускался
Вторую вставку отклоняет база, а не код. Проверка «сначала SELECT, потом INSERT если нет» проигрывает гонку: две доставки, пришедшие в одну миллисекунду, обе проходят такую проверку и обе начинают работу.
|
|
Транзакция, update_id, event_id. Сгенерированный локально id разный на каждой доставке, и каждый повтор выглядит новым событием. |
Ответить ошибкой — значит заставить провайдера слать снова и снова, пока он не упрётся в лимит и не пометит платёж проблемным. |
Воркера убил деплой, контейнер заменили, процесс упал. Квитанция осталась в
processing, и без отдельного правила это событие заблокировано навсегда.
// Захват считается протухшим через 300 секунд по умолчанию.
$guard = new IdempotencyGuard($store, new SystemClock(), staleAfterSeconds: 600);Порог ставится выше самого медленного реального прогона, а не выше среднего: слишком низкий — два воркера делают одну работу, слишком высокий — упавшая доставка столько же ждёт повтора.
Пока захват свежий, второй воркер получает RequestInProgress. Это не повод
ответить 200: данные ещё не записаны, и 200 сказал бы провайдеру, что всё
готово. Отвечайте 409 или 503 — провайдер вернётся.
| Класс | Зачем |
|---|---|
IdempotencyGuard |
run(RequestKey, callable) — весь сценарий: захват, выполнение, сохранение, повтор |
RequestKey |
Личность доставки: провайдер + идентификатор от провайдера. Валидирует длину под индекс |
Outcome |
->response, ->isReplay, ->attempts — что вернуть провайдеру и что записать в лог |
Receipt |
Состояние доставки в хранилище, включая число попыток и текст ошибки |
PdoStore |
Квитанции в таблице MySQL или SQLite |
InMemoryStore |
Для тестов и однопроцессного воркера — не для php-fpm |
Schema |
DDL таблицы под нужный драйвер |
RequestInProgress |
Доставку держит другой воркер |
Остальные методы гвардиана
// Уже обработано? Без захвата — для дашборда и логов, не вместо run().
$guard->alreadyHandled($key);
// Уборка: удалить завершённые квитанции старше срока хранения.
$guard->purge(retentionDays: 30);Срок хранения держите длиннее графика повторов провайдера. Удалите квитанцию, пока провайдер ещё может переслать событие, — и пересылка будет выглядеть новой. Сутки мало, месяц для платежей безопасно.
use Ecomdev\WebhookIdempotency\Store\Schema;
Schema::createTable($pdo); // быстрый старт
Schema::statements('mysql'); // или готовый DDL в вашу миграцию| Поле | Тип | Смысл |
|---|---|---|
provider |
VARCHAR(160) |
Часть составного ключа: один и тот же id у Payme и Click — разные события |
external_id |
VARCHAR(160) |
Идентификатор доставки, присланный провайдером |
status |
VARCHAR(16) |
processing · completed · failed |
response |
MEDIUMTEXT |
Тело ответа, которое проиграется повтору дословно |
attempts |
INT |
Заодно оптимистическая блокировка при перехвате протухшего захвата |
claimed_at |
DATETIME |
От него считается протухание |
finished_at |
DATETIME |
По нему работает уборка |
Индексы: UNIQUE (provider, external_id), (status, claimed_at), (finished_at).
composer config repositories.webhook-idempotency vcs https://github.com/Shohruh1997/webhook-idempotency-php
composer require ecomdev/webhook-idempotency:dev-mainТребуется PHP 8.1+, ext-pdo, ext-json. Сторонних пакетов нет — ни одного.
php tests/run.phpБез composer install: у библиотеки нет зависимостей, поэтому и у тестов их
нет. 68 проверок, каждый сценарий прогоняется дважды — против
InMemoryStore и против реального SQLite через PDO, включая то самое нарушение
UNIQUE, на котором держится вся конструкция.
CI гоняет матрицу PHP 8.1 · 8.2 · 8.3 · 8.4.
Честная граница важнее длинного списка возможностей:
- Не проверяет подпись. У каждого провайдера своя схема; проверка должна идти до разбора тела запроса и не зависит от идемпотентности.
- Не управляет вашей транзакцией. Захват намеренно живёт вне неё: он должен стать виден другим соединениям сразу, иначе второй воркер его не заметит.
- Не разбирает payload и не знает про заказы. Что делать с событием — ваша бизнес-логика, библиотека решает только «делать ли вообще».
- Не очередь. Если обработка долгая, внутри замыкания ставьте задачу в очередь, а не тяните вебхук.
- payment-integration-notes — Payme, Click, Uzum: состояния платежа, подпись, сверка
- db-schema-notes — схемы БД продакшен-систем, включая таблицы транзакций
- telegram-bot-architecture-notes — та же задача для апдейтов Telegram