Рецепты
Вы возьмёте готовый код под пять частых задач интеграции и встроите его в свой сервер и приложение.
Перед началом
- Для серверных рецептов — Node 18 или новее и Express 4 или 5.
- Три значения из кабинета владельца проекта, Настройки проекта → Подключение приложения → Для разработчика: «Адрес API» (
https://api.subster.ai), ключ доступаsk_live_…(«Аутентификация») и секрет получателя событий («Вебхуки»). Код читает их из переменных окруженияSUBSTER_API_BASE_URL,SUBSTER_API_KEYиSUBSTER_WEBHOOK_SECRET. - Идентификатор покупателя (
guid), сохранённый за пользователем после опознания, — «Как мы узнаём покупателя».
Общий клиент
Серверные рецепты зовут нас через одну функцию. Какие сбои повторять, решает isTemporary; почему именно эти — в рецепте «Если наш сервер не ответил».
// subster.js — общий клиент для серверных рецептов (Node 18+)
const API = process.env.SUBSTER_API_BASE_URL; // «Адрес API» из кабинета
const KEY = process.env.SUBSTER_API_KEY; // ключ доступа sk_live_… или sk_test_…
class SubsterError extends Error {
constructor(status, code, message) {
super(message);
this.status = status; // null — ответа не было: тайм-аут или обрыв связи
this.code = code; // машинный код отказа из тела ответа
}
}
async function call(method, path, body) {
let res;
try {
res = await fetch(API + path, {
method,
headers: {
Authorization: `Bearer ${KEY}`,
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(5000),
});
} catch (err) {
throw new SubsterError(null, "network", err.message);
}
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new SubsterError(res.status, data.error?.code ?? "unknown", data.error?.message ?? res.statusText);
}
return data;
}
// Сбой, который есть смысл повторить: нет ответа, 5xx, 429 и временная недоступность у нас.
function isTemporary(err) {
return (
err instanceof SubsterError &&
(err.status === null ||
err.status >= 500 ||
err.status === 429 ||
err.code === "bridge_temporarily_unavailable")
);
}
module.exports = { call, isTemporary, SubsterError };
Проверить доступ со своего сервера
Когда нужен: приложение спрашивает ваш сервер, открывать ли платное, а сервер спрашивает нас с ключом. Этот путь советует «Проверить право доступа».
// access.js — проверка доступа со своего сервера
const express = require("express");
const { call, isTemporary } = require("./subster");
// guid → последний удачный ответ. В бою — таблица вашей базы.
const saved = new Map();
// Идентификатор покупателя, сохранённый за пользователем после опознания.
// req.user кладёт ваша авторизация — подставьте своё.
const guidOf = (req) => req.user.substerGuid;
// Спросить нас и сохранить ответ. Нет ответа — бросает ошибку.
async function refreshAccess(guid) {
let data;
try {
data = await call("GET", `/s2s/v1/entitlement?guid=${encodeURIComponent(guid)}`);
} catch (err) {
if (err.status !== 404) throw err;
data = { grants: [] }; // идентификатор неизвестен или из чужого проекта
}
const grants = data.grants
.filter((g) => g.status === "active")
.map((g) => ({
level: g.level,
priceId: g.price_id,
expiresAt: g.expires_at, // null — срока нет или он ещё не вычислен
willRenew: g.will_renew,
interval: g.interval, // day, week, month, year — у подписки
}));
const answer = { paid: grants.length > 0, grants, testMode: data.testMode === true };
saved.set(guid, answer);
return answer;
}
// То же, но при нашем сбое — последний удачный ответ без истёкших записей.
async function getAccess(guid) {
try {
return await refreshAccess(guid);
} catch (err) {
const last = saved.get(guid);
if (!isTemporary(err) || !last) throw err;
const grants = last.grants.filter((g) => !g.expiresAt || Date.parse(g.expiresAt) > Date.now());
return { ...last, paid: grants.length > 0, grants, stale: true };
}
}
const router = express.Router();
router.get("/me/access", async (req, res) => {
try {
res.json(await getAccess(guidOf(req)));
} catch (err) {
console.error("access check failed:", err.status, err.code);
res.status(503).json({ error: "try_later" });
}
});
module.exports = { router, refreshAccess, getAccess, guidOf };
Что проверить:
- На незнакомый идентификатор запрос с ключом отвечает
404— функция отдаёт «не оплачено», а не ошибку. - Пока в кабинете включён режим проверки, приходит одна выдуманная запись с уровнем
testи признакомtestMode. Выключите режим перед выходом на бой — «Диагностика», раздел «Перед выходом в эфир». - Сразу после возврата с оплаты список изредка пуст несколько секунд. Пусть приложение спросит ещё раз.
Принять вебхук целиком
Когда нужен: ваш сервер хранит, у кого что оплачено, и узнаёт о продлениях, отменах и возвратах нашими событиями. Адрес обработчика заводится в кабинете получателем событий — «Вебхуки», раздел «Как поднять получателя».
Два места в коде при переносе не меняйте:
- Сырое тело. Подпись считается по байтам тела, как они пришли. Поэтому маршрут вебхука подключается до
express.json(): тело, разобранное и собранное заново, даст другую подпись, и обработчик ответит отказом на каждое наше событие. - Право перечитывается целиком. События про доступ приходят в разной форме: отзыв, например, иногда несёт только идентификатор покупателя. Поэтому обработчик не собирает состояние из событий, а на каждое такое событие спрашивает право заново функцией
refreshAccessиз рецепта выше.
// webhook.js — приём наших событий (Express 4 или 5)
const crypto = require("crypto");
const express = require("express");
const { refreshAccess } = require("./access");
// Секрет получателя событий (whsec_…), а не «Секрет подписи входящих запросов».
const SECRET = process.env.SUBSTER_WEBHOOK_SECRET;
// Заголовок: t=<секунды>,v1=<hex>; в дни смены секрета v1 две.
function verifySignature(rawBody, header, secret, toleranceSec = 300) {
let t = "";
const signatures = [];
for (const part of header.split(",")) {
const [key, value] = part.trim().split("=");
if (key === "t") t = value;
if (key === "v1" && value) signatures.push(Buffer.from(value, "hex"));
}
const ts = Number(t);
if (!t || !Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > toleranceSec) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
return signatures.some((s) => s.length === expected.length && crypto.timingSafeEqual(s, expected));
}
// Идентификаторы обработанных событий. В бою — таблица с уникальным ключом.
const processed = new Set();
// На эти события перечитываем право целиком: форма и порядок событий тогда не важны.
const REFRESH_ON = new Set([
"entitlement.granted",
"entitlement.renewed",
"entitlement.revoked",
"subscription.plan_changed",
"refund.processed",
"chargeback.resolved",
]);
async function handleEvent(event) {
const guid = event.data?.guid;
// webhook.test приходит без покупателя; у событий подписки guid бывает null — покупателя не нашли
if (!guid) return;
if (REFRESH_ON.has(event.type)) {
await refreshAccess(guid); // без повторов: сбой → ответ 500, и мы пришлём событие снова
return;
}
if (event.type === "subscription.payment_failed") {
// списание не прошло: попросите покупателя обновить карту до grace_period_ends_at
console.log("payment failed", guid, event.data.grace_period_ends_at);
}
}
async function handler(req, res) {
const header = req.get("X-Web2App-Signature") || "";
if (!Buffer.isBuffer(req.body) || !verifySignature(req.body, header, SECRET)) {
return res.status(400).send("invalid signature");
}
const event = JSON.parse(req.body.toString("utf8"));
if (processed.has(event.id)) return res.sendStatus(200); // повтор — уже обработали
try {
await handleEvent(event);
} catch (err) {
console.error("webhook failed:", event.id, err.message);
return res.sendStatus(500); // на 5xx мы повторим доставку
}
processed.add(event.id);
res.sendStatus(200);
}
// Сырое тело нужно байт в байт: разобранный и собранный заново JSON даст другую подпись.
const webhook = [express.raw({ type: "application/json", limit: "1mb" }), handler];
module.exports = { webhook, verifySignature };
// server.js — порядок подключения решает, сойдётся ли подпись
const express = require("express");
const { webhook } = require("./webhook");
const access = require("./access");
const cancel = require("./cancel");
const app = express();
app.post("/subster/webhook", webhook); // 1. до express.json()
app.use(express.json()); // 2. всё остальное — после
app.use(access.router);
app.use(cancel.router);
app.listen(3000);
Что проверить:
- Нажмите «Отправить тестовое событие» у получателя в кабинете: обработчик отвечает
200, в «Журнале» появляется строка с этим кодом. - В переменной стоит секрет получателя: им мы подписываем события вам. Секрет подписи входящих запросов тоже начинается с
whsec_, но им вы подписываете свои запросы к нам, и здесь подпись с ним не сойдётся. - На неверную подпись обработчик отвечает
400, а отказы 4xx, кроме429, мы не повторяем. Пока секрет не тот, события теряются — проверьте его тестовым событием до первой продажи. - Сотни событий в минуту упрутся в потолок 120 запросов в минуту на ключ. Обработчик ответит
500, и событие придёт повтором: мы пробуем до восьми раз в течение часа.
Сверка раз в сутки
Событий, которые пришлись на простой вашего сервера дольше часа или на выключенный адрес, мы не досылаем. Раз в сутки перечитывайте право у тех, у кого по вашей базе доступ открыт: так найдутся пропущенные продления и отзывы. Пропущенную выдачу поймает проверка доступа из первого рецепта: она спрашивает нас при каждом обращении.
// resync.js — запускайте раз в сутки планировщиком вашего сервера
const { refreshAccess } = require("./access");
// guids — покупатели, у которых по вашей базе доступ открыт
async function resync(guids) {
for (const guid of guids) {
await refreshAccess(guid).catch((err) => console.error("resync failed:", guid, err.status, err.code));
await new Promise((resolve) => setTimeout(resolve, 600)); // ~100 запросов в минуту, потолок ключа — 120
}
}
module.exports = { resync };
Отменить подписку по кнопке в приложении
Когда нужен: покупатель отключает продление нативной кнопкой в приложении, а не в портале. Сначала владелец проекта включает переключатель «Управление подписками через API»: Платежи → Правила и деньги → Деньги покупателей («Деньги из приложения»).
Приложение присылает цену той записи, у которой показало кнопку. Показывайте кнопку у записи, где willRenew равно true, а interval — day, week, month или year: разовую покупку отменить нельзя.
// cancel.js — кнопка «Отменить подписку» в приложении
const express = require("express");
const { call } = require("./subster");
const { refreshAccess, guidOf } = require("./access");
const router = express.Router();
// Приложение присылает { priceId } той записи, у которой показало кнопку.
router.post("/me/subscription/cancel", async (req, res) => {
const guid = guidOf(req);
try {
const result = await call("POST", "/s2s/v1/subscription/cancel", {
guid,
priceId: req.body.priceId,
});
// result.status: "scheduled_cancel" или "already_canceled"; доступ живёт до result.expiresAt
await refreshAccess(guid).catch(() => {}); // запись уже приходит с will_renew: false
return res.json({ canceled: true, activeUntil: result.expiresAt });
} catch (err) {
if (err.code === "not_found") {
// нет действующей подписки с этой ценой — обновите экран
return res.status(404).json({ error: "nothing_to_cancel" });
}
if (err.status === 400) return res.status(400).json({ error: "bad_request" });
// subscription_manage_disabled, project_readonly, bridge_tier_expired — чинит владелец проекта;
// нет ответа, 5xx, 429 — повтор безопасен
console.error("cancel failed:", err.status, err.code);
return res.status(503).json({ error: "try_later" });
}
});
module.exports = { router };
Что проверить:
- Сразу после отмены запрос права отдаёт запись с
will_renew: false. Покажите покупателю: «Подписка активна до <дата>, продление отключено». - Повторное нажатие ничего не ломает: вторая отмена той же подписки тоже отвечает успехом.
- Отказ
403с кодомsubscription_manage_disabledзначит, что переключатель выключен. Повтор не поможет — нужен владелец проекта. - Когда оплаченный период кончится, придёт
entitlement.revoked, и обработчик из рецепта выше снимет доступ.
Проверить доступ из приложения без библиотеки
Когда нужен: своего сервера нет, а нашу библиотеку вы не ставите. Приложение спрашивает открытый адрес GET /public/entitlement — в кабинете это поле «Адрес запроса права». Он отвечает без ключа тем же списком записей, что и запрос с ключом.
Открытый адрес отвечает любому, кто знает идентификатор. Есть свой сервер — пускайте в платное по первому рецепту.
iOS
// iOS 15+: проверка доступа без библиотеки
import Foundation
enum PaidAccess {
static let apiBase = "https://api.subster.ai" // «Адрес API» из кабинета
private struct Response: Decodable { let grants: [Grant] }
private struct Grant: Decodable {
let status: String
let expiresAt: String?
enum CodingKeys: String, CodingKey { case status, expiresAt = "expires_at" }
}
/// true — открыть платное. Нет ответа от нас — решает последний удачный ответ.
static func isPaid(guid: String) async -> Bool {
let cacheKey = "subster.entitlement.\(guid)"
var components = URLComponents(string: apiBase + "/public/entitlement")!
components.queryItems = [URLQueryItem(name: "guid", value: guid)]
let request = URLRequest(url: components.url!, timeoutInterval: 10)
do {
let (data, response) = try await URLSession.shared.data(for: request)
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
if code == 200 {
UserDefaults.standard.set(data, forKey: cacheKey)
return hasActive(data, checkExpiry: false)
}
if code == 429 || code >= 500 {
return hasActive(UserDefaults.standard.data(forKey: cacheKey), checkExpiry: true)
}
return false // 400 — идентификатор не той длины
} catch { // нет сети, тайм-аут
return hasActive(UserDefaults.standard.data(forKey: cacheKey), checkExpiry: true)
}
}
private static func hasActive(_ data: Data?, checkExpiry: Bool) -> Bool {
guard let data, let body = try? JSONDecoder().decode(Response.self, from: data) else { return false }
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return body.grants.contains { grant in
guard grant.status == "active" else { return false }
guard checkExpiry, let raw = grant.expiresAt, let end = iso.date(from: raw) else { return true }
return end > Date()
}
}
}
Android
// Android: проверка доступа без библиотеки. Нужны kotlinx-coroutines и Android 8+ (java.time).
import android.content.Context
import android.net.Uri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import java.time.Instant
object PaidAccess {
private const val API_BASE = "https://api.subster.ai" // «Адрес API» из кабинета
/** true — открыть платное. Нет ответа от нас — решает последний удачный ответ. */
suspend fun isPaid(context: Context, guid: String): Boolean = withContext(Dispatchers.IO) {
val prefs = context.getSharedPreferences("subster", Context.MODE_PRIVATE)
val cacheKey = "entitlement.$guid"
val url = Uri.parse("$API_BASE/public/entitlement").buildUpon()
.appendQueryParameter("guid", guid).build().toString()
try {
val conn = URL(url).openConnection() as HttpURLConnection
conn.connectTimeout = 10_000
conn.readTimeout = 10_000
try {
val code = conn.responseCode
when {
code == 200 -> {
val body = conn.inputStream.bufferedReader().use { it.readText() }
prefs.edit().putString(cacheKey, body).apply()
hasActive(body, checkExpiry = false)
}
code == 429 || code >= 500 -> hasActive(prefs.getString(cacheKey, null), checkExpiry = true)
else -> false // 400 — идентификатор не той длины
}
} finally {
conn.disconnect()
}
} catch (e: IOException) { // нет сети, тайм-аут
hasActive(prefs.getString(cacheKey, null), checkExpiry = true)
}
}
private fun hasActive(body: String?, checkExpiry: Boolean): Boolean {
if (body == null) return false
return runCatching {
val grants = JSONObject(body).getJSONArray("grants")
val now = Instant.now()
(0 until grants.length()).any { i ->
val grant = grants.getJSONObject(i)
grant.getString("status") == "active" &&
(!checkExpiry || grant.isNull("expires_at") ||
Instant.parse(grant.getString("expires_at")).isAfter(now))
}
}.getOrDefault(false)
}
}
Что проверить:
- На незнакомый идентификатор приходит пустой список, а не ошибка, и функция отвечает
false. - После удачной проверки включите авиарежим: платное остаётся открытым до конца оплаченного периода.
- Открытый адрес принимает 60 запросов в минуту с одного IP-адреса. Спрашивайте на запуске и после возврата с оплаты, а не по таймеру.
Если наш сервер не ответил
Когда нужен: до первого запроса к нам.
| Что пришло | Что делать |
|---|---|
нет ответа за пять секунд, обрыв связи, 5xx | повторить; не вышло — решать по последнему удачному ответу |
429 | повторить с паузой. Потолки по адресам — «Аутентификация», раздел «Сколько запросов можно» |
403 с кодом bridge_temporarily_unavailable | сбой на нашей стороне, с тарифом и ключами всё в порядке; повторить, как 5xx |
другие 4xx | не повторять: ошибка в запросе, ключе или правах |
// retry.js — повтор запросов, которые можно повторять: проверка права и отмена
const { isTemporary } = require("./subster");
async function withRetry(fn, pausesMs = [1000]) {
for (let attempt = 0; ; attempt++) {
try {
return await fn();
} catch (err) {
if (!isTemporary(err) || attempt >= pausesMs.length) throw err;
const jitter = Math.random() * 300; // чтобы серверы не повторяли хором
await new Promise((resolve) => setTimeout(resolve, pausesMs[attempt] + jitter));
}
}
}
module.exports = { withRetry };
// В запросе, который ждёт покупатель, — один повтор, в фоновой задаче — больше:
// await withRetry(() => refreshAccess(guid));
// await withRetry(() => refreshAccess(guid), [1000, 5000, 30000]);
Не отнимайте платное из-за нашего сбоя. Покупатель заплатил, и наша недоступность — не его забота. Храните последний удачный ответ и при сбое открывайте платное по нему, пока не кончился срок записи. Так уже устроены getAccess на сервере и PaidAccess в приложении. Приложение, которое спрашивает ваш сервер, держит такой же запас у себя.
Повторяйте проверку права и отмену. Списание с сохранённой карты повторяется только с тем же ключом идемпотентности — «Деньги из приложения».
Что проверить:
- Подставьте вместо адреса API недоступный: ответ вашего
/me/accessприходит сstale: trueи тем жеpaid, что до сбоя. - Покупатель ждёт на экране: пять секунд ожидания, пауза и ещё пять — это предел, больше одного повтора там не ставьте.
Дальше
- Проверить право доступа — все поля записи о доступе и режим проверки.
- Вебхуки — состав каждого события, повторы доставки и журнал.
- Деньги из приложения — портал, списание с сохранённой карты и возврат.
- Диагностика — событие не пришло или доступ не сошёлся.