@gamecore-api/sdk
v0.89.0
Published
TypeScript SDK for GameCore API — browser-safe, zero dependencies
Maintainers
Readme
@gamecore-api/sdk
TypeScript SDK for GameCore API — zero external dependencies, browser-safe.
Install
npm install @gamecore-api/sdkFor AI agents
If an AI coding assistant is reading this, start with AGENTS.md for a short orientation, then look at runnable code in examples/:
| File | Covers |
|---|---|
| examples/01-quickstart.ts | catalog → checkout → status polling |
| examples/02-locale-switching.ts | RU/EN switching: constructor, runtime, per-call |
| examples/03-error-handling.ts | GameCoreError, status/code patterns, retries |
| examples/04-webhook-verify.ts | HMAC verification with /server entry point |
| examples/05-currency-switching.ts | Display currency (RUB / USD / EUR / KZT / UAH / TRY …) |
Locale switching (RU / EN / ES / PT-BR)
The SDK ships with built-in multilingual support. Pass a locale
("ru" | "en" | "es" | "pt-br"; CMS articles accept the same set) to the
constructor and every catalog / CMS response comes back in that language.
The client sends an Accept-Language header on every request; for CATALOG
data the API resolves it against the unified catalog_translations store
and falls back to the base copy when a translation is missing. CMS guides
and articles are EXACT-locale since 0.69.0: an article absent in the
requested locale is a 404/null, never a Russian-body fallback.
import { GameCoreClient, type SdkLocale } from "@gamecore-api/sdk";
const gc = new GameCoreClient({
apiKey: "gc_live_...",
baseUrl: "https://api.gamecore-api.tech",
locale: "en", // default for this client instance
});
// Runtime switch — wire this to a storefront language toggle:
gc.setLocale("ru");
const game = await gc.catalog.getGame("afk-journey");
// game.name / game.description / game.shortDescription are now in RU
// Per-call override still wins over the client default:
const enGame = await gc.catalog.getGame("afk-journey", "en");Supported locales: "ru" (default when nothing is passed), "en", "es",
and "pt-br". CMS guides/articles accept the same set but resolve the
requested locale EXACTLY (no RU fallback since 0.69.0 — an untranslated
article is a 404).
Email language of the buyer (0.85.0)
const data = await gc.profile.getSettings({ locale: "en" });
// У поля ТРИ состояния, и «ключа нет» — НЕ то же самое, что `false`:
// ключа НЕТ → API старше этого контракта ⇒ читаем `language` как раньше
// (это выбор покупателя), а не подставляем язык страницы;
// `false` → ключа `language` в профиле нет, а `"ru"` в этом же ответе —
// серверный дефолт, а не выбор покупателя;
// `true` → покупатель язык писем ВЫБИРАЛ.
const stored =
"languageExplicit" in data
? data.languageExplicit
? data.language
: undefined
: data.language; // ключа нет = старый API ⇒ читаем как раньшеПоле приезжает ТОЛЬКО при явной локали. Его ОТСУТСТВИЕ значит «API старше
этого контракта» — тогда читать data.language как раньше (это выбор
покупателя), а не подставлять язык страницы. Без аргумента адрес запроса и
ответ прежние.
Per-locale UI config (0.85.0)
const cfg = await gc.site.getUIConfig("en"); // header?/footer?/trustPills?
if (cfg.footer) renderFooter(cfg.footer); // секции может не бытьБез аргумента метод возвращает прежний полный SiteUIConfig. С локалью —
SiteUIConfigForLocale, где каждая секция необязательна.
🔴 Отсутствующая секция — это ДАННЫЕ, а не сбой. «Для этого языка
оператор ничего не настраивал» и «API недоступен» различаются так:
первое — отсутствующий ключ в успешном ответе, второе — брошенная
GameCoreError. Подставлять на первый случай свои дефолты значит
напечатать чужой язык на странице.
Display currency (RUB / USD / EUR / KZT / UAH / TRY …)
Since 0.27.0 the SDK can quote product prices in any of the supported ISO-4217 currencies. The server uses live FX rates (cached 30 minutes) and rounds per currency convention (whole KZT/UAH/TRY/RUB, 2-decimal USD/EUR/GBP).
import { GameCoreClient, type SdkCurrency } from "@gamecore-api/sdk";
const gc = new GameCoreClient({
apiKey: "gc_live_...",
baseUrl: "https://api.gamecore-api.tech",
currency: "USD", // SDK adds X-Currency: USD on every catalog request
});
// Runtime switch — wire to a storefront currency picker:
gc.setCurrency("KZT");
const products = await gc.catalog.getProducts("free-fire");
console.log(products[0].price, products[0].currency); // 480, "KZT"
// Per-call override:
const usdProducts = await gc.catalog.getProducts("free-fire", { currency: "USD" });Supported currencies: RUB (default), USD, EUR, GBP, KZT,
UAH, TRY, BRL, ARS, INR, PLN, CZK. Every product
response carries the resolved currency field — read that instead of
tracking the requested code separately.
Checkout is unrelated: payment gateways still settle in RUB or USD
depending on the chosen paymentMethod. The display currency is a
catalog-side feature today.
What's new in 0.25.0
- Locale switching (RU / EN). New
localeoption onGameCoreClientplussetLocale/getLocaleruntime helpers. See section above. - New exported type
SdkLocale = "ru" | "en". - Backwards-compatible: clients that don't pass
localekeep the previous "server falls back to RU" behaviour.
What's new in 0.14.0
- BREAKING
giftCards.purchase()signature changed: first arg is nowamountRub(wasamountUsd). GiftCard payload fields renamed —amount_usd→amount_rub, addedcurrency,remainingBalance,expiresAt.denominationis now optional (legacy) cart.merge(items)— guest → authed cart handoff; new response fieldsquantity,addedAt,gameIconauth.linkEmail(email, password)— add email identity to an existing Telegram/VK accountprofile.getConversations / getConversationMessages / submitCode / submitScreenshot— in-profile support chatprofile.getPushPublicKey / subscribePush / unsubscribePush— web push subscriptionsreferrals.getPopularProducts(limit)+referrals.getPerformance({ from, to })— affiliate analyticssite.requestGame({ gameName, contact })— public "request a game" lead capturesite.getSitemapData()— data source forsitemap.xmlcheckout.completeWithBalancenow returns{ newBalance }
Quick Start
import { GameCoreClient } from "@gamecore-api/sdk";
const gc = new GameCoreClient({
apiKey: "gc_live_YOUR_KEY",
baseUrl: "https://api.gamecore-api.tech",
onAuthError: () => window.location.href = "/login",
});
// Browse catalog
const games = await gc.catalog.getGames();
const game = await gc.catalog.getGame("honkai-star-rail");
const products = await gc.catalog.getProducts("honkai-star-rail");
// Search
const results = await gc.catalog.search("roblox");Authentication
Telegram Auth — two paths, pick both
Two flavours, designed to coexist as two buttons in the UI:
1. Official Login Widget (fastest, needs official Telegram Web session)
// Mount the blue "Log in with Telegram" button into your own <div>.
// Bot username is pulled from /site/config; no hardcoding.
// BotFather /setdomain must point at your storefront's origin, or
// telegram.org refuses to render the widget.
const cleanup = await gc.auth.renderTelegramWidget({
container: document.querySelector("#tg-login")!,
size: "large",
onAuth: (user) => {
console.log("Logged in:", user.firstName);
window.location.href = "/profile";
},
onError: (err) => console.error(err),
});
// Later (React unmount, SPA route change):
cleanup();2. Bot-link flow (works in every Telegram client including 3rd-party)
const user = await gc.auth.loginViaTelegramBot({
onBotLinkReady: (botLink) => {
// Open in new tab — every TG client handles tg:// deep links.
// For desktop-only users you could also render botLink as a QR.
window.open(botLink, "_blank");
},
pollMs: 2000,
timeoutMs: 120_000,
});
console.log("Logged in:", user.firstName);Low-level pieces (for custom flows)
// Manual init+poll — equivalent to loginViaTelegramBot above
const { token, botLink } = await gc.auth.initTelegram();
window.open(botLink, "_blank");
const user = await gc.auth.pollTelegramStatus(token);
// Manual widget verification — when you render Telegram's <script> yourself
// and wire data-onauth to your own JS callback
const auth = await gc.auth.verifyTelegramWidget(telegramWidgetUser);
// Mini App (inside the Telegram bot's built-in WebApp)
const auth = await gc.auth.verifyMiniApp(window.Telegram.WebApp.initData);VK Auth
const { user } = await gc.auth.verifyVk(vkAccessToken);Email + Password
await gc.auth.register(email, password, firstName, ref);
await gc.auth.login(email, password);
await gc.auth.changePassword(currentPassword, newPassword);Link additional identity
// Add email to an existing Telegram/VK account
await gc.auth.linkEmail(email, password);
// Or link a VK access token
await gc.auth.linkVk(vkAccessToken);Session
const me = await gc.auth.getMe(); // Get current user
await gc.auth.logout(); // Clear session
const identities = await gc.auth.getIdentities(); // Linked providersCatalog
// All games
const games = await gc.catalog.getGames({ type: "game" });
// Homepage ranked games
const homepage = await gc.catalog.getHomepageGames();
// Single game with categories
const game = await gc.catalog.getGame("genshin-impact");
// Products (optionally filtered by category)
const products = await gc.catalog.getProducts("genshin-impact", "crystals");
// Products grouped by category
const grouped = await gc.catalog.getProductsGrouped("genshin-impact");
// Search (returns games + products)
const { games, products } = await gc.catalog.search("roblox");
// Search suggestions
const { suggestions } = await gc.catalog.searchSuggestions("rob");Region of a product: serverRegion + regionNote (0.88.0)
Most SKUs are either region-free or locked to ONE server, and serverRegion
carries the code for that ("ru", "sea", "br", null). Some supplier
segments state something a code cannot: Free Fire's
free-fire:diamonds-latam-us-na-exbr:110 sells on LATAM, US and NA but not
in Brazil. Those SKUs answer serverRegion: null and a finished, localized
phrase in regionNote.
// The chip. Exactly one of the two is ever filled, so no priority question
// arises — the note is emitted ONLY where `serverRegion` is null.
const chip =
formatServerRegion(product.serverRegion, locale) ?? product.regionNote ?? null;
// Region tabs / grouping: a note is its OWN region, not "fits everyone".
const regionKey =
product.serverRegion ?? (product.regionNote ? `note:${product.regionNote}` : "global");regionNote: string— render it.null— the API knows the field and this SKU has no scope note (the common case). ABSENT — an API older than 2026-09-15.- The matching caution already arrives through the existing
warningMessage(«Только для серверов LATAM / US / NA, кроме Бразилии…»), so a storefront that renders that banner needs no change for it. - 🔴 Copy, never identity.
regionNoteis localized prose: merging, sorting or keying cards on the STRING makes the set of products a buyer can see depend on the language they asked for. Key onserverRegion(and treat "has a note" as one extra bucket, as above). - The note follows the explicit
?locale=the SDK sends; a request without one answers Russian, because the catalog CDN caches those bodies by URL.
Cart & Checkout
// Cart
const items = await gc.cart.get();
// items: [{ id, productId, quantity, addedAt, gameIcon, ... }]
await gc.cart.add({ productId: 10, gameId: "roblox", gameName: "Roblox", productName: "800 Robux", price: 799, deliveryData: { username: "player123" }, quantity: 2 });
await gc.cart.remove(itemId);
await gc.cart.clear();
// Merge guest cart into authed session (on login)
await gc.cart.merge(guestItems);
// Preview first (authed buyers, balance rail): what will the wallet cover?
const quote = await gc.checkout.preview(
[
{
productId: 10,
gameId: "roblox",
gameName: "Roblox",
productName: "800 Robux",
deliveryData: { username: "player123" },
},
],
{ useBonus: true },
);
// «Заказ 289.59 ₽ · бонусами 27.69 ₽ · с баланса 8.89 ₽ · не хватает 253.01 ₽»
// quote.shortfallAmount is the whole-ruble topup that clears it (0 = nothing).
// Checkout (auto-generates a RANDOM idempotency key per call)
const checkout = await gc.checkout.create({
items: [
{
productId: 10,
gameId: "roblox",
gameName: "Roblox",
productName: "800 Robux",
deliveryData: { username: "player123" },
},
],
paymentMethod: "antilopay",
});
// Automated re-submit? Pass a STABLE key (and the preview's echoed flag) so a
// repeat replays the first payment instead of minting a second one:
// await gc.checkout.create(cart, { idempotencyKey: `chain:${topupCode}`, useBonus: quote.useBonus });
// Push rails (DukPay Pakistan) have NO payment page — check `flow` FIRST.
// On those, `paymentUrl` is present too (it points at OUR success page), so
// branching on the URL alone sends the buyer to a green "paid" screen before
// he has opened his wallet app.
if (checkout.payment?.flow === "push") {
// Stay on the page and poll — see "Push rails" below.
} else if (checkout.payment?.paymentUrl) {
window.location.href = checkout.payment.paymentUrl;
}
// Or pay with balance
await gc.checkout.completeWithBalance(checkout.payment.code);Push rails — no redirect (0.87.0)
DukPay's Pakistani rails (EasyPaisa / JazzCash) have no payment page. The provider pushes a request into the buyer's wallet app; the shop tab stays put.
const checkout = await gc.checkout.create(cart);
const payment = checkout.payment;
if (payment?.flow === "push") {
// Stay on the page. `paymentUrl` is set here too — it points at OUR success
// page, a fallback for storefronts that predate this flow — so branching on
// the URL would show a green "paid" screen before the buyer pays.
const wallet = payment.walletName ?? t("push.walletFallback");
// VISIBLE countdown: how long the buyer can still approve the request in
// his wallet app (~120s today).
const approveWithinSec = payment.pushValiditySeconds ?? 120;
// POLL deadline — longer, and NOT a payable window: past it the server's
// reconciliation job cancels an unpaid row on its next tick.
const deadline = payment.pushExpiresAt
? Date.parse(payment.pushExpiresAt)
: Date.now() + approveWithinSec * 1000;
showWaitingScreen({ wallet, approveWithinSec });
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
let settled = false;
while (Date.now() < deadline) {
await sleep(3000);
let status: CheckoutStatus;
try {
// Pass the page language explicitly — the client-wide pin is NOT
// substituted here; it localizes `walletName` only.
status = await gc.checkout.getStatus(payment.code, { locale: "en" });
} catch (err) {
// 429 is rate limiting, not a payment failure: back off and keep going.
if (err instanceof GameCoreError && err.status === 429) {
const retryAfterSec = Number(err.details?.retryAfter) || 5;
await sleep(retryAfterSec * 1000);
continue;
}
throw err;
}
// `flow` and both deadlines are rebuilt from the stored row, so a reloaded
// tab or a return from the wallet app finds the same screen.
const s = status.payment.status;
if (s === "completed" || s === "failed" || s === "cancelled") {
renderOutcome(s);
settled = true;
break;
}
}
if (!settled) {
// Deadline passed with the row still `pending`. Do NOT declare failure
// yourself — read the status once more, the job owns that verdict (it
// cancels the unpaid row on its next tick, up to ~45 s later).
}
}flowabsent ⇒"redirect". Every other rail, and every API older than 2026-09-15, answers that way.walletNameisstring | nullon both surfaces.nullmeans the label did not resolve — render your own dictionary's fallback phrase; the API deliberately ships no Russian constant that an English page could not translate.gatewayPaymentUrlisnullon a push row always,pendingincluded.- Two different clocks:
pushValiditySecondsis the approval window shown to the buyer,pushExpiresAtis the later poll/reconciliation deadline. Past it the server's job closes an unpaid row — the storefront never does. - Polling is rate-limited per payment and per site. A
429throws aGameCoreError— waitdetails.retryAfterSECONDS and keep polling; it is not a terminal state. checkout.push_already_pendingon create means the buyer already has a live push request. Send him back to that payment's waiting screen.
Bonus spend preview (0.67.0)
Bonus rubles are spend-capped per order, so the wallet total is NOT what the buyer can spend here. Ask before rendering — and before taking money:
import type { CheckoutPreview } from "@gamecore-api/sdk";
// Authed buyers only; a guest 401s (and fires onAuthError).
let quote: CheckoutPreview | null = null;
try {
quote = await gc.checkout.preview(cartItems, { useBonus });
} catch {
quote = null; // fail OPEN for display: show the raw wallet, keep Pay enabled
}
if (quote) {
render(`Заказ ${quote.total} ₽ · бонусами ${quote.bonusApplied} ₽ · с баланса ${quote.permanentApplied} ₽`);
// 0.68.0: null/undefined = nothing being spent here expires soon.
if (quote.bonusExpiringSoon) {
render(`из них ${quote.bonusExpiringSoon.amount} ₽ сгорят ${fmt(quote.bonusExpiringSoon.expiresAt)}`);
}
if (quote.shortfall > 0) showTopupButton(quote.shortfallAmount); // 0 = nothing to top up
}bonusAppliedis what WILL be drawn for this cart, not a ceiling — render it as a number, not as «до N ₽».bonusExpiringSoonis ONE deadline, not a weekly total:amountis what dies onexpiresAt(the nearest date within 7 days), so 10 ₽ dying Friday and 15 ₽ dying Sunday answers{amount: 10, expiresAt: Friday}. Render the two together or not at all.bonusExpiringSoon.amountis also a SLICE ofbonusApplied, never an extra sum — adding the two double-counts the buyer's money. It covers only the lots THIS cart spends, not the whole wallet.shortfallAmountcomes from the server; never re-roundshortfallyourself. It is0when the cart is affordable, unlike the 402's same-named field which is never below 1.- The preview writes nothing and has its own rate-limit bucket, so it can never starve the Pay button. Debounce it anyway.
- Always submit the flag with the payment — but pick the right one. After a
SUCCESSFUL preview send its echo; when the preview failed or was never called,
send the buyer's CURRENT selection explicitly:
gc.checkout.create(cart, { useBonus: quote?.useBonus ?? useBonus }). Never omit it: an unticked box must reach the wire asfalse, because the server reads an absent flag astrueand would spend the bonuses he declined.
Payment fees (3-mode)
A payment method may carry a processor fee. method.feeMode decides who pays:
| feeMode | Customer pays | Storefront shows a surcharge line? |
| ------------ | ------------- | ---------------------------------- |
| "included" | goods total | no |
| "absorb" | goods total | no (merchant eats the fee) |
| "surcharge"| goods + fee | yes |
Preview the surcharge at method-select time with estimateSurcharge() — a
bit-exact client-side copy of the server math, so the previewed number matches
the charge to the kopeck (no round-trip):
import { estimateSurcharge } from "@gamecore-api/sdk";
const preview = estimateSurcharge({
goods: cartTotal,
feePercent: method.feePercent,
feeFixed: method.feeFixed,
feeMode: method.feeMode,
});
// { applies: true, fee: 25, gross: 1025, change: 0 } for 2.5% on 1000 ₽
if (preview.applies) showSurchargeLine(preview.fee, preview.gross);
if (preview.change > 0) showChangeLine(preview.change); // «вернётся на баланс»gross is the whole-ruble amount the gateway bills — every gateway rail
ceils (integer-charge spec) and credits the remainder back as «сдача»
(change). That is true for included/absorb too, where no fee is added but
the ceil still applies. Pass integerCharge: false for the balance option
and only for it: balance is kopeck-exact with no fee and no change.
⚠️ goods is YOUR line-sum, so this helper cannot see a server-side bundle
discount or the buyer's active coupon. When a round-trip is acceptable, ask the
server for the quote instead — it prices the same cart the money path prices:
// ⚠️ `method.type` IS the option id on the wire — PaymentMethod has no `id`.
const q = await gc.checkout.preview(items, { paymentMethod: method.type });
if (q.charge) {
// { goodsTotal, feeMode, feeAmount, total, change }
showPayButton(q.charge.total); // exactly what the bank will charge
}After checkout.create(), the authoritative fee is on the response:
const fee = res.payment?.fee; // { mode, amount, goodsTotal } | undefined
// ⚠️ res.payment.total is GROSS (goods + surcharge). NEVER re-add fee.amount.
// Balance payments always carry { mode: "included", amount: 0 }.⚠️ Top-ups are always net.
topup.getPaymentMethods()surfacesfeeModefor display parity only — never draw a surcharge line on a top-up.
⚠️ Some rails need buyer data. A method may arrive with
buyerFields: ["phone"](today: DukPay Pakistan — EasyPaisa / JazzCash). Ask for it when that rail is SELECTED and pass it ascheckout.create({ …, buyerPhone }). No key = nothing extra is needed. Branch on the flag, never on the rail id.
Error handling — GameCoreError.details
Every non-2xx response throws a GameCoreError carrying status, code, and
details (the full parsed error body). Read machine fields off details
instead of parsing the message:
import { isMethodAmountLimitError } from "@gamecore-api/sdk";
try {
await gc.checkout.create({ ... });
} catch (e) {
if (isMethodAmountLimitError(e)) {
// e.details = { code, limit, currency: "RUB", label, methodId }
showLimit(e.details.limit, e.details.code); // "min N ₽" / "max N ₽"
}
}Checkout refusal codes (0.85.0)
A checkout refusal now also carries a MACHINE code in details.errorCode,
next to the human (Russian) sentence the API has always sent. Read the code
and print your OWN localized text — a non-Russian storefront must never echo
the server sentence.
import { CHECKOUT_ERROR_CODES, getCheckoutErrorCode } from "@gamecore-api/sdk";
import type { CheckoutErrorCode } from "@gamecore-api/sdk";
try {
await gc.checkout.create({ ... });
} catch (e) {
const code = getCheckoutErrorCode(e); // CheckoutErrorCode | undefined
showError(code ? t(code) : t("checkout.generic_refusal"));
}CHECKOUT_ERROR_CODES is the full list this SDK version knows (all of it in
the checkout.* namespace); CheckoutErrorCode is its union type — key your
dictionary off it and TypeScript will tell you when a code has no translation.
🔴 An UNKNOWN code reads as undefined, not as itself. A newer API may
refuse for a reason this SDK version has no name for; passing that raw string
through would hand your dictionary a key it lacks and print an identifier to
the buyer. Fall back to one generic localized sentence instead. Older APIs send
no errorCode at all — same branch, no special case.
Orders
const orders = await gc.orders.list();
const order = await gc.orders.get("ORD-A7X9K2");
await gc.orders.cancel("ORD-A7X9K2");
// Track order in real-time (SSE)
const source = gc.sse.trackOrder("ORD-A7X9K2");
source.addEventListener("order_status", (e) => {
const data = JSON.parse(e.data);
console.log("Status:", data.status, "Items:", data.items);
});One-click reorder (0.66.0)
Buy a FAILED item again in one tap — a new single-item order at today's price,
paid from balance. Render the button only while the item says so, and quote
reorderCurrentPrice (today's price), never the item's frozen price:
import { getInsufficientBalanceDetails } from "@gamecore-api/sdk";
const item = order.items[0];
if (item.reorderEligible) {
try {
// Omit `deliveryData` to retry with the original data; pass a correction
// (e.g. { login: "fixed" }) when `cancelReasonCode === "wrong_field"`.
const res = await gc.orders.reorderItem(order.code, item.id);
if (res.success) goToOrder(res.data.order_code); // 201
else if (res.code === "validation_error") markField(res.field); // 422
else if (res.error === "not_eligible") explain(res.reason); // 409
else retryLater(); // 503
} catch (e) {
// 402 is a THROW — same refusal body (and same reader) as checkout's.
const gap = getInsufficientBalanceDetails(e);
if (gap) showTopup(gap.shortfallAmount); // whole rubles, always ≥ 1
else throw e;
}
} else if (item.reorderedAsOrderCode) {
// Already bought again — link to the replacement instead of a button.
linkToOrder(item.reorderedAsOrderCode);
}Profile
// Balance (permanent + bonus with expiration details)
const balance = await gc.profile.getBalance();
// { permanent: 500, bonus: 100, total: 600, bonusDetails: [{ remaining: 100, expiresAt: "..." }] }
// Level status with progress
const level = await gc.profile.getLevelStatus();
// { currentLevel: 3, currentDiscount: 5, nextLevel: 4, requirements: { spending: { current: 5000, required: 10000 } } }
// Transaction history
const transactions = await gc.profile.getTransactions({ limit: 20 });
// Orders
const orders = await gc.profile.getOrders();
// Notifications
const notifications = await gc.profile.getNotifications();
const { count } = await gc.profile.getUnreadCount();
await gc.profile.markRead(notificationId);
await gc.profile.markAllRead();
// Support conversations (in-profile chat)
const conversations = await gc.profile.getConversations();
const messages = await gc.profile.getConversationMessages(conversationId);
await gc.profile.submitCode(conversationId, requestId, "ABC-123");
await gc.profile.submitScreenshot(conversationId, requestId, file);
// Web push subscriptions
const { publicKey } = await gc.profile.getPushPublicKey();
await gc.profile.subscribePush({ endpoint, keys: { p256dh, auth } });
await gc.profile.unsubscribePush(endpoint);Favorites
const favorites = await gc.favorites.list();
await gc.favorites.add(productId, "genshin-impact"); // gameId as slug string
await gc.favorites.remove(productId);Reviews
// Public reviews (paginated)
const { data, pagination } = await gc.reviews.listPublic({ limit: 10 });
// Stats
const stats = await gc.reviews.getStats("genshin-impact");
// { averageRating: 4.8, totalCount: 156,
// deliveryAverage: 4.6, deliveryCount: 92,
// supportAverage: 4.9, supportCount: 40 }
// Decide with the count, never the average: an unrated dimension arrives as
// { deliveryAverage: 0, deliveryCount: 0 }, not as null.
// Random reviews (for homepage)
const random = await gc.reviews.getRandom(5);
// Submit review (authenticated) — positional form, unchanged
const review = await gc.reviews.create(orderId, 5, "Great service!");
// …or the options form, the only one that carries the optional dimensions
// (0.62.0+). A skipped dimension is omitted, never sent as null.
const detailed = await gc.reviews.create(orderId, {
rating: 5,
deliveryRating: 4, // "how fast was delivery", optional
supportRating: 5, // "how did support do", optional
text: "Great service!",
});
// Guest submit (0.62.0+) — `rt` is the signed token from the review-request
// email, read off the order page URL. No account, no bonus.
const rt = new URLSearchParams(window.location.search).get("rt");
if (rt) {
await gc.reviews.createGuest(rt, { rating: 5, authorName: "Иван" });
}
// Orders waiting for review
const pending = await gc.reviews.getPending();Coupons & Gift Cards
// Apply coupon (LOGGED-IN buyer only — needs a session)
const result = await gc.coupons.apply("WELCOME10");
// { type: "bonus_balance", value: 10, code: "WELCOME10", bonusAmount: 100 }
await gc.coupons.remove();
const active = await gc.coupons.getActive();
// Gift cards
const card = await gc.giftCards.purchase(500, "Happy birthday!"); // amountRub + optional message
await gc.giftCards.redeem("GC-XXXX-XXXX-XXXX");
const mine = await gc.giftCards.getMine();
// { code, amount_rub, currency, remainingBalance, expiresAt, ... }Guest promo codes (0.89.0)
A buyer checking out without logging in can now use a promo code. The
logged-in flow above is unchanged — apply() / remove() still need a
session; this is a separate path with a separate field name.
import { getCouponRefusalCode, isGuestCouponsEnabled } from "@gamecore-api/sdk";
// 1. Show the field only if the shop has the feature on.
const cfg = await gc.site.getConfig();
if (!isGuestCouponsEnabled(cfg)) return; // modules.guestCoupons !== true
// 2. Live-check the code the guest typed (no session needed). Debounce it.
try {
await gc.coupons.preview(code, gameSlug); // → CouponResult
show("Код принят — скидка применится при оформлении");
} catch (e) {
const refusal = getCouponRefusalCode(e); // CouponRefusalCode | undefined
show(refusal ? t(refusal) : t("coupon.generic_refusal"));
}
// 3. Carry the code into the checkout body.
const out = await gc.checkout.create({
email,
items,
paymentMethod,
guestCouponCode: code, // ← NOT the deprecated `couponCode`
});
// 4. Verify what the server ACTUALLY applied.
const applied = out.payment?.couponApplied; // absent = no discount
if (applied) show(`−${applied.savedRub} ₽ по коду ${applied.code}`);🔴 preview() is not a promise of a discount. It has no idea who the buyer
is — it deliberately takes no email — so it never checks "this mailbox
already burned this code", daily caps or first-order-only. Making it answer
those would turn it into an oracle over other people's mailboxes. The only
authority on whether a code applies is the checkout. Render "code accepted, the
discount applies at checkout", never a re-priced cart.
🔴 payment.couponApplied is an acceptance criterion, not decoration.
Compare it with what you showed the buyer. Without that comparison the failure
mode "we silently charged full price" is indistinguishable from a normal
purchase and the buyer learns the truth from their bank. savedRub is
measured by the server — do not recompute it from percent: the coupon cuts
the MARKUP and stops at cost price, so "percent × total" will not match. The key
is present only on a guest checkout that carried a discount; its absence
means "no guest coupon discount", and it is never null.
🔴 A coupon refusal must never block the sale. No refusal creates or spends anything, so every one of them needs a one-click "order without the promo code" next to it. Otherwise a coupon budget cap turns into a lost sale.
⚠ Where you call preview() from decides its rate-limit budget. The
endpoint is metered per-IP and per-(code, IP). Call it from the buyer's
browser, or from a server whose egress IP the platform knows as a trusted
proxy and which forwards X-Forwarded-For. Otherwise both buckets degrade from
per-buyer to per-shop and the per-code ceiling starts refusing real buyers of
your own campaign code.
🔴 If you hold your own idempotencyKey, change it when the buyer changes the
code. checkout.create always sends a key header — yours if you passed one,
otherwise a fresh one per call. Reusing a held key after the buyer edited the
promo code replays the earlier answer, with the earlier price and the earlier
(or absent) couponApplied — so "the code applied" becomes a lie. No refusal
of a guest code leaves a held key behind (the early ones refuse before the
key is ever claimed, the later ones release the claim), so retrying right after
one is safe under the same key. ⚠ The one exception is the account-path pre-gate
coupon_payment_method_mismatch (a 400 for a LOGGED-IN buyer whose active
coupon is bound to another rail): it does not release the key, so retry that
one with a new key. Callers that bypass this SDK and send no key get a
server-derived one, and guestCouponCode feeds it: the same cart with and
without a code are two different purchase addresses.
Refusal codes (CouponRefusalCode, read with getCouponRefusalCode). The
marker says which surface can answer it — a code marked (checkout only) is
one preview() never returns, so do not wire a preview branch for it:
| Code | Means |
|---|---|
| coupon_guest_disabled | (checkout only) feature off for this shop — says nothing about the code; preview() answers 404 Module disabled instead |
| coupon_invalid | no such public guest code here (also: personal / bonus / not guest-enabled — collapsed on purpose) |
| coupon_inactive | (checkout only) the operator switched it off — preview() collapses this into coupon_invalid |
| coupon_not_started / coupon_expired | outside the validity window |
| coupon_exhausted | the coupon's budget is spent |
| coupon_already_used | (checkout only) this email already used this code |
| coupon_daily_cap | (checkout only) the ROLLING 24-hour share is spent, per code or per email. Do not promise "tomorrow" or "later": the slot frees when the oldest use ages out, and a cap configured as 0 refuses forever — say "unavailable, order without it" |
| coupon_velocity | (checkout only) rolling one-hour burst brake on the code, counting ALL of its uses. Same caveat as the daily cap: a brake configured as 0 refuses forever, so promise no waiting time |
| coupon_first_order_only | (checkout only) the code is for a first order only |
| coupon_payment_method_mismatch | (checkout only) the code is bound to another payment rail |
| coupon_guest_cap_exceeded | percent above the shop's ceiling (operator typo) |
| coupon_game_mismatch | (preview only) the code is for another game — checkout says coupon_no_effect instead |
| coupon_code_not_applicable | (checkout only) sent by a LOGGED-IN buyer — use coupons.apply() |
| coupon_no_effect | (checkout only) valid, but worth nothing on this cart |
| coupon_pricing_changed / coupon_guest_unavailable | (checkout only) the coupon changed mid-checkout — retry |
| coupon_pricing_conflict | (checkout only, ACCOUNT path) the cart priced against two different coupons — retry |
An unknown code reads as undefined (same rule as getCheckoutErrorCode) —
print one generic localized sentence, never the raw identifier.
🔴 Two of these refusals put the machine token in error, not in code —
coupon_payment_method_mismatch (from the transactional race) and
coupon_pricing_conflict. The SDK uses error as the error message, so a
storefront printing err.message would show the buyer the identifier itself.
getCouponRefusalCode looks there too (exact match against the closed list
only), which is one more reason to render from the code and never from
err.message.
Referrals
const stats = await gc.referrals.getStats();
const links = await gc.referrals.getLinks();
const link = await gc.referrals.createLink({ label: "YouTube", slug: "my-channel" });
await gc.referrals.updateLink(link.id, { label: "Updated" });
const linkStats = await gc.referrals.getLinkStats(link.id);
const commissions = await gc.referrals.getCommissions();
// Popular products referred by this user
const popular = await gc.referrals.getPopularProducts(10);
// Performance over a date range
const perf = await gc.referrals.getPerformance({
from: "2026-04-01",
to: "2026-04-30",
});
// Click beacon (since 0.46.0) — call server-side from the storefront's
// /ref/[code] route handler before redirecting. Public (no user auth);
// unknown refs still resolve OK, so fire-and-forget is safe.
await gc.referrals.trackClick("my-channel"); // code or slugGuest-checkout attribution: persist the ref (cookie) on landing and pass it
as ref in gc.checkout.create({ ... , ref }) — a new guest account
created by that checkout is attributed to the referrer (existing accounts
and authenticated buyers are unaffected).
Balance Top-up
const methods = await gc.topup.getPaymentMethods();
const topup = await gc.topup.create(500, "lava");
// Redirect to topup.paymentUrl
const status = await gc.topup.getStatus(topup.code);
// Automated top-up (checkout chain): a FRESH key per deliberate attempt, so the
// server's derived-key window can never report an old invoice as new money.
const gap = 254; // in the real chain: quote.shortfallAmount from checkout.preview()
// Generate the key ONCE per deliberate attempt and RETAIN it — do not inline
// crypto.randomUUID() in the call. Re-sending this attempt after a timeout must
// reuse attemptKey (that replays the invoice); a NEW key would mint a second one.
// Rotate only when the buyer deliberately tops up again.
const attemptKey = `chain:${crypto.randomUUID()}`;
const leg = await gc.topup.create(gap, "lava", { idempotencyKey: attemptKey });SSE (Real-time Events)
// Authenticated notification stream
const events = gc.sse.connectEvents();
events.addEventListener("notification", (e) => {
const { type, data } = JSON.parse(e.data);
// type: "order_completed", "balance_updated", "level_up", etc.
});
// Order tracking (no auth, uses order code)
const tracker = gc.sse.trackOrder("ORD-A7X9K2");SEO
// entityId is numeric (canonical game ID), not slug
const seo = await gc.seo.getContent("game", 1076, "ru");
// Schema is available for "product" page type
const schema = await gc.seo.getSchema("product", 42);
// Since 0.85.0 the schema can be computed for a language segment —
// `priceCurrency` and `areaServed` follow it. The client locale pin is
// NOT applied here: pass the segment explicitly or get the default one.
const enSchema = await gc.seo.getSchema("product", 42, { locale: "en" });Webhook Verification (Server-side)
// Import from server entrypoint (uses node:crypto)
import { verifyWebhookSignature, parseWebhookPayload } from "@gamecore-api/sdk/server";
const isValid = verifyWebhookSignature(
requestBody,
request.headers["x-webhook-signature"],
WEBHOOK_SECRET,
300, // freshness window in seconds (default). 0 disables it.
request.headers["x-webhook-timestamp"], // B2B events only; omit/undefined for storefront
);
if (isValid) {
const payload = parseWebhookPayload(requestBody);
console.log(payload.event, payload.data);
}GameCore signs webhooks with two compatible schemes and one call handles both:
- Storefront events (order/payment notifications) — body-only signature, the timestamp lives in the body. Pass nothing for the 5th argument.
- B2B events — the signature binds an
X-Webhook-Timestampheader. Pass that header value through (as above); for storefront requests it's simply absent.
Always forward the X-Webhook-Timestamp header when present. The freshness window
is the only built-in replay defense — dedupe on the X-Idempotency-Key header (or
the body event id) for full idempotency, and note that maxAgeSeconds = 0 turns
the window off for both schemes. (Requires SDK ≥ 0.37.0 to verify B2B webhooks.)
Utilities
import { convertPrice, formatPrice, generateIdempotencyKey } from "@gamecore-api/sdk";
const rub = convertPrice(1.99, 92.5); // 184.08
const formatted = formatPrice(rub, "RUB"); // "184 ₽"
const key = generateIdempotencyKey(); // UUID v4Next.js App Router Examples
Server Component (SSR catalog)
// app/catalog/page.tsx
import { GameCoreClient } from "@gamecore-api/sdk";
const gc = new GameCoreClient({
apiKey: process.env.GAMECORE_API_KEY!,
baseUrl: process.env.GAMECORE_API_URL!,
});
export default async function CatalogPage() {
const games = await gc.catalog.getGames();
return <GameGrid games={games} />;
}Client Component (cart)
"use client";
import { useEffect, useState } from "react";
import { gc } from "@/lib/gamecore-browser";
export function CartWidget() {
const [items, setItems] = useState([]);
useEffect(() => { gc.cart.get().then(setItems); }, []);
return <span>{items.length} items</span>;
}Route Handler (webhook)
// app/api/webhooks/gamecore/route.ts
import { verifyWebhookSignature, parseWebhookPayload } from "@gamecore-api/sdk/server";
export async function POST(req: Request) {
const body = await req.text();
const sig = req.headers.get("x-webhook-signature") || "";
if (!verifyWebhookSignature(body, sig, process.env.WEBHOOK_SECRET!)) {
return new Response("Unauthorized", { status: 401 });
}
const payload = parseWebhookPayload(body);
// Handle event...
return new Response("OK");
}API Namespaces
| Namespace | Methods |
|-----------|---------|
| gc.site | getConfig, getRates, getLegal, getStats, getSocialProof, getThemeConfig, getTranslations, getUIConfig, getCookieConsent, getCatalogSections, getBanners, getAnnouncementBar, getSitemapData, requestGame |
| gc.auth | initTelegram, pollTelegramStatus, verifyMiniApp, verifyTelegramWidget, getVkAuthUrl, vkCallback, verifyVk, register, login, changePassword, getMe, logout, getIdentities, linkVk, linkEmail, unlinkProvider, mergePreview, mergeConfirm |
| gc.catalog | getGames, getHomepageGames, getGame, getRecommendations, getCategories, getProducts, getProductsGrouped, search, searchSuggestions, getProduct |
| gc.cart | get, add, merge, sync, remove, clear |
| gc.checkout | preview, create, completeWithBalance, getPaymentMethods |
| gc.orders | list, get, getByPayment, cancelPreview, cancel, requestCancel, clientReady, requestRetry, setKeyState, reorderItem |
| gc.profile | getBalance, getLevelStatus, getTransactions, getOrders, getNotifications, getUnreadCount, markRead, markAllRead, getConversations, getConversationMessages, submitCode, submitScreenshot, getPushPublicKey, subscribePush, unsubscribePush |
| gc.favorites | list, add, remove |
| gc.coupons | apply, remove, validate, getActive, getActiveForGame, preview |
| gc.referrals | getStats, getLinks, createLink, updateLink, deleteLink, getLinkStats, getCommissions, getPopularProducts, getPerformance, trackClick |
| gc.reviews | listPublic, getStats, getRandom, getMine, getPending, create |
| gc.topup | getPaymentMethods, create, getStatus |
| gc.giftCards | purchase, redeem, check, getMine |
| gc.announcements | list, get |
| gc.analytics | recordView |
| gc.seo | getContent, getSchema |
| gc.sse | connectEvents, trackOrder |
| gc.packRequests | listGames, list, get, uploadImage, create, pay, cancel |
Browser vs Server
| Import | Environment | Includes |
|--------|-------------|----------|
| @gamecore-api/sdk | Browser + Node | Client, types, utilities |
| @gamecore-api/sdk/server | Node only | Webhook verification (uses node:crypto) |
