TeePayDocs
SDKs

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-Go
import 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 typeWhen
*AuthenticationError401 invalid, revoked, expired or wrong-environment key
*PermissionError403 scope_denied, access_denied, developer_access_denied
*AccountRestrictedError403 account_restricted (unwraps to *PermissionError)
*NotFoundError404
*ConflictError409 idempotency_conflict, payout quote expired
*ValidationError400/422
*RateLimitError429 (RetryAfter)
*APIErrorevery error status unwraps to it; also returned as-is for 5xx
*ConnectionError / *TimeoutErrorno response (*TimeoutError unwraps to *ConnectionError)
*WebhookVerificationErrorwebhook signature or timestamp invalid
*ConfigErrormissing 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.Create and ControlNumbers.Create send an Idempotency-Key header; Disbursements.Create sends idempotency_key in the body.
  • If you leave IdempotencyKey empty, 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.IdempotentReplayed is true for 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 a Retry-After over 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 prefixclient.Environment()Use
tp_test_sandboxAuthentication, scopes, capability discovery and error handling
tp_live_liveReal 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

OptionDefault
apiKey argumentTEEPAY_API_KEY envtp_test_... or tp_live_...
WithBaseURLhttps://api.teepay.me
WithTimeout30sper attempt
WithMaxRetries2
WithAPIVersionunsetsends TeePay-Version when set
WithHTTPClienta new *http.Client that does not follow redirectsbring your own (proxies, transports)

On this page