Блог

Integrations 8 хв читання

Stripe webhooks на PHP: надійний обробник для платежів

Webhook Stripe, який працює на ноутбуці, ще не можна довіряти гроші. У цьому гайді розбираємо перевірку підпису, швидку відповідь, дедуплікацію подій та повторні доставки на чистому PHP та Laravel, з кодом, який можна взяти у проект.

Обкладинка статті: Stripe webhooks на PHP: надійний обробник для платежів

Коротко

  • Джерело правди про платіж - webhook, а не редирект браузера після оплати.
  • Читайте сире тіло запиту та перевіряйте заголовок Stripe-Signature до будь-якої роботи з даними.
  • Відповідайте 2xx швидко, а основну роботу робіть у черзі або після збереження події.
  • Зберігайте ID події з унікальним індексом: Stripe може надіслати одну і ту ж подію кілька разів та в іншому порядку.
  • Перед запуском протестуйте все через Stripe CLI (stripe listen та stripe trigger).

Якщо ви приймаєте платежі через Stripe, саме Stripe webhooks на PHP вирішують, чи буде замовлення помічено оплаченим, чи підписка активується і потрапить повернення у вашу базу. Сторінка оплати – тільки вхідні двері: клієнти закривають вкладки, банки підтверджують із затримкою, а деякі способи оплати проходять через хвилини чи дні. Webhook – це повідомлення від Stripe про те, що сталося насправді.

Ми робимо та супроводжуємо платіжні інтеграції для клієнтів та для власних продуктів (наприклад, Bill приймає оплату через Stripe та PayPal на публічній сторінці). Нижче - чек-лист, яким ми пишемо endpoint для Stripe webhooks на PHP.

Як працюють Stripe webhooks за хвилину

Ви реєструєте HTTPS-адресу в Stripe Dashboard (або через API) та вибираєте типи подій. Коли щось відбувається - завершилася Checkout Session, сплачено рахунок, зроблено повернення - Stripe відправляє на цю адресу POST з JSON-об'єктом Event. Ваш сервер має відповісти статусом 2xx. Якщо ні, Stripe повторює доставку з експонентною затримкою до трьох днів у live-режимі.

З цього випливають три факти, які визначають весь дизайн:

  • Подія може прийти більше одного разу. Повтори та мережеві збої – нормальна ситуація.
  • Події можуть стати не по порядку. Ви можете отримати invoice.paid раніше, ніж customer.subscription.created.
  • POST на вашу адресу може відправити будь-хто. Без перевірки підпису зловмисник сам відзначить своє замовлення сплаченим.

Крок 1. Встановіть SDK та створіть endpoint

Використовуйте офіційну бібліотеку stripe/stripe-php, а секрет endpoint (він починається з whsec_) зберігайте у змінній оточенні, але не в репозиторії.

composer require stripe/stripe-php

# .env
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...

У кожного endpoint у Dashboard свій секрет підпису, а у stripe listen у розробці – ще один. Найчастіша причина помилки "signature verification failed" - просто не той секрет.

Крок 2. Перевірте підпис по сирому тілу

Stripe підписує кожен запит. Заголовок Stripe-Signature містить мітку часу та один або кілька підписів - HMAC-SHA256 від рядка timestamp.payload із секретом endpoint. Бібліотека перевіряє підпис і за умовчанням відхиляє події старші за п'ять хвилин.

Важлива деталь: для перевірки потрібні ті ж байти, які відправив Stripe. Якщо фреймворк розпарив JSON, а ви закодували його назад, пробіли зміняться і підпис не співпаде.

<?php
// public/webhooks/stripe.php (plain PHP)
require __DIR__ . '/../../vendor/autoload.php';

$payload   = file_get_contents('php://input');
$sigHeader = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
$secret    = getenv('STRIPE_WEBHOOK_SECRET');

try {
    $event = \Stripe\Webhook::constructEvent($payload, $sigHeader, $secret);
} catch (\UnexpectedValueException $e) {
    http_response_code(400);   // invalid JSON
    exit;
} catch (\Stripe\Exception\SignatureVerificationException $e) {
    http_response_code(400);   // bad or missing signature
    exit;
}

У Laravel читайте $request->getContent(), а не $request->all() і виключіть маршрут із CSRF-захисту (у webhook немає CSRF-токена, захистом служить підпис). По можливості виносите маршрут із групи web із сесіями.

Крок 3. Зробіть обробник ідемпотентним

Ідемпотентність – це коли повторна обробка події дає той самий результат, що й перша. Найпростіший надійний спосіб – таблиця оброблених ID подій з унікальним індексом. Спочатку вставляємо запис, і якщо вставка падає через дублікат, то подія вже була.

CREATE TABLE stripe_events (
  id            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  event_id      VARCHAR(64) NOT NULL,
  type          VARCHAR(100) NOT NULL,
  payload       JSON NOT NULL,
  status        ENUM('received','processed','failed') NOT NULL DEFAULT 'received',
  received_at   TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  processed_at  TIMESTAMP NULL,
  UNIQUE KEY uniq_event (event_id)
);
try {
    $pdo->prepare('INSERT INTO stripe_events (event_id, type, payload) VALUES (?, ?, ?)')
        ->execute([$event->id, $event->type, $payload]);
} catch (PDOException $e) {
    if ($e->getCode() === '23000') {   // duplicate key: already received
        http_response_code(200);
        exit;
    }
    throw $e;
}

Збережений payload - це журнал для розбору: коли клієнт запитує "чому з мене списали двічі?", Ви можете точно показати, що повідомив Stripe. А ще так можна повторно обробити події, що впали після виправлення бага.

Крок 4. Відповідайте швидко, обробляйте окремо

Stripe чекає на відповідь протягом декількох секунд. Якщо обробник прямо в запиті надсилає листи, ходить в інші API або будує PDF, він упреться в тайм-аут, Stripe повторить доставку, і робота виконається ще раз. У запиті робіть мінімум - перевірити, зберегти, відповісти 200 - а логіку виконуйте в черзі (черги Laravel, Symfony Messenger, cron, який читає записи зі status = 'received', або worker під supervisor).

Якщо подію взагалі не вдалося зберегти (впала база), поверніть 5xx. Це єдиний випадок, коли ви хочете, щоб Stripe повторив пізніше.

Крок 5. Опрацюйте потрібні події

Усі типи подій не потрібні. Підписуйтесь тільки на ті, що використовує ваша логіка: менше шуму та менше поверхня атаки. Типовий набір:

  • checkout.session.completed – клієнт завершив Checkout. Знайдіть замовлення по client_reference_id або metadata, які ви задали під час створення сесії.
  • payment_intent.succeeded та payment_intent.payment_failed - для власних платіжних форм.
  • invoice.paid та invoice.payment_failed - білінг підписок.
  • customer.subscription.updated та customer.subscription.deleted - зміна тарифу та скасування.
  • charge.refunded і charge.dispute.created - гроші йдуть назад.
switch ($event->type) {
    case 'checkout.session.completed':
        $session = $event->data->object;
        $orderId = $session->client_reference_id;
        if ($session->payment_status === 'paid') {
            markOrderPaid($orderId, $session->payment_intent);
        }
        break;

    case 'invoice.payment_failed':
        notifyBillingProblem($event->data->object->customer);
        break;

    case 'charge.refunded':
        markOrderRefunded($event->data->object->payment_intent);
        break;

    default:
        // unknown or unsubscribed type: acknowledge and ignore
        break;
}

Дві звички запобігають більшості помилок. По-перше, не довіряйте сумам із браузера: порівнюйте сплачену суму та валюту з події із замовленням у вашій базі. По-друге, оскільки порядок подій не гарантований, робіть зміни стану монотонними: не переводьте замовлення з «оплачений» назад у «очікує» лише тому, що запізнилася стара подія. Якщо є сумніви, запитайте актуальний об'єкт через API, а не покладайтеся на знімок з події.

Крок 6. Тестуйте локально через Stripe CLI

Stripe CLI пересилає справжні тестові події на вашу машину, публічний тунель не потрібен:

stripe login
stripe listen --forward-to http://localhost:8000/webhooks/stripe
# in another terminal:
stripe trigger checkout.session.completed
stripe trigger invoice.payment_failed

stripe listen друкує тимчасовий секрет whsec_, його потрібно покласти в локальний .env. Перевірте і погані сценарії спеціально: надішліть запит з невірним підписом (очікуємо 400), відправте одну подію двічі (має бути один ефект у базі) і зупиніть worker черги на хвилину, щоб переконатися, що події накопичуються, а не пропадають.

Часті помилки, які ми знаходимо в аудитах

  • Редирект вважається доказом оплати. Сторінку success_url може відкрити будь-хто; показуйте «підтверджуємо оплату», доки прийде webhook.
  • JSON парситься до перевірки. Перекодоване тіло ламає підпис, частий винуватець – body parser фреймворку.
  • Один секрет для test та live. У тестового та бойового режимів різні ключі та різні webhook endpoint.
  • Нема лігв. Без збережених подій та помітного каналу помилок баги платежів знаходять клієнти.
  • Повільні обробники. Все, що довше за пару секунд, викликає повторні доставки та подвійну роботу.
  • Stripe блокується на файрволі або WAF. Якщо ви використовуєте Cloudflare або інший WAF, дозвольте шлях webhook і перевірте, чи правила для ботів не віддають 403 серверам Stripe.

Моніторинг: як зрозуміти, що все продовжує працювати

Webhook може зламатися через місяці після запуску: деплой перейменував колонку, ротували секрет, минув сертифікат. Stripe Dashboard показує спроби доставки та помилки по кожному endpoint, а при постійних збоях Stripe надсилає листа. Зі свого боку налаштуйте алерти на три речі: зростаюча кількість подій у статусах received і failed, різке падіння числа подій на годину, а також частку відповідей 4xx та 5xx на маршруті webhook. Невелика щоденна звірка оплачених замовлень у базі зі списком успішних платежів у Stripe закриє інше.

Чим ми можемо допомогти

Ми проектуємо та робимо платіжні та webhook-інтеграції для продакшену: перевірка підпису, ідемпотентність, повторні доставки та логи, зрозумілі вашій команді. Якщо Stripe-потік вже є і потрібен свіжий погляд, найшвидше знайти слабкі місця на консультації чи архітектурному рев'ю. Про те, як виносити критичні події з Zapier і Make, читайте у статті коли no-code ламається, а про іншого провайдера - в гайді з інтеграції WayForPay на PHP.

Часті запитання

Чому перевірка підпису Stripe webhook не відбувається?

Зазвичай причина в невірному секреті підпису (test і live, CLI та endpoint з Dashboard), у перевірці розпареного або перекодованого тіла замість сирого або проксі, який змінює payload. Перевіряйте точні сирі байти секретом саме цього endpoint (whsec_).

Як не обробити подію Stripe двічі?

Зберігайте ID кожної події у таблиці з унікальним індексом та вставляйте запис до початку роботи. Якщо вставка падає через дублікат, поверніть 200 і пропустіть обробку. Сама бізнес-дія теж має бути безпечною при повторі.

Який код відповіді повинен повертати webhook Stripe ?

Код 2xx (200 або 204) відразу після перевірки та збереження події. 400 - при невірному підписі, 5xx - тільки якщо подію не вдалося зберегти, і ви хочете, щоб Stripe повторив доставку.

Як довго Stripe повторює невдалі webhook?

У live-режимі Stripe повторює доставку з експонентною затримкою до трьох днів. У тестовому режимі вікно повторів набагато коротше, тому не судіть про поведінку продакшена за повторами test.

Чи можна тестувати Stripe webhooks без публічної адреси?

Так. Запустіть stripe listen --forward-to у Stripe CLI, щоб пересилати тестові події localhost, а stripe trigger створює приклади подій.

Безкоштовний discovery-дзвінок

Хаотичний стек чи зависла інтеграція - Integrono намітить найрозумніший перший крок.

Ви отримуєте senior-погляд на бюджетні пастки, залежності і коли купувати, склеювати чи писати - до старту оплати. CEO лишається в контурі.