ЮKassa — один из самых удобных платёжных API на российском рынке: понятная модель, официальные SDK, вменяемая документация. Ровно поэтому интеграцию с ней часто пишут быстро и невнимательно, оставляя те же три дыры, что и везде: доверие к возврату пользователя, отсутствие проверки суммы и неидемпотентный обработчик уведомлений.
Разберём реализацию и эти три места отдельно.
ЮKassa API
ЮKassa (бывшая Яндекс.Касса) — популярный платёжный агрегатор в России. Поддерживает:
- Банковские карты
- ЮMoney, QIWI, WebMoney
- СБП
- Apple Pay / Google Pay
- Рассрочка
Тестовый режим: Используйте тестовый магазин для отладки. Тестовые карты: 5555 5555 5555 4444 (успех), 5555 5555 5555 4002 (отклонение).
TypeScript: Next.js интеграция
Установка SDK
npm install @yoomoney/sdkКлиент
// src/lib/yookassa.ts
import { YooCheckout, ICreatePayment, IPayment } from '@yoomoney/sdk';
const checkout = new YooCheckout({
shopId: process.env.YOOKASSA_SHOP_ID!,
secretKey: process.env.YOOKASSA_SECRET_KEY!,
});
type CreatePaymentParams = {
amount: number;
currency?: string;
description: string;
orderId: string;
customerEmail?: string;
returnUrl: string;
metadata?: Record<string, string>;
};
export async function createPayment(
params: CreatePaymentParams
): Promise<{ id: string; confirmationUrl: string }> {
const idempotenceKey = `payment-${params.orderId}-${Date.now()}`;
const paymentData: ICreatePayment = {
amount: {
value: params.amount.toFixed(2),
currency: params.currency || 'RUB',
},
confirmation: {
type: 'redirect',
return_url: params.returnUrl,
},
capture: true, // Автоматическое подтверждение
description: params.description,
metadata: {
order_id: params.orderId,
...params.metadata,
},
};
if (params.customerEmail) {
paymentData.receipt = {
customer: { email: params.customerEmail },
items: [
{
description: params.description,
amount: {
value: params.amount.toFixed(2),
currency: params.currency || 'RUB',
},
quantity: '1',
vat_code: 1, // Без НДС
},
],
};
}
const payment = await checkout.createPayment(paymentData, idempotenceKey);
return {
id: payment.id,
confirmationUrl: payment.confirmation?.confirmation_url || '',
};
}
export async function getPayment(paymentId: string): Promise<IPayment> {
return checkout.getPayment(paymentId);
}
export async function refundPayment(
paymentId: string,
amount: number,
reason?: string
): Promise<boolean> {
const idempotenceKey = `refund-${paymentId}-${Date.now()}`;
const refund = await checkout.createRefund(
{
payment_id: paymentId,
amount: {
value: amount.toFixed(2),
currency: 'RUB',
},
description: reason,
},
idempotenceKey
);
return refund.status === 'succeeded';
}API Routes
// src/app/api/payment/create/route.ts
import { NextResponse } from 'next/server';
import { createPayment } from '@/lib/yookassa';
import { prisma } from '@/lib/prisma';
import { getSession } from '@/lib/auth';
export async function POST(request: Request) {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { orderId } = await request.json();
const order = await prisma.order.findUnique({
where: { id: orderId, userId: session.userId },
});
if (!order) {
return NextResponse.json({ error: 'Order not found' }, { status: 404 });
}
if (order.status !== 'pending') {
return NextResponse.json({ error: 'Order already paid' }, { status: 400 });
}
try {
const payment = await createPayment({
amount: order.total,
description: `Заказ №${order.id}`,
orderId: order.id,
customerEmail: order.email,
returnUrl: `${process.env.NEXT_PUBLIC_URL}/orders/${order.id}`,
});
// Сохраняем ID платежа
await prisma.order.update({
where: { id: orderId },
data: { paymentId: payment.id },
});
return NextResponse.json({ confirmationUrl: payment.confirmationUrl });
} catch (error) {
console.error('Payment error:', error);
return NextResponse.json(
{ error: 'Payment creation failed' },
{ status: 500 }
);
}
}Webhook обработчик
Обработчик уведомлений — самая ответственная часть интеграции. Три обязательных требования к нему.
Проверяйте подлинность уведомления. Эндпоинт публичный, и прислать туда «оплату» может кто угодно. Минимально надёжный вариант — не доверять телу уведомления, а по полученному идентификатору запросить платёж через API и работать с ответом. Дополнительно стоит ограничить доступ к обработчику по списку адресов платёжной системы.
Сверяйте сумму и валюту с заказом. Проверки «статус — succeeded» недостаточно: она отвечает на вопрос «платёж прошёл», а не «оплачен ли этот заказ полностью».
Делайте обработку идемпотентной. ЮKassa повторяет уведомление, если не получила быстрый ответ 200. Без защиты повторная обработка второй раз спишет товар, отправит письмо и начислит бонусы. Храните идентификаторы обработанных платежей и на повторе отвечайте успехом, ничего не делая.
И отвечайте быстро: тяжёлая работа внутри обработчика ведёт к таймауту, таймаут — к повтору, повтор — к дублям.
// src/app/api/payment/webhook/route.ts
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import crypto from 'crypto';
// Проверка подписи webhook
function verifySignature(body: string, signature: string): boolean {
const secretKey = process.env.YOOKASSA_SECRET_KEY!;
const expectedSignature = crypto
.createHmac('sha256', secretKey)
.update(body)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
export async function POST(request: Request) {
const body = await request.text();
const signature = request.headers.get('x-signature') || '';
// Проверяем подпись (в production обязательно!)
// if (!verifySignature(body, signature)) {
// return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
// }
const event = JSON.parse(body);
console.log('YooKassa webhook:', event.event, event.object?.id);
const payment = event.object;
const orderId = payment?.metadata?.order_id;
if (!orderId) {
return NextResponse.json({ error: 'No order_id' }, { status: 400 });
}
switch (event.event) {
case 'payment.succeeded':
await prisma.order.update({
where: { id: orderId },
data: {
status: 'paid',
paidAt: new Date(),
},
});
// Отправляем уведомление
await sendOrderConfirmation(orderId);
break;
case 'payment.canceled':
await prisma.order.update({
where: { id: orderId },
data: { status: 'cancelled' },
});
break;
case 'refund.succeeded':
await prisma.order.update({
where: { id: orderId },
data: { status: 'refunded' },
});
break;
}
return NextResponse.json({ status: 'ok' });
}
async function sendOrderConfirmation(orderId: string) {
// Логика отправки email/telegram
}PHP: Битрикс интеграция
<?php
// /local/lib/Payment/YooKassa.php
namespace Local\Payment;
class YooKassa
{
private string $shopId;
private string $secretKey;
private string $baseUrl = 'https://api.yookassa.ru/v3/';
public function __construct()
{
$this->shopId = getenv('YOOKASSA_SHOP_ID');
$this->secretKey = getenv('YOOKASSA_SECRET_KEY');
}
/**
* Создание платежа
*/
public function createPayment(array $params): array
{
$idempotenceKey = uniqid('payment-', true);
$data = [
'amount' => [
'value' => number_format($params['amount'], 2, '.', ''),
'currency' => $params['currency'] ?? 'RUB',
],
'confirmation' => [
'type' => 'redirect',
'return_url' => $params['return_url'],
],
'capture' => true,
'description' => $params['description'],
'metadata' => [
'order_id' => $params['order_id'],
],
];
// Чек для 54-ФЗ
if (!empty($params['items'])) {
$data['receipt'] = [
'customer' => [
'email' => $params['email'],
],
'items' => array_map(function ($item) {
return [
'description' => mb_substr($item['name'], 0, 128),
'amount' => [
'value' => number_format($item['price'], 2, '.', ''),
'currency' => 'RUB',
],
'quantity' => (string) $item['quantity'],
'vat_code' => $item['vat_code'] ?? 1,
'payment_subject' => 'commodity',
'payment_mode' => 'full_payment',
];
}, $params['items']),
];
}
$response = $this->request('POST', 'payments', $data, $idempotenceKey);
return [
'id' => $response['id'],
'status' => $response['status'],
'confirmation_url' => $response['confirmation']['confirmation_url'] ?? null,
];
}
/**
* Получение информации о платеже
*/
public function getPayment(string $paymentId): array
{
return $this->request('GET', "payments/{$paymentId}");
}
/**
* Возврат платежа
*/
public function refund(string $paymentId, float $amount, string $reason = ''): array
{
$idempotenceKey = uniqid('refund-', true);
return $this->request('POST', 'refunds', [
'payment_id' => $paymentId,
'amount' => [
'value' => number_format($amount, 2, '.', ''),
'currency' => 'RUB',
],
'description' => $reason,
], $idempotenceKey);
}
private function request(
string $method,
string $endpoint,
?array $data = null,
?string $idempotenceKey = null
): array {
$ch = curl_init($this->baseUrl . $endpoint);
$headers = [
'Content-Type: application/json',
'Authorization: Basic ' . base64_encode("{$this->shopId}:{$this->secretKey}"),
];
if ($idempotenceKey) {
$headers[] = "Idempotence-Key: {$idempotenceKey}";
}
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 30,
]);
if ($method === 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$result = json_decode($response, true);
if ($httpCode >= 400) {
throw new \RuntimeException(
$result['description'] ?? 'YooKassa API error',
$httpCode
);
}
return $result;
}
}Webhook обработчик для Битрикс
<?php
// /local/tools/yookassa-webhook.php
require_once $_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_before.php';
use Bitrix\Main\Web\Json;
// Логируем
$input = file_get_contents('php://input');
file_put_contents(
$_SERVER['DOCUMENT_ROOT'] . '/local/logs/yookassa-webhook.log',
date('Y-m-d H:i:s') . ' ' . $input . "\n",
FILE_APPEND
);
try {
$event = Json::decode($input);
$eventType = $event['event'] ?? '';
$payment = $event['object'] ?? [];
$orderId = $payment['metadata']['order_id'] ?? '';
if (empty($orderId)) {
throw new \RuntimeException('Missing order_id');
}
// Загружаем модуль
\Bitrix\Main\Loader::includeModule('sale');
// Находим заказ
$order = \Bitrix\Sale\Order::load($orderId);
if (!$order) {
throw new \RuntimeException('Order not found');
}
switch ($eventType) {
case 'payment.succeeded':
// Помечаем оплаченным
$paymentCollection = $order->getPaymentCollection();
foreach ($paymentCollection as $payment) {
if (!$payment->isPaid()) {
$payment->setPaid('Y');
}
}
$order->save();
// Отправляем уведомления
\CEvent::Send('ORDER_PAID', SITE_ID, [
'ORDER_ID' => $orderId,
'EMAIL' => $order->getPropertyCollection()->getUserEmail()->getValue(),
]);
break;
case 'payment.canceled':
$order->setField('CANCELED', 'Y');
$order->save();
break;
case 'refund.succeeded':
// Обрабатываем возврат
break;
}
echo 'OK';
} catch (\Exception $e) {
file_put_contents(
$_SERVER['DOCUMENT_ROOT'] . '/local/logs/yookassa-errors.log',
date('Y-m-d H:i:s') . ' ' . $e->getMessage() . "\n",
FILE_APPEND
);
http_response_code(500);
echo 'Error';
}Рекуррентные платежи
// src/lib/yookassa.ts
export async function createRecurrentPayment(
savedPaymentMethodId: string,
amount: number,
description: string
): Promise<{ id: string; status: string }> {
const idempotenceKey = `recurrent-${Date.now()}`;
const payment = await checkout.createPayment(
{
amount: {
value: amount.toFixed(2),
currency: 'RUB',
},
capture: true,
payment_method_id: savedPaymentMethodId,
description,
},
idempotenceKey
);
return {
id: payment.id,
status: payment.status,
};
}
// Первый платёж с сохранением метода
export async function createPaymentWithSave(
params: CreatePaymentParams
): Promise<{ id: string; confirmationUrl: string }> {
const idempotenceKey = `payment-save-${params.orderId}`;
const payment = await checkout.createPayment(
{
amount: {
value: params.amount.toFixed(2),
currency: 'RUB',
},
confirmation: {
type: 'redirect',
return_url: params.returnUrl,
},
capture: true,
description: params.description,
save_payment_method: true, // Сохраняем для будущих платежей
metadata: {
order_id: params.orderId,
user_id: params.userId,
},
},
idempotenceKey
);
return {
id: payment.id,
confirmationUrl: payment.confirmation?.confirmation_url || '',
};
}Безопасность:
- Всегда проверяйте webhook подпись в production
- Используйте HTTPS для return_url
- Не храните секретный ключ в коде — используйте env-переменные
Итоги
| Операция | Endpoint | Метод |
|---|---|---|
| Создание платежа | /payments | POST |
| Статус платежа | /payments/{id} | GET |
| Возврат | /refunds | POST |
| Подтверждение | /payments/{id}/capture | POST |
| Отмена | /payments/{id}/cancel | POST |
События webhook:
| Событие | Значение |
|---|---|
payment.waiting_for_capture | Средства заблокированы, но не списаны |
payment.succeeded | Оплачен — списание проведено |
payment.canceled | Отменён |
refund.succeeded | Возврат выполнен |
waiting_for_capture — это не «оплачено». При двухстадийной схеме средства только заблокированы на карте покупателя; списание происходит при подтверждении, и до него заказ оплаченным не является. Отгрузка по этому статусу — классическая ошибка, обнаруживаемая при сверке.
И обратная сторона: заблокированные средства нужно либо подтвердить, либо отменить в отведённый срок. Забытый холд возвращается покупателю сам, а у вас в системе заказ остаётся «в обработке» — с товаром, зарезервированным под несостоявшуюся продажу.
Итоги по интеграции
Ключ идемпотентности при создании платежа (Idempotence-Key) — не формальность: он защищает от создания двух платежей при повторной отправке формы или при сбое сети на середине запроса. ЮKassa его поддерживает, и пользоваться им нужно.
Статус заказа меняется только по данным от платёжной системы. Возврат пользователя на страницу успеха — это навигация, а не подтверждение оплаты.
Проверьте возврат средств до запуска. Эта часть почти никогда не тестируется и почти всегда содержит ошибку, а обнаруживается в момент, когда возврат требует клиент.
Разделите тестовые и боевые ключи так, чтобы перепутать было невозможно. Боевой платёж, ушедший на тестовый магазин, выглядит успешным.
Ведите полный лог платёжных операций с первого дня. Это единственный аргумент в спорах и единственный способ разобраться, что произошло, когда деньги списались, а заказ не оформился.
Для 54-ФЗ формируйте чеки — отдельная задача со своими требованиями к составу данных и срокам, а не галочка в настройках.
Комментарии
Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.