Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама
Переклади 03 листопада 2026

Як приймати вебхуки в Laravel і не обпектися: підписи, захист від повторів та ідемпотентність

Перший баг із вебхуками, який я випустив у продакшен, виглядав нешкідливо: платіжний провайдер повторив подію, бо мій ендпоїнт надто довго не відповідав, і те саме замовлення позначилося як «оплачене» двічі. Нічого не впало. Логи були зелені. Бухгалтери знайшли це через три тижні.

Вебхуки - найпоширеніший патерн інтеграції в бізнес-програмах. Платіжні провайдери, служби доставки, шлюзи електронних рахунків, CRM і маркетплейси самі надсилають вам події, а не чекають, поки ви їх опитаєте. І майже в кожному ендпоїнті для вебхуків, який я переглядав, ті самі три діри: він довіряє тому, хто його викликає, приймає старі повідомлення і вважає, що кожна подія надходить рівно один раз.

У цій статті ми розберемо невеликий приймач вебхуків у Laravel, придатний для продакшену, який закриває всі три.

Проблема: публічна адреса, яка змінює ваші дані

Ендпоїнт для вебхуків - це публічний маршрут POST, який за задумом змінює бізнес-записи без автентифікованого користувача. Тому перед будь-якою бізнес-логікою треба відповісти на три питання:

  1. Автентичність: чи справді цей запит надіслав провайдер?
  2. Свіжість: це нове повідомлення чи перехоплене, яке повторно відтворюють через кілька годин?
  3. Ідемпотентність: якщо провайдер надішле ту саму подію двічі (а він надішле), чи обробимо ми її лише один раз?

Більшість провайдерів розв'язують перші дві задачі, підписуючи кожен запит спільним секретом - зазвичай це 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-системах і системах виставлення рахунків, де один дублікат може означати подвійну оплату чи неправильний залишок на складі, ці кілька десятків рядків - одні з найцінніших, які ви напишете.

Переклад оригіналу: dev.to
19

Читати в документації

Коментарі

Увійдіть, щоб залишити коментар

Будьте першим, хто залишить коментар!

Читайте також

Переклади 04 листопада 2026

Спільна мова для PHP: навіщо стандартній бібліотеці SortDirection і Time\Duration

Переказ колонки Тіма Дюстерхуса в блозі PHP Foundation: чому крихітні типи SortDirection і Time\Duration у PHP 8.6 важать більше за свій розмір, чому від'ємні тривалості заклали з першої версії і що дадуть Time\Instant та Time\Clock.

Переклади 03 листопада 2026

Звіт, який зсунувся на три години: правила роботи з часовими поясами в бізнес-застосунках на Laravel

Щоденний звіт про продажі щоранку недораховував замовлення, зроблені між північчю і 03:00. Шість правил, які не дають цій помилці повернутися: UTC для моментів часу, напіввідкриті діапазони замість whereDate(), часовий пояс як IANA-назва в даних і конвертація лише для показу.

15

Вакансії за темою

King Group Нова
Сьогодні

Middle PHP developer

Middle PHP розробник для високонавантаженої веб-платформи Social Casino/Online Casino: розвиток продукту, перехід монолітного проєкту на мікросервіси (Symfony), оптимізація баз даних, написання unit та functional тестів. Стек: PHP 7.4, Symfony 4, Yii, PostgreSQL 14, Redis, RabbitMQ, NGINX. Вимоги: 3-4+ роки продуктового досвіду з PHP, Yii, Symfony, PostgreSQL, розуміння REST API, Git, ООП та SOLID. Досвід з брокерами повідомлень і DDD буде плюсом.

Tech Lead — Fintech

Tech Lead для фінтех-платформи бізнес-витрат і платежів: активна розробка, архітектурні рішення, стабільність продакшену та менторство команди. Ключовий стек: PHP, Laravel/Lumen, MySQL, Redis, Docker, знання JavaScript (Vue.js, React на базовому рівні). Вимоги: розуміння життєвого циклу карткових транзакцій і криптовалют, досвід побудови надійних фінансових систем (баланси, звірка, аудит, безпечні API), DevOps-навички на Linux.

Bewort Нова
Сьогодні

Senior Full-Stack Developer (PHP/Laravel, Vue)

Розробка та розвиток ключових функцій корпоративної платформи на Laravel з обробкою бекенду та фронтенду на Vue. Потрібні глибокі знання внутрішньої архітектури фреймворку, досвід застосування SOLID, DDD, подій і черг, а також досвід роботи зі складними проєктами. Обов'язкове активне використання AI-інструментів для написання коду та англійська не нижче B2. Переваги: досвід із Vue, мультитенантними SaaS, AWS, CI/CD та LLM-інтеграціями.

Пакети за темою

Kalimera

welldigit/kalimera

Interactive installer that scaffolds Laravel applications the Well Digit way.

v1.0.0 16

Bagisto

bagisto/bagisto

Bagisto — це платформа для електронної комерції, побудована на Laravel. Вона надає готове рішення для створення та управління інтернет-магазинами з підтримкою каталогу товарів, замовлень, платежів та клієнтів.

28,325 v2.5.0 13 28