---
title: "Як приймати вебхуки в Laravel і не обпектися: підписи, захист від повторів та ідемпотентність"
url: https://laravelukraine.com/blog/iak-priimati-vebxuki-v-laravel-i-ne-obpektisia-pidpisi-zaxist-vid-povtoriv-ta-idempotentnist
date: 2026-11-03
source: https://dev.to/developertugrul/receiving-webhooks-in-laravel-without-getting-burned-signatures-replay-protection-and-idempotency-2c1k
---

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

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

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

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

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

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

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

Більшість провайдерів розв'язують перші дві задачі, підписуючи кожен запит спільним секретом - зазвичай це HMAC-SHA256 від сирого тіла запиту разом із часовою міткою. Точні назви заголовків і формати в кожного провайдера свої, тож завжди звіряйтеся з документацією саме вашого. У цій статті я використаю поширений варіант:

```text
X-Signature: t=1793260800,v1=5f2b...e91c
```

Тут `t` - це Unix-мітка часу, а `v1` - `HMAC-SHA256(secret, "{t}.{raw_body}")` у шістнадцятковому вигляді.

### Крок 1: таблиця, яка пам'ятає кожну подію

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

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