Приймання криптоплатежів в ASP.NET Core

Інтеграція Moonvex у застосунок на ASP.NET Core (Minimal API, .NET 8): іменований HttpClient створює платіж, webhook читає тіло повністю, перевіряє підпис HMACSHA256 і позначає замовлення через EF Core рівно один раз.

Покроково

  1. 1.Отримайте ключ API і секрет webhook

    У кабінеті Moonvex відкрийте «Інтеграція» → «Створити ключ» → тип «Секретний» (sk_live_…). Ключ показується один раз — збережіть його на сервері в MOONVEX_SECRET_KEY. Там само додайте webhook на https-адресу вашого сервера і збережіть секрет підпису в MOONVEX_WEBHOOK_SECRET. В ASP.NET Core зберігайте їх у конфігурації: User Secrets під час розробки, змінні оточення в продакшені.

    // appsettings.json (у продакшені — змінні оточення Moonvex__SecretKey і Moonvex__WebhookSecret)
    {
      "Moonvex": {
        "SecretKey": "sk_live_...",
        "WebhookSecret": "whsec_..."
      }
    }
  2. 2.Створіть платіж через HttpClient

    Іменований клієнт moonvex реєструється через AddHttpClient з базовою адресою і Bearer-ключем. Сума форматується з CultureInfo.InvariantCulture — інакше на сервері з українською локаллю вийде 25,00.

    // Program.cs
    using System.Globalization;
    using System.Net.Http.Headers;
    using System.Security.Cryptography;
    using System.Text;
    using System.Text.Json;
    
    var builder = WebApplication.CreateBuilder(args);
    builder.Services.AddHttpClient("moonvex", c =>
    {
        c.BaseAddress = new Uri("https://api.moonvex.org");
        c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", builder.Configuration["Moonvex:SecretKey"]);
        c.Timeout = TimeSpan.FromSeconds(15);
    });
    builder.Services.AddDbContext<AppDb>();
    var app = builder.Build();
    
    app.MapPost("/orders/{id:long}/pay", async (long id, AppDb db, IHttpClientFactory http) =>
    {
        var order = await db.Orders.FindAsync(id);
        if (order is null) return Results.NotFound();
    
        using var request = new HttpRequestMessage(HttpMethod.Post, "/v1/payments")
        {
            Content = JsonContent.Create(new
            {
                orderId = id.ToString(CultureInfo.InvariantCulture),
                // decimal → "25.00" з крапкою за будь-якої культури
                amount = order.Total.ToString("0.00", CultureInfo.InvariantCulture),
                currency = "USDT",
                network = "TRON",
            }),
        };
        request.Headers.Add("Idempotency-Key", "order-" + id + "-1");
    
        using var response = await http.CreateClient("moonvex").SendAsync(request);
        var payment = await response.Content.ReadFromJsonAsync<JsonElement>();
        if (!response.IsSuccessStatusCode)
            return Results.Problem(payment.GetProperty("error").GetProperty("code").GetString(), statusCode: 502);
    
        return Results.Redirect(payment.GetProperty("paymentUrl").GetString()!);
    });
  3. 3.Надішліть клієнта на paymentUrl

    paymentUrl — готова сторінка оплати: точна сума з комісією, адреса, QR-код, офіційний контракт токена й таймер, статус оновлюється сам. Відкривайте її редиректом або в новій вкладці — вбудувати у фрейм не можна. Повернення клієнта на ваш сайт ще не оплата: замовлення позначається оплаченим лише за webhook або статусом із GET /v1/payments/:id. Results.Redirect(url) надішле клієнта на сторінку оплати.

  4. 4.Прийміть webhook і перевірте підпис

    Moonvex надсилає POST із заголовком Moonvex-Signature: t=<unix-час>,v1=<підпис>. Підпис — HMAC-SHA256 секретом webhook від рядка «t.сире_тіло» у hex. Рахуйте його за байтами тіла до розбору JSON, порівнюйте за сталий час, відхиляйте події, старші за 5 хвилин. Після зміни секрету 24 години в заголовку два v1 — підходить будь-який. Тіло копіюється з request.Body у MemoryStream без прив’язки моделі. HMACSHA256.HashData і CryptographicOperations.FixedTimeEquals — вбудовані засоби .NET 6+.

    // Program.cs (продовження)
    app.MapPost("/moonvex/webhook", async (HttpRequest request, AppDb db, IConfiguration config) =>
    {
        // читаємо тіло повністю, без моделі — потрібні початкові байти
        using var ms = new MemoryStream();
        await request.Body.CopyToAsync(ms);
        var raw = ms.ToArray();
    
        if (!MoonvexSignature.Verify(raw, request.Headers["Moonvex-Signature"], config["Moonvex:WebhookSecret"]!))
            return Results.BadRequest("bad signature");
    
        using var doc = JsonDocument.Parse(raw);
        var ev = doc.RootElement;
        var type = ev.GetProperty("type").GetString();
        if (type is "payment.confirmed" or "payment.overpaid")
        {
            var orderId = long.Parse(ev.GetProperty("data").GetProperty("orderId").GetString()!, CultureInfo.InvariantCulture);
            await OrderPayments.MarkPaidOnceAsync(db, ev.GetProperty("id").GetString()!, orderId);
        }
        return Results.Ok();
    });
    
    app.Run();
    
    static class MoonvexSignature
    {
        public static bool Verify(byte[] raw, string? header, string secret, int toleranceSec = 300)
        {
            if (string.IsNullOrEmpty(header)) return false;
            string? ts = null;
            var sigs = new List<string>();
            foreach (var part in header.Split(','))
            {
                var kv = part.Split('=', 2);
                if (kv.Length != 2) continue;
                if (kv[0] == "t") ts = kv[1];
                else if (kv[0] == "v1") sigs.Add(kv[1]);
            }
            if (!long.TryParse(ts, NumberStyles.None, CultureInfo.InvariantCulture, out var t)) return false;
            if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - t) > toleranceSec) return false;
    
            var signed = Encoding.UTF8.GetBytes(ts + ".").Concat(raw).ToArray();
            var expected = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), signed);
            foreach (var s in sigs)
            {
                byte[] got;
                try { got = Convert.FromHexString(s); } catch (FormatException) { continue; }
                // FixedTimeEquals — порівняння за сталий час
                if (CryptographicOperations.FixedTimeEquals(got, expected)) return true;
            }
            return false;
        }
    }
  5. 5.Позначте замовлення оплаченим рівно один раз

    Під час повторної доставки id події той самий, тому запишіть його в таблицю з унікальним ключем в одній транзакції з оновленням замовлення: дубль нічого не змінить. Видавайте товар за payment.confirmed і payment.overpaid, а не за payment.detected. ExecuteSqlAsync повертає кількість змінених рядків, ExecuteUpdateAsync оновлює замовлення без завантаження — обидва з EF Core 7+.

    // OrderPayments.cs (EF Core 7+, PostgreSQL)
    // CREATE TABLE processed_events (event_id text PRIMARY KEY, created_at timestamptz NOT NULL DEFAULT now());
    using Microsoft.EntityFrameworkCore;
    
    static class OrderPayments
    {
        public static async Task MarkPaidOnceAsync(AppDb db, string eventId, long orderId)
        {
            await using var tx = await db.Database.BeginTransactionAsync();
            // параметри інтерполяції EF Core перетворює на SQL-параметри, не на текст
            var fresh = await db.Database.ExecuteSqlAsync(
                $"INSERT INTO processed_events (event_id) VALUES ({eventId}) ON CONFLICT DO NOTHING");
            if (fresh == 1)
            {
                await db.Orders
                    .Where(o => o.Id == orderId && o.Status != "paid")
                    .ExecuteUpdateAsync(s => s.SetProperty(o => o.Status, "paid"));
            }
            await tx.CommitAsync();
        }
    }
  6. 6.Перевірте інтеграцію

    Тестової мережі в Moonvex немає — усе працює в основних мережах. Кнопка «Надіслати тест» у розділі «Інтеграція» надішле подію webhook.test — так перевіряється підпис. Потім оплатіть невеликий рахунок, наприклад 1 USDT у TRON: це звичайний платіж, гроші надійдуть на баланс. Журнал доставок із кодами відповідей і кнопкою «Повторити» — там само.

Підводні камені

  • Форматуйте суми з CultureInfo.InvariantCulture: десяткова кома в amount — помилка валідації.
  • Не оголошуйте параметр-модель для тіла webhook: після десеріалізації байти не збіжаться з підписаними.
  • Глобальна політика авторизації (FallbackPolicy) закриє webhook — додайте .AllowAnonymous() до маршруту.
  • Створюйте HttpClient через IHttpClientFactory, а не new HttpClient() на кожен запит.

Часті запитання

Чи працює це з контролерами MVC?

Так. У дії контролера прочитайте Request.Body у MemoryStream так само, як у прикладі, і не використовуйте [FromBody]. Для MVC з antiforgery додайте [IgnoreAntiforgeryToken].

Що робити, якщо клієнт не встиг оплатити?

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

Чи є тестовий режим або sandbox?

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

Інші гайди

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

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

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