Приём криптоплатежей на Go

Приём USDT, BTC и других монет в сервисе на Go только стандартной библиотекой: net/http создаёт платёж и принимает webhook, crypto/hmac проверяет подпись, database/sql с pgx отмечает заказ оплаченным один раз.

Пошагово

  1. 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/shop
  2. 2.Создайте платёж

    Суммы в 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. 3.Отправьте клиента на paymentUrl

    paymentUrl — готовая страница оплаты: точная сумма с комиссией, адрес, QR-код, официальный контракт токена и таймер, статус обновляется сам. Открывайте её редиректом или в новой вкладке — встроить во фрейм нельзя. Возврат клиента на ваш сайт ещё не оплата: заказ отмечается оплаченным только по webhook или статусу из GET /v1/payments/:id. http.Redirect(w, r, url, http.StatusSeeOther).

  4. 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. 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. 6.Проверьте интеграцию

    Тестовой сети у Moonvex нет — всё работает в основных сетях. Кнопка «Отправить тест» в разделе «Интеграция» пришлёт событие webhook.test — так проверяется подпись. Затем оплатите небольшой счёт, например 1 USDT в TRON: это обычный платёж, деньги придут на баланс. Журнал доставок с кодами ответов и кнопкой «Повторить» — там же.

Подводные камни

  • Не декодируйте тело через json.NewDecoder(r.Body) до проверки — поток будет прочитан, а байты для HMAC потеряны.
  • Для сумм используйте string или пакет decimal, не float64.
  • Задайте http.Client с Timeout — клиент по умолчанию ждёт бесконечно.
  • Не логируйте секрет webhook и полный заголовок Authorization.

Частые вопросы

Есть ли Go SDK?

Официальный SDK сейчас только для Node.js. Для Go хватает стандартной библиотеки: два HTTP-запроса и функция проверки подписи из этого гайда.

Что делать, если клиент не успел оплатить?

Платёж перейдёт в EXPIRED и придёт payment.expired. По тому же orderId можно создать новый платёж — с новым Idempotency-Key (например, увеличьте номер попытки в конце ключа). Срок оплаты задаётся в expiresInMinutes: от 5 до 1440 минут, по умолчанию 30.

Есть ли тестовый режим или sandbox?

Нет, тестовой сети нет — Moonvex работает только в основных сетях. Подпись проверяется кнопкой «Отправить тест» (событие webhook.test), а весь путь оплаты — реальным платежом на небольшую сумму, например 1 USDT в TRON.

Другие гайды

Начните принимать оплату сегодня

Аккаунт и первый проект за минуту, без абонентской платы.

Создать аккаунт