Developers
v1
Stripe Connect Direct Charges

Payments integration guide

Канонический контракт для приёма карточных платежей через Sunrift Hub. Следуй пошагово — описанные правила формируют контракт; обходные пути ломают идемпотентность, бухгалтерию и доставку webhook'ов.

0 · Модель сущностей
Источник истины о статусе платежа — Hub, не Stripe. Никогда не дёргай Stripe API напрямую, только через Hub.
СущностьЧто это
payment_record_idUUID платежа в Hub. Канонический ключ. Известен сразу при создании checkout-сессии. Используй для идемпотентного зачисления.
checkout_session_idStripe cs_test_… / cs_live_….
client_reference_idТвой идентификатор бизнес-операции (например deposit:42). Эхом возвращается в webhook'е и в API статуса.
amount_minorСумма в минорных единицах ISO 4217 (центы, копейки). Для zero-decimal валют (JPY, KRW, VND) — целое в основных единицах.
currencyLowercase ISO 4217 (usd, eur, kzt…).
1 · Авторизация
Два типа Bearer-токенов: project API key (sk_live_… / sk_test_…) или OAuth client_credentials. Скоупы — payments:write, payments:read.

Для server-to-server проще использовать project API key: один долгоживущий секрет на окружение, ротируется в кабинете Hub. OAuth подходит, если у тебя несколько сервисов с разными правами или требуется ротация по времени.

OAuth access token кэшируй до истечения с запасом 60 сек. Не запрашивай новый токен на каждый HTTP-запрос — попадёшь под rate limit на /oauth/token.

2 · Создание checkout-сессии
POST /api/v1/payments/checkout-sessions · scope payments:write · Idempotency-Key обязателен (8..200 chars, стабильный per бизнес-операция).
import { randomUUID } from "node:crypto";

const res = await fetch("https://hub.sunrift-hub.com/api/v1/payments/checkout-sessions", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.HUB_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": `deposit:${depositId}`,   // стабильный per бизнес-операция
  },
  body: JSON.stringify({
    amount_minor: 1031,
    currency: "eur",
    success_url: "https://app.example.com/pay/success?ref=" + depositId,
    cancel_url:  "https://app.example.com/pay/cancel?ref="  + depositId,
    product_name: "Пополнение баланса",
    client_reference_id: `deposit:${depositId}`,
    customer_email: user.email,
    metadata: { deposit_id: String(depositId), user_id: user.id },
  }),
});
const { payment_record_id, url } = (await res.json()).data;
// СОХРАНИ payment_record_id в свою БД ДО редиректа
await db.deposits.update({ id: depositId, hub_payment_id: payment_record_id });
return res.redirect(url);

Сохрани payment_record_id в свою БД сразу после ответа

До редиректа пользователя. Это твой ключ для polling'а статуса и для джойна с webhook'ом. Без него — единственный путь найти платёж через client_reference_id, что хуже.

3 · Webhook — канонический путь подтверждения
~95% случаев платёж подтверждается webhook'ом до того, как пользователь вернётся на success-страницу. Polling — fallback, не основной канал.
Запрос: POST <your webhook_url> с заголовками x-hub-event, x-hub-delivery, x-hub-signature-alg: ed25519, x-hub-signature-kid, x-hub-signature-timestamp (unix sec), и x-hub-signature (base64url Ed25519 над ${ts}.${raw_body}). Публичный ключ — /api/public/jwks.json. Никакого общего секрета у вас не хранится.

Конверт payload

{
  "event_type": "payment.succeeded",
  "delivery_id": "8e2c1d4a-...-uuid",
  "payload": {
    "payment_record_id": "3e76c0ec-d582-4524-9fe4-e59cee2d22a2",
    "checkout_session_id": "cs_live_a1...",
    "payment_intent_id": "pi_3...",
    "client_reference_id": "deposit:42",
    "amount_minor": 1031,
    "currency": "eur",
    "status": "succeeded",
    "succeeded_at": "2026-06-05T08:25:10.400Z",
    "metadata": { "deposit_id": "42", "user_id": "uuid-..." }
  }
}

Проверка подписи + идемпотентное зачисление

// Express + raw body capture. Ed25519 + JWKS — no shared secret.
import express from "express";
import { createPublicKey, verify } from "node:crypto";

const JWKS_URL = "https://sunrift-hub.com/api/public/jwks.json";
let jwksCache: { fetchedAt: number; keys: Map<string, ReturnType<typeof createPublicKey>> } | null = null;
async function getKey(kid: string) {
  if (!jwksCache || Date.now() - jwksCache.fetchedAt > 5 * 60_000) {
    const jwks = await (await fetch(JWKS_URL)).json();
    const keys = new Map();
    for (const jwk of jwks.keys) keys.set(jwk.kid, createPublicKey({ key: jwk, format: "jwk" }));
    jwksCache = { fetchedAt: Date.now(), keys };
  }
  return jwksCache.keys.get(kid);
}

const app = express();
app.post("/hub/webhook",
  express.raw({ type: "application/json", limit: "1mb" }),
  async (req, res) => {
    const sig = req.header("x-hub-signature") ?? "";
    const kid = req.header("x-hub-signature-kid") ?? "";
    const ts  = req.header("x-hub-signature-timestamp") ?? "";
    if (!sig || !kid || !ts) return res.status(401).end();
    if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.status(401).end();
    const key = await getKey(kid);
    if (!key) return res.status(401).end();
    const sigBuf = Buffer.from(sig.replace(/-/g, "+").replace(/_/g, "/"), "base64");
    const msg = Buffer.concat([Buffer.from(`${ts}.`), req.body]); // raw Buffer
    if (!verify(null, msg, key, sigBuf)) return res.status(401).end("invalid signature");

    const { event_type, delivery_id, payload } = JSON.parse(req.body.toString());
    if (event_type === "payment.succeeded") {
      // Идемпотентность по payment_record_id, под FOR UPDATE
      await db.tx(async (tx) => {
        const dep = await tx.one(
          "SELECT id, status FROM deposits WHERE hub_payment_id = $1 FOR UPDATE",
          [payload.payment_record_id],
        );
        if (dep.status === "credited") return;
        await creditDeposit(tx, dep.id, payload.amount_minor, payload.currency);
        await tx.none("UPDATE deposits SET status = 'credited' WHERE id = $1", [dep.id]);
      });
    }
    res.status(200).end("ok");
  },
);

HMAC считается от RAW body

Не от JSON.parse → JSON.stringify. Любая нормализация (пробелы, порядок ключей, юникод) сломает подпись. Если фреймворк парсит body автоматически — отключи парсер на этом роуте или захвати raw отдельно.

Stripe BT race — что приходит и что игнорировать

Первая доставка payment.succeeded штатно приходит с net_amount_minor=null, processing_fee_minor=null, balance_transaction_id=null и валидным gross_amount_minor — это нормальный лаг Stripe BalanceTransaction (секунды–минуты). Не ставь deposit в awaiting_reconciliation и не жди BT. Кредитуй пользователю свою договорную сумму сразу (для wallet top-up — см. раздел 9 ниже про expected_net_minor; для обычного checkout — gross_amount_minor). Hub дошлёт follow-up payment.balance_transaction_ready с тем же hub_payment_id и заполненным триплетом — это informational событие для аудита/PnL, на пользовательский баланс оно влиять не должно.

4 · Retry policy

На любой не-2xx ответ или таймаут (>10 сек) Hub ретраит до 5 попыток с расписанием 1 мин → 5 мин → 30 мин → 2 ч → 6 ч. После последней попытки доставка помечается failed и видна в кабинете для ручного replay.

x-hub-delivery стабилен между ретраями — используй его если делаешь идемпотентность по delivery, а не по payment_record_id.

5 · Polling статуса — fallback для success-страницы
GET /api/v1/payments/{id} или GET /api/v1/payments?checkout_session_id=… · ?payment_id=… · ?client_reference_id=…. Scope payments:read.
// На success-странице, ПОСЛЕ редиректа от Stripe.
// Сначала проверь свой локальный статус — может, webhook уже прилетел.
async function pollStatus(paymentRecordId) {
  const started = Date.now();
  while (Date.now() - started < 60_000) {
    const r = await fetch(
      `https://hub.sunrift-hub.com/api/v1/payments/${paymentRecordId}`,
      { headers: { Authorization: `Bearer ${TOKEN}` } },
    );
    const { data } = await r.json();
    if (["succeeded", "failed", "canceled", "expired"].includes(data.status)) {
      return data;
    }
    const elapsed = Date.now() - started;
    await new Promise((r) => setTimeout(r, elapsed < 10_000 ? 1000 : 2500));
  }
  return { status: "pending" };   // не блокируй UI, дальше через webhook
}

Polling — это чтение, не действие

Зачисление делает только webhook-обработчик. Polling нужен лишь чтобы показать пользователю актуальный статус на success-странице. Если поллер увидел succeeded, а локально ещё pending — значит webhook вот-вот придёт, не дублируй запись.

6 · Rate limits и ошибки

По умолчанию 120 req/min per credential. Ответ 429 содержит Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset. Backoff 1s/2s/4s/8s/16s.

Полная таблица ошибок и кодов — в OpenAPI и в полном гайде ниже.

7 · Антипаттерны
❌ Так нельзя✅ Правильно
Зачислять депозит в success-page handler без проверки локального статусаSingle source of truth — твоя БД. Проверяй до записи.
Использовать payment_intent_id как ключ идемпотентностиpayment_record_id (известен сразу) или delivery_id
Генерировать новый Idempotency-Key при HTTP-ретраеОдин ключ на бизнес-операцию, ретраи с тем же ключом
Возвращать 200 из webhook handler до записи в БДСначала запись + commit, потом 200
Считать pending финальным статусомПосле expires_atexpired, не ретраить
HMAC от JSON.parse → JSON.stringifyHMAC от raw body до парсинга
Хранить webhook_secret в коде / репоТолько в секретах рантайма
8 · Чек-лист готовности к проду
  • Idempotency-Key генерируется per бизнес-операция, не per HTTP retry
  • payment_record_id сохраняется в локальную БД сразу после ответа
  • Webhook signature верифицируется через timingSafeEqual от raw body
  • Зачисление идемпотентно по payment_record_id с блокировкой строки
  • Webhook handler возвращает 2xx после успешного commit'а в БД
  • Polling включается только на success-странице, не как основной канал
  • Polling не делает write-операций (зачисление — только в webhook handler)
  • Нет прямых вызовов Stripe API в обход Hub
  • sk_live_… и webhook_secret хранятся в секретах
  • Обработаны 409 (stripe_account_not_connected / charges_not_enabled)
9 · Wallet top-up (deterministic-net v2)
POST /api/v1/hub-wallets/topup · scope hub_wallets:topup. Используй когда нужно гарантированно зачислить пользователю ровно ту сумму, которую ты ему пообещал — независимо от того, сколько с тебя удержит Stripe (домашняя карта, международная, FX-surcharge).

Контракт: ты передаёшь amount_minor (gross, сколько списать с карты) и expected_net_minor (net, сколько зачислить пользователю). Hub проверяет математику gross-up'а:

amount_minor = ceil((expected_net_minor + fee_fixed_minor) / (1 − fee_rate_bps / 10000))

Рекомендованный пресет: fee_rate_bps=400 (4%), fee_fixed_minor=30 (0.30 в валюте платежа). Это покрывает Stripe domestic (2.9% + 0.30) с маржой и Stripe international (3.9% + 0.30) почти в ноль. Все три v2-поля — all-or-nothing: либо передаёшь все три, либо ни одного (legacy режим, кредит идёт по gross).

КРИТИЧЕСКОЕ правило кредитования v2

В webhook-обработчике payment.succeeded кредитуй пользователю свой expected_net_minor (из своего deposit_request, или прочти эхо из payload.metadata.hub_expected_net_minor). Не используй net_amount_minor — это actual Stripe net, который меняется по типу карты, и пользователь увидит «обещали $15.00, получил $14.57». На этом обжёгся не один интегратор.

processing_fee_minor, balance_transaction_id и follow-up payment.balance_transaction_ready informational для Hub PnL/аудита, на пользовательский баланс не влияют. Можно сохранять в audit log или игнорировать.

Полный спек: docs/deposit-fee-spec.md. Внутренний учёт Hub'а (три-leg ledger entry +net → user_wallet, −gross → gateway:stripe, +(gross−net) → revenue:deposit_margin) — целиком на стороне Hub'а, тебе считать ничего не надо.

Полный технический референс
Расширенная версия этого гайда с таблицами ошибок, версионированием и подробностями — в репозитории интеграции: docs/integration-guide.md.