Перший баг із вебхуками, який я випустив у продакшен, виглядав нешкідливо: платіжний провайдер повторив подію, бо мій ендпоїнт надто довго не відповідав, і те саме замовлення позначилося як «оплачене» двічі. Нічого не впало. Логи були зелені. Бухгалтери знайшли це через три тижні.
Вебхуки - найпоширеніший патерн інтеграції в бізнес-програмах. Платіжні провайдери, служби доставки, шлюзи електронних рахунків, CRM і маркетплейси самі надсилають вам події, а не чекають, поки ви їх опитаєте. І майже в кожному ендпоїнті для вебхуків, який я переглядав, ті самі три діри: він довіряє тому, хто його викликає, приймає старі повідомлення і вважає, що кожна подія надходить рівно один раз.
У цій статті ми розберемо невеликий приймач вебхуків у Laravel, придатний для продакшену, який закриває всі три.
Проблема: публічна адреса, яка змінює ваші дані
Ендпоїнт для вебхуків - це публічний маршрут POST, який за задумом змінює бізнес-записи без автентифікованого користувача. Тому перед будь-якою бізнес-логікою треба відповісти на три питання:
- Автентичність: чи справді цей запит надіслав провайдер?
- Свіжість: це нове повідомлення чи перехоплене, яке повторно відтворюють через кілька годин?
- Ідемпотентність: якщо провайдер надішле ту саму подію двічі (а він надішле), чи обробимо ми її лише один раз?
Більшість провайдерів розв'язують перші дві задачі, підписуючи кожен запит спільним секретом - зазвичай це HMAC-SHA256 від сирого тіла запиту разом із часовою міткою. Точні назви заголовків і формати в кожного провайдера свої, тож завжди звіряйтеся з документацією саме вашого. У цій статті я використаю поширений варіант:
X-Signature: t=1793260800,v1=5f2b...e91c
Тут t - це Unix-мітка часу, а v1 - HMAC-SHA256(secret, "{t}.{raw_body}") у шістнадцятковому вигляді.
Крок 1: таблиця, яка пам'ятає кожну подію
Ідемпотентність потребує пам'яті. Перш ніж писати ендпоїнт, створіть таблицю, де кожна вхідна подія зберігається один раз, і забезпечує це унікальний індекс на рівні бази даних, а не PHP:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('webhook_events', function (Blueprint $table) {
$table->id();
$table->string('provider', 50);
$table->string('event_id', 191);
$table->string('type', 100);
$table->json('payload');
$table->timestamp('processed_at')->nullable();
$table->text('last_error')->nullable();
$table->timestamps();
$table->unique(['provider', 'event_id']);
});
}
public function down(): void
{
Schema::dropIfExists('webhook_events');
}
};
Унікальний індекс стоїть на парі provider + event_id, а не на самому event_id. Два різні провайдери цілком можуть генерувати ID в однаковому форматі.
Крок 2: перевіряємо підпис у middleware
Тримайте перевірку поза контролером. Middleware можна повторно використати для різних провайдерів, і його неможливо забути на новому маршруті.
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class VerifyWebhookSignature
{
private const TOLERANCE_SECONDS = 300;
public function handle(Request $request, Closure $next): Response
{
$header = (string) $request->header('X-Signature', '');
$parts = $this->parseHeader($header);
if (! isset($parts['t'], $parts['v1']) || ! ctype_digit($parts['t'])) {
abort(400, 'Missing or malformed signature header.');
}
$timestamp = (int) $parts['t'];
if (abs(time() - $timestamp) > self::TOLERANCE_SECONDS) {
abort(400, 'Signature timestamp outside tolerance.');
}
// Підписуємо СИРЕ тіло. Ніколи не робіть json_decode і повторне кодування перед перевіркою.
$signedPayload = $timestamp.'.'.$request->getContent();
foreach ($this->secrets() as $secret) {
$expected = hash_hmac('sha256', $signedPayload, $secret);
// Спочатку відомий рядок, потім рядок від користувача.
if (hash_equals($expected, $parts['v1'])) {
return $next($request);
}
}
abort(401, 'Invalid signature.');
}
private function parseHeader(string $header): array
{
$result = [];
foreach (explode(',', $header) as $pair) {
[$key, $value] = array_pad(explode('=', trim($pair), 2), 2, null);
if ($key !== '' && $value !== null) {
$result[$key] = $value;
}
}
return $result;
}
/** Спочатку поточний секрет, попередній лишається на час ротації. */
private function secrets(): array
{
return array_filter([
config('services.payments.webhook_secret'),
config('services.payments.webhook_secret_previous'),
]);
}
}
Тут важливі кілька деталей:
hash_equals() - порівняння, стійке до атак за часом (timing attacks). Звичайне === може через час відповіді видати, скільки початкових символів збіглося. Посібник PHP також просить передавати значення від користувача другим аргументом.
- Перевірка часової мітки - це і є ваш захист від повторного відтворення (replay). Перехоплений запит стає марним через п'ять хвилин, бо підпис охоплює мітку часу.
- Підтримка двох секретів дозволяє змінити ключ без простою: розгорніть новий секрет як основний, залиште старий як
previous, оновіть налаштування в провайдера, а потім приберіть старий.
Крок 3: реєструємо маршрут поза групою із захистом CSRF
Провайдери не можуть надіслати CSRF-токен. Розмістіть маршрут у routes/api.php: цей файл не зберігає стан і не належить до групи middleware web. У Laravel 11 і новіших команда php artisan install:api створює цей файл і додає префікс /api.
<?php
use App\Http\Controllers\PaymentWebhookController;
use App\Http\Middleware\VerifyWebhookSignature;
use Illuminate\Support\Facades\Route;
Route::post('/webhooks/payments', PaymentWebhookController::class)
->middleware(VerifyWebhookSignature::class);
Якщо маршрут обов'язково має лишитися у web.php, виключіть його URI з перевірки CSRF у bootstrap/app.php, як описано на сторінці про CSRF у документації вашої версії Laravel (у нових випусках назва методу змінилася, тож перевірте ту версію, яку використовуєте).
Крок 4: спочатку зберегти, потім обробити
У контролера одне завдання: записати подію і швидко відповісти. Важка робота всередині запиту - головна причина більшості дублікатів подій: провайдер не дочікується відповіді, вирішує, що сталася помилка, і надсилає подію повторно.
<?php
namespace App\Http\Controllers;
use App\Jobs\ProcessWebhookEvent;
use App\Models\WebhookEvent;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PaymentWebhookController
{
public function __invoke(Request $request): JsonResponse
{
$payload = json_decode($request->getContent(), true);
if (! is_array($payload) || empty($payload['id']) || empty($payload['type'])) {
return response()->json(['error' => 'Invalid payload'], 422);
}
try {
$event = WebhookEvent::create([
'provider' => 'payments',
'event_id' => (string) $payload['id'],
'type' => (string) $payload['type'],
'payload' => $payload,
]);
} catch (UniqueConstraintViolationException) {
// Уже отримано. Відповідаємо 200, щоб провайдер припинив повтори.
return response()->json(['status' => 'duplicate']);
}
ProcessWebhookEvent::dispatch($event);
return response()->json(['status' => 'queued']);
}
}
Зверніть увагу на дві речі. По-перше, перевірка на дублікат - це сама вставка в базу. Підхід «перевірити, чи існує, а потім вставити» має стан гонитви (race condition), коли два повтори приходять в ту саму мілісекунду; в унікального індексу такої проблеми немає. По-друге, на дублікат ми відповідаємо 200, а не помилкою. Якщо відповісти на дублікат кодом 4xx чи 5xx, багато провайдерів продовжать його надсилати.
Модель мінімальна:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class WebhookEvent extends Model
{
protected $fillable = ['provider', 'event_id', 'type', 'payload', 'processed_at', 'last_error'];
protected function casts(): array
{
return [
'payload' => 'array',
'processed_at' => 'datetime',
];
}
}
Крок 5: ідемпотентне завдання
Черги теж повторюють спроби. Завдання має безпечно виконуватися двічі, тому воно блокує рядок і перевіряє processed_at усередині транзакції:
<?php
namespace App\Jobs;
use App\Models\WebhookEvent;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
use Throwable;
class ProcessWebhookEvent implements ShouldQueue
{
use Queueable;
public function __construct(public WebhookEvent $event) {}
public function tries(): int
{
return 5;
}
public function handle(): void
{
DB::transaction(function () {
$event = WebhookEvent::whereKey($this->event->getKey())
->lockForUpdate()
->first();
if ($event === null || $event->processed_at !== null) {
return; // Уже оброблено іншим воркером або спробою.
}
match ($event->type) {
'payment.succeeded' => $this->markInvoicePaid($event->payload),
'payment.refunded' => $this->registerRefund($event->payload),
default => null, // Невідомі типи зберігаємо, а не валимо завдання.
};
$event->update(['processed_at' => now(), 'last_error' => null]);
});
}
public function failed(?Throwable $exception): void
{
$this->event->update(['last_error' => $exception?->getMessage()]);
}
private function markInvoicePaid(array $payload): void
{
// Ваша доменна логіка. Її теж робіть ідемпотентною:
// «встановити статус paid» можна безпечно повторити, «додати суму до балансу» - ні.
}
private function registerRefund(array $payload): void
{
// ...
}
}
Коментар у markInvoicePaid - це і є головний урок. Віддавайте перевагу операціям, які встановлюють стан («рахунок оплачено»), а не тим, що накопичують («додати 100 до балансу»). Якщо без накопичення не обійтися, пишіть рядок у журнал операцій із ключем за ID події, щоб повторна вставка впала на унікальному індексі.
Крок 6: тестуємо зі справжнім підписом
Код перевірки підпису, який ніхто не тестує, зламається, щойно хтось «прибере» в middleware. Сформуйте сире тіло самостійно і підпишіть саме цей рядок:
<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class PaymentWebhookTest extends TestCase
{
use RefreshDatabase;
public function test_valid_event_is_stored_once(): void
{
Queue::fake();
config(['services.payments.webhook_secret' => 'test-secret']);
$body = json_encode(['id' => 'evt_123', 'type' => 'payment.succeeded']);
$t = time();
$sig = hash_hmac('sha256', $t.'.'.$body, 'test-secret');
$server = [
'CONTENT_TYPE' => 'application/json',
'HTTP_X_SIGNATURE' => "t={$t},v1={$sig}",
];
$this->call('POST', '/api/webhooks/payments', [], [], [], $server, $body)
->assertOk()->assertJson(['status' => 'queued']);
$this->call('POST', '/api/webhooks/payments', [], [], [], $server, $body)
->assertOk()->assertJson(['status' => 'duplicate']);
$this->assertDatabaseCount('webhook_events', 1);
}
public function test_tampered_body_is_rejected(): void
{
config(['services.payments.webhook_secret' => 'test-secret']);
$t = time();
$sig = hash_hmac('sha256', $t.'.{"id":"evt_1"}', 'test-secret');
$this->call('POST', '/api/webhooks/payments', [], [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_X_SIGNATURE' => "t={$t},v1={$sig}",
], '{"id":"evt_2"}')->assertStatus(401);
}
}
Типові пастки
- Перевірка повторно закодованого тіла.
json_encode(json_decode($body)) може змінити пробіли, порядок ключів чи екранування, і підпис не збігатиметься. Завжди підписуйте $request->getContent().
- Порівняння через
===. Використовуйте hash_equals().
- Немає перевірки часової мітки. Без неї підписаний запит дійсний вічно.
- Робота всередині запиту. Повільні відповіді спричиняють повтори з боку провайдера, а повтори - дублікати. Зберегти, поставити в чергу, відповісти.
- «Існує? тоді вставити» замість унікального індексу. Працює під час розробки і ламається, коли повтори приходять одночасно.
- Відповідь помилкою на дублікати. Повертайте 200, щоб провайдер зупинився.
- Падіння на невідомих типах подій. Зберігайте їх і йдіть далі: провайдери додають нові типи, не питаючи вас.
- Секрети в коді. Тримайте їх у змінних оточення, читайте через
config() і плануйте ротацію.
Підсумок
Надійний приймач вебхуків - це не стільки хитрий код, скільки правильний порядок дій: перевірити сире тіло HMAC-підписом із порівнянням, стійким до атак за часом, відкинути застарілі мітки часу, записати подію під захистом унікального індексу, швидко відповісти і дати ідемпотентному завданню в черзі виконати справжню роботу. В ERP-системах і системах виставлення рахунків, де один дублікат може означати подвійну оплату чи неправильний залишок на складі, ці кілька десятків рядків - одні з найцінніших, які ви напишете.