Приём криптоплатежей на 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.