Skip to content

Go SDK — sdk/go

Use this page to call the merchant API from your Go backend (claimless payments, refunds, terminals, sub-merchants, settlements), verify webhook signatures, and exchange a mini app's tokens with golang.org/x/oauth2. ~15 minutes. You need: Go ≥ 1.22, a test API key from the console (gk_test_… / gks_test_…, or the old mapp_test_… / gma_cs_…), the webhook secret (whsec_…).

Module github.com/gerege-systems/app-gerege-nexus/sdk/go, package miniapp. No runtime dependencies (net/http and the standard library; testify only in its own tests). The client makes every call with the app's client credentials (Basic) — what is called with a person's token (the launch-code exchange, /me, an app intent) goes through a plain OAuth client (below).

Servers only

The client secret and the webhook secret never reach a terminal, a machine, a mobile app or a browser — those get a PayURL or a terminal's URL, nothing else.

Install

The module is in a private repository (until it moves to its own public one) and has no version tag yet. Once Gerege has given you read access (Support):

export GOPRIVATE=github.com/gerege-systems/*
# a git credential that can read that repository (e.g. a GitHub token):
git config --global url."https://x-access-token:${GITHUB_TOKEN}@github.com/".insteadOf "https://github.com/"

go get github.com/gerege-systems/app-gerege-nexus/sdk/go@<commit>

Alternatively, copy its two files, client.go and webhook.go (Apache-2.0), into your project — they have no external dependencies.

Creating a client

import miniapp "github.com/gerege-systems/app-gerege-nexus/sdk/go"

c := miniapp.NewClient(os.Getenv("GEREGE_CLIENT_ID"), os.Getenv("GEREGE_CLIENT_SECRET"),
    // miniapp.WithBaseURL("https://geregeapp.mn"),
    // miniapp.WithHTTPClient(&http.Client{Timeout: 20 * time.Second}),
    // miniapp.WithUserAgent("my-shop/1.0"),
)
Option Default
WithBaseURL(u) https://geregeapp.mn (DefaultBaseURL)
WithHTTPClient(h) &http.Client{Timeout: 20 * time.Second}
WithUserAgent(ua) gerege-miniapp-go/0.2.0

Empty credentials are reported by the first call (ErrNoCredentials), not by NewClient, so a misconfigured process still starts. Every call needs the key's scope (Refund → refunds:write, Terminals.Batch → terminals:write, … — API conventions); without it an *APIError with Code: "insufficient_scope" and Extra["required_scope"].

Merchant: claimless payments

// Redirect checkout: the return_url's host is one of the app's return_hosts.
pi, err := c.PaymentIntents.Create(ctx, "order:"+order.ID, miniapp.CreatePaymentIntentParams{
    Amount: order.Total, Description: "Захиалга №" + order.ID, Reference: order.ID,
    Presentment: miniapp.PresentmentRedirect, ReturnURL: "https://shop.mn/orders/" + order.ID + "/done",
})
if err != nil {
    return err
}
http.Redirect(w, r, pi.PayURL, http.StatusSeeOther) // back with ?pi=… — check with Get

// Terminal (vending): manual per basket, a short window. Idempotency-Key = the basket's number.
pi, err = c.PaymentIntents.Create(ctx, basket.ID, miniapp.CreatePaymentIntentParams{
    Amount: 3500, Description: "Ус 0.5л", Reference: basket.ID, ExpiresIn: 300,
    Presentment: miniapp.PresentmentTerminal, TerminalID: vm.ID,
    CaptureMethod: miniapp.CaptureManual, CaptureWithin: 120,
})

Create returns a ClaimlessPaymentIntent (PaymentIntent + PayURL). PayURL is only in this answer (and a replay with the same key) — never in Get, List or a webhook. A Presentment other than qr / redirect / terminal is ErrPresentment before anything is sent; a bad key is ErrIdempotencyKey. More: Pay with Gerege App.

Intents and refunds

pi, err := c.PaymentIntents.Get(ctx, "pi_…")
page, err := c.PaymentIntents.List(ctx, miniapp.ListPaymentIntentsParams{Reference: order.ID, Limit: 20})
// the next page: After = page.Data[len(page.Data)-1].ID, while page.HasMore

pi, err = c.PaymentIntents.Capture(ctx, pi.ID) // manual + requires_capture; the whole amount
pi, err = c.PaymentIntents.Cancel(ctx, pi.ID)  // cancel an unpaid one, or release a hold
pi, err = c.PaymentIntents.Unclaim(ctx, pi.ID) // release a claimed, unconfirmed one

rf, err := c.PaymentIntents.Refund(ctx, pi.ID, "refund:"+basket.ID,
    miniapp.RefundParams{Amount: 1500, Reason: "not_dispensed"}) // Amount 0 = everything left
Method HTTP Answer
PaymentIntents.Create(ctx, key, CreatePaymentIntentParams) POST /payment-intents ClaimlessPaymentIntent
PaymentIntents.Get(ctx, id) GET /payment-intents/{id} PaymentIntent
PaymentIntents.List(ctx, ListPaymentIntentsParams{Reference, Status, After, Limit}) GET /payment-intents PaymentIntentPage{Data, HasMore}
PaymentIntents.Capture · Cancel · Unclaim (ctx, id) POST …/capture · /cancel · /unclaim PaymentIntent
PaymentIntents.Refund(ctx, intentID, key, RefundParams{Amount, Reason}) POST …/refunds Refund

A capture's 202 (processing) is not an error — check PaymentIntent.Status and Get it again later. Partial capture is coming soon — capture the whole amount and refund the rest. More: Payment intent, Refunds.

Settlements

page, err := c.Settlements.List(ctx, miniapp.ListSettlementsParams{Status: "paid", Limit: 20})
for page.HasMore && page.NextCursor != nil {
    page, err = c.Settlements.List(ctx, miniapp.ListSettlementsParams{Status: "paid", Cursor: *page.NextCursor})
}
detail, err := c.Settlements.Get(ctx, "st_…") // Settlement + Intents, RefundItems, Truncated

Live only — empty with a test credential. Settlement.SubMerchant tells a sub-merchant's settlements apart. More: Settlements.

Terminals and sub-merchants

vm, err := c.Terminals.Create(ctx, miniapp.CreateTerminalParams{Label: "Машин №7", Location: "СБД, 1-р хороо", Reference: "vm-7"})
// vm.URL → the sticker's QR and the NFC tag. The same Reference again → that terminal.
list, err := c.Terminals.List(ctx)
vm, err = c.Terminals.Update(ctx, vm.ID, miniapp.UpdateTerminalParams{Status: miniapp.String(miniapp.StatusDisabled)})

// Many at once (≤ MaxBatchTerminals = 500): one bad row refuses them all; a row with a Reference is "existing" on a retry.
batch, err := c.Terminals.Batch(ctx, []miniapp.TerminalBatchRow{
    {Label: "Машин №8", Reference: "vm-8"},
    {Label: "Машин №9", Reference: "vm-9", SubMerchantCode: "owner-17"},
})
// batch.Data[i].Status: created | existing | failed | skipped; batch.Created, .Existing, .Failed, .Skipped

// The stickers as a PDF (a test key may print); without TerminalIDs, every active terminal.
pdf, err := c.Terminals.PrintSheet(ctx, miniapp.PrintSheetParams{Layout: miniapp.LayoutLabel40, Locale: "en",
    CropMarks: miniapp.Bool(false)})
_ = os.WriteFile("stickers.pdf", pdf, 0o644)

owner, err := c.SubMerchants.Create(ctx, miniapp.CreateSubMerchantParams{Code: "owner-17", DisplayName: "Батаа ХХК"})
link, err := c.SubMerchants.RequestPayoutLink(ctx, owner.ID, "MN750098000981234560")
links, err := c.SubMerchants.ListPayoutLinks(ctx, owner.ID)
Method HTTP
Terminals.Create · List · Get · Update POST/GET /terminals, GET/PATCH /terminals/{tid}
Terminals.Batch(ctx, []TerminalBatchRow) POST /terminals/batch → TerminalBatch
Terminals.PrintSheet(ctx, PrintSheetParams{TerminalIDs, Layout, Locale, CropMarks}) POST /terminals/print-sheet → a PDF ([]byte, ≤ 32 MiB); LayoutA4 (default) · LayoutLabel50 · LayoutLabel40 · LayoutTentA6
SubMerchants.Create · List · Get · Update POST/GET /sub-merchants, GET/PATCH /sub-merchants/{sid}
SubMerchants.RequestPayoutLink(ctx, id, account) · ListPayoutLinks(ctx, id) POST …/payout-link · GET …/payout-links

The fields of Update…Params are pointers — only those set change (miniapp.String(…); miniapp.Bool(…) for PrintSheetParams.CropMarks). Writing (Batch included) takes the live credential (403 live_only with a test one); PrintSheet and reads work in test too; an empty account is ErrMissingAccount. The objects: Objects.

APIs the SDK does not wrap yet

These server APIs work, but the client has no method for them — call them with net/http (the same Basic): GET /events, GET /events/{id} (events:read — Events API), /webhook-endpoints… (webhooks:write, live — Webhooks), GET /statements (settlements:read — Statements). data[i] decodes as a miniapp.Event — the very webhook body.

req, _ := http.NewRequestWithContext(ctx, http.MethodGet,
    "https://geregeapp.mn/api/v1/miniapp/events?limit=100&after="+url.QueryEscape(lastID), nil)
req.SetBasicAuth(os.Getenv("GEREGE_CLIENT_ID"), os.Getenv("GEREGE_CLIENT_SECRET"))
res, err := http.DefaultClient.Do(req)
// …
var page struct {
    Data       []miniapp.Event `json:"data"` // oldest first
    NextCursor *string         `json:"next_cursor"`
}
err = json.NewDecoder(res.Body).Decode(&page)

Webhooks

secrets := strings.Split(os.Getenv("GEREGE_WEBHOOK_SECRETS"), ",") // while rotating: "new,old"

http.HandleFunc("POST /webhooks/gerege", func(w http.ResponseWriter, r *http.Request) {
    ev, _, err := miniapp.VerifyRequest(r, secrets, time.Now()) // the raw body, ≤ 1 MiB
    if err != nil {
        http.Error(w, "invalid webhook", http.StatusBadRequest)
        return
    }
    switch ev.Type {
    case miniapp.EventPaymentIntentSucceeded, miniapp.EventPaymentIntentRequiresCapture:
        pi, err := ev.PaymentIntent()
        if err == nil {
            enqueue(ev.ID, pi.ID) // dedupe on ev.ID; re-read with Get before fulfilling
        }
    case miniapp.EventRefundFailed:
        rf, _ := ev.Refund()
        alert(rf)
    case miniapp.EventSettlementPaid:
        st, _ := ev.Settlement()
        record(st)
    }
    w.WriteHeader(http.StatusNoContent) // within 10 seconds; 2xx for unknown types too
})
  • Verify(body, header, secrets, now, tolerance) — when you supply the body and header yourself (tolerance 0 = DefaultTolerance, 5 minutes). VerifyRequest reads Gerege-Signature, falling back to OpenPay-Signature, and returns the body too (to store it).
  • Event decoders: ev.PaymentIntent(), ev.Refund(), ev.Settlement(), ev.AppStatus(), ev.Consent().
  • Sign(secret, t, body), SignatureValue(secrets, t, body) — build signed requests for your own tests.
  • Reasons to refuse (answer 400 to all): ErrMissingHeader, ErrMalformedHeader, ErrTimestamp, ErrSignatureMismatch, ErrInvalidPayload, ErrNoSecret (no secret configured — your configuration's fault).

More: Verifying signatures, Event reference.

Mini app: golang.org/x/oauth2

The Go client has no person-token calls — it is plain OAuth, so use the standard library:

conf := &oauth2.Config{
    ClientID:     os.Getenv("GEREGE_CLIENT_ID"),     // gk_… (oauth:token scope) or the old mapp_…
    ClientSecret: os.Getenv("GEREGE_CLIENT_SECRET"), // gks_… or the old gma_cs_…
    Endpoint: oauth2.Endpoint{
        TokenURL:  "https://geregeapp.mn/api/v1/miniapp/oauth/token",
        AuthStyle: oauth2.AuthStyleInHeader, // Basic — do not also send it in the body
    },
}
tok, err := conf.Exchange(ctx, launchCode)                    // ?launch=<code>, 60 seconds
user := tok.Extra("user").(map[string]any)                    // {"ref", "sub", "verified", "locale"}
client := conf.Client(ctx, tok)                               // refreshes (with rotation) by itself
res, err := client.Get("https://geregeapp.mn/api/v1/miniapp/me")
// an app intent: client.Post(".../payment-intents", "application/json", body) + an Idempotency-Key header

conf.Client keeps the new refresh token in memory only — if you need it after a restart, take Token() from the TokenSource and store it yourself. Reusing an old refresh token revokes the whole chain. More: Mini app launch & tokens.

Errors

_, err := c.PaymentIntents.Capture(ctx, id)
var apiErr *miniapp.APIError
switch {
case errors.As(err, &apiErr) && apiErr.Retryable(): // 429, 5xx — again with the same Idempotency-Key
    retryLater()
case errors.As(err, &apiErr) && apiErr.Code == "terminal_busy":
    var open string
    _ = json.Unmarshal(apiErr.Extra["payment_intent"], &open)
    _, _ = c.PaymentIntents.Cancel(ctx, open)
case errors.As(err, &apiErr) && apiErr.Code == "validation_error":
    log.Println(apiErr.Details) // []Issue{Path, Message}
case err != nil:
    return err // a network error (not an APIError) — also safe to retry
}
Type Meaning
*APIError a non-2xx answer: StatusCode, Code, Message, RequestID (X-Request-Id), Details ([]Issue), Extra (every other field, raw JSON), Retryable() (429, 5xx). Reads both the module's {"error","code"} and OAuth's {"error","error_description"}; without a code, http_<status>
A wrapped error network, timeout, decoding — not an APIError
ErrNoCredentials · ErrIdempotencyKey · ErrPresentment · ErrMissingID · ErrMissingAccount before the request is sent

Every code: Error codes.

Constants

DefaultBaseURL, APIBasePath (/api/v1/miniapp), SignatureHeader, OMASSignatureHeader, EventIDHeader, OMASEventIDHeader, DefaultTolerance (5 min), MaxBodyBytes (1 MiB), MaxBatchTerminals (500); PresentmentApp · PresentmentQR · PresentmentRedirect · PresentmentTerminal; CaptureAutomatic · CaptureManual; StatusActive · StatusDisabled; LayoutA4 · LayoutLabel50 · LayoutLabel40 · LayoutTentA6; the helpers String(s), Bool(b); an Event… constant per event (EventPaymentIntentSucceeded, …Canceled, …Failed, …RequiresCapture, …Claimed, …Expired, EventRefundSucceeded, EventRefundFailed, EventAppStatusChanged, EventConsentRevoked, EventWebhookTest, EventSettlementPaid). Minor versions may add events — answer 2xx to the ones you do not know.