Приём криптоплатежей в 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 с антифорджери добавьте [IgnoreAntiforgeryToken].

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

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

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

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

Другие гайды

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

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

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