Платежі

Інтеграція Monobank Acquiring API

Покрокова інтеграція Monobank Acquiring — Invoice API, QR-оплата, ECDSA верифікація webhook та реалізація MonobankPaymentProvider.

Інтеграція Monobank Acquiring API

Monobank як платіжний партнер для бізнесу

Monobank — найскоріший за зростанням банк України, що має понад 9 мільйонів клієнтів (дані 2024 року). Для бізнесу це означає: значна частина ваших потенційних покупців вже є клієнтами Monobank і звикли до платежів через їхній додаток.

Monobank Acquiring — B2B-продукт банку з REST API для прийому безготівкових платежів. Серед його переваг: простота інтеграції, конкурентні тарифи та підтримка QR-інвойсів (особливо актуально для харчового бізнесу та сервісних компаній).


Крок 0: Підготовка

Реєстрація

Monobank Acquiring доступний через business.monobank.ua. Для тестування вам потрібно отримати токен у підтримці Monobank (або через особистий кабінет після верифікації ФОП/юрособи).

Тестовий токен: Напишіть у Monobank Business підтримку з проханням надати sandbox-токен для тестування API.

Документація

Актуальна документація: api.monobank.ua/docs


Крок 1: Конфігурація

appsettings.Development.json
{
  "Monobank": {
    "Token": "your_monobank_acquiring_token",
    "WebhookPublicKey": "your_public_key_for_verification",
    "IsSandbox": true
  }
}
Options/MonobankOptions.cs
public class MonobankOptions
{
    public string Token { get; set; } = null!;
    // Публічний ключ для верифікації ECDSA-підпису webhook
    public string WebhookPublicKey { get; set; } = null!;
    public bool IsSandbox { get; set; }
}

Крок 2: Механіка Monobank API

Monobank Acquiring відрізняється від LiqPay за підходом до автентифікації та структурою API.

Автентифікація: Bearer-токен в заголовку X-Token. Жодних підписів для запитів — токен є єдиним ключем.

Webhook-підпис: На відміну від LiqPay (HMAC-SHA1), Monobank використовує ECDSA-підпис (Elliptic Curve Digital Signature Algorithm) на основі публічного ключа. Верифікація є складнішою, але криптографічно надійнішою.

Providers/Monobank/MonobankClient.cs
public class MonobankClient
{
    private readonly HttpClient _http;
    private readonly MonobankOptions _options;

    private const string BaseUrl = "https://api.monobank.ua/api/merchant";

    public MonobankClient(HttpClient http, IOptions<MonobankOptions> options)
    {
        _http = http;
        _options = options.Value;
        _http.DefaultRequestHeaders.Add("X-Token", _options.Token);
        _http.BaseAddress = new Uri(BaseUrl);
    }

    /// <summary>
    /// Створює інвойс на оплату. Повертає URL та QR-код.
    /// </summary>
    public async Task<MonobankInvoiceResponse?> CreateInvoiceAsync(
        MonobankInvoiceRequest request,
        CancellationToken ct = default)
    {
        var response = await _http.PostAsJsonAsync("/invoice/create", request, ct);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<MonobankInvoiceResponse>(ct);
    }

    /// <summary>
    /// Отримує статус інвойсу за його ID.
    /// </summary>
    public async Task<MonobankInvoiceStatus?> GetInvoiceStatusAsync(
        string invoiceId,
        CancellationToken ct = default)
    {
        var response = await _http.GetAsync($"/invoice/status?invoiceId={invoiceId}", ct);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<MonobankInvoiceStatus>(ct);
    }

    /// <summary>
    /// Скасовує інвойс (лише до його оплати).
    /// </summary>
    public async Task CancelInvoiceAsync(string invoiceId, CancellationToken ct = default)
    {
        var response = await _http.PostAsJsonAsync("/invoice/cancel",
            new { invoiceId }, ct);
        response.EnsureSuccessStatusCode();
    }

    /// <summary>
    /// Ініціює повернення коштів.
    /// </summary>
    public async Task<MonobankRefundResponse?> RefundAsync(
        string invoiceId, decimal amount, CancellationToken ct = default)
    {
        var response = await _http.PostAsJsonAsync("/invoice/cancel/list",
            new { invoiceList = new[] { new { invoiceId } } }, ct);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<MonobankRefundResponse>(ct);
    }

    public MonobankOptions Options => _options;
}

Крок 3: DTOs для Monobank API

Providers/Monobank/MonobankDtos.cs
using System.Text.Json.Serialization;

// Запит на створення інвойсу
public class MonobankInvoiceRequest
{
    [JsonPropertyName("amount")]
    public long Amount { get; set; } // У копійках! 100 грн = 10000

    [JsonPropertyName("ccy")]
    public int Ccy { get; set; } = 980; // 980 = UAH (ISO 4217)

    [JsonPropertyName("merchantPaymInfo")]
    public MerchantPaymInfo? MerchantPaymInfo { get; set; }

    [JsonPropertyName("redirectUrl")]
    public string? RedirectUrl { get; set; }

    [JsonPropertyName("webHookUrl")]
    public string? WebHookUrl { get; set; }
}

public class MerchantPaymInfo
{
    [JsonPropertyName("reference")]
    public string Reference { get; set; } = null!; // Ваш order/payment ID

    [JsonPropertyName("destination")]
    public string Destination { get; set; } = null!; // Опис для виписки клієнта
}

// Відповідь від API при створенні інвойсу
public record MonobankInvoiceResponse(
    [property: JsonPropertyName("invoiceId")] string InvoiceId,
    [property: JsonPropertyName("pageUrl")] string PageUrl,   // Redirect URL
    [property: JsonPropertyName("qrCode")] string? QrCode     // SVG рядок QR-коду
);

// Статус інвойсу
public record MonobankInvoiceStatus(
    [property: JsonPropertyName("invoiceId")] string InvoiceId,
    [property: JsonPropertyName("status")] string Status,
    [property: JsonPropertyName("amount")] long Amount,
    [property: JsonPropertyName("ccy")] int Ccy,
    [property: JsonPropertyName("reference")] string? Reference
);

public record MonobankRefundResponse(
    [property: JsonPropertyName("invoiceId")] string InvoiceId,
    [property: JsonPropertyName("status")] string Status
);
Важливо: Monobank приймає суму у копійках (найменших одиницях валюти). 100 грн = 10000. Це поширена помилка при інтеграції — завжди конвертуйте decimal amount * 100long.

Крок 4: ECDSA верифікація webhook

Це найскладніша частина інтеграції Monobank. Замість HMAC-SHA1 (як у LiqPay), Monobank підписує webhook за допомогою ECDSA з кривою P-256 (також відомою як secp256r1).

Providers/Monobank/MonobankSignatureVerifier.cs
using System.Security.Cryptography;
using System.Text;

public static class MonobankSignatureVerifier
{
    /// <summary>
    /// Верифікує ECDSA-підпис webhook від Monobank.
    /// </summary>
    /// <param name="body">Сире тіло HTTP-запиту (байти)</param>
    /// <param name="signatureBase64">Підпис з заголовка X-Sign</param>
    /// <param name="publicKeyBase64">Публічний ключ у Base64 (з документації Monobank)</param>
    public static bool Verify(byte[] body, string signatureBase64, string publicKeyBase64)
    {
        try
        {
            // Декодуємо підпис та публічний ключ з Base64
            var signature = Convert.FromBase64String(signatureBase64);
            var publicKeyBytes = Convert.FromBase64String(publicKeyBase64);

            // Імпортуємо публічний ключ з X.509 SubjectPublicKeyInfo формату
            using var ecdsa = ECDsa.Create();
            ecdsa.ImportSubjectPublicKeyInfo(publicKeyBytes, out _);

            // Верифікуємо підпис. Monobank використовує SHA256 digest.
            // DSASignatureFormat.Rfc3279DerSequence — стандартний DER-формат підпису.
            return ecdsa.VerifyData(
                body,
                signature,
                HashAlgorithmName.SHA256,
                DSASignatureFormat.Rfc3279DerSequence);
        }
        catch (Exception)
        {
            // Будь-яка помилка при верифікації = підпис невалідний
            return false;
        }
    }
}

Крок 5: Реалізація MonobankPaymentProvider

Providers/Monobank/MonobankPaymentProvider.cs
public class MonobankPaymentProvider : IPaymentProvider
{
    private readonly MonobankClient _client;

    public string ProviderName => "monobank";

    public MonobankPaymentProvider(MonobankClient client)
        => _client = client;

    public async Task<CreatePaymentResult> CreatePaymentAsync(
        PaymentRequest request,
        CancellationToken ct = default)
    {
        var invoiceRequest = new MonobankInvoiceRequest
        {
            // Конвертуємо гривні в копійки
            Amount = (long)(request.Amount * 100),
            Ccy = 980, // UAH
            MerchantPaymInfo = new MerchantPaymInfo
            {
                Reference = request.PaymentId.ToString(),
                Destination = request.Description
            },
            RedirectUrl = request.ReturnUrl,
            WebHookUrl = request.CallbackUrl
        };

        var response = await _client.CreateInvoiceAsync(invoiceRequest, ct);

        if (response is null)
            return new CreatePaymentResult(false, null, null, null,
                "Null response from Monobank");

        return new CreatePaymentResult(
            Success: true,
            CheckoutUrl: response.PageUrl,
            FormData: response.QrCode,       // QR-код у SVG для відображення
            ProviderPaymentId: response.InvoiceId,
            ErrorMessage: null
        );
    }

    public async Task<PaymentStatusResult> GetPaymentStatusAsync(
        string providerTransactionId,
        CancellationToken ct = default)
    {
        var status = await _client.GetInvoiceStatusAsync(providerTransactionId, ct);

        if (status is null)
            return new PaymentStatusResult(false, PaymentStatus.Failed, null, "Null response");

        return new PaymentStatusResult(
            Success: true,
            Status: MapMonobankStatus(status.Status),
            ProviderTransactionId: status.InvoiceId,
            ErrorMessage: null
        );
    }

    public async Task<RefundResult> RefundAsync(
        string providerTransactionId,
        decimal amount,
        CancellationToken ct = default)
    {
        var result = await _client.RefundAsync(providerTransactionId, amount, ct);

        return result?.Status == "success"
            ? new RefundResult(true, result.InvoiceId, null)
            : new RefundResult(false, null, "Refund failed");
    }

    public Task<WebhookResult> ProcessWebhookAsync(
        HttpRequest httpRequest,
        CancellationToken ct = default)
        => throw new NotSupportedException("Use WebhookEndpoints for Monobank webhook");

    private static PaymentStatus MapMonobankStatus(string? status) =>
        status switch
        {
            "success"    => PaymentStatus.Settled,
            "processing" => PaymentStatus.Pending,
            "hold"       => PaymentStatus.Authorized,
            "failure"    => PaymentStatus.Failed,
            "reversed"   => PaymentStatus.Refunded,
            "expired"    => PaymentStatus.Failed,
            _            => PaymentStatus.Failed
        };
}

Крок 6: Monobank Webhook endpoint

Endpoints/WebhookEndpoints.cs
// Додайте до MapWebhookEndpoints:
app.MapPost("/webhooks/monobank", HandleMonobankWebhook)
    .WithTags("Webhooks");

private static async Task<IResult> HandleMonobankWebhook(
    HttpRequest request,
    IOptions<MonobankOptions> options,
    PaymentService paymentService,
    ILogger<Program> logger,
    CancellationToken ct)
{
    // Зчитуємо тіло як байти (необхідно для верифікації підпису!)
    using var ms = new MemoryStream();
    await request.Body.CopyToAsync(ms, ct);
    var bodyBytes = ms.ToArray();

    // Отримуємо підпис з заголовка X-Sign
    var signature = request.Headers["X-Sign"].ToString();

    if (string.IsNullOrEmpty(signature))
    {
        logger.LogWarning("Monobank webhook: missing X-Sign header");
        return Results.BadRequest();
    }

    // Верифікуємо ECDSA підпис
    var isValid = MonobankSignatureVerifier.Verify(
        bodyBytes, signature, options.Value.WebhookPublicKey);

    if (!isValid)
    {
        logger.LogWarning("Monobank webhook: invalid ECDSA signature");
        return Results.Ok(); // Відповідаємо 200 щоб уникнути retry
    }

    // Парсимо JSON payload
    var payload = JsonSerializer.Deserialize<MonobankWebhookPayload>(bodyBytes);

    if (payload is null) return Results.Ok("Parse error");

    logger.LogInformation(
        "Monobank webhook: invoiceId={InvoiceId}, status={Status}",
        payload.InvoiceId, payload.Status);

    // Знаходимо payment за invoiceId та оновлюємо статус
    await paymentService.UpdatePaymentByProviderIdAsync(
        providerTransactionId: payload.InvoiceId,
        newStatus: MapStatus(payload.Status),
        rawPayload: Encoding.UTF8.GetString(bodyBytes));

    return Results.Ok();
}

private static PaymentStatus MapStatus(string? status) =>
    status switch
    {
        "success" => PaymentStatus.Settled,
        "failure" => PaymentStatus.Failed,
        "reversed" => PaymentStatus.Refunded,
        _ => PaymentStatus.Pending
    };
Providers/Monobank/MonobankWebhookPayload.cs
public record MonobankWebhookPayload(
    [property: JsonPropertyName("invoiceId")] string InvoiceId,
    [property: JsonPropertyName("status")] string Status,
    [property: JsonPropertyName("amount")] long Amount,
    [property: JsonPropertyName("ccy")] int Ccy,
    [property: JsonPropertyName("reference")] string? Reference
);

Підсумок

Monobank Acquiring виділяється серед українських PSP чистим REST API та сучаснішою криптографією (ECDSA замість HMAC-SHA1). Ключові відмінності від LiqPay: сума передається у копійках, автентифікація через Bearer токен, а верифікація webhook потребує імпорту ECDSA публічного ключа. Завдяки IPaymentProvider-абстракції, підключення Monobank не вплинуло на логіку PaymentService — ми тільки додали нову реалізацію.