Блог

Integrations 7 мин чтения

Интеграция WayForPay на PHP: форма оплаты, подпись и callback

WayForPay - практичный способ принимать карты в UAH, USD и EUR для украинского бизнеса. В этом гайде пройдём всю интеграцию на PHP: подпишем платёжную форму, примем серверный callback, правильно ответим на него и подтвердим статус заказа.

Обложка статьи: Интеграция WayForPay на PHP: форма оплаты, подпись и callback

Коротко

  • В потоке три части: подписанная форма, которая отправляет клиента на WayForPay, серверный callback (serviceUrl) и ваша страница статуса заказа.
  • Каждый запрос и ответ подписывается HMAC_MD5 с секретным ключом мерчанта, порядок полей важен.
  • Помечайте заказ оплаченным только по проверенному callback или ответу CHECK_STATUS, но не по возврату клиента на сайт.
  • На callback нужно ответить подписанным JSON-ответом «accept», иначе WayForPay будет повторять запрос.
  • Храните сумму, валюту и номер заказа в своей базе и сверяйте их с данными callback.

Интеграция WayForPay на PHP - это немного кода, но в нескольких местах тихая ошибка приводит либо к неоплаченным заказам, помеченным оплаченными, либо к оплаченным заказам, которые никто не выполнил. Мы используем WayForPay на собственных сайтах (в том числе для платных услуг на этом сайте) и подключали его в нескольких магазинах клиентов. Ниже - структура, которой мы пользуемся, и детали, которые есть в документации, но на которые часто не обращают внимания.

Как устроен платёжный поток

  1. Клиент нажимает «Оплатить». Сервер создаёт в базе заказ с уникальным orderReference, фиксированной суммой и валютой.
  2. Сервер формирует поля платёжной формы, подписывает их и отдаёт HTML-форму с автоотправкой на страницу оплаты WayForPay.
  3. Клиент платит на странице WayForPay (карта, Apple Pay, Google Pay и другие методы, включённые в кабинете мерчанта).
  4. WayForPay вызывает ваш serviceUrl с результатом транзакции. Именно этому серверному вызову вы доверяете.
  5. Клиента возвращают на ваш returnUrl, где вы показываете текущий статус заказа.

Пятый шаг может случиться раньше завершения четвёртого, поэтому страница возврата должна показывать «подтверждаем оплату», пока callback не обработан.

Что нужно из кабинета мерчанта

  • merchantAccount - логин мерчанта.
  • merchantSecretKey - секрет для подписей. Храните его в переменной окружения или в неотслеживаемом конфиге, но не в git и не во фронтенде.
  • merchantDomainName - домен сайта, как он указан в кабинете.
  • Включённые для вашего аккаунта валюты и способы оплаты.

Шаг 1. Соберите и подпишите платёжную форму

Подпись - это HMAC с MD5 от выбранных полей, соединённых точкой с запятой. Для покупки порядок такой: merchant account, домен мерчанта, номер заказа, дата заказа (Unix timestamp), сумма, валюта, затем все названия товаров, все количества и все цены.

function wfpSignature(array $parts, string $secret): string
{
    return hash_hmac('md5', implode(';', $parts), $secret);
}

$orderReference = 'ORD-' . $orderId . '-' . time();
$orderDate      = time();
$amount         = '50.00';          // string with a dot, the same value everywhere
$currency       = 'EUR';
$products       = [['name' => 'Consultation, 1 hour', 'count' => 1, 'price' => '50.00']];

$parts = [$merchantAccount, $merchantDomain, $orderReference, $orderDate, $amount, $currency];
foreach ($products as $p) { $parts[] = $p['name']; }
foreach ($products as $p) { $parts[] = (string) $p['count']; }
foreach ($products as $p) { $parts[] = $p['price']; }

$signature = wfpSignature($parts, $secretKey);

Затем отрисуйте форму. Поля товаров - массивы, поэтому у них суффикс []:

<form id="wfp" method="post" action="https://secure.wayforpay.com/pay" accept-charset="utf-8">
  <input type="hidden" name="merchantAccount" value="...">
  <input type="hidden" name="merchantAuthType" value="SimpleSignature">
  <input type="hidden" name="merchantDomainName" value="example.com">
  <input type="hidden" name="merchantSignature" value="<?= htmlspecialchars($signature) ?>">
  <input type="hidden" name="orderReference" value="<?= htmlspecialchars($orderReference) ?>">
  <input type="hidden" name="orderDate" value="<?= $orderDate ?>">
  <input type="hidden" name="amount" value="50.00">
  <input type="hidden" name="currency" value="EUR">
  <input type="hidden" name="productName[]" value="Consultation, 1 hour">
  <input type="hidden" name="productCount[]" value="1">
  <input type="hidden" name="productPrice[]" value="50.00">
  <input type="hidden" name="returnUrl" value="https://example.com/pay/return">
  <input type="hidden" name="serviceUrl" value="https://example.com/pay/callback">
</form>
<script>document.getElementById('wfp').submit();</script>

Самые частые ошибки подписи: разный формат числа в подписи и в форме (50 и 50.00), название товара, изменённое после подписи (обрезано, экранировано), и забытый фиксированный порядок полей.

Шаг 2. Примите callback на serviceUrl

Когда платёж доходит до финального состояния, WayForPay отправляет результат на serviceUrl. Читайте сырое тело (это JSON, в некоторых конфигурациях приходят и поля формы, поэтому поддержите оба варианта) и проверяйте подпись. Для callback она считается по полям: merchant account, номер заказа, сумма, валюта, код авторизации, маска карты, статус транзакции и код причины.

$raw  = file_get_contents('php://input');
$data = json_decode($raw, true) ?: $_POST;

$line = implode(';', [
    $data['merchantAccount'] ?? '',
    $data['orderReference'] ?? '',
    $data['amount'] ?? '',
    $data['currency'] ?? '',
    $data['authCode'] ?? '',
    $data['cardPan'] ?? '',
    $data['transactionStatus'] ?? '',
    $data['reasonCode'] ?? '',
]);
$expected = hash_hmac('md5', $line, $secretKey);

if (!hash_equals(strtolower($expected), strtolower((string) ($data['merchantSignature'] ?? '')))) {
    http_response_code(400);
    exit;
}

Для сравнения используйте hash_equals, чтобы не было утечки через время. Только после валидной подписи ищите заказ и сверяйте amount и currency с тем, что вы сохранили. Если значения отличаются, запишите в лог и не помечайте заказ оплаченным.

Шаг 3. Обновите заказ и ответьте правильно

Поле transactionStatus говорит, что произошло: Approved - деньги списаны; Declined, Expired и подобные - неуспех; Pending или InProcessing - нужно ждать; Refunded и Voided - деньги вернулись. Обновляйте заказ в транзакции и делайте это идемпотентно: если заказ уже оплачен, ничего не меняйте, но всё равно отвечайте успехом.

WayForPay требует подписанный ответ, чтобы знать, что вы получили сообщение. Подпись считается от номера заказа, слова accept и текущего времени:

$time   = time();
$status = 'accept';
$reply  = [
    'orderReference' => $data['orderReference'],
    'status'         => $status,
    'time'           => $time,
    'signature'      => hash_hmac('md5', "{$data['orderReference']};{$status};{$time}", $secretKey),
];

header('Content-Type: application/json');
echo json_encode($reply);

Если сервер так не отвечает, WayForPay считает доставку неудачной и повторяет её, поэтому вы получите один и тот же callback несколько раз. Это ещё одна причина для проверки идемпотентности.

Шаг 4. Подтвердите статус через CHECK_STATUS

Callback можно пропустить: сервер лежал, файрвол заблокировал запрос, деплой сломал маршрут. У WayForPay есть вызов API CHECK_STATUS, который возвращает текущее состояние заказа по его номеру. Используйте его в двух местах: на странице возврата, если callback не пришёл через несколько секунд, и в небольшом cron-задании, которое перепроверяет заказы, зависшие в «ожидает», скажем, дольше пятнадцати минут. Запрос подписывается HMAC_MD5 от merchant account и номера заказа.

$payload = [
    'transactionType'   => 'CHECK_STATUS',
    'merchantAccount'   => $merchantAccount,
    'orderReference'    => $orderReference,
    'merchantSignature' => hash_hmac('md5', "{$merchantAccount};{$orderReference}", $secretKey),
    'apiVersion'        => 1,
];

$ch = curl_init('https://api.wayforpay.com/api');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => json_encode($payload),
    CURLOPT_HTTPHEADER     => ['Content-Type: application/json'],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 15,
]);
$result = json_decode((string) curl_exec($ch), true);

Применяйте те же проверки, что и для callback: проверяйте подпись ответа, если она есть, сверяйте сумму и валюту и обновляйте заказ идемпотентно.

Тестирование перед запуском

  • Используйте тестовый режим и тестовые карты из документации WayForPay, пока в логах не появятся полный успешный платёж, отказ и возврат.
  • Ваш serviceUrl должен быть доступен из интернета по HTTPS. Локально используйте туннель или выкатите на staging-домен.
  • Логируйте каждый callback: сырое тело, результат проверки и итог. Секретный ключ в логи не пишите никогда.
  • Убедитесь, что WAF или защита от ботов (например, в Cloudflare) не блокирует и не проверяет капчей запросы callback от WayForPay.

Частые ошибки

  • Доверие к return URL. Клиент может открыть его, не заплатив.
  • Повторное использование номера заказа. Для каждой попытки нужен уникальный номер, иначе попытки перепутаются.
  • Изменение суммы после подписи или показ одной цены при подписи другой.
  • Нет ответа на callback подписанным ответом accept.
  • Пометка «оплачено» до проверки суммы и валюты.
  • Несколько валют, один секрет и никакой привязки: храните валюту в заказе и проверяйте её в callback.

Чем мы можем помочь

Мы делаем платёжные интеграции под ключ: WayForPay, Stripe, PayPal, рекуррентные платежи, возвраты и отчёты сверки. Если у вас уже работает оплата, но вы не уверены в сценариях сбоев, закажите архитектурный анализ, и мы проверим обработку callback, идемпотентность и логирование. Похожий гайд по другому провайдеру - Stripe webhooks на PHP, а общий подход описан в нашем плейбуке интеграций для стартапов.

Частые вопросы

Какую подпись использует WayForPay?

HMAC_MD5 от выбранных полей, соединённых точкой с запятой, с секретным ключом мерчанта. Порядок полей фиксирован и отличается для платёжной формы, callback и ответа на callback.

Что должен вернуть endpoint serviceUrl?

JSON с orderReference, статусом «accept», текущим Unix-временем и подписью HMAC_MD5 от строки «orderReference;accept;time». Без такого ответа WayForPay повторяет callback.

Можно ли по странице returnUrl помечать заказ оплаченным?

Нет. Браузер клиента может открыть этот адрес, не заплатив. Помечайте заказ оплаченным только после проверенного callback на serviceUrl или ответа CHECK_STATUS со статусом Approved и совпадающими суммой и валютой.

Как проверить статус заказа WayForPay?

Отправьте подписанный запрос CHECK_STATUS в API WayForPay с вашим merchant account и номером заказа. Используйте его на странице возврата и в cron-задании, которое перепроверяет заказы в статусе pending.

Почему WayForPay пишет, что подпись неверна?

Обычно формат суммы в подписи и в форме различается (50 и 50.00), название товара изменили после подписи, порядок полей неверный или используется не тот секретный ключ.

Бесплатный discovery-звонок

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

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