Go SDK
Accept payments, create hosted checkouts, send payouts and verify webhooks from Go with the standard library only.
- Module
- github.com/kraftonnotgar-spec/teepay-Go
- Install
- go get github.com/kraftonnotgar-spec/teepay-Go
- Runtime
- Go 1.22 or newer; standard library only, no dependencies
- Client
- teepay.NewClient, context.Context on every call
Install
go get github.com/kraftonnotgar-spec/teepay-Goimport teepay "github.com/kraftonnotgar-spec/teepay-Go"Create a client
Create a secret API key in the TeePay dashboard (Developers → API keys) and keep it on your server.
ctx := context.Background()
client, err := teepay.NewClient(os.Getenv("TEEPAY_API_KEY")) // or NewClient("") to read TEEPAY_API_KEY
if err != nil {
log.Fatal(err)
}
caps, err := client.Capabilities(ctx)
if err != nil {
log.Fatal(err)
}
fmt.Println(caps.Environment, caps.Features.CollectionCreate, caps.SupportedCurrencies)Mobile money push
Sends a USSD prompt to the customer's phone. See Mobile money push.
payment, err := client.Collections.Create(ctx, &teepay.CollectionCreateParams{
Money: teepay.Money{AmountMinor: "500000", Currency: "TZS"}, // TZS 5,000.00
Customer: teepay.CollectionCustomer{Country: "TZ", Phone: "+255712345678", Name: "Asha Juma"},
Description: "Order 1001",
IdempotencyKey: "order-1001",
})
if err != nil {
return err
}
fmt.Println(payment.PublicReference, payment.Status) // "TP8K2M4QZ1" "pending"
// 202 means the prompt was sent, not that the customer paid.
// Wait for the payment.completed webhook, or poll:
latest, err := client.Collections.Retrieve(ctx, payment.PublicReference)
if err == nil && latest.Status == teepay.StatusSucceeded {
fulfilOrder()
}Hosted checkout session
See Checkout sessions.
session, err := client.CheckoutSessions.Create(ctx, &teepay.CheckoutSessionCreateParams{
Title: "Order 1001",
Description: "2 x Coffee bag",
AmountMinor: "500000",
Currency: "TZS",
Reference: "order-1001",
Metadata: map[string]string{"cart": "c-77"},
IdempotencyKey: "checkout-order-1001",
})
if err != nil {
return err
}
http.Redirect(w, r, session.CheckoutURL, http.StatusSeeOther)
// Later: status becomes "paid" only after TeePay verified the payment.
session, err = client.CheckoutSessions.Retrieve(ctx, session.ID)
if err == nil && session.Status == teepay.CheckoutSessionStatusPaid {
fmt.Println(session.Payment.Reference)
}Control Numbers and payouts
number, err := client.ControlNumbers.Create(ctx, &teepay.ControlNumberCreateParams{
Kind: teepay.ControlNumberKindBill,
Customer: teepay.ControlNumberCustomer{Name: "Asha Juma", Phone: "0712345678"},
Description: "School fees, term 1",
AmountMinor: "5000000",
IdempotencyKey: "invoice-2026-0077",
})
if err == nil && number.LastResponse.StatusCode == 202 { // still being issued
// call Create again later with the SAME IdempotencyKey
}
quote, err := client.Disbursements.Quote(ctx, &teepay.PayoutQuoteParams{
Money: teepay.PayoutMoney{Amount: 2500000, Currency: "TZS"},
Destination: teepay.MobileDestination("Juma Ali", "255712345678"),
Narration: "Supplier payment October",
})
if err != nil {
return err
}
payout, err := client.Disbursements.Create(ctx, &teepay.PayoutCreateParams{
QuoteID: quote.ID,
IdempotencyKey: "payout-2026-10-0001",
})Use teepay.BankDestination(name, bank, account) for a bank account. In payout quotes
money.amount is an integer of minor units, as the API expects; everywhere else amounts are
teepay.MinorUnits strings.
Lists and pagination
page, err := client.Collections.List(ctx, &teepay.TransactionListParams{Limit: 50, Status: teepay.StatusSucceeded})
if err != nil {
return err
}
for _, item := range page.Items {
fmt.Println(item.PublicReference)
}
it := client.Collections.ListAll(ctx, &teepay.TransactionListParams{Status: teepay.StatusSucceeded}) // follows next_cursor
for it.Next() {
fmt.Println(it.Current().PublicReference)
}
if err := it.Err(); err != nil {
return err
}Results are typed structs whose fields are the API's own (AmountMinor is amount_minor,
PublicReference is public_reference). Control Number lists put their rows in page.Data.
Amounts are teepay.MinorUnits: decimal strings of integer minor units, never floats
(teepay.Minor(500000) builds one from an int64, .Int64() parses one).
Verify webhooks
TeePay signs every webhook with your signing secret (whsec_..., from Developers → Webhooks).
Verify the raw request body before trusting it; VerifyWebhook checks the
X-TeePay-Signature HMAC in constant time and rejects timestamps more than 5 minutes away.
http.HandleFunc("POST /webhooks/teepay", func(w http.ResponseWriter, r *http.Request) {
payload, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // the raw body, before any JSON decoding
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
event, err := teepay.VerifyWebhook(payload, r.Header, os.Getenv("TEEPAY_WEBHOOK_SECRET"))
if err != nil { // *teepay.WebhookVerificationError
http.Error(w, "invalid signature", http.StatusBadRequest)
return
}
if event.Event == teepay.EventPaymentCompleted {
markPaid(event.Data.Reference) // de-duplicate on event.ID
}
w.WriteHeader(http.StatusOK)
})client.Webhooks.Verify(...) does the same. Pass teepay.WithTolerance(d) to change the 5-minute
window. Use teepay.GenerateTestHeaders(payload, secret, time.Now()) to build signed requests in
your own tests.
Errors
_, err := client.Collections.Create(ctx, params)
var (
invalid *teepay.ValidationError
restricted *teepay.AccountRestrictedError
denied *teepay.PermissionError
limited *teepay.RateLimitError
conn *teepay.ConnectionError
apiErr *teepay.APIError
)
switch {
case errors.As(err, &invalid): // 422: Code is invalid_request, unsupported_market, invalid_amount, ...
log.Println(invalid.Code, invalid.Message)
case errors.As(err, &restricted): // 403 account_restricted: contact TeePay support
case errors.As(err, &denied): // 403 scope_denied / access_denied
case errors.As(err, &limited): // 429 after retries; wait limited.RetryAfter
case errors.As(err, &conn): // network error or timeout: the payment may still exist
// retry with the SAME idempotency key, or read it
case errors.As(err, &apiErr): // anything else, e.g. 503
log.Println(apiErr.StatusCode, apiErr.Code, apiErr.RequestID)
}| Error type | When |
|---|---|
*AuthenticationError | 401 invalid, revoked, expired or wrong-environment key |
*PermissionError | 403 scope_denied, access_denied, developer_access_denied |
*AccountRestrictedError | 403 account_restricted (unwraps to *PermissionError) |
*NotFoundError | 404 |
*ConflictError | 409 idempotency_conflict, payout quote expired |
*ValidationError | 400/422 |
*RateLimitError | 429 (RetryAfter) |
*APIError | every error status unwraps to it; also returned as-is for 5xx |
*ConnectionError / *TimeoutError | no response (*TimeoutError unwraps to *ConnectionError) |
*WebhookVerificationError | webhook signature or timestamp invalid |
*ConfigError | missing or malformed key, invalid option |
All of the above implement teepay.Error. Every *APIError has StatusCode, Code, Message,
RequestID, Header, Body, RetryAfter and Response. Quote RequestID when you contact support.
Timeouts also unwrap to context.DeadlineExceeded, and a canceled context to context.Canceled.
TeePay rejects request JSON whose object keys are not all lowercase (metadata keys included); the SDK
checks this before sending and returns an *ArgumentError. An empty path parameter (for example
Retrieve(ctx, "")) is also an *ArgumentError, and no request is sent.
Idempotency and retries
Collections.Create,CheckoutSessions.CreateandControlNumbers.Createsend anIdempotency-Keyheader;Disbursements.Createsendsidempotency_keyin the body.- If you leave
IdempotencyKeyempty, the SDK generates one (sdk-<uuid>) per call and reuses it for that call's retries. Prefer keys derived from your own records (order-1001) so a retry after your process restarts cannot create a second payment. - Same key + same body returns the original result (
LastResponse.IdempotentReplayedistruefor replayed checkout sessions). Same key + different body returns a*ConflictError. - The SDK retries (default 2 times, exponential backoff with jitter, honouring
Retry-After) only when it is safe:GETs and keyed creates on connection errors, timeouts, 408, 429 and 5xx. A payout quote is retried only on 429. Other 4xx errors are never retried, and aRetry-Afterover 60 seconds returns the error instead of waiting.
client, err := teepay.NewClient("", teepay.WithTimeout(20*time.Second), teepay.WithMaxRetries(3))
// Per request:
client.Collections.Retrieve(ctx, "TP8K2M4QZ1",
teepay.WithRequestTimeout(5*time.Second), teepay.WithRequestMaxRetries(0))
// A context deadline bounds the whole call, retries included:
ctx, cancel := context.WithTimeout(ctx, 45*time.Second)
defer cancel()Response metadata
payment, err := client.Collections.Create(ctx, params)
info := payment.LastResponse
info.StatusCode // 202
info.RequestID // for support
info.RateLimit // &RateLimit{Limit: 120, Remaining: 117, Reset: 42}
info.IdempotentReplayed // true when TeePay replayed an earlier result
info.APIVersion // "2026-10-18"Sandbox and live
| Key prefix | client.Environment() | Use |
|---|---|---|
tp_test_ | sandbox | Authentication, scopes, capability discovery and error handling |
tp_live_ | live | Real money |
Both use https://api.teepay.me; the key decides the environment. Money movement (mobile money push,
checkout, Control Numbers, payouts) currently runs in live only, so check client.Capabilities(ctx) to
see what a key can do. Pass teepay.WithAPIVersion(teepay.APIVersion) to send a TeePay-Version
header and have requests rejected if that version is no longer served.
Never put a secret key in a browser, mobile app or public repository. The client never logs and
hides the key when printed with fmt.
Configuration
| Option | Default | |
|---|---|---|
apiKey argument | TEEPAY_API_KEY env | tp_test_... or tp_live_... |
WithBaseURL | https://api.teepay.me | |
WithTimeout | 30s | per attempt |
WithMaxRetries | 2 | |
WithAPIVersion | unset | sends TeePay-Version when set |
WithHTTPClient | a new *http.Client that does not follow redirects | bring your own (proxies, transports) |