Polling vs Webhook
Glossary overview

Polling vs Webhook

Два способа получить данные от внешнего сервиса:

  • постоянно спрашивать самому (polling)
  • или попросить сервис сообщить, когда что-то произойдёт (webhook).

Аналогия

Polling — звонить в пиццерию каждые 2 минуты: «Моя пицца готова?», «А сейчас?», «А сейчас?».

Webhook — оставить свой номер и ждать, пока позвонят сами: «Ваш заказ готов, забирайте».

Polling

Твой сервер периодически отправляет запросы к API внешнего сервиса и проверяет, есть ли новые данные.

Твой сервер                          Stripe API
    |                                    |
    |--- GET /payments?status=new ------>|
    |<-- 200 OK (пусто) ----------------|
    |                                    |
    |  ... ждём 5 минут ...              |
    |                                    |
    |--- GET /payments?status=new ------>|
    |<-- 200 OK (пусто) ----------------|
    |                                    |
    |  ... ждём 5 минут ...              |
    |                                    |
    |--- GET /payments?status=new ------>|
    |<-- 200 OK (1 новый платёж) --------|
    |                                    |
    |  Обрабатываем платёж               |

Пример polling в Laravel

Scheduled-команда, которая запускается каждые 5 минут через php artisan schedule:run:

// app/Console/Commands/CheckNewPayments.php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;

class CheckNewPayments extends Command
{
    protected $signature = 'payments:check';
    protected $description = 'Проверить новые платежи через API';

    public function handle(): void
    {
        $response = Http::withToken(config('services.stripe.secret'))
            ->get('https://api.stripe.com/v1/payment_intents', [
                'created[gte]' => now()->subMinutes(5)->timestamp,
            ]);

        $payments = $response->json('data');

        foreach ($payments as $payment) {
            if ($payment['status'] === 'succeeded') {
                // Обновить заказ, отправить чек
                $this->processPayment($payment);
            }
        }

        $this->info("Проверено. Найдено: " . count($payments));
    }

    private function processPayment(array $payment): void
    {
        // логика обработки
    }
}
// routes/console.php

use Illuminate\Support\Facades\Schedule;

Schedule::command('payments:check')->everyFiveMinutes();

Минусы polling

Задержка — платёж прошёл, но ты узнаешь об этом только через 5 минут (или сколько выставлен интервал). Пользователь ждёт.

Лишняя нагрузка — 99% запросов возвращают пустой результат. Сервер работает впустую, API внешнего сервиса тоже нагружается.

Rate limits — у большинства API есть лимиты на количество запросов. Частый polling может упереться в них.

Webhook — тебе сообщают

Ты регистрируешь URL на стороне внешнего сервиса. Когда происходит событие, сервис сам отправляет POST-запрос с данными на этот URL.

Stripe                               Твой сервер
    |                                    |
    |  (платёж прошёл)                   |
    |                                    |
    |--- POST /webhooks/stripe --------->|
    |    { "type": "payment.succeeded",  |
    |      "data": { ... } }             |
    |                                    |
    |<-- 200 OK -------------------------|
    |                                    |
    |  Твой сервер сразу обработал       |

Пример webhook в Laravel

Роут принимает POST-запрос от внешнего сервиса. Важно: webhook-роуты обычно не используют CSRF-защиту, поэтому их выносят в routes/api.php или исключают из middleware VerifyCsrfToken.

// routes/api.php

use App\Http\Controllers\Webhooks\StripeWebhookController;

Route::post('/webhooks/stripe', [StripeWebhookController::class, 'handle']);

Контроллер принимает запрос, проверяет подпись и обрабатывает событие:

// app/Http/Controllers/Webhooks/StripeWebhookController.php

namespace App\Http\Controllers\Webhooks;

use App\Http\Controllers\Controller;
use App\Models\Order;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class StripeWebhookController extends Controller
{
    public function handle(Request $request): JsonResponse
    {
        // 1. Проверяем подпись — убеждаемся, что запрос от Stripe
        $this->verifySignature($request);

        // 2. Определяем тип события
        $type = $request->input('type');
        $data = $request->input('data.object');

        // 3. Реагируем на нужные события
        match ($type) {
            'payment_intent.succeeded' => $this->handlePaymentSuccess($data),
            'payment_intent.failed'    => $this->handlePaymentFailed($data),
            'charge.refunded'          => $this->handleRefund($data),
            default                    => null, // Игнорируем неизвестные
        };

        // 4. Отвечаем 200 — Stripe знает, что мы получили
        return response()->json(['ok' => true]);
    }

    private function handlePaymentSuccess(array $data): void
    {
        $order = Order::where('stripe_payment_id', $data['id'])->first();

        if ($order) {
            $order->update(['status' => 'paid']);
            event(new \App\Events\OrderPaid($order));
        }
    }

    private function handlePaymentFailed(array $data): void
    {
        $order = Order::where('stripe_payment_id', $data['id'])->first();

        if ($order) {
            $order->update(['status' => 'payment_failed']);
        }
    }

    private function handleRefund(array $data): void
    {
        // логика возврата
    }

    private function verifySignature(Request $request): void
    {
        $signature = $request->header('Stripe-Signature');
        $secret = config('services.stripe.webhook_secret');

        // Stripe SDK проверяет, что запрос не подделан
        \Stripe\Webhook::constructEvent(
            $request->getContent(),
            $signature,
            $secret
        );
    }
}

Что Stripe отправляет на webhook

Обычный POST-запрос с JSON-телом:

{
    "id": "evt_1234567890",
    "type": "payment_intent.succeeded",
    "data": {
        "object": {
            "id": "pi_abc123",
            "amount": 25998,
            "currency": "usd",
            "status": "succeeded",
            "metadata": {
                "order_id": "42"
            }
        }
    }
}

Зачем проверять подпись

URL вебхука — публичный. Любой может отправить на него POST-запрос с фейковыми данными: «платёж на $10 000 прошёл». Подпись — это способ убедиться, что запрос действительно от Stripe, а не от злоумышленника. Stripe подписывает каждый запрос секретным ключом, а ты проверяешь подпись на своей стороне.

Мой пример с сайта Tech Store

// routes/web.php

Route::post('/webhook/stripe', [PaymentController::class, 'webhook'])->name('webhook.stripe');

Цей маршрут виключений з CSRF-захисту (bootstrap/app.php).

<?php

use App\Http\Middleware\SetLocale;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware) {
        $middleware->validateCsrfTokens(except: [
            'webhook/stripe',
        ]);
    })
    ->withExceptions(function (Exceptions $exceptions): void {
        //
    })->create();

Флоу

  1. Stripe надсилає POST-запит на /webhook/stripe після завершення оплати.
  2. PaymentController@webhook отримує:
    • тіло запиту ($request->getContent()) — raw body
    • заголовок Stripe-Signature
  3. StripePaymentService@handleWebhook верифікує підпис через Webhook::constructEvent() з STRIPE_WEBHOOK_SECRET — це захист від підроблених запитів.
  4. Якщо подія — checkout.session.completed, дістає order_id з metadata сесії та оновлює замовлення: payment_status = ‘paid’.
  5. Повертає 200 OK або 400 з помилкою. Як order_id потрапляє в вебхук? При створенні Stripe Checkout Session (createCheckoutSession) передається metadata: [‘order_id’ => $order->id] — Stripe зберігає це і повертає назад у події вебхука.
<?php

namespace App\Http\Controllers;

use App\Models\Order;
use App\Services\Payment\PaymentServiceInterface;
use Illuminate\Http\Request;

class PaymentController extends Controller
{
    public function __construct(
        private PaymentServiceInterface $paymentService
    ) {}

    ...

    public function webhook(Request $request)
    {
        try {
            $this->paymentService->handleWebhook([
                'body' => $request->getContent(),
                'signature' => $request->header('Stripe-Signature'),
            ]);

            return response('OK', 200);
        } catch (\Exception $e) {
            return response('Webhook error: ' . $e->getMessage(), 400);
        }
    }
}
// app/Services/Payment/StripePaymentService.php

<?php

namespace App\Services\Payment;

use App\Models\Order;
use Stripe\Checkout\Session;
use Stripe\Stripe;
use Stripe\Webhook;

class StripePaymentService implements PaymentServiceInterface
{
    public function __construct()
    {
        Stripe::setApiKey(config('services.stripe.secret'));
    }

    ...

    public function handleWebhook(array $payload): void
    {
        $event = Webhook::constructEvent(
            $payload['body'],
            $payload['signature'],
            config('services.stripe.webhook_secret')
        );

        if ($event->type === 'checkout.session.completed') {
            $orderId = $event->data->object->metadata->order_id;
            $order = Order::findOrFail($orderId);
            $order->update(['payment_status' => 'paid']);
        }
    }
}

Сравнение

                    Polling                  Webhook
─────────────────────────────────────────────────────────────
Кто инициирует      Ты (клиент)              Сервис (сервер)
Задержка            Зависит от интервала      Мгновенно
Нагрузка            Постоянная, даже          Только когда
                    если нет данных           есть событие
Rate limits         Можно упереться           Не твоя проблема
Реализация          Cron / Scheduler          Один endpoint
Надёжность          Простая — повторил        Нужна обработка
                    запрос и всё              повторных доставок
Настройка           Ничего на стороне         Нужно зарегистрировать
                    сервиса                   URL в сервисе

Когда что использовать

Webhook — когда внешний сервис поддерживает их (Stripe, GitHub, Telegram, PayPal, WooCommerce, Slack). Это основной подход для получения событий.

Polling — когда у сервиса нет вебхуков, или как резервный механизм. Например, вебхук мог не дойти (сервер был недоступен), и периодическая проверка подхватит пропущенные события.

Оба вместе — самый надёжный вариант. Webhook для мгновенной реакции + polling раз в час как страховка для пропущенных событий.

Структура файлов

app/
├── Console/
│   └── Commands/
│       └── CheckNewPayments.php          ← polling (scheduler)
├── Http/
│   └── Controllers/
│       └── Webhooks/
│           └── StripeWebhookController.php  ← webhook endpoint
├── Events/
│   └── OrderPaid.php                     ← событие после оплаты
└── Listeners/
    ├── SendReceiptEmail.php              ← реакция на оплату
    └── UpdateInventory.php               ← реакция на оплату

Аналогия с WordPress

В WordPress webhook-и тоже встречаются. WooCommerce умеет отправлять webhook-и при создании заказа — ты настраиваешь URL в админке и WooCommerce отправляет на него POST-запрос. А для приёма вебхуков используется rest_api_init с кастомным endpoint — по сути тот же контроллер, только зарегистрированный через хук.