Два способа получить данные от внешнего сервиса:
- постоянно спрашивать самому (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();
Флоу
- Stripe надсилає POST-запит на /webhook/stripe після завершення оплати.
- PaymentController@webhook отримує:
- тіло запиту ($request->getContent()) — raw body
- заголовок Stripe-Signature
- StripePaymentService@handleWebhook верифікує підпис через Webhook::constructEvent() з STRIPE_WEBHOOK_SECRET — це захист від підроблених запитів.
- Якщо подія — checkout.session.completed, дістає order_id з metadata сесії та оновлює замовлення: payment_status = ‘paid’.
- Повертає 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 — по сути тот же контроллер, только зарегистрированный через хук.