Подключить

Документация Капсула

Платёжный шлюз для приёма платежей через СБП на сайтах и в ботах. Полное руководство — от регистрации до интеграции и проверки подписи webhook'ов.

О платформе

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

Сейчас поддерживается:

В планах: банковские карты (когда будут пройдены требования PCI DSS).

💡 Базовый URL API: https://api.kapsula.pro/v1

Быстрый старт

Минимальная интеграция — 5 шагов и около 10 минут.

  1. Зарегистрируйтесьkapsula.pro/auth
  2. Создайте кассу в кабинете → Кассы. Укажите сайт или бота
  3. Пройдите верификацию (для сайтов — загрузка HTML-файла) и дождитесь модерации
  4. Получите API-ключ вида pk_live_... на странице кассы
  5. Сделайте первый запрос:
    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 сейчас не требуется — после регистрации вы сразу попадаете в кабинет.

Создание кассы

«Касса» — это отдельная точка приёма платежей: один сайт или один бот. У вас может быть сколько угодно касс.

Перейдите в Кассы → Создать кассу. Заполните 2 шага:

Шаг 1. Тип кассы

Шаг 2. Реквизиты

Верификация сайта

Чтобы подтвердить что вы владеете сайтом — нужно загрузить наш файл в его корень.

  1. В кабинете кассы нажмите «Скачать файл верификации» — скачается kapsula-verify-<токен>.html
  2. Загрузите файл в корень вашего сайта так чтобы он открывался по адресу: https://ваш-сайт.com/kapsula-verify-<токен>.html
  3. В кабинете нажмите «Проверить» — мы скачаем файл и убедимся что токен совпадает
  4. После успешной проверки касса автоматически уйдёт на модерацию
⚠ Для ботов верификация не требуется — статус сразу pending_moderation.

Модерация

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

API-ключи

После одобрения кассы в её карточке появятся два ключа:

КлючПрефиксНазначение
API-ключpk_live_Авторизация запросов от вашего сервера к нашему API
Webhook secretwhsec_Проверка подписи входящих webhook-уведомлений

Оба ключа можно пересоздать в любой момент — старые сразу перестают работать.

Безопасность

Платёжная система обрабатывает деньги — безопасность критична. Минимальные правила:

1. Хранение ключей

2. 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.

Жизненный цикл платежа

[Ваш сервер] [Капсула] [6Tech / СБП] │ │ │ │ POST /payment/create │ │ ├─────────────────────────────────>│ │ │ │ POST /v1/payment/pay │ │ ├─────────────────────────────>│ │ │<──── request_uuid ───────────┤ │ │<──── callback (QR данные) ───┤ │<─── 200 + payment_url / qr ──────┤ │ │ │ │ │ [Покупатель сканирует QR] │ │ │ │ │ │<──── callback (Success) ─────┤ │<─── webhook payment.success ─────┤ │ │ │ │ ▼ ▼ ▼

Режим Hosted (рекомендуется)

Самый простой способ. Покупатель видит страницу оплаты на нашем домене (pay.kapsula.pro/p/...) — мы обеспечиваем UI, QR, обработку статусов.

  1. Создаёте платёж с mode: "hosted"
  2. В ответе получаете payment_url
  3. Перенаправляете покупателя туда (302 Redirect или открываете в новом окне)
  4. После оплаты мы:
    • Шлём webhook на ваш webhook_url
    • Перенаправляем покупателя на ваш success_url (или fail_url при отказе)
✅ Hosted-режим не требует реализации UI оплаты на вашей стороне — идеально для быстрого старта.

Режим Host (свой UI)

Если нужно полностью кастомное оформление — используйте режим host. Мы вернём вам данные QR, дальше вы рисуете и обрабатываете всё сами.

  1. Создаёте платёж с mode: "host"
  2. В ответе получаете qr.data (PNG в base64) и qr.link (deeplink на qr.nspk.ru)
  3. Рисуете QR на своей странице:
    <img src="data:image/png;base64,..." alt="СБП QR">
  4. Опционально — кнопка «Открыть в банке» с qr.link (для оплаты с того же телефона)
  5. Опрашиваете GET /payment/:id/status каждые 2–3 секунды или просто ждёте webhook

Создание платежа

POST /api/v1/payment/create

Параметры запроса

ПараметрТипОбяз.Описание
amountintegerдаСумма в копейках. От 50000 (500 ₽) до 3000000 (30 000 ₽)
currencystringнетТолько RUB (по умолчанию)
methodstringнетТолько sbp (по умолчанию)
modestringнетhosted (по умолчанию) или host
order_idstringнетВаш ID заказа (до 128 символов). Вернётся в webhook'е
descriptionstringнетОписание (до 500 символов). Видит покупатель
customer_emailstringнетEmail покупателя (для будущих чеков)
success_urlstringнетOverride для hosted-режима
fail_urlstringнет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 секунды.

Получение информации о платеже

GET /api/v1/payment/:id

Полная информация о платеже.

curl https://api.kapsula.pro/v1/payment/pay_a1b2c3d4e5f6... \
  -H "Authorization: Bearer $KAPSULA_API_KEY"
GET /api/v1/payment/:id/status

Лёгкий запрос для 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
}

События

Проверка подписи webhook

Обязательно проверяйте подпись. Без проверки злоумышленник может прислать поддельный 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). Чтобы не выдать товар дважды:

Повторы и сбои

💡 Делайте обработчик webhook'а быстрым — отвечайте 200 сразу, тяжёлую работу делайте в фоне (очередь, async). Иначе таймаут.

Сценарий: интеграция на сайт

Типовой flow для интернет-магазина:

  1. Пользователь нажимает «Оплатить» — ваш сервер создаёт платёж через POST /payment/create и редиректит покупателя на payment_url
  2. Покупатель оплачивает на странице Капсулы
  3. Мы шлём webhook на ваш /webhook/kapsula
  4. Ваш обработчик webhook'а помечает заказ как оплаченный, отправляет товар/чек
  5. Мы редиректим покупателя на ваш success_url (там вы показываете «Спасибо!»)
⚠ Не выдавайте товар на странице success_url — это ненадёжный сигнал. Покупатель мог открыть URL вручную не оплатив. Источник истины — webhook.

Сценарий: Telegram-бот

  1. Пользователь пишет в боте «/buy» — бот вызывает POST /payment/create с mode: "host"
  2. Бот отправляет покупателю QR-картинку (из qr.data) и кнопку «Открыть в банке» (с qr.link)
  3. Бот сохраняет payment.id с привязкой к chat_id
  4. На 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

Готовых модулей пока нет. Подключение делается так же как обычный платёжный шлюз — нужно написать обработчик который:

Если вам нужна помощь с интеграцией — напишите на support@kapsula.pro, поможем.

Кабинет: Главная

На главной кабинета отображается:

Аналитика

На странице конкретной кассы (/shops/:id/manage) есть блок аналитики:

Транзакции

Список последних 25 платежей по кассе с фильтрацией по статусу. Каждая транзакция показывает:

Через API: GET /api/shops/:id/payments?status=success&limit=50

Выплаты

Раздел в разработке. Сейчас принятые средства учитываются за вашей кассой; механизм автоматического вывода на расчётный счёт мерчанта — будет добавлен в ближайших обновлениях. Текущий процесс — связь с поддержкой для ручного вывода.

Ошибки

Все ошибки возвращаются в формате:

{ "error": "Текст ошибки на русском" }
КодПричина
400Невалидные параметры (сумма вне диапазона, неверный email и т.д.)
401API-ключ не передан или неверный
403Касса не активна (на модерации, заблокирована)
404Платёж не найден или принадлежит другой кассе
429Превышен лимит запросов (rate limit)
502Платёжный шлюз недоступен или вернул ошибку

Лимиты

FAQ

Можно ли тестировать без реальных платежей?
Сейчас нет — sandbox-окружение в разработке. Тестировать можно с реальной картой на минимальной сумме (500 ₽), потом запросить возврат через поддержку.
Какая комиссия?
Текущая ставка комиссии видна в кабинете для каждой кассы. По умолчанию — 2% от суммы платежа. Уточняйте у поддержки если ваш профиль предполагает льготные условия.
Что делать если webhook не пришёл?
Проверьте: (1) webhook_url задан в настройках кассы, (2) URL HTTPS и доступен снаружи (не localhost), (3) ваш сервер отвечает 2xx за 10 секунд, (4) не блокирует ваш фаервол. В крайнем случае — статус всегда можно получить через GET /payment/:id.
Можно ли изменить сумму платежа после создания?
Нет. Создайте новый платёж и используйте новый payment_url / QR.
Что если QR истёк а покупатель его уже отсканировал?
Если оплата была завершена в банке до истечения — мы получим callback и установим статус success. Если банк не успел подтвердить — придёт expired и потребуется создать новый платёж.
Поддерживаются ли возвраты (refund)?
Сейчас возврат делается через поддержку вручную. API для refund будет добавлен в ближайших версиях.
Можно ли использовать один API-ключ для нескольких сайтов?
Технически да, но мы рекомендуем создавать отдельную кассу для каждого сайта/проекта — это даёт раздельную аналитику, упрощает модерацию и компрометация одного ключа не затрагивает остальные.
Что если мой сайт DDoS'ят и мы не успеваем отвечать на webhook?
Мы повторим webhook 3 раза (через 0с / 30с / 5мин). Если все попытки неуспешны — поднимайте сервис и подтягивайте статусы через GET /payment/:id (например, для всех заказов в статусе awaiting_confirmation старше N минут).

Поддержка

📧 support@kapsula.pro

В обращении укажите:

Удачи в подключении! Если что-то непонятно — пишите, доработаем документацию.