Главная/Статьи/ЮKassa — приём платежей в Next.js и Битрикс

ЮKassa — приём платежей в Next.js и Битрикс

Интеграция ЮKassa: создание платежей, webhook-уведомления, возвраты, рекуррентные платежи. Production-ready код для TypeScript и PHP.

ДМ
Дмитрий Мещеряков
📅 22 июля 2026 г.📖 9 мин чтения

Ю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

bash
npm install @yoomoney/sdk

Клиент

typescript
// 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

typescript
// 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. Без защиты повторная обработка второй раз спишет товар, отправит письмо и начислит бонусы. Храните идентификаторы обработанных платежей и на повторе отвечайте успехом, ничего не делая.

И отвечайте быстро: тяжёлая работа внутри обработчика ведёт к таймауту, таймаут — к повтору, повтор — к дублям.

typescript
// 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
<?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
<?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';
}

Рекуррентные платежи

typescript
// 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Метод
Создание платежа/paymentsPOST
Статус платежа/payments/{id}GET
Возврат/refundsPOST
Подтверждение/payments/{id}/capturePOST
Отмена/payments/{id}/cancelPOST

События webhook:

СобытиеЗначение
payment.waiting_for_captureСредства заблокированы, но не списаны
payment.succeededОплачен — списание проведено
payment.canceledОтменён
refund.succeededВозврат выполнен
⚠️ Важно

waiting_for_capture — это не «оплачено». При двухстадийной схеме средства только заблокированы на карте покупателя; списание происходит при подтверждении, и до него заказ оплаченным не является. Отгрузка по этому статусу — классическая ошибка, обнаруживаемая при сверке.

И обратная сторона: заблокированные средства нужно либо подтвердить, либо отменить в отведённый срок. Забытый холд возвращается покупателю сам, а у вас в системе заказ остаётся «в обработке» — с товаром, зарезервированным под несостоявшуюся продажу.

Итоги по интеграции

Ключ идемпотентности при создании платежа (Idempotence-Key) — не формальность: он защищает от создания двух платежей при повторной отправке формы или при сбое сети на середине запроса. ЮKassa его поддерживает, и пользоваться им нужно.

Статус заказа меняется только по данным от платёжной системы. Возврат пользователя на страницу успеха — это навигация, а не подтверждение оплаты.

Проверьте возврат средств до запуска. Эта часть почти никогда не тестируется и почти всегда содержит ошибку, а обнаруживается в момент, когда возврат требует клиент.

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

Ведите полный лог платёжных операций с первого дня. Это единственный аргумент в спорах и единственный способ разобраться, что произошло, когда деньги списались, а заказ не оформился.

Для 54-ФЗ формируйте чеки — отдельная задача со своими требованиями к составу данных и срокам, а не галочка в настройках.

🚀

Хотите такое же решение?

Настрою окружение под ваш проект, учту специфику инфраструктуры и обучу команду.

Обсудить проект →
Бесплатная консультация · Ответ в течение дня

Комментарии

Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.