# @gamecore-api/sdk

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

## Install

```bash
npm install @gamecore-api/sdk
```

## For AI agents

If an AI coding assistant is reading this, start with [**AGENTS.md**](./AGENTS.md)
for a short orientation, then look at runnable code in
[**examples/**](./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.

```ts
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).

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

```ts
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_usd` → `amount_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

```typescript
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)

```typescript
// 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)

```typescript
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)**

```typescript
// 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

```typescript
const { user } = await gc.auth.verifyVk(vkAccessToken);
```

### Email + Password

```typescript
await gc.auth.register(email, password, firstName, ref);
await gc.auth.login(email, password);
await gc.auth.changePassword(currentPassword, newPassword);
```

### Link additional identity

```typescript
// 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

```typescript
const me = await gc.auth.getMe();       // Get current user
await gc.auth.logout();                   // Clear session
const identities = await gc.auth.getIdentities(); // Linked providers
```

## Catalog

```typescript
// 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");
```

## Cart & Checkout

```typescript
// 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 });

// Redirect to payment page
if (checkout.payment?.paymentUrl) {
  window.location.href = checkout.payment.paymentUrl;
}

// Or pay with balance
await gc.checkout.completeWithBalance(checkout.payment.code);
```

### 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:

```typescript
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):

```typescript
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 } for a 2.5% surcharge on 1000 ₽
if (preview.applies) showSurchargeLine(preview.fee, preview.gross);
```

After `checkout.create()`, the **authoritative** fee is on the response:

```typescript
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.

### 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:

```typescript
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 ₽"
  }
}
```

## Orders

```typescript
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`:

```typescript
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

```typescript
// 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

```typescript
const favorites = await gc.favorites.list();
await gc.favorites.add(productId, "genshin-impact"); // gameId as slug string
await gc.favorites.remove(productId);
```

## Reviews

```typescript
// 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

```typescript
// Apply coupon
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, ... }
```

## Referrals

```typescript
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

```typescript
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)

```typescript
// 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

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

## Webhook Verification (Server-side)

```typescript
// 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

```typescript
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)

```typescript
// 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)

```typescript
"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)

```typescript
// 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 |
| `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) |
