// Package paynex is the official Go client for the Paynex merchant API (v1). // // It signs every request with your API key (Paynex-HMAC), exposes the payment / refund / // store endpoints, and verifies inbound webhooks and checkout return redirects. // // c := paynex.New("https://pay.example.com", "pk_live_…", "sk_live_…") // p, err := c.CreatePayment(ctx, paynex.CreatePaymentParams{ // Amount: "1500.00", MerchantRef: "INV-1042", // ReturnURL: "https://shop.example.com/thanks", CancelURL: "https://shop.example.com/cart", // }, "INV-1042-1500.00") // // redirect the customer to p.CheckoutURL // // Never ship the secret inside a mobile app: create payments from your server and hand the // app only checkout_url (see README). package paynex import ( "bytes" "context" "crypto/hmac" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" ) // Client talks to one store's API key. type Client struct { BaseURL string // e.g. https://pay.example.com (no trailing slash) KeyID string // pk_live_… / pk_sandbox_… Secret string // sk_… HTTP *http.Client } // New returns a client. The base URL is the gateway address without /v1. func New(baseURL, keyID, secret string) *Client { return &Client{BaseURL: strings.TrimRight(baseURL, "/"), KeyID: keyID, Secret: secret, HTTP: &http.Client{Timeout: 30 * time.Second}} } // ---------- types ---------- // Payment mirrors the API payment object. Amounts are decimal strings ("1500.00"). type Payment struct { ID string `json:"id"` Status string `json:"status"` Amount string `json:"amount"` Currency string `json:"currency"` MerchantRef string `json:"merchant_ref"` Description string `json:"description"` CheckoutToken string `json:"checkout_token,omitempty"` CheckoutURL string `json:"checkout_url,omitempty"` ProviderTxnID *string `json:"provider_txn_id"` Customer map[string]any `json:"customer"` Metadata json.RawMessage `json:"metadata"` ExpiresAt time.Time `json:"expires_at"` PaidAt *time.Time `json:"paid_at"` CreatedAt time.Time `json:"created_at"` Attempts []Attempt `json:"attempts,omitempty"` } // Paid reports whether money is confirmed (paid, or paid then partially/fully refunded). func (p *Payment) Paid() bool { return p.Status == "paid" || p.Status == "refunded" || p.Status == "partially_refunded" } type Attempt struct { ID string `json:"id"` ProviderAccountID string `json:"provider_account_id"` Status string `json:"status"` ProviderRef *string `json:"provider_ref"` StartedAt *time.Time `json:"started_at"` } type CreatePaymentParams struct { Amount string `json:"amount"` Currency string `json:"currency,omitempty"` MerchantRef string `json:"merchant_ref,omitempty"` Description string `json:"description,omitempty"` Customer map[string]any `json:"customer,omitempty"` Metadata any `json:"metadata,omitempty"` ReturnURL string `json:"return_url"` CancelURL string `json:"cancel_url"` ExpiresIn int `json:"expires_in,omitempty"` // seconds, default 1800 } type ListParams struct { Status string MerchantRef string Limit int Cursor string } type PaymentList struct { Data []Payment `json:"data"` NextCursor string `json:"next_cursor"` } type Refund struct { ID string `json:"id"` PaymentID string `json:"payment_id"` Amount string `json:"amount"` Currency string `json:"currency"` Status string `json:"status"` // succeeded | requested | failed Reason string `json:"reason,omitempty"` ProviderRefundID *string `json:"provider_refund_id,omitempty"` CreatedAt *time.Time `json:"created_at,omitempty"` } type RefundParams struct { PaymentID string `json:"payment_id"` Amount string `json:"amount,omitempty"` // empty = full remaining balance Reason string `json:"reason,omitempty"` } type Store struct { Slug string `json:"slug"` Name string `json:"name"` DefaultCurrency string `json:"default_currency"` Methods []Method `json:"methods"` } type Method struct { ID string `json:"id"` Provider string `json:"provider"` Label string `json:"label"` Mode string `json:"mode"` } // APIError is returned for any non-2xx response. type APIError struct { HTTPStatus int Code string Message string } func (e *APIError) Error() string { return fmt.Sprintf("paynex: %s (%d): %s", e.Code, e.HTTPStatus, e.Message) } // ---------- endpoints ---------- // CreatePayment creates a payment. idempotencyKey is required and should be stable per // order (e.g. "-") so retries return the same payment. func (c *Client) CreatePayment(ctx context.Context, p CreatePaymentParams, idempotencyKey string) (*Payment, error) { if idempotencyKey == "" { return nil, errors.New("paynex: idempotency key is required") } var out Payment if err := c.do(ctx, "POST", "/v1/payments", p, idempotencyKey, &out); err != nil { return nil, err } return &out, nil } func (c *Client) GetPayment(ctx context.Context, id string) (*Payment, error) { var out Payment if err := c.do(ctx, "GET", "/v1/payments/"+url.PathEscape(id), nil, "", &out); err != nil { return nil, err } return &out, nil } func (c *Client) ListPayments(ctx context.Context, p ListParams) (*PaymentList, error) { q := url.Values{} if p.Status != "" { q.Set("status", p.Status) } if p.MerchantRef != "" { q.Set("merchant_ref", p.MerchantRef) } if p.Limit > 0 { q.Set("limit", strconv.Itoa(p.Limit)) } if p.Cursor != "" { q.Set("cursor", p.Cursor) } path := "/v1/payments" if len(q) > 0 { path += "?" + q.Encode() } var out PaymentList if err := c.do(ctx, "GET", path, nil, "", &out); err != nil { return nil, err } return &out, nil } func (c *Client) CancelPayment(ctx context.Context, id string) (*Payment, error) { var out Payment if err := c.do(ctx, "POST", "/v1/payments/"+url.PathEscape(id)+"/cancel", nil, "", &out); err != nil { return nil, err } return &out, nil } // VerifyPayment asks Paynex to re-check the payment with the provider right now. func (c *Client) VerifyPayment(ctx context.Context, id string) (*Payment, error) { var out Payment if err := c.do(ctx, "POST", "/v1/payments/"+url.PathEscape(id)+"/verify", nil, "", &out); err != nil { return nil, err } return &out, nil } func (c *Client) CreateRefund(ctx context.Context, p RefundParams, idempotencyKey string) (*Refund, error) { var out Refund if err := c.do(ctx, "POST", "/v1/refunds", p, idempotencyKey, &out); err != nil { return nil, err } return &out, nil } func (c *Client) GetRefund(ctx context.Context, id string) (*Refund, error) { var out Refund if err := c.do(ctx, "GET", "/v1/refunds/"+url.PathEscape(id), nil, "", &out); err != nil { return nil, err } return &out, nil } func (c *Client) GetStore(ctx context.Context) (*Store, error) { var out Store if err := c.do(ctx, "GET", "/v1/store", nil, "", &out); err != nil { return nil, err } return &out, nil } // ---------- signing ---------- // Sign returns the headers for one request (exported so you can use another HTTP stack). // path must include /v1 and exclude the query string. func (c *Client) Sign(method, path string, body []byte) (http.Header, error) { nonce := make([]byte, 16) if _, err := rand.Read(nonce); err != nil { return nil, err } ts := strconv.FormatInt(time.Now().Unix(), 10) n := hex.EncodeToString(nonce) h := http.Header{} h.Set("Authorization", "Paynex-HMAC key_id="+c.KeyID) h.Set("X-PG-Timestamp", ts) h.Set("X-PG-Nonce", n) h.Set("X-PG-Signature", Signature(c.Secret, ts, n, method, path, body)) return h, nil } // Signature computes hex(HMAC-SHA256(secret, ts\nnonce\nMETHOD\npath\nhex(sha256(body)))). func Signature(secret, ts, nonce, method, path string, body []byte) string { sum := sha256.Sum256(body) msg := ts + "\n" + nonce + "\n" + strings.ToUpper(method) + "\n" + path + "\n" + hex.EncodeToString(sum[:]) mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(msg)) return hex.EncodeToString(mac.Sum(nil)) } func (c *Client) do(ctx context.Context, method, pathWithQuery string, in any, idem string, out any) error { var body []byte if in != nil { var err error if body, err = json.Marshal(in); err != nil { return err } } path := pathWithQuery if i := strings.IndexByte(path, '?'); i >= 0 { path = path[:i] } hdr, err := c.Sign(method, path, body) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+pathWithQuery, bytes.NewReader(body)) if err != nil { return err } req.Header = hdr req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", "paynex-go/1.0") if idem != "" { req.Header.Set("Idempotency-Key", idem) } res, err := c.HTTP.Do(req) if err != nil { return err } defer res.Body.Close() data, err := io.ReadAll(io.LimitReader(res.Body, 4<<20)) if err != nil { return err } if res.StatusCode >= 300 { var e struct { Error struct { Code string `json:"code"` Message string `json:"message"` } `json:"error"` } _ = json.Unmarshal(data, &e) if e.Error.Code == "" { e.Error.Code = "http_" + strconv.Itoa(res.StatusCode) e.Error.Message = strings.TrimSpace(string(data)) } return &APIError{HTTPStatus: res.StatusCode, Code: e.Error.Code, Message: e.Error.Message} } if out == nil || len(data) == 0 { return nil } return json.Unmarshal(data, out) } // ---------- inbound: webhooks & return redirect ---------- // Event is a decoded webhook. type Event struct { Event string `json:"event"` // payment.paid, payment.failed, refund.succeeded, … Data json.RawMessage `json:"data"` } // Payment decodes Data as a payment (for payment.* events). func (e *Event) Payment() (*Payment, error) { var p Payment return &p, json.Unmarshal(e.Data, &p) } // Refund decodes Data as a refund (for refund.* events). func (e *Event) Refund() (*Refund, error) { var r Refund return &r, json.Unmarshal(e.Data, &r) } // ErrBadSignature is returned when a webhook or return signature does not verify. var ErrBadSignature = errors.New("paynex: invalid signature") // VerifyWebhook checks X-PG-Signature ("t=…,v1=…") against the raw body and decodes the // event. Reject deliveries older than tolerance (5 minutes is a good default). // // Receiver contract: verify → GET /v1/payments/{id} → trust only that response → respond 2xx. func VerifyWebhook(webhookSecret, signatureHeader string, rawBody []byte, tolerance time.Duration) (*Event, error) { var t, v1 string for _, part := range strings.Split(signatureHeader, ",") { k, v, _ := strings.Cut(strings.TrimSpace(part), "=") switch k { case "t": t = v case "v1": v1 = v } } if t == "" || v1 == "" { return nil, ErrBadSignature } ts, err := strconv.ParseInt(t, 10, 64) if err != nil { return nil, ErrBadSignature } if tolerance > 0 && absDur(time.Since(time.Unix(ts, 0))) > tolerance { return nil, errors.New("paynex: webhook timestamp outside tolerance") } mac := hmac.New(sha256.New, []byte(webhookSecret)) mac.Write([]byte(t)) mac.Write([]byte(".")) mac.Write(rawBody) if subtle.ConstantTimeCompare([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(strings.ToLower(v1))) != 1 { return nil, ErrBadSignature } var ev Event if err := json.Unmarshal(rawBody, &ev); err != nil { return nil, err } return &ev, nil } // WebhookHandler wraps a function into an http.Handler that verifies the signature, calls // fn with the event, and answers 200 (or 401 / 500). func WebhookHandler(webhookSecret string, fn func(ctx context.Context, ev *Event) error) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { http.Error(w, "read error", http.StatusBadRequest) return } ev, err := VerifyWebhook(webhookSecret, r.Header.Get("X-PG-Signature"), body, 5*time.Minute) if err != nil { http.Error(w, err.Error(), http.StatusUnauthorized) return } if err := fn(r.Context(), ev); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) // Paynex will retry return } w.WriteHeader(http.StatusOK) }) } // ReturnParams are the signed query parameters Paynex appends to return_url after a paid // checkout: ?pg_ref=&pg_status=&pg_ts=&pg_sig=. They are for UX only — confirm with the API. type ReturnParams struct { PaymentID string Status string At time.Time } // VerifyReturn validates the return redirect (web page or mobile deep link). func VerifyReturn(webhookSecret string, query url.Values, tolerance time.Duration) (*ReturnParams, error) { ref, st, ts, sig := query.Get("pg_ref"), query.Get("pg_status"), query.Get("pg_ts"), query.Get("pg_sig") if ref == "" || st == "" || ts == "" || sig == "" { return nil, ErrBadSignature } n, err := strconv.ParseInt(ts, 10, 64) if err != nil { return nil, ErrBadSignature } if tolerance > 0 && absDur(time.Since(time.Unix(n, 0))) > tolerance { return nil, errors.New("paynex: return timestamp outside tolerance") } mac := hmac.New(sha256.New, []byte(webhookSecret)) mac.Write([]byte(ref + "." + st + "." + ts)) if subtle.ConstantTimeCompare([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(strings.ToLower(sig))) != 1 { return nil, ErrBadSignature } return &ReturnParams{PaymentID: ref, Status: st, At: time.Unix(n, 0)}, nil } func absDur(d time.Duration) time.Duration { if d < 0 { return -d } return d }