npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@gamecore-api/sdk

v0.89.0

Published

TypeScript SDK for GameCore API — browser-safe, zero dependencies

Readme

@gamecore-api/sdk

TypeScript SDK for GameCore API — zero external dependencies, browser-safe.

Install

npm install @gamecore-api/sdk

For 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 locale option on GameCoreClient plus setLocale / getLocale runtime helpers. See section above.
  • New exported type SdkLocale = "ru" | "en".
  • Backwards-compatible: clients that don't pass locale keep the previous "server falls back to RU" behaviour.

What's new in 0.14.0

  • BREAKING giftCards.purchase() signature changed: first arg is now amountRub (was amountUsd). GiftCard payload fields renamed — amount_usdamount_rub, added currency, remainingBalance, expiresAt. denomination is now optional (legacy)
  • cart.merge(items) — guest → authed cart handoff; new response fields quantity, addedAt, gameIcon
  • auth.linkEmail(email, password) — add email identity to an existing Telegram/VK account
  • profile.getConversations / getConversationMessages / submitCode / submitScreenshot — in-profile support chat
  • profile.getPushPublicKey / subscribePush / unsubscribePush — web push subscriptions
  • referrals.getPopularProducts(limit) + referrals.getPerformance({ from, to }) — affiliate analytics
  • site.requestGame({ gameName, contact }) — public "request a game" lead capture
  • site.getSitemapData() — data source for sitemap.xml
  • checkout.completeWithBalance now 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 providers

Catalog

// 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. regionNote is 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 on serverRegion (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).
  }
}
  • flow absent ⇒ "redirect". Every other rail, and every API older than 2026-09-15, answers that way.
  • walletName is string | null on both surfaces. null means 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.
  • gatewayPaymentUrl is null on a push row always, pending included.
  • Two different clocks: pushValiditySeconds is the approval window shown to the buyer, pushExpiresAt is 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 429 throws a GameCoreError — wait details.retryAfter SECONDS and keep polling; it is not a terminal state.
  • checkout.push_already_pending on 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
}
  • bonusApplied is what WILL be drawn for this cart, not a ceiling — render it as a number, not as «до N ₽».
  • bonusExpiringSoon is ONE deadline, not a weekly total: amount is what dies on expiresAt (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.amount is also a SLICE of bonusApplied, 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.
  • shortfallAmount comes from the server; never re-round shortfall yourself. It is 0 when 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 as false, because the server reads an absent flag as true and 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() surfaces feeMode for 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 as checkout.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 codecoupon_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 slug

Guest-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-Timestamp header. 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 v4

Next.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) |