Skip to content

Node.js SDK — @gerege/miniapp-node

Use this page to call the Gerege App API from your Node.js backend through the SDK (mini app launches, payments, refunds, settlements, terminals, sub-merchants) and to verify webhook signatures. About 15 minutes from install to the first call. You need: Node.js ≥ 20, a test API key from the console (gk_test_… / gks_test_…, or the old mapp_test_… / gma_cs_…), the webhook secret (whsec_…).

Version 0.2.0. No runtime dependencies (the global fetch, node:crypto), ESM only, with TypeScript types. The SDK logs nothing and does not retry by itself.

Servers only

The client secret, access/refresh tokens and the webhook secret never go into a browser, a mobile app, a terminal, a log or a URL.

Install

The package is not published to npm yet (it will be when the SDK moves to its own repository). For now its source is available from Gerege on request (Support) — then build it into a .tgz and install that:

# In the SDK's source (sdk/miniapp-node)
npm ci && npm run build && npm pack          # → gerege-miniapp-node-0.2.0.tgz

# In your project
npm install ./gerege-miniapp-node-0.2.0.tgz

npm test reads test vectors generated from the platform's own code (signatures, event bodies, routes) — the SDK and the server are tested against the same data.

Creating a client

import { GeregeMiniApp, GeregeError } from "@gerege/miniapp-node";

const gerege = new GeregeMiniApp({
  clientId: process.env.GEREGE_CLIENT_ID!,         // gk_test_… / gk_live_… (the old mapp_test_… / mapp_…)
  clientSecret: process.env.GEREGE_CLIENT_SECRET!, // gks_… (the old gma_cs_…)
  // baseUrl: "https://geregeapp.mn",  timeoutMs: 20_000,  userAgent, fetch (tests, proxies)
});
Option Default
clientId, clientSecret — (required) TypeError when missing
baseUrl https://geregeapp.mn
timeoutMs 20 000 per request; beyond it GeregeError network_error
userAgent gerege-miniapp-node/0.2.0
fetch the global fetch

Authentication

Each method picks which credential it sends the way the server does (API conventions):

  • With a last argument { accessToken } (AuthOption) the person's token is sent (Bearer); without it the app's client credentials (Basic) — retrieve, list, capture, cancel, unclaim, refunds.create, settlements.*.
  • paymentIntents.create(accessToken, params, opts) — a mini app's app intent (the person's token). paymentIntents.create(params, opts) (with presentment qr / redirect / terminal) — a merchant's claimless intent (Basic). Mixing the two throws a TypeError before anything is sent.
  • terminals.*, subMerchants.* — Basic only. oauth.* — Basic. me(accessToken) — Bearer.
  • Every Basic call needs the key's scope (refunds.create → refunds:write, terminals.batch → terminals:write, …); without it a GeregeError with status: 403, code: "insufficient_scope" and details.required_scope. Which method needs which scope — API conventions.

Mini app: launch, person, payment

// 1. The frontend sends the entry URL's ?launch=<code> to your own backend (60 seconds, single use).
const tokens = await gerege.oauth.exchangeLaunchCode(launchCode);
// tokens.user.sub (= tokens.user.ref) — the person's stable pairwise name in this app.

// 2. When it expires (30 min): store the NEW refresh token — presenting the old one again revokes the whole chain.
const next = await gerege.oauth.refresh(tokens.refresh_token);

// 3. Who it is (as far as they agreed): { ref, sub, verified, locale, scope, environment, email?, phone? }
const me = await gerege.me(next.access_token);

// 4. Payment: the amount from YOUR catalogue, never from the frontend.
const intent = await gerege.paymentIntents.create(
  next.access_token,
  { amount: 45000, description: "Japan 5GB · 15 хоног", reference: order.id },
  { idempotencyKey: `order:${order.id}` },
);
// → frontend: window.gerege.requestPayment({ intentId: intent.id })

More: Mini app launch & tokens, Mini app payments.

Merchant: claimless payments

// Redirect checkout — the return_url's host is one of the app's return_hosts.
const pi = await gerege.paymentIntents.create(
  { presentment: "redirect", return_url: `https://shop.mn/orders/${order.id}/done`,
    amount: order.total, description: `Захиалга №${order.id}`, reference: order.id },
  { idempotencyKey: `order:${order.id}` },   // a retry: the same intent, the same pay_url (200)
);
res.redirect(303, pi.pay_url);               // back with ?pi=… — check with retrieve, never trust the URL

// QR (kiosk, POS): { presentment: "qr", … } → draw pi.pay_url as a QR on the screen.

// Terminal (vending): manual capture per basket, with a short window.
try {
  await gerege.paymentIntents.create(
    { presentment: "terminal", terminal_id: terminal.id, amount: 3500, description: "Ус 0.5л",
      reference: basket.id, capture_method: "manual", capture_within: 120 },
    { idempotencyKey: basket.id },
  );
} catch (err) {
  if (err instanceof GeregeError && err.code === "terminal_busy") {
    await gerege.paymentIntents.cancel(String(err.details?.payment_intent)); // cancel the open one, then again
  } else throw err;
}

pay_url is only in the create's answer (and its replay) — never in retrieve, a list or a webhook. More: Pay with Gerege App.

Intents and refunds

const pi = await gerege.paymentIntents.retrieve("pi_…");          // Basic; with { accessToken }, Bearer
const page = await gerege.paymentIntents.list({ reference: order.id, status: "succeeded", limit: 20 });
for await (const p of gerege.paymentIntents.listAll({ status: "succeeded" })) reconcile(p);

await gerege.paymentIntents.capture(pi.id);   // manual + requires_capture; 202 = processing, read again
await gerege.paymentIntents.cancel(pi.id);    // cancel an unpaid one, or release a hold
await gerege.paymentIntents.unclaim(pi.id);   // release a claimed, unconfirmed one

const rf = await gerege.refunds.create(pi.id, { amount: 1500, reason: "not_dispensed" },
  { idempotencyKey: `refund:${basket.id}` }); // amount omitted = everything left; within 90 days
Method HTTP Answer
paymentIntents.create(accessToken, params, { idempotencyKey }) POST /payment-intents (Bearer) PaymentIntent
paymentIntents.create(params, { idempotencyKey }) POST /payment-intents (Basic) ClaimlessPaymentIntent (pay_url)
paymentIntents.retrieve(id, opts?) GET /payment-intents/{id} PaymentIntent
paymentIntents.list(params?, opts?) GET /payment-intents { data, has_more }
paymentIntents.listAll(params?, opts?) the above, following after AsyncGenerator<PaymentIntent>
paymentIntents.capture(id, opts?) · cancel · unclaim POST …/capture · /cancel · /unclaim PaymentIntent
refunds.create(intentId, { amount?, reason? }, { idempotencyKey, accessToken? }) POST …/refunds Refund

An intent's fields and statuses: Payment intent; refunds: Refunds.

Settlements

const { items, next_cursor, has_more } = await gerege.settlements.list({ status: "paid", limit: 20 });
for await (const st of gerege.settlements.listAll({ status: "paid" })) books.record(st);
const detail = await gerege.settlements.retrieve("st_…"); // + intents[], refund_items[], truncated

Live only — a test credential gets an empty list. More: Settlements.

Terminals and sub-merchants

const t = await gerege.terminals.create({ label: "Машин №7", location: "СБД, 1-р хороо", reference: "vm-7" });
// t.url → the sticker's QR and the NFC tag (an NDEF URI). The same reference again → that terminal (200).
const { data: terminals } = await gerege.terminals.list();
await gerege.terminals.update(t.id, { status: "disabled" });

// Many at once (≤ 500): one bad row refuses them all (400 validation_error, terminals[i].…);
// a row with a reference answers existing on a retry — after a timeout, send the same batch again.
const batch = await gerege.terminals.batch([
  { label: "Машин №8", reference: "vm-8" },
  { label: "Машин №9", reference: "vm-9", sub_merchant_code: "owner-17" },
]);
// batch.data[i].status: created | existing | failed | skipped

// The stickers as a PDF (a test key may print): without terminal_ids, every active terminal.
const pdf = await gerege.terminals.printSheet({ layout: "label-40", locale: "en" }); // Uint8Array
await writeFile("stickers.pdf", pdf); // import { writeFile } from "node:fs/promises"

const owner = await gerege.subMerchants.create({ code: "owner-17", display_name: "Батаа ХХК" });
const link = await gerege.subMerchants.requestPayoutLink(owner.id, { account: "MN75 0098 0009 8123 4560" });
const { data: links } = await gerege.subMerchants.listPayoutLinks(owner.id);
Method HTTP
terminals.create(params) · list() · retrieve(id) · update(id, params) POST/GET /terminals, GET/PATCH /terminals/{tid}
terminals.batch(rows) POST /terminals/batch → TerminalBatch (an empty array throws a TypeError)
terminals.printSheet({ terminal_ids?, layout?, locale?, crop_marks? }) POST /terminals/print-sheet → a PDF (Uint8Array); layouts a4-24 (default) · label-50 · label-40 · tent-a6
subMerchants.create(params) · list() · retrieve(id) · update(id, params) POST/GET /sub-merchants, GET/PATCH /sub-merchants/{sid}
subMerchants.requestPayoutLink(id, { account }) · listPayoutLinks(id) POST …/payout-link · GET …/payout-links

Writing (a batch included) takes the live credential (403 live_only with a test one), and so does a payout link; printSheet and reads work in test too. A missing account throws a TypeError. The objects: Objects.

APIs the SDK does not wrap yet

These server APIs work, but SDK 0.2.0 has no method for them — call them with plain fetch (the same Basic): GET /events, GET /events/{id} (events:read — Events API), /webhook-endpoints… (webhooks:write, live — Webhooks), GET /statements (settlements:read — Statements). To handle errors as GeregeError does, read the code and error fields yourself.

const auth = "Basic " + Buffer.from(`${process.env.GEREGE_CLIENT_ID}:${process.env.GEREGE_CLIENT_SECRET}`).toString("base64");
const res = await fetch(`https://geregeapp.mn/api/v1/miniapp/events?after=${lastId}&limit=100`, {
  headers: { Authorization: auth, Accept: "application/json" },
});
const { data, next_cursor } = await res.json(); // oldest first; data[i] is the very webhook body

Webhooks

verifyWebhook(rawBody, header, secrets, { toleranceSec?, now? }) checks the signature over the raw body (before any JSON parsing) in constant time and returns the event; a bad one throws GeregeWebhookError. secrets is a string or an array — [new, old] while rotating. signatureFromHeaders(headers) reads Gerege-Signature (or, when absent, OpenPay-Signature). Also gerege.webhooks.verify / .sign / .signatureHeader / .signatureFromHeaders.

node:http (no framework):

import { createServer } from "node:http";
import { verifyWebhook, signatureFromHeaders, GeregeWebhookError } from "@gerege/miniapp-node";

const secrets = process.env.GEREGE_WEBHOOK_SECRETS!.split(","); // "new,old"

createServer(async (req, res) => {
  const chunks: Buffer[] = [];
  for await (const c of req) chunks.push(c as Buffer);
  try {
    const event = verifyWebhook(Buffer.concat(chunks), signatureFromHeaders(req.headers), secrets);
    await queue.add(event.id, event);          // dedupe on event.id, then process
    res.writeHead(204).end();                  // within 10 seconds
  } catch (err) {
    res.writeHead(err instanceof GeregeWebhookError ? 400 : 500).end();
  }
}).listen(8080);

Express — express.raw is required (express.json() parses the body and the signature no longer matches):

app.post("/webhooks/gerege", express.raw({ type: "application/json" }), async (req, res) => {
  let event;
  try {
    event = verifyWebhook(req.body, signatureFromHeaders(req.headers), secrets);
  } catch {
    return res.sendStatus(400);
  }
  switch (event.type) {
    case "payment_intent.succeeded": await queue.add("fulfil", { intentId: event.data.object.id }); break;
    case "payment_intent.requires_capture": await queue.add("dispense", { intentId: event.data.object.id }); break;
    default: break;                            // unknown types: 2xx and ignore
  }
  res.sendStatus(204);
});

Re-read the intent with retrieve before fulfilling. signWebhook(secret, t, raw) and webhookSignatureHeader(secrets, t, raw) build signed requests for your own tests. More: Verifying signatures, Event reference.

Errors

try {
  await gerege.paymentIntents.capture(id);
} catch (err) {
  if (err instanceof GeregeError) {
    console.log(err.status, err.code, err.message, err.requestId, err.details);
    if (err.retryable) return retryLater();          // 0 (network), 429, 5xx — with the same Idempotency-Key
    if (err.code === "expired") return closeOrder(); // the hold's window has passed
  }
  throw err;
}
Class When Fields
GeregeError every non-2xx answer; network/timeout (status: 0, code: "network_error"); a 2xx that is not JSON (invalid_response) status, code, message, requestId, details (payment_intent, details[], status …), retryable
GeregeOAuthError (a GeregeError) the token endpoint's refusals error, error_description
GeregeWebhookError a webhook to refuse — answer 400 reason: missing_header · malformed_header · timestamp_out_of_tolerance · signature_mismatch · invalid_payload
TypeError before the request: a missing credential, id, idempotencyKey or account; a token and a presentment that disagree

An answer without code (404, 429, 500) gets http_<status>. Every code: Error codes.

Constants and types

DEFAULT_BASE_URL, API_BASE_PATH (/api/v1/miniapp), SIGNATURE_HEADER (Gerege-Signature), OMAS_SIGNATURE_HEADER, EVENT_ID_HEADER (Gerege-Event-Id), OMAS_EVENT_ID_HEADER, DEFAULT_TOLERANCE_SEC (300), PREVIOUS_SECRET_OVERLAP_SEC (86 400), PREFIXES (launchCode, accessToken, refreshToken, clientSecret, webhookSecret, liveClientId, testClientId — the default key's mapp_ / gma_cs_; liveKeyClientId, testKeyClientId, liveKeySecret, testKeySecret — an API key's gk_<env>_ / gks_<env>_), IDEMPOTENCY_KEY_PATTERN. Types: PaymentIntent, ClaimlessPaymentIntent, Refund, Settlement, SettlementDetail, Terminal, TerminalBatch, TerminalBatchRow, TerminalBatchResult, PrintSheetParams, PrintSheetLayout, SubMerchant, PayoutLink, TokenResponse, MiniAppUser, WebhookEvent (a discriminated union of every event — inside switch (event.type) data.object has the right type).