S Subster

Recipes

You will build ready code for five common integration tasks into your server and app.

Before you start

Shared client

Server recipes call us through one function. isTemporary decides which failures to retry; why these, see "If our server did not answer".

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

Check access from your server

When: the app asks your server whether to open paid content, and the server asks us with a key. Check the entitlement recommends this path.

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

What to check:

Receive a webhook in full

When: your server stores who paid for what and learns of renewals, cancellations and refunds from our events. Add the handler address in the cabinet as a webhook endpoint: Webhooks, section "How to set up an endpoint".

When porting, keep two places unchanged:

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

What to check:

Daily reconciliation

We do not resend events lost to over an hour of your downtime or to a disabled address. Daily, reread the entitlement of everyone your database shows with access: this finds missed renewals and revocations. A missed grant is caught by the first recipe's access check: it asks us on every call.

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

Cancel a subscription with a button in the app

When: the customer turns off renewal with a native app button, not in the portal. First the project owner turns on "Subscription management via API": Payments → Rules and money → Customer money (Money from the app).

The app sends the price of the grant it showed the button on. Show it where willRenew is true and interval is day, week, month or year: a one-time purchase cannot be canceled.

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

What to check:

Check access from the app without the library

When: you have neither a server nor our library. The app asks the public address GET /public/entitlement, the "Entitlement request address" field in the cabinet. It answers without a key, with the same grant list as the keyed request.

The public address answers anyone who knows the identifier. Have a server? Gate paid content by the first recipe.

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)
    }
}

What to check:

If our server did not answer

When: before your first request to us.

What cameWhat to do
no answer within five seconds, connection dropped, 5xxretry; if that fails, decide by the last successful answer
429retry with a pause. Ceilings per address: Authentication, section "How many requests are allowed"
403 with the code bridge_temporarily_unavailablea failure on our side, your plan and keys are fine; retry as for 5xx
other 4xxdo not retry: an error in the request, key or scopes
// 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]);

Do not take paid content away because of our outage. The customer paid; our downtime is not their concern. Keep the last successful answer and during an outage open paid content by it until the grant expires. getAccess on the server and PaidAccess in the app already do this. An app that asks your server keeps the same reserve.

Retry the entitlement check and cancellation. Retry a saved-card charge only with the same idempotency key: Money from the app.

What to check:

Next