Эквайринг — интеграция, где код проверяется не тестами, а деньгами: ошибка означает либо отданный без оплаты товар, либо потерянный платёж. При этом сама схема несложная — зарегистрировать заказ, отправить пользователя на страницу оплаты, обработать возврат.
Разберём API и то, что определяет надёжность приёма платежей независимо от банка.
СберБанк Эквайринг
СберБанк предоставляет API для приёма платежей на сайте. Основные возможности:
- Оплата банковскими картами
- Apple Pay / Google Pay
- СБП (Система быстрых платежей)
- Рекуррентные платежи
Два окружения:
- Тестовое:
https://3dsec.sberbank.ru - Боевое:
https://securepayments.sberbank.ru
Регистрация и настройка
- Заключите договор эквайринга со СберБанком
- Получите логин и пароль для API
- Настройте callback URL в личном кабинете
PHP: Класс для работы с API
<?php
// /local/lib/Payment/SberBank.php
namespace Local\Payment;
class SberBank
{
private string $login;
private string $password;
private string $baseUrl;
private bool $isTest;
public function __construct(bool $isTest = false)
{
$this->isTest = $isTest;
$this->baseUrl = $isTest
? 'https://3dsec.sberbank.ru/payment/rest/'
: 'https://securepayments.sberbank.ru/payment/rest/';
$this->login = $isTest
? getenv('SBER_TEST_LOGIN')
: getenv('SBER_PROD_LOGIN');
$this->password = $isTest
? getenv('SBER_TEST_PASSWORD')
: getenv('SBER_PROD_PASSWORD');
}
/**
* Регистрация заказа
*
* @return array{orderId: string, formUrl: string}
*/
public function registerOrder(
string $orderNumber,
int $amountKopecks,
string $returnUrl,
string $failUrl = '',
string $description = '',
array $additionalParams = []
): array {
$params = [
'orderNumber' => $orderNumber,
'amount' => $amountKopecks,
'returnUrl' => $returnUrl,
'failUrl' => $failUrl ?: $returnUrl,
'description' => $description,
];
// Дополнительные параметры
if (!empty($additionalParams['email'])) {
$params['email'] = $additionalParams['email'];
}
if (!empty($additionalParams['phone'])) {
$params['phone'] = $additionalParams['phone'];
}
// Данные для чека (54-ФЗ)
if (!empty($additionalParams['orderBundle'])) {
$params['orderBundle'] = json_encode(
$additionalParams['orderBundle'],
JSON_UNESCAPED_UNICODE
);
}
$response = $this->request('register.do', $params);
if (!empty($response['errorCode'])) {
throw new \RuntimeException(
$response['errorMessage'] ?? 'Unknown error',
(int) $response['errorCode']
);
}
return [
'orderId' => $response['orderId'],
'formUrl' => $response['formUrl'],
];
}
/**
* Проверка статуса заказа
*/
public function getOrderStatus(string $orderId): array
{
$response = $this->request('getOrderStatusExtended.do', [
'orderId' => $orderId,
]);
return [
'orderNumber' => $response['orderNumber'] ?? '',
'orderStatus' => $response['orderStatus'] ?? 0,
'actionCode' => $response['actionCode'] ?? 0,
'actionCodeDescription' => $response['actionCodeDescription'] ?? '',
'amount' => $response['amount'] ?? 0,
'currency' => $response['currency'] ?? '643',
'ip' => $response['ip'] ?? '',
'cardAuthInfo' => $response['cardAuthInfo'] ?? [],
'paymentAmountInfo' => $response['paymentAmountInfo'] ?? [],
];
}
/**
* Возврат средств
*/
public function refund(string $orderId, int $amountKopecks): bool
{
$response = $this->request('refund.do', [
'orderId' => $orderId,
'amount' => $amountKopecks,
]);
return ($response['errorCode'] ?? '1') === '0';
}
/**
* Отмена неоплаченного заказа
*/
public function cancel(string $orderId): bool
{
$response = $this->request('reverse.do', [
'orderId' => $orderId,
]);
return ($response['errorCode'] ?? '1') === '0';
}
private function request(string $method, array $params): array
{
$params['userName'] = $this->login;
$params['password'] = $this->password;
$url = $this->baseUrl . $method;
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($params),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_SSL_VERIFYPEER => true,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($error) {
throw new \RuntimeException("cURL error: {$error}");
}
if ($httpCode !== 200) {
throw new \RuntimeException("HTTP error: {$httpCode}");
}
$data = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \RuntimeException('Invalid JSON response');
}
return $data;
}
}Использование в Битрикс
<?php
// Создание заказа и редирект на оплату
use Local\Payment\SberBank;
$sber = new SberBank(isTest: true);
try {
// Формируем данные для чека 54-ФЗ
$orderBundle = [
'cartItems' => [
'items' => array_map(function ($item) {
return [
'positionId' => $item['ID'],
'name' => mb_substr($item['NAME'], 0, 100),
'quantity' => [
'value' => $item['QUANTITY'],
'measure' => 'шт',
],
'itemAmount' => $item['PRICE'] * $item['QUANTITY'] * 100,
'itemCode' => $item['PRODUCT_ID'],
'tax' => [
'taxType' => 6, // Без НДС
],
'itemPrice' => $item['PRICE'] * 100,
];
}, $basketItems),
],
];
$result = $sber->registerOrder(
orderNumber: 'ORDER-' . $orderId,
amountKopecks: $totalPrice * 100,
returnUrl: 'https://site.ru/payment/success?order=' . $orderId,
failUrl: 'https://site.ru/payment/fail?order=' . $orderId,
description: 'Заказ №' . $orderId,
additionalParams: [
'email' => $customerEmail,
'phone' => $customerPhone,
'orderBundle' => $orderBundle,
]
);
// Сохраняем orderId СберБанка
savePaymentOrderId($orderId, $result['orderId']);
// Редирект на форму оплаты
LocalRedirect($result['formUrl']);
} catch (\Exception $e) {
// Логируем ошибку
AddMessage2Log('SberBank error: ' . $e->getMessage());
// Показываем ошибку пользователю
$APPLICATION->ThrowException('Ошибка создания платежа. Попробуйте позже.');
}Обработка callback
Три правила, без которых приём платежей небезопасен. Они одинаковы для любого эквайринга, и нарушение любого из них рано или поздно приводит к потерям.
Статус заказа меняется только после проверки на стороне банка. Возврат пользователя на страницу успеха ничего не подтверждает: адрес можно открыть вручную. Получив уведомление или вернувшегося пользователя, запрашивайте фактический статус методом проверки заказа — и доверяйте только его ответу.
Сумма и валюта сверяются с заказом в вашей базе. Проверять только «оплачено/не оплачено» недостаточно: расхождение в сумме означает либо ошибку в вашем коде, либо попытку заплатить меньше.
Обработчик идемпотентен. Уведомление придёт повторно — это штатное поведение при отсутствии быстрого ответа. Без защиты повторная обработка второй раз спишет товар, отправит письмо и начислит бонусы. Проверяйте, не обработан ли уже этот платёж, и возвращайте успех, ничего не делая.
<?php
// /local/tools/payment-callback.php
require_once $_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_before.php';
use Local\Payment\SberBank;
use Bitrix\Main\Web\Json;
// Логируем входящие данные
file_put_contents(
$_SERVER['DOCUMENT_ROOT'] . '/local/logs/sber-callback.log',
date('Y-m-d H:i:s') . ' ' . Json::encode($_REQUEST) . "\n",
FILE_APPEND
);
$orderId = $_REQUEST['orderId'] ?? '';
$status = (int) ($_REQUEST['status'] ?? 0);
if (empty($orderId)) {
http_response_code(400);
exit('Missing orderId');
}
try {
$sber = new SberBank(isTest: true);
$orderStatus = $sber->getOrderStatus($orderId);
// Находим наш заказ по orderId СберБанка
$localOrderId = getLocalOrderIdByPaymentId($orderId);
if (!$localOrderId) {
throw new \RuntimeException('Order not found');
}
// Обрабатываем статус
// 0 - заказ зарегистрирован, но не оплачен
// 1 - предавторизованная сумма захолдирована
// 2 - проведена полная авторизация суммы заказа
// 3 - авторизация отменена
// 4 - по транзакции была проведена операция возврата
// 5 - инициирована авторизация через сервер контроля доступа
// 6 - авторизация отклонена
switch ($orderStatus['orderStatus']) {
case 2: // Оплачен
markOrderAsPaid($localOrderId);
sendPaymentConfirmation($localOrderId);
break;
case 3: // Отменён
case 6: // Отклонён
markOrderAsFailed($localOrderId, $orderStatus['actionCodeDescription']);
break;
case 4: // Возврат
markOrderAsRefunded($localOrderId);
break;
}
echo 'OK';
} catch (\Exception $e) {
file_put_contents(
$_SERVER['DOCUMENT_ROOT'] . '/local/logs/sber-callback-errors.log',
date('Y-m-d H:i:s') . ' ' . $e->getMessage() . "\n",
FILE_APPEND
);
http_response_code(500);
echo 'Error: ' . $e->getMessage();
}TypeScript: Next.js интеграция
// src/lib/sberbank.ts
type SberConfig = {
login: string;
password: string;
isTest: boolean;
};
type RegisterOrderParams = {
orderNumber: string;
amount: number; // в копейках
returnUrl: string;
failUrl?: string;
description?: string;
email?: string;
phone?: string;
};
type OrderStatus = {
orderNumber: string;
orderStatus: number;
amount: number;
actionCode: number;
actionCodeDescription: string;
};
export class SberBankClient {
private baseUrl: string;
private login: string;
private password: string;
constructor(config: SberConfig) {
this.baseUrl = config.isTest
? 'https://3dsec.sberbank.ru/payment/rest/'
: 'https://securepayments.sberbank.ru/payment/rest/';
this.login = config.login;
this.password = config.password;
}
async registerOrder(params: RegisterOrderParams): Promise<{
orderId: string;
formUrl: string;
}> {
const response = await this.request('register.do', {
orderNumber: params.orderNumber,
amount: params.amount,
returnUrl: params.returnUrl,
failUrl: params.failUrl || params.returnUrl,
description: params.description || '',
email: params.email,
phone: params.phone,
});
if (response.errorCode) {
throw new Error(response.errorMessage || 'Registration failed');
}
return {
orderId: response.orderId,
formUrl: response.formUrl,
};
}
async getOrderStatus(orderId: string): Promise<OrderStatus> {
const response = await this.request('getOrderStatusExtended.do', {
orderId,
});
return {
orderNumber: response.orderNumber || '',
orderStatus: response.orderStatus || 0,
amount: response.amount || 0,
actionCode: response.actionCode || 0,
actionCodeDescription: response.actionCodeDescription || '',
};
}
async refund(orderId: string, amount: number): Promise<boolean> {
const response = await this.request('refund.do', { orderId, amount });
return response.errorCode === '0';
}
private async request(method: string, params: Record<string, unknown>) {
const body = new URLSearchParams({
userName: this.login,
password: this.password,
...Object.fromEntries(
Object.entries(params)
.filter(([, v]) => v !== undefined)
.map(([k, v]) => [k, String(v)])
),
});
const response = await fetch(`${this.baseUrl}${method}`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: body.toString(),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
}
}API Routes
// src/app/api/payment/create/route.ts
import { NextResponse } from 'next/server';
import { SberBankClient } from '@/lib/sberbank';
import { prisma } from '@/lib/prisma';
const sber = new SberBankClient({
login: process.env.SBER_LOGIN!,
password: process.env.SBER_PASSWORD!,
isTest: process.env.NODE_ENV !== 'production',
});
export async function POST(request: Request) {
const { orderId } = await request.json();
const order = await prisma.order.findUnique({
where: { id: orderId },
include: { items: true },
});
if (!order) {
return NextResponse.json({ error: 'Order not found' }, { status: 404 });
}
try {
const result = await sber.registerOrder({
orderNumber: `ORDER-${order.id}`,
amount: Math.round(order.total * 100),
returnUrl: `${process.env.NEXT_PUBLIC_URL}/payment/success?order=${order.id}`,
failUrl: `${process.env.NEXT_PUBLIC_URL}/payment/fail?order=${order.id}`,
description: `Заказ №${order.id}`,
email: order.email,
});
// Сохраняем ID платежа
await prisma.order.update({
where: { id: orderId },
data: { paymentId: result.orderId },
});
return NextResponse.json({ formUrl: result.formUrl });
} catch (error) {
console.error('Payment error:', error);
return NextResponse.json(
{ error: 'Payment creation failed' },
{ status: 500 }
);
}
}Безопасность:
- Храните логин/пароль в переменных окружения
- Валидируйте callback по IP СберБанка
- Всегда проверяйте статус через API, не доверяйте query-параметрам
Итоги
| Операция | Метод API |
|---|---|
| Создание платежа | register.do |
| Статус платежа | getOrderStatusExtended.do |
| Возврат | refund.do |
| Отмена | reverse.do |
Статусы заказа:
| Код | Значение |
|---|---|
0 | Зарегистрирован, но не оплачен |
1 | Средства захолдированы (двухстадийная оплата) |
2 | Оплачен — списание проведено |
3 | Отменён |
4 | Возврат |
6 | Отклонён |
Не путайте статус 1 со статусом 2. При двухстадийной оплате 1 означает, что средства только заблокированы на карте покупателя, а не списаны: списание произойдёт при завершении операции, и до этого момента заказ оплаченным не является. Отгрузка товара по статусу 1 — ошибка, которая обнаруживается на сверке через месяц.
Итоги
Проверяйте статус запросом к банку, а не по возврату пользователя.
Сверяйте сумму — всегда.
Делайте обработчик идемпотентным — уведомления повторяются.
Логируйте каждый шаг: запрос, ответ, решение, время. В спорах о платежах это единственное доказательство, и заводить лог нужно с первого дня, а не после первого спора.
Проверьте возврат средств до запуска, а не когда его потребует клиент. Возврат — та часть интеграции, которую почти никогда не тестируют и которая почти всегда содержит ошибку.
Разделите тестовые и боевые ключи так, чтобы их нельзя было перепутать. Разные переменные окружения, разные имена, явная проверка окружения при старте. Боевой платёж, ушедший на тестовый контур, выглядит как успешный — и обнаруживается по отсутствию денег.
Комментарии
Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.