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 (tolerance0 =DefaultTolerance, 5 minutes).VerifyRequestreadsGerege-Signature, falling back toOpenPay-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.