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'sappintent (the person's token).paymentIntents.create(params, opts)(withpresentmentqr/redirect/terminal) — a merchant's claimless intent (Basic). Mixing the two throws aTypeErrorbefore 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 aGeregeErrorwithstatus: 403,code: "insufficient_scope"anddetails.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).