Recipes
You will build ready code for five common integration tasks into your server and app.
Before you start
- Server recipes: Node 18+ and Express 4 or 5.
- Three values from the owner's cabinet, Project settings → App connection → For developers: "API address" (
https://api.subster.ai), the access keysk_live_…(Authentication) and the endpoint signing secret (Webhooks). The code reads them fromSUBSTER_API_BASE_URL,SUBSTER_API_KEYandSUBSTER_WEBHOOK_SECRET. - The buyer identifier (
guid) stored for the user after identification: How we recognise the customer.
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:
- An unknown identifier gets
404; the function returns "not paid", not an error. - While test mode is on, one made-up grant comes with level
testand thetestModeflag. Turn it off before launch: Troubleshooting, "Before going live". - Right after payment the list may be empty for a few seconds: let the app ask again.
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:
- Raw body. The signature covers the bytes as received, so the webhook route goes before
express.json(): a parsed and rebuilt body gives a different signature, and the handler refuses every event. - The entitlement is reread in full. Access events vary in shape: a revocation sometimes carries only the buyer identifier. So the handler builds no state from events: on each one it rereads the entitlement with
refreshAccessabove.
// 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:
- Press "Send a test event" at the endpoint: the handler answers
200, and the "Log" shows a row with that code. - The variable holds the endpoint signing secret, which signs our events to you. The "Signing secret for inbound requests" also starts with
whsec_, but signs your requests to us and will not match here. - On a wrong signature the handler answers
400, and we do not retry 4xx except429. While the secret is wrong, events are lost: check it with a test event before the first sale. - Hundreds of events a minute hit the 120-per-minute key ceiling. The handler answers
500, and the event comes again: up to eight tries within an hour.
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:
- Right after cancellation the grant comes with
will_renew: false. Show the customer: "Subscription active until <date>, renewal off". - Pressing again breaks nothing: a second cancellation also succeeds.
403withsubscription_manage_disabledmeans the switch is off. Retrying will not help: the project owner is needed.- When the paid period ends,
entitlement.revokedcomes, and the webhook handler removes access.
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:
- An unknown identifier gets an empty list, not an error, and the function answers
false. - After a successful check, turn on airplane mode: paid content stays open until the paid period ends.
- The public address accepts 60 requests a minute per IP address. Ask at launch and after payment, not on a timer.
If our server did not answer
When: before your first request to us.
| What came | What to do |
|---|---|
no answer within five seconds, connection dropped, 5xx | retry; if that fails, decide by the last successful answer |
429 | retry with a pause. Ceilings per address: Authentication, section "How many requests are allowed" |
403 with the code bridge_temporarily_unavailable | a failure on our side, your plan and keys are fine; retry as for 5xx |
other 4xx | do 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:
- Swap the API address for an unreachable one:
/me/accessanswers withstale: trueand the samepaidas before. - Where the customer waits on screen, five seconds, a pause and five more is the limit: one retry at most.
Next
- Check the entitlement: all grant fields and test mode.
- Webhooks: each event's contents, delivery retries and the log.
- Money from the app: the portal, saved-card charge and refund.
- Troubleshooting: an event did not arrive or access does not match.