Unofficial Go client for Saweria — Indonesia's tip / donation platform.
It wraps Saweria's internal donation endpoints so you can integrate Saweria into your own application as a lightweight payment backend (QRIS, GoPay, DANA), and ships a standalone webhook server you can run to receive payment notifications.
Disclaimer: This library targets Saweria's undocumented backend. It is not affiliated with or endorsed by Saweria. Endpoints may break without notice. Test thoroughly before using in production.
- ✅ Calculate payment-gateway fees per method (QRIS / GoPay / DANA)
- ✅ Create pending donation transactions and obtain payment instruments (QR string, redirect URL, DANA mobile/web actions)
- ✅ Parse Saweria webhook callbacks
- ✅ Configurable username + user ID (one client per Saweria account, multiple clients OK)
- ✅ Production-ready webhook server with idempotency, slog logging, graceful shutdown, body size limit
- ✅ 100% standard library — no external dependencies
go get github.com/sholehbaktiabadi/saweraspayRequires Go 1.22+.
You need two values from your Saweria account:
| Field | Example | Where to find it |
|---|---|---|
username |
your-saweria-username |
The slug in your Saweria URL (saweria.co/your-saweria-username) |
userID |
595ace77-9e88-493b-acae-e9752b0cd829 |
Open DevTools on your Saweria donation page, trigger a donation, and inspect the request to /donations/snap/{uuid} |
Display the final amount to the donor before they confirm:
package main
import (
"context"
"fmt"
"github.com/sholehbaktiabadi/saweraspay"
)
func main() {
client := saweraspay.NewClient(
"your-saweria-username",
"595ace77-9e88-493b-acae-e9752b0cd829",
)
resp, err := client.CalculatePayment(context.Background(), saweraspay.CalculatePaymentRequest{
Message: "INV-12345",
Amount: 5000,
PaymentType: saweraspay.PaymentTypeQRIS,
Customer: saweraspay.CustomerInfo{
FirstName: "John",
Email: "john@example.com",
Phone: "081234567890",
},
})
if err != nil {
panic(err)
}
fmt.Printf("Donor will pay %d IDR (fee: %d)\n", resp.AmountToPay, resp.PgFee)
}resp, err := client.GeneratePayment(ctx, saweraspay.GeneratePaymentRequest{
Message: "INV-12345", // YOUR reference id — echoed back in the webhook
Amount: 5000,
PaymentType: saweraspay.PaymentTypeQRIS,
Customer: customer,
})The response shape depends on the payment method:
| Payment type | Use field | Description |
|---|---|---|
qris |
resp.QRString |
EMV QRIS payload — render as a QR code |
gopay |
resp.RedirectURL |
Midtrans Snap URL — redirect donor's browser |
dana |
resp.RedirectURL or resp.Actions |
Use Actions[].URLType == "MOBILE" for in-app, "WEB" for browser |
switch resp.PaymentType {
case saweraspay.PaymentTypeQRIS:
renderQR(resp.QRString) // your QR rendering
case saweraspay.PaymentTypeGoPay:
http.Redirect(w, r, resp.RedirectURL, http.StatusFound)
case saweraspay.PaymentTypeDana:
// mobile flow: prefer MOBILE action; fall back to RedirectURL
for _, a := range resp.Actions {
if a.URLType == "MOBILE" {
return a.URL
}
}
return resp.RedirectURL
}Important: persist resp.ID and req.Message together in your DB. When the webhook fires, you match by Message to find the originating order.
Saweria POSTs a JSON payload to your callback URL on successful payment. Configure the URL in your Saweria dashboard.
http.HandleFunc("POST /webhook", func(w http.ResponseWriter, r *http.Request) {
payload, err := saweraspay.ParseWebhook(r.Body)
if err != nil {
http.Error(w, "bad payload", http.StatusBadRequest)
return
}
// Match payload.Message back to your order, mark it paid.
order := lookupOrder(payload.Message)
order.MarkPaid(payload.AmountRaw, payload.ID)
w.WriteHeader(http.StatusOK)
})The cmd/webhook-server binary is a production-ready receiver that handles parsing, idempotency, logging, and graceful shutdown. Use it as a starting point — extend cmd/webhook-server/handler.go to call into your business logic.
# install
go install github.com/sholehbaktiabadi/saweraspay/cmd/webhook-server@latest
# run with defaults (listens on :8080/webhook)
webhook-server
# run with a secret path (recommended — see Security)
WEBHOOK_PATH=/webhook/$(openssl rand -hex 16) webhook-server| Variable | Default | Description |
|---|---|---|
ADDR |
:8080 |
Listen address |
WEBHOOK_PATH |
/webhook |
URL path — embed a secret token here (see Security) |
MAX_BODY_BYTES |
65536 |
Request body size limit |
LOG_LEVEL |
info |
debug, info, warn, error (JSON output to stdout) |
Saweria does not sign webhook payloads. Anyone who knows your webhook URL can forge a "payment success" event and trick your app into marking orders as paid. You MUST mitigate this:
-
Use a long secret in the URL path — treat the path itself as a shared secret.
WEBHOOK_PATH=/webhook/$(openssl rand -hex 32) webhook-serverRegister this exact URL in Saweria. Rotate periodically.
-
Always terminate TLS — run behind nginx, caddy, Cloudflare, or a load balancer. Never expose the webhook server over plain HTTP in production.
-
(Optional) Restrict by source IP — if you can identify Saweria's outbound IP range, allowlist it at the firewall.
-
Verify idempotency at the storage layer — the bundled handler dedupes by
payload.IDin memory; this resets on restart and won't dedupe across replicas. Persist processed IDs in Redis / a DB if you run more than one replica or care about replay attacks across restarts.
- The webhook payload does not include
payment_type. Only QRIS is detectable (via the presence ofqr_string). For GoPay/DANA, look up the method byMessagein your DB. - The webhook also does not include a status field — receiving the webhook means the payment succeeded.
- Saweria's
donator.phoneis sometimesnullin responses even when supplied in the request. - This wrapper currently exposes only
calculate_pg_amountanddonations/snap(create). A status-check endpoint is not exposed; rely on the webhook for state.
type Client struct{ /* ... */ }
func NewClient(username, userID string, opts ...Option) *Client
type Option func(*Client)
func WithBaseURL(url string) Option // override the Saweria backend host
func WithHTTPClient(h *http.Client) Option // custom transport / timeouts
func WithUserAgent(ua string) Option // override User-Agent
type PaymentType string
const (
PaymentTypeQRIS PaymentType = "qris"
PaymentTypeGoPay PaymentType = "gopay"
PaymentTypeDana PaymentType = "dana"
)func (c *Client) CalculatePayment(ctx, req CalculatePaymentRequest) (*CalculatePaymentResponse, error)
func (c *Client) GeneratePayment(ctx, req GeneratePaymentRequest) (*GeneratePaymentResponse, error)
func ParseWebhook(r io.Reader) (*WebhookPayload, error)All validation errors are sentinel values — use errors.Is:
errors.Is(err, saweraspay.ErrEmptyMessage)
errors.Is(err, saweraspay.ErrInvalidAmount)
errors.Is(err, saweraspay.ErrInvalidPayment)
errors.Is(err, saweraspay.ErrEmptyEmail)
errors.Is(err, saweraspay.ErrEmptyFirstName)
errors.Is(err, saweraspay.ErrMissingUsername)
errors.Is(err, saweraspay.ErrMissingUserID)HTTP errors from Saweria are returned as *APIError:
var apiErr *saweraspay.APIError
if errors.As(err, &apiErr) {
log.Printf("saweria returned %d: %s", apiErr.StatusCode, apiErr.Body)
}go test ./... # run tests
go vet ./... # static checks
go build ./... # build everything (including cmd/webhook-server)The repo has no third-party dependencies; tests use the standard net/http/httptest.
Issues and PRs welcome. If Saweria changes their backend and an endpoint breaks, please open an issue with a curl reproduction.
MIT (suggested) — add a LICENSE file to publish.