Приймання криптоплатежів на Go
Приймання USDT, BTC та інших монет у сервісі на Go лише стандартною бібліотекою: net/http створює платіж і приймає webhook, crypto/hmac перевіряє підпис, database/sql із pgx позначає замовлення оплаченим один раз.
Покроково
1.Отримайте ключ API і секрет webhook
У кабінеті Moonvex відкрийте «Інтеграція» → «Створити ключ» → тип «Секретний» (sk_live_…). Ключ показується один раз — збережіть його на сервері в MOONVEX_SECRET_KEY. Там само додайте webhook на https-адресу вашого сервера і збережіть секрет підпису в MOONVEX_WEBHOOK_SECRET.
go get github.com/jackc/pgx/v5 export MOONVEX_SECRET_KEY=sk_live_... export MOONVEX_WEBHOOK_SECRET=whsec_... export DATABASE_URL=postgres://user:pass@localhost:5432/shop2.Створіть платіж
Суми в Moonvex — рядки, тому в структурі Payment поле Amount має тип string, а з PostgreSQL сума читається як total::text. Маршрути з методом і {id} — з net/http Go 1.22+.
// payment.go package main import ( "bytes" "context" "database/sql" "encoding/json" "fmt" "log" "net/http" "os" "time" _ "github.com/jackc/pgx/v5/stdlib" ) var ( db, _ = sql.Open("pgx", os.Getenv("DATABASE_URL")) httpClient = &http.Client{Timeout: 15 * time.Second} ) type Payment struct { ID string `json:"id"` Status string `json:"status"` OrderID string `json:"orderId"` Amount string `json:"amount"` // суми — рядки, не float64 Asset string `json:"asset"` Network string `json:"network"` PaymentURL string `json:"paymentUrl"` } type apiError struct { Error struct { Code string `json:"code"` Message string `json:"message"` } `json:"error"` } func createPayment(ctx context.Context, orderID, amount string, attempt int) (*Payment, error) { body, _ := json.Marshal(map[string]string{ "orderId": orderID, "amount": amount, "currency": "USDT", "network": "TRON", }) req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.moonvex.org/v1/payments", bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+os.Getenv("MOONVEX_SECRET_KEY")) req.Header.Set("Idempotency-Key", fmt.Sprintf("order-%s-%d", orderID, attempt)) req.Header.Set("Content-Type", "application/json") res, err := httpClient.Do(req) if err != nil { return nil, err } defer res.Body.Close() if res.StatusCode >= 400 { var e apiError _ = json.NewDecoder(res.Body).Decode(&e) return nil, fmt.Errorf("moonvex: %s: %s", e.Error.Code, e.Error.Message) } var p Payment return &p, json.NewDecoder(res.Body).Decode(&p) } func payHandler(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") var total string // numeric → text: сума без втрати точності err := db.QueryRowContext(r.Context(), `SELECT total::text FROM orders WHERE id = $1`, id).Scan(&total) if err != nil { http.NotFound(w, r) return } p, err := createPayment(r.Context(), id, total, 1) if err != nil { http.Error(w, err.Error(), http.StatusBadGateway) return } http.Redirect(w, r, p.PaymentURL, http.StatusSeeOther) } func main() { mux := http.NewServeMux() mux.HandleFunc("POST /orders/{id}/pay", payHandler) mux.HandleFunc("POST /moonvex/webhook", webhookHandler) log.Fatal(http.ListenAndServe(":8080", mux)) }3.Надішліть клієнта на paymentUrl
paymentUrl — готова сторінка оплати: точна сума з комісією, адреса, QR-код, офіційний контракт токена й таймер, статус оновлюється сам. Відкривайте її редиректом або в новій вкладці — вбудувати у фрейм не можна. Повернення клієнта на ваш сайт ще не оплата: замовлення позначається оплаченим лише за webhook або статусом із GET /v1/payments/:id. http.Redirect(w, r, url, http.StatusSeeOther).
4.Прийміть webhook і перевірте підпис
Moonvex надсилає POST із заголовком Moonvex-Signature: t=<unix-час>,v1=<підпис>. Підпис — HMAC-SHA256 секретом webhook від рядка «t.сире_тіло» у hex. Рахуйте його за байтами тіла до розбору JSON, порівнюйте за сталий час, відхиляйте події, старші за 5 хвилин. Після зміни секрету 24 години в заголовку два v1 — підходить будь-який. Тіло читається io.ReadAll з обмеженням http.MaxBytesReader, підписи порівнюються hmac.Equal. У HMAC підставляється t саме в тому вигляді, у якому він прийшов у заголовку.
// webhook.go package main import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "io" "net/http" "os" "strconv" "strings" "time" ) func verifyMoonvex(raw []byte, header, secret string, tolerance time.Duration) bool { var ts string var sigs []string for _, part := range strings.Split(header, ",") { k, v, ok := strings.Cut(part, "=") if !ok { continue } switch k { case "t": ts = v case "v1": sigs = append(sigs, v) } } t, err := strconv.ParseInt(ts, 10, 64) if err != nil { return false } if d := time.Since(time.Unix(t, 0)); d > tolerance || d < -tolerance { return false } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(ts + ".")) mac.Write(raw) expected := mac.Sum(nil) for _, s := range sigs { got, err := hex.DecodeString(s) // hmac.Equal — порівняння за сталий час if err == nil && hmac.Equal(got, expected) { return true } } return false } func webhookHandler(w http.ResponseWriter, r *http.Request) { raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) if err != nil { http.Error(w, "bad body", http.StatusBadRequest) return } if !verifyMoonvex(raw, r.Header.Get("Moonvex-Signature"), os.Getenv("MOONVEX_WEBHOOK_SECRET"), 5*time.Minute) { http.Error(w, "bad signature", http.StatusBadRequest) return } var event struct { ID string `json:"id"` Type string `json:"type"` Data struct { OrderID string `json:"orderId"` } `json:"data"` } if err := json.Unmarshal(raw, &event); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } if event.Type == "payment.confirmed" || event.Type == "payment.overpaid" { if err := markOrderPaidOnce(r.Context(), event.ID, event.Data.OrderID); err != nil { // 5xx — Moonvex повторить доставку http.Error(w, "retry later", http.StatusInternalServerError) return } } w.WriteHeader(http.StatusOK) }5.Позначте замовлення оплаченим рівно один раз
Під час повторної доставки id події той самий, тому запишіть його в таблицю з унікальним ключем в одній транзакції з оновленням замовлення: дубль нічого не змінить. Видавайте товар за payment.confirmed і payment.overpaid, а не за payment.detected. RowsAffected() після INSERT … ON CONFLICT DO NOTHING: 0 — подія вже була.
// store.go package main import "context" // CREATE TABLE processed_events (event_id text PRIMARY KEY, created_at timestamptz NOT NULL DEFAULT now()); func markOrderPaidOnce(ctx context.Context, eventID, orderID string) error { tx, err := db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() res, err := tx.ExecContext(ctx, `INSERT INTO processed_events (event_id) VALUES ($1) ON CONFLICT DO NOTHING`, eventID) if err != nil { return err } if n, _ := res.RowsAffected(); n == 1 { // 0 — подію вже оброблено if _, err := tx.ExecContext(ctx, `UPDATE orders SET status = 'paid' WHERE id = $1 AND status <> 'paid'`, orderID); err != nil { return err } } return tx.Commit() }6.Перевірте інтеграцію
Тестової мережі в Moonvex немає — усе працює в основних мережах. Кнопка «Надіслати тест» у розділі «Інтеграція» надішле подію webhook.test — так перевіряється підпис. Потім оплатіть невеликий рахунок, наприклад 1 USDT у TRON: це звичайний платіж, гроші надійдуть на баланс. Журнал доставок із кодами відповідей і кнопкою «Повторити» — там само.
Підводні камені
- Не декодуйте тіло через json.NewDecoder(r.Body) до перевірки — потік буде прочитано, а байти для HMAC втрачено.
- Для сум використовуйте string або пакет decimal, не float64.
- Задайте http.Client з Timeout — клієнт за замовчуванням чекає нескінченно.
- Не логуйте секрет webhook і повний заголовок Authorization.