S Subster

Рецепты

Вы возьмёте готовый код под пять частых задач интеграции и встроите его в свой сервер и приложение.

Перед началом

Общий клиент

Серверные рецепты зовут нас через одну функцию. Какие сбои повторять, решает 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 };

Что проверить:

Принять вебхук целиком

Когда нужен: ваш сервер хранит, у кого что оплачено, и узнаёт о продлениях, отменах и возвратах нашими событиями. Адрес обработчика заводится в кабинете получателем событий — «Вебхуки», раздел «Как поднять получателя».

Два места в коде при переносе не меняйте:

// 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);

Что проверить:

Сверка раз в сутки

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

// 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 };

Что проверить:

Проверить доступ из приложения без библиотеки

Когда нужен: своего сервера нет, а нашу библиотеку вы не ставите. Приложение спрашивает открытый адрес 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)
    }
}

Что проверить:

Если наш сервер не ответил

Когда нужен: до первого запроса к нам.

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

Повторяйте проверку права и отмену. Списание с сохранённой карты повторяется только с тем же ключом идемпотентности — «Деньги из приложения».

Что проверить:

Дальше