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

Інші гайди

Почніть приймати оплату вже сьогодні

Обліковий запис і перший проєкт за хвилину, без абонентської плати.

Створити обліковий запис