ЮKassa — один из самых удобных платёжных API на российском рынке: понятная модель, официальные SDK, вменяемая документация. Ровно поэтому интеграцию с ней часто пишут быстро и невнимательно, оставляя те же три дыры, что и везде: доверие к возврату пользователя, отсутствие проверки суммы и неидемпотентный обработчик уведомлений.
Разберём реализацию и эти три места отдельно.
Что умеет API ЮKassa
Ю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-переменные
Какие методы и события есть в API ЮKassa
| Операция | 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-ФЗ формируйте чеки — отдельная задача со своими требованиями к составу данных и срокам, а не галочка в настройках.
Частые вопросы
Какие методы есть в API ЮKassa?
Основные пять: POST на /payments создаёт платёж, GET на /payments с идентификатором возвращает статус, POST на /refunds делает возврат, POST на /payments/{id}/capture подтверждает списание при двухстадийной схеме, POST на /payments/{id}/cancel отменяет платёж. События вебхука: payment.waiting_for_capture, payment.succeeded, payment.canceled и refund.succeeded.
Что означает статус waiting_for_capture?
Что средства заблокированы на карте покупателя, но не списаны. Это двухстадийная схема: списание происходит при подтверждении, и до него заказ оплаченным не является — отгрузка по этому статусу классическая ошибка, обнаруживаемая при сверке. Обратная сторона: заблокированные средства нужно либо подтвердить, либо отменить в отведённый срок, иначе холд вернётся покупателю сам, а у вас в системе заказ останется в обработке с зарезервированным товаром.
Как проверить подлинность уведомления от ЮKassa?
Не доверять телу уведомления, а по полученному идентификатору запросить платёж через API и работать с ответом. Эндпоинт публичный, и прислать туда «оплату» может кто угодно. Дополнительно стоит ограничить доступ к обработчику по списку адресов платёжной системы. И сверяйте сумму с валютой: проверки статуса succeeded недостаточно, она отвечает на вопрос «платёж прошёл», а не «оплачен ли этот заказ полностью».
Зачем нужен ключ идемпотентности при создании платежа?
Он защищает от создания двух платежей при повторной отправке формы или при сбое сети на середине запроса. ЮKassa поддерживает заголовок Idempotence-Key: повторный запрос с тем же ключом вернёт тот же платёж, а не создаст новый. Это не формальность, и пользоваться им нужно.
Почему обработчик уведомлений должен отвечать быстро?
Потому что ЮKassa повторяет уведомление, если не получила ответ 200 вовремя. Тяжёлая работа внутри обработчика ведёт к таймауту, таймаут — к повтору, повтор — к дублям. Схема простая: принять, сохранить, ответить 200, обработать отдельно. И хранить идентификаторы обработанных платежей, чтобы на повторе отвечать успехом, ничего не делая.
Что проверить перед запуском приёма платежей?
Возврат средств — эта часть почти никогда не тестируется и почти всегда содержит ошибку, а обнаруживается в момент, когда возврат требует клиент. Разделение тестовых и боевых ключей так, чтобы перепутать было невозможно: боевой платёж, ушедший на тестовый магазин, выглядит успешным. Полный лог платёжных операций с первого дня — единственный аргумент в спорах. И формирование чеков по 54-ФЗ: это отдельная задача со своими требованиями к составу данных и срокам, а не галочка в настройках.