Документация Капсула
Платёжный шлюз для приёма платежей через СБП на сайтах и в ботах. Полное руководство — от регистрации до интеграции и проверки подписи webhook'ов.
О платформе
Капсула — платёжный посредник между вами (мерчантом) и платёжной системой. Мы упрощаем интеграцию: вместо того чтобы проходить верификацию у банка-эквайера месяцами, вы регистрируетесь у нас за 5 минут, проходите модерацию и принимаете оплаты.
Сейчас поддерживается:
- СБП (Система Быстрых Платежей) — оплата через QR-код в банковском приложении
- Валюта — только
RUB - Сумма — от 500 ₽ до 30 000 ₽ за один платёж
В планах: банковские карты (когда будут пройдены требования PCI DSS).
https://api.kapsula.pro/v1Быстрый старт
Минимальная интеграция — 5 шагов и около 10 минут.
- Зарегистрируйтесь — kapsula.pro/auth
- Создайте кассу в кабинете → Кассы. Укажите сайт или бота
- Пройдите верификацию (для сайтов — загрузка HTML-файла) и дождитесь модерации
- Получите API-ключ вида
pk_live_...на странице кассы - Сделайте первый запрос:
В ответе придётcurl -X POST https://api.kapsula.pro/v1/payment/create \ -H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "amount": 50000, "order_id": "test-1", "mode": "hosted" }'payment_url— откройте его в браузере и протестируйте оплату с телефона.
Регистрация
Откройте страницу авторизации и заполните форму:
- Email — будет использоваться для входа
- Пароль — минимум 8 символов, должен содержать буквы и цифры
Подтверждение по email сейчас не требуется — после регистрации вы сразу попадаете в кабинет.
Создание кассы
«Касса» — это отдельная точка приёма платежей: один сайт или один бот. У вас может быть сколько угодно касс.
Перейдите в Кассы → Создать кассу. Заполните 2 шага:
Шаг 1. Тип кассы
- 🌐 Сайт — для интернет-магазина или сервиса с сайтом. Потребуется верификация (загрузка файла на сайт)
- 🤖 Бот — для Telegram-ботов. Верификация не требуется, идёт сразу на модерацию
Шаг 2. Реквизиты
- Название — отобразится покупателям на странице оплаты
- URL — адрес сайта или ссылка на бота (
t.me/...) - Описание — что вы продаёте (1–2 предложения для модератора)
Верификация сайта
Чтобы подтвердить что вы владеете сайтом — нужно загрузить наш файл в его корень.
- В кабинете кассы нажмите «Скачать файл верификации» — скачается
kapsula-verify-<токен>.html - Загрузите файл в корень вашего сайта так чтобы он открывался по адресу:
https://ваш-сайт.com/kapsula-verify-<токен>.html - В кабинете нажмите «Проверить» — мы скачаем файл и убедимся что токен совпадает
- После успешной проверки касса автоматически уйдёт на модерацию
pending_moderation.Модерация
После верификации модератор проверяет вашу кассу: законность деятельности, соответствие правилам платёжной системы, наличие политики конфиденциальности и оферты на сайте.
- ⏱ Срок модерации: обычно 1–3 рабочих дня
- ✅ После одобрения статус становится
activeи автоматически генерируются API-ключ и webhook secret - ❌ В случае отказа — придёт уведомление с причиной, можно исправить и подать снова
API-ключи
После одобрения кассы в её карточке появятся два ключа:
| Ключ | Префикс | Назначение |
|---|---|---|
| API-ключ | pk_live_ | Авторизация запросов от вашего сервера к нашему API |
| Webhook secret | whsec_ | Проверка подписи входящих webhook-уведомлений |
Оба ключа можно пересоздать в любой момент — старые сразу перестают работать.
Безопасность
Платёжная система обрабатывает деньги — безопасность критична. Минимальные правила:
1. Хранение ключей
- Не коммитьте API-ключ и webhook secret в git. Используйте переменные окружения (
.envфайл, добавленный в.gitignore) - Не передавайте API-ключ на клиент (в JS-фронтенде, в мобильное приложение). Любой вызов к нашему API делается с вашего сервера
- Ротация: при подозрении на компрометацию сразу пересоздайте ключ через кабинет
2. HTTPS обязателен
- Webhook URL должен быть
https://— мы блокируемhttp://и приватные IP - Возвратные URL (success/fail) тоже должны быть HTTPS
3. Проверка подписи webhook
Каждый webhook от нас подписан HMAC-SHA256. Всегда проверяйте подпись — иначе злоумышленник может прислать вам поддельное «уведомление об оплате» и вы выдадите товар бесплатно.
Подробности с примерами на разных языках — в разделе Подпись webhook.
4. Идемпотентность
Webhook может прийти несколько раз для одного и того же события (например, при сбое сети мы повторим). На вашей стороне используйте payment.id для дедупликации — если уже обработали, не выдавайте товар повторно.
5. Сверяйте сумму
Перед выдачей товара в обработчике webhook'а проверяйте что payment.amount совпадает с ожидаемой суммой заказа. Это защищает от ситуации когда ваш сервис создал заказ на 1000 ₽, а кто-то заплатил 500 ₽.
6. Сверяйте order_id
Используйте поле order_id чтобы привязать платёж к вашему заказу. В webhook'е оно вернётся в payment.order_id. Это защищает от подмены.
Авторизация
Все запросы к публичному API требуют заголовок:
Authorization: Bearer pk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Если ключ невалиден или касса не активна — вернём 401 или 403.
Жизненный цикл платежа
Режим Hosted (рекомендуется)
Самый простой способ. Покупатель видит страницу оплаты на нашем домене (pay.kapsula.pro/p/...) — мы обеспечиваем UI, QR, обработку статусов.
- Создаёте платёж с
mode: "hosted" - В ответе получаете
payment_url - Перенаправляете покупателя туда (
302 Redirectили открываете в новом окне) - После оплаты мы:
- Шлём webhook на ваш
webhook_url - Перенаправляем покупателя на ваш
success_url(илиfail_urlпри отказе)
- Шлём webhook на ваш
Режим Host (свой UI)
Если нужно полностью кастомное оформление — используйте режим host. Мы вернём вам данные QR, дальше вы рисуете и обрабатываете всё сами.
- Создаёте платёж с
mode: "host" - В ответе получаете
qr.data(PNG в base64) иqr.link(deeplink наqr.nspk.ru) - Рисуете QR на своей странице:
<img src="data:image/png;base64,..." alt="СБП QR"> - Опционально — кнопка «Открыть в банке» с
qr.link(для оплаты с того же телефона) - Опрашиваете
GET /payment/:id/statusкаждые 2–3 секунды или просто ждёте webhook
Создание платежа
Параметры запроса
| Параметр | Тип | Обяз. | Описание |
|---|---|---|---|
amount | integer | да | Сумма в копейках. От 50000 (500 ₽) до 3000000 (30 000 ₽) |
currency | string | нет | Только RUB (по умолчанию) |
method | string | нет | Только sbp (по умолчанию) |
mode | string | нет | hosted (по умолчанию) или host |
order_id | string | нет | Ваш ID заказа (до 128 символов). Вернётся в webhook'е |
description | string | нет | Описание (до 500 символов). Видит покупатель |
customer_email | string | нет | Email покупателя (для будущих чеков) |
success_url | string | нет | Override для hosted-режима |
fail_url | string | нет | Override для hosted-режима |
Примеры запроса
curl -X POST https://api.kapsula.pro/v1/payment/create \
-H "Authorization: Bearer $KAPSULA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount": 50000,
"order_id": "order_12345",
"description": "Подписка Premium на месяц",
"mode": "hosted",
"customer_email": "user@example.com"
}'
// Node.js (fetch — встроен в Node 18+)
const res = await fetch('https://api.kapsula.pro/v1/payment/create', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.KAPSULA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount: 50000,
order_id: order.id,
description: 'Подписка Premium',
mode: 'hosted',
customer_email: order.customerEmail,
}),
});
if (!res.ok) throw new Error('Kapsula error: ' + await res.text());
const payment = await res.json();
// Сохраните payment.id у себя для последующего матчинга с webhook
await db.orders.update(order.id, { kapsula_payment_id: payment.id });
return res.redirect(payment.payment_url);
<?php
$ch = curl_init('https://api.kapsula.pro/v1/payment/create');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('KAPSULA_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'amount' => 50000,
'order_id' => $order_id,
'description' => 'Подписка Premium',
'mode' => 'hosted',
]),
]);
$resp = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($code !== 200) {
error_log("Kapsula error: $resp");
die('Payment error');
}
$payment = json_decode($resp, true);
header('Location: ' . $payment['payment_url']);
exit;
import os, requests
def create_kapsula_payment(amount_kop: int, order_id: str, description: str = ''):
r = requests.post(
'https://api.kapsula.pro/v1/payment/create',
headers={
'Authorization': f"Bearer {os.environ['KAPSULA_API_KEY']}",
'Content-Type': 'application/json',
},
json={
'amount': amount_kop,
'order_id': order_id,
'description': description,
'mode': 'hosted',
},
timeout=15,
)
r.raise_for_status()
return r.json()
# использование во Flask:
@app.route('/checkout/<order_id>')
def checkout(order_id):
order = Order.get(order_id)
payment = create_kapsula_payment(int(order.amount * 100), order.id, order.title)
order.kapsula_payment_id = payment['id']
order.save()
return redirect(payment['payment_url'])
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
type CreateReq struct {
Amount int `json:"amount"`
OrderID string `json:"order_id"`
Description string `json:"description"`
Mode string `json:"mode"`
}
type CreateResp struct {
ID string `json:"id"`
PaymentURL string `json:"payment_url"`
}
func createPayment(amount int, orderID string) (*CreateResp, error) {
body, _ := json.Marshal(CreateReq{amount, orderID, "", "hosted"})
req, _ := http.NewRequest("POST", "https://api.kapsula.pro/v1/payment/create", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("KAPSULA_API_KEY"))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer resp.Body.Close()
if resp.StatusCode != 200 { return nil, fmt.Errorf("kapsula: HTTP %d", resp.StatusCode) }
var p CreateResp
json.NewDecoder(resp.Body).Decode(&p)
return &p, nil
}
Ответ — Hosted
{
"id": "pay_a1b2c3d4e5f6789012345678abcdef01",
"status": "awaiting_confirmation",
"amount": 50000,
"currency": "RUB",
"method": "sbp",
"mode": "hosted",
"payment_url": "https://pay.kapsula.pro/p/pay_a1b2c3d4e5f6789012345678abcdef01",
"created_at": 1714478400000,
"expires_at": 1714480200000
}
Ответ — Host
{
"id": "pay_a1b2c3d4e5f6789012345678abcdef01",
"status": "awaiting_confirmation",
"amount": 50000,
"currency": "RUB",
"method": "sbp",
"mode": "host",
"qr": {
"data": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAA...",
"link": "https://qr.nspk.ru/AS10000XXXXXXXXX?type=02&bank=1000..."
},
"qr_pending": false,
"created_at": 1714478400000,
"expires_at": 1714480200000
}
qr_pending: true — QR ещё не пришёл от платёжной системы за 8 секунд (бывает редко). В этом случае дёрните GET /payment/:id через 1–2 секунды.Получение информации о платеже
Полная информация о платеже.
curl https://api.kapsula.pro/v1/payment/pay_a1b2c3d4e5f6... \
-H "Authorization: Bearer $KAPSULA_API_KEY"
Лёгкий запрос для polling — только статус и время оплаты:
{ "status": "success", "paid_at": 1714478560000 }
Статусы платежа
| Статус | Финальный? | Описание |
|---|---|---|
pending | Нет | Создан, ждём ответа от шлюза |
awaiting_confirmation | Нет | QR выдан, ждём оплату |
success | Да | Платёж успешно прошёл |
decline | Да | Отклонён банком |
expired | Да | QR истёк (30 минут не было оплаты) |
error | Да | Внутренняя ошибка |
Webhook-уведомления
При смене статуса на финальный (success, decline, expired, error) — мы шлём POST на ваш webhook_url (настраивается в кабинете кассы).
Структура события
POST https://your-site.com/your/webhook/path
Content-Type: application/json
X-Kapsula-Signature: 4a8b2d6e9f0a1b3c5d7e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c
{
"event": "payment.success",
"payment": {
"id": "pay_a1b2c3d4e5f6789012345678abcdef01",
"order_id": "order_12345",
"status": "success",
"amount": 50000,
"currency": "RUB",
"method": "sbp",
"paid_at": 1714478560000,
"error_code": null,
"error_message": null,
"created_at": 1714478400000
},
"timestamp": 1714478561234
}
События
payment.success— платёж прошёлpayment.decline— платёж отклонёнpayment.expired— истёк срок QRpayment.error— внутренняя ошибка обработки
Проверка подписи webhook
Алгоритм: HMAC-SHA256(webhook_secret, raw_body). Подпись приходит в hex в заголовке X-Kapsula-Signature.
Важно: подписывается сырое тело запроса как байты, до парсинга JSON. Иначе malformed JSON или whitespace-различия сломают проверку.
const express = require('express');
const crypto = require('crypto');
const app = express();
// ВАЖНО: для проверки подписи нужно raw body, не parsed JSON
app.post('/kapsula/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const sig = req.headers['x-kapsula-signature'];
const expected = crypto
.createHmac('sha256', process.env.KAPSULA_WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
// timing-safe сравнение
if (!sig || sig.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return res.status(401).send('Bad signature');
}
const event = JSON.parse(req.body);
handleKapsulaEvent(event);
res.json({ ok: true });
}
);
async function handleKapsulaEvent(event) {
const p = event.payment;
const order = await db.orders.findOne({ kapsula_payment_id: p.id });
if (!order || order.status === 'paid') return; // идемпотентность
if (event.event === 'payment.success' && p.amount === order.amount_kop) {
order.status = 'paid';
await order.save();
await sendOrderConfirmation(order);
}
}
<?php
$body = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_KAPSULA_SIGNATURE'] ?? '';
$secret = getenv('KAPSULA_WEBHOOK_SECRET');
$expected = hash_hmac('sha256', $body, $secret);
if (!hash_equals($expected, $sig)) {
http_response_code(401);
echo 'Bad signature';
exit;
}
$event = json_decode($body, true);
$p = $event['payment'];
// Идемпотентность + проверка суммы
$order = $pdo->query("SELECT * FROM orders WHERE kapsula_payment_id = '{$p['id']}'")->fetch();
if (!$order || $order['status'] === 'paid') {
echo json_encode(['ok' => true]);
exit;
}
if ($event['event'] === 'payment.success' && $p['amount'] == $order['amount_kop']) {
$pdo->exec("UPDATE orders SET status = 'paid' WHERE id = {$order['id']}");
sendOrderConfirmation($order);
}
echo json_encode(['ok' => true]);
import hmac, hashlib, json, os
from flask import Flask, request, jsonify
app = Flask(__name__)
SECRET = os.environ['KAPSULA_WEBHOOK_SECRET'].encode()
@app.post('/kapsula/webhook')
def kapsula_webhook():
raw = request.get_data() # сырое тело — НЕ request.json
sig = request.headers.get('X-Kapsula-Signature', '')
expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
return 'Bad signature', 401
event = json.loads(raw)
p = event['payment']
order = Order.query.filter_by(kapsula_payment_id=p['id']).first()
if not order or order.status == 'paid':
return jsonify(ok=True) # идемпотентность
if event['event'] == 'payment.success' and p['amount'] == order.amount_kop:
order.status = 'paid'
db.session.commit()
send_order_confirmation(order)
return jsonify(ok=True)
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"os"
)
func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
sig := r.Header.Get("X-Kapsula-Signature")
secret := []byte(os.Getenv("KAPSULA_WEBHOOK_SECRET"))
mac := hmac.New(sha256.New, secret)
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(sig), []byte(expected)) {
http.Error(w, "Bad signature", 401)
return
}
var event struct {
Event string `json:"event"`
Payment struct {
ID string `json:"id"`
OrderID string `json:"order_id"`
Amount int `json:"amount"`
Status string `json:"status"`
} `json:"payment"`
}
json.Unmarshal(body, &event)
// обработка...
w.Write([]byte(`{"ok":true}`))
}
Идемпотентность
Webhook может прийти несколько раз для одного и того же события (при retry). Чтобы не выдать товар дважды:
- Сохраняйте у себя
payment.id - В обработчике сначала проверяйте: «уже обработали этот
payment.idс этимstatus?» — если да, верните 200 без действий - Используйте транзакции БД с
SELECT ... FOR UPDATEили unique-индекс
Повторы и сбои
- До 3 попыток с задержкой: сразу, через 30 секунд, через 5 минут
- Считается успехом — HTTP-код
2xx - Таймаут запроса — 10 секунд
- Если все попытки неуспешны — статус доступен через
GET /payment/:id
Сценарий: интеграция на сайт
Типовой flow для интернет-магазина:
- Пользователь нажимает «Оплатить» — ваш сервер создаёт платёж через
POST /payment/createи редиректит покупателя наpayment_url - Покупатель оплачивает на странице Капсулы
- Мы шлём webhook на ваш
/webhook/kapsula - Ваш обработчик webhook'а помечает заказ как оплаченный, отправляет товар/чек
- Мы редиректим покупателя на ваш
success_url(там вы показываете «Спасибо!»)
success_url — это ненадёжный сигнал. Покупатель мог открыть URL вручную не оплатив. Источник истины — webhook.Сценарий: Telegram-бот
- Пользователь пишет в боте «/buy» — бот вызывает
POST /payment/createсmode: "host" - Бот отправляет покупателю QR-картинку (из
qr.data) и кнопку «Открыть в банке» (сqr.link) - Бот сохраняет
payment.idс привязкой кchat_id - На webhook от Капсулы — бот уведомляет пользователя «Оплата прошла» и выдаёт услугу
Пример отправки QR в Telegram (Python + aiogram)
import base64, io
from aiogram.types import BufferedInputFile, InlineKeyboardMarkup, InlineKeyboardButton
@dp.message(Command('buy'))
async def cmd_buy(message):
payment = create_kapsula_payment(50000, f"tg_{message.from_user.id}", "Premium")
qr_b64 = payment['qr']['data'].split(',')[1]
qr_bytes = base64.b64decode(qr_b64)
kb = InlineKeyboardMarkup(inline_keyboard=[[
InlineKeyboardButton(text='Открыть в банке', url=payment['qr']['link'])
]])
await message.answer_photo(
BufferedInputFile(qr_bytes, 'qr.png'),
caption=f"Оплатите {500} ₽ через СБП. QR действует 30 минут.",
reply_markup=kb,
)
Сценарий: WordPress / 1C-Битрикс / другие CMS
Готовых модулей пока нет. Подключение делается так же как обычный платёжный шлюз — нужно написать обработчик который:
- На «Оплатить» — вызывает
POST /payment/createи делает редирект - На свой webhook-эндпоинт — принимает уведомления и помечает заказ оплаченным
Если вам нужна помощь с интеграцией — напишите на support@kapsula.pro, поможем.
Кабинет: Главная
На главной кабинета отображается:
- Список ваших касс с быстрым доступом
- Сводная статистика по всем кассам
- Последние транзакции
Аналитика
На странице конкретной кассы (/shops/:id/manage) есть блок аналитики:
- Всего платежей — за всё время
- Успешных — количество и процент конверсии
- Принято — сумма успешных платежей
- За 24 часа — последние сутки
Транзакции
Список последних 25 платежей по кассе с фильтрацией по статусу. Каждая транзакция показывает:
- ID платежа в Капсуле и ваш
order_id - Описание и email покупателя
- Сумму, статус, дату создания
Через API: GET /api/shops/:id/payments?status=success&limit=50
Выплаты
Раздел в разработке. Сейчас принятые средства учитываются за вашей кассой; механизм автоматического вывода на расчётный счёт мерчанта — будет добавлен в ближайших обновлениях. Текущий процесс — связь с поддержкой для ручного вывода.
Ошибки
Все ошибки возвращаются в формате:
{ "error": "Текст ошибки на русском" }
| Код | Причина |
|---|---|
400 | Невалидные параметры (сумма вне диапазона, неверный email и т.д.) |
401 | API-ключ не передан или неверный |
403 | Касса не активна (на модерации, заблокирована) |
404 | Платёж не найден или принадлежит другой кассе |
429 | Превышен лимит запросов (rate limit) |
502 | Платёжный шлюз недоступен или вернул ошибку |
Лимиты
- Сумма платежа: 500 ₽ – 30 000 ₽ (СБП)
- Время жизни QR: 30 минут
- Метод: только СБП (карты — позже)
- Валюта: только RUB
- Rate limit API: 300 запросов / 15 минут с одного IP
- Webhook timeout: 10 секунд на ответ от вашего сервера
FAQ
webhook_url задан в настройках кассы, (2) URL HTTPS и доступен снаружи (не localhost), (3) ваш сервер отвечает 2xx за 10 секунд, (4) не блокирует ваш фаервол. В крайнем случае — статус всегда можно получить через GET /payment/:id.payment_url / QR.success. Если банк не успел подтвердить — придёт expired и потребуется создать новый платёж.GET /payment/:id (например, для всех заказов в статусе awaiting_confirmation старше N минут).Поддержка
В обращении укажите:
- ID кассы (виден в кабинете)
- ID платежа (если вопрос по конкретному платежу)
- Запрос/ответ от API (без API-ключа в открытом виде!)