Платежі

Інтеграція LiqPay (ПриватБанк)

[object Object]

Інтеграція LiqPay (ПриватБанк)

Чому LiqPay — перша реальна інтеграція

LiqPay — платіжний сервіс ПриватБанку, найбільшого банку України. За оцінками ринку, LiqPay є найпоширенішим PSP в українському e-commerce: значна частина онлайн-магазинів, маркетплейсів та SaaS-сервісів використовують саме його. Він підтримує картки Visa/Mastercard/ПРОСТІР, Apple Pay, Google Pay, «Оплату частинами» та send-money операції.

У цій статті ми крок за кроком побудуємо повноцінний робочий проєкт — ASP.NET Minimal API з інтеграцією LiqPay через Hosted Checkout з обробкою webhook.

Що ми отримаємо в результаті: повноцінний платіжний endpoint, що створює checkout-сесію LiqPay, веб-форму для тестування та обробник webhook з перевіркою підпису. Весь код запускається в Sandbox-режимі без реальних грошей.

Крок 0: Підготовка — Sandbox та ключі

Перш ніж писати код, потрібно зареєструватися в LiqPay та отримати тестові ключі.

Реєстрація в LiqPay

Перейдіть на liqpay.ua та зареєструйтесь. Для тестування не потрібно проходити верифікацію бізнесу — Sandbox доступний одразу.

Отримання API-ключів

  1. Увійдіть у кабінет розробника
  2. Перейдіть до розділу «Бізнес»«Сайти»«Прийом платежів»
  3. Скопіюйте Public Key та Private Key
Ніколи не додавайте Private Key у код або git-репозиторій. Зберігайте його у appsettings.Development.json (локально) або у Secret Manager / Environment Variables (production).

Ознайомлення з Sandbox

LiqPay автоматично переключається в тестовий режим, якщо у запиті є sandbox: 1. У Sandbox:

  • Гроші не списуються реально
  • Тестові картки: 4242424242424242, CVV: 123, будь-який термін у майбутньому

Крок 1: Структура проєкту

Ми будуємо реальний проєкт з чіткою структурою, що відповідає архітектурі з попередньої статті.


Крок 2: Механіка підпису LiqPay

Перш ніж писати будь-який API-запит, зрозумійте найважливіший концепт LiqPay — підпис (signature).

LiqPay не використовує Bearer токени чи API ключі в заголовках. Замість цього кожен запит супроводжується криптографічним підписом, що доводить автентичність відправника.

Як формується підпис

signature = Base64(SHA1(private_key + data + private_key))

де data — це Base64-encoded JSON-рядок з параметрами запиту.

Providers/LiqPay/LiqPaySignatureHelper.cs
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

public static class LiqPaySignatureHelper
{
    /// <summary>
    /// Генерує підпис для запиту до LiqPay.
    /// Алгоритм: Base64(SHA1(private_key + data + private_key))
    /// </summary>
    public static string GenerateSignature(string data, string privateKey)
    {
        // Крок 1: конкатенація private_key + data + private_key
        var rawString = privateKey + data + privateKey;

        // Крок 2: SHA1-хеш від сконкатенованого рядка
        var bytes = Encoding.UTF8.GetBytes(rawString);
        var hash = SHA1.HashData(bytes);

        // Крок 3: Base64 кодування хешу
        return Convert.ToBase64String(hash);
    }

    /// <summary>
    /// Кодує параметри запиту в Base64(JSON).
    /// </summary>
    public static string EncodeData(object parameters)
    {
        var json = JsonSerializer.Serialize(parameters,
            new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower });
        var bytes = Encoding.UTF8.GetBytes(json);
        return Convert.ToBase64String(bytes);
    }

    /// <summary>
    /// Декодує Base64 data з webhook у словник.
    /// </summary>
    public static Dictionary<string, string>? DecodeWebhookData(string base64Data)
    {
        var bytes = Convert.FromBase64String(base64Data);
        var json = Encoding.UTF8.GetString(bytes);
        return JsonSerializer.Deserialize<Dictionary<string, string>>(json);
    }

    /// <summary>
    /// Перевіряє підпис вхідного webhook.
    /// Повертає true, якщо підпис валідний.
    /// </summary>
    public static bool VerifySignature(string data, string signature, string privateKey)
    {
        var expectedSignature = GenerateSignature(data, privateKey);
        // Порівняння через CryptographicOperations.FixedTimeEquals 
        // запобігає timing attacks
        return CryptographicOperations.FixedTimeEquals(
            Encoding.UTF8.GetBytes(expectedSignature),
            Encoding.UTF8.GetBytes(signature));
    }
}

Розберімо ключові моменти:

  • SHA1: LiqPay досі використовує SHA1 (а не SHA256). Це рішення LiqPay, яке ми не контролюємо — просто дотримуємося специфікації
  • FixedTimeEquals: порівняння підписів виконується за константний час, що унеможливлює timing attack — атаку де зловмисник за часом відповіді вгадує правильний підпис по байтах
  • SnakeCaseLower: LiqPay очікує JSON зі snake_case полями (order_id, а не orderId)

Крок 3: Низькорівневий клієнт LiqPay

Providers/LiqPay/LiqPayClient.cs
using Microsoft.Extensions.Options;

public class LiqPayClient
{
    private readonly HttpClient _http;
    private readonly LiqPayOptions _options;

    // LiqPay API endpoint для перевірки статусу транзакцій
    private const string ApiUrl = "https://www.liqpay.ua/api/request";

    // URL checkout-форми (генерується на стороні клієнта)
    public const string CheckoutBaseUrl = "https://www.liqpay.ua/api/3/checkout";

    public LiqPayClient(HttpClient http, IOptions<LiqPayOptions> options)
    {
        _http = http;
        _options = options.Value;
    }

    /// <summary>
    /// Відправляє API-запит до LiqPay та повертає словник відповіді.
    /// </summary>
    public async Task<Dictionary<string, string>?> SendApiRequestAsync(
        object payload,
        CancellationToken ct = default)
    {
        var data = LiqPaySignatureHelper.EncodeData(payload);
        var signature = LiqPaySignatureHelper.GenerateSignature(data, _options.PrivateKey);

        var formContent = new FormUrlEncodedContent([
            new KeyValuePair<string, string>("data", data),
            new KeyValuePair<string, string>("signature", signature)
        ]);

        var response = await _http.PostAsync(ApiUrl, formContent, ct);
        response.EnsureSuccessStatusCode();

        var json = await response.Content.ReadAsStringAsync(ct);
        return JsonSerializer.Deserialize<Dictionary<string, string>>(json);
    }

    /// <summary>
    /// Генерує параметри для Checkout redirect (data + signature).
    /// Ці параметри передаються у форму або JavaScript Widget.
    /// </summary>
    public (string Data, string Signature) GetCheckoutParams(object payload)
    {
        var data = LiqPaySignatureHelper.EncodeData(payload);
        var signature = LiqPaySignatureHelper.GenerateSignature(data, _options.PrivateKey);
        return (data, signature);
    }

    public string PublicKey => _options.PublicKey;
    public bool IsSandbox => _options.IsSandbox;
}

Крок 4: Реалізація LiqPayPaymentProvider

Providers/LiqPay/LiqPayPaymentProvider.cs
using Microsoft.Extensions.Options;

public class LiqPayPaymentProvider : IPaymentProvider
{
    private readonly LiqPayClient _client;

    public string ProviderName => "liqpay";

    public LiqPayPaymentProvider(LiqPayClient client)
        => _client = client;

    public Task<CreatePaymentResult> CreatePaymentAsync(
        PaymentRequest request,
        CancellationToken ct = default)
    {
        // Формуємо payload для LiqPay Checkout
        var payload = new
        {
            version = 3,
            public_key = _client.PublicKey,
            action = "pay",             // тип операції: оплата
            amount = request.Amount,
            currency = request.Currency,
            description = request.Description,
            order_id = request.PaymentId.ToString(),
            result_url = request.ReturnUrl,
            server_url = request.CallbackUrl,
            sandbox = _client.IsSandbox ? 1 : 0
        };

        // Отримуємо закодовані параметри для форми
        var (data, signature) = _client.GetCheckoutParams(payload);

        // Формуємо URL checkout-сторінки LiqPay
        var checkoutUrl =
            $"{LiqPayClient.CheckoutBaseUrl}?data={Uri.EscapeDataString(data)}" +
            $"&signature={Uri.EscapeDataString(signature)}";

        var result = new CreatePaymentResult(
            Success: true,
            CheckoutUrl: checkoutUrl,
            FormData: data,         // Також повертаємо для embedded виджету
            ProviderPaymentId: null, // LiqPay не повертає ID до редиректу
            ErrorMessage: null
        );

        return Task.FromResult(result);
    }

    public async Task<PaymentStatusResult> GetPaymentStatusAsync(
        string providerTransactionId,
        CancellationToken ct = default)
    {
        var payload = new
        {
            version = 3,
            public_key = _client.PublicKey,
            action = "status",
            order_id = providerTransactionId
        };

        var response = await _client.SendApiRequestAsync(payload, ct);

        if (response is null)
            return new PaymentStatusResult(false, PaymentStatus.Failed, null,
                "Empty response from LiqPay");

        response.TryGetValue("status", out var status);
        response.TryGetValue("transaction_id", out var txId);

        return new PaymentStatusResult(
            Success: true,
            Status: MapLiqPayStatus(status),
            ProviderTransactionId: txId,
            ErrorMessage: null
        );
    }

    public async Task<RefundResult> RefundAsync(
        string providerTransactionId,
        decimal amount,
        CancellationToken ct = default)
    {
        var payload = new
        {
            version = 3,
            public_key = _client.PublicKey,
            action = "refund",
            order_id = providerTransactionId,
            amount = amount
        };

        var response = await _client.SendApiRequestAsync(payload, ct);

        if (response is null)
            return new RefundResult(false, null, "Empty response from LiqPay");

        response.TryGetValue("result", out var result);
        response.TryGetValue("transaction_id", out var txId);

        return result == "ok"
            ? new RefundResult(true, txId, null)
            : new RefundResult(false, null, response.GetValueOrDefault("err_description"));
    }

    public Task<WebhookResult> ProcessWebhookAsync(
        HttpRequest httpRequest,
        CancellationToken ct = default)
    {
        // Webhook обробляється в окремому ендпоінті — делегуємо туди
        throw new NotSupportedException(
            "Use WebhookEndpoints directly for LiqPay webhook processing");
    }

    // Маппінг статусів LiqPay → внутрішня PaymentStatus
    private static PaymentStatus MapLiqPayStatus(string? liqpayStatus) =>
        liqpayStatus switch
        {
            "success"           => PaymentStatus.Settled,
            "sandbox"           => PaymentStatus.Settled,   // sandbox success
            "hold_wait"         => PaymentStatus.Authorized, // pre-auth очікує capture
            "processing"        => PaymentStatus.Pending,
            "wait_accept"       => PaymentStatus.Pending,    // очікує підтвердження банку
            "wait_card"         => PaymentStatus.Pending,
            "wait_3ds"          => PaymentStatus.Pending,    // очікує 3DS
            "failure"           => PaymentStatus.Failed,
            "error"             => PaymentStatus.Failed,
            "reversed"          => PaymentStatus.Refunded,
            "cash_wait"         => PaymentStatus.Pending,
            _                   => PaymentStatus.Failed
        };
}

Маппінг статусів (MapLiqPayStatus) — критично важлива частина інтеграції. LiqPay повертає більше десяти різних статусів, і кожен потрібно правильно відобразити на вашу внутрішню стан-машину. Зверніть увагу: sandbox — це успішна sandbox-транзакція, вона маппується як Settled.


Крок 5: Endpoints — Minimal API

Endpoints/PaymentEndpoints.cs
public static class PaymentEndpoints
{
    public static IEndpointRouteBuilder MapPaymentEndpoints(
        this IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/api/payments")
            .WithTags("Payments");

        // Ініціювання платежу → повертає checkout URL
        group.MapPost("/", CreatePayment)
            .WithName("CreatePayment")
            .WithSummary("Ініціювати платіж через LiqPay");

        // Перевірка статусу
        group.MapGet("/{id}/status", GetPaymentStatus)
            .WithName("GetPaymentStatus");

        return app;
    }

    private static async Task<IResult> CreatePayment(
        [FromBody] CreatePaymentRequest request,
        PaymentService paymentService,
        CancellationToken ct)
    {
        var payment = await paymentService.CreatePaymentAsync(request);
        var checkoutUrl = await paymentService.InitiatePaymentAsync(payment.Id, "liqpay");

        return Results.Ok(new
        {
            PaymentId = payment.Id,
            CheckoutUrl = checkoutUrl,
            Message = "Перейдіть за посиланням для оплати"
        });
    }

    private static async Task<IResult> GetPaymentStatus(
        Guid id,
        PaymentService paymentService,
        CancellationToken ct)
    {
        var payment = await paymentService.GetPaymentAsync(id);
        if (payment is null)
            return Results.NotFound();

        return Results.Ok(new
        {
            payment.Id,
            payment.Status,
            payment.ProviderTransactionId,
            payment.Amount,
            payment.Currency
        });
    }
}

Крок 6: Webhook endpoint

Webhook — це HTTP POST-запит від LiqPay на ваш server_url, що повідомляє про зміну статусу транзакції. Обробка webhook — критична частина будь-якої платіжної інтеграції.

Endpoints/WebhookEndpoints.cs
public static class WebhookEndpoints
{
    public static IEndpointRouteBuilder MapWebhookEndpoints(
        this IEndpointRouteBuilder app)
    {
        app.MapPost("/webhooks/liqpay", HandleLiqPayWebhook)
            .WithTags("Webhooks")
            .WithName("LiqPayWebhook");

        return app;
    }

    private static async Task<IResult> HandleLiqPayWebhook(
        HttpRequest request,
        IOptions<LiqPayOptions> options,
        PaymentService paymentService,
        ILogger<Program> logger,
        CancellationToken ct)
    {
        // Крок 1: Зчитуємо тіло запиту
        var form = await request.ReadFormAsync(ct);
        var data = form["data"].ToString();
        var signature = form["signature"].ToString();

        if (string.IsNullOrEmpty(data) || string.IsNullOrEmpty(signature))
        {
            logger.LogWarning("LiqPay webhook: missing data or signature");
            return Results.BadRequest("Missing required fields");
        }

        // Крок 2: Верифікація підпису
        var isValid = LiqPaySignatureHelper.VerifySignature(
            data, signature, options.Value.PrivateKey);

        if (!isValid)
        {
            logger.LogWarning("LiqPay webhook: invalid signature detected");
            // Повертаємо 200 щоб уникнути retry від LiqPay з тими ж даними
            return Results.Ok("Invalid signature");
        }

        // Крок 3: Декодуємо payload
        var payload = LiqPaySignatureHelper.DecodeWebhookData(data);
        if (payload is null)
        {
            logger.LogError("LiqPay webhook: failed to decode payload");
            return Results.Ok("Decode error");
        }

        // Крок 4: Витягуємо ключові поля
        payload.TryGetValue("order_id", out var orderId);
        payload.TryGetValue("status", out var status);
        payload.TryGetValue("transaction_id", out var transactionId);
        payload.TryGetValue("amount", out var amountStr);

        logger.LogInformation(
            "LiqPay webhook received: order={OrderId}, status={Status}, txId={TxId}",
            orderId, status, transactionId);

        // Крок 5: Оновлюємо статус платежу в нашій системі
        if (orderId is not null && Guid.TryParse(orderId, out var paymentId))
        {
            await paymentService.UpdatePaymentFromWebhookAsync(
                paymentId: paymentId,
                providerTransactionId: transactionId,
                newStatus: MapLiqPayStatus(status),
                rawPayload: data);
        }

        // LiqPay очікує відповідь 200 — будь-які помилки потрібно логувати, не повертати 5xx
        return Results.Ok();
    }

    private static PaymentStatus MapLiqPayStatus(string? status) =>
        status switch
        {
            "success"  => PaymentStatus.Settled,
            "sandbox"  => PaymentStatus.Settled,
            "failure"  => PaymentStatus.Failed,
            "reversed" => PaymentStatus.Refunded,
            _          => PaymentStatus.Pending
        };
}
Чому ми повертаємо 200 OK навіть при помилці підпису? LiqPay поводить себе агресивно з retry: якщо отримає 4xx або 5xx — надішле webhook повторно. Логуємо проблему, але відповідаємо 200, щоб LiqPay не ретраїв нескінченно.

Крок 7: Тестова HTML-форма

Щоб протестувати інтеграцію без окремого фронтенду, додамо просту HTML-сторінку:

Endpoints/PaymentEndpoints.cs
// Додайте до MapPaymentEndpoints:
group.MapGet("/test-form", () => Results.Content("""
    <!DOCTYPE html>
    <html>
    <head><title>LiqPay Test</title></head>
    <body>
        <h1>Тестова оплата LiqPay</h1>
        <button onclick="createPayment()">Оплатити 100 грн</button>
        <div id="result"></div>
        <script>
        async function createPayment() {
            const resp = await fetch('/api/payments', {
                method: 'POST',
                headers: {'Content-Type': 'application/json'},
                body: JSON.stringify({
                    orderId: crypto.randomUUID(),
                    amount: 100,
                    currency: 'UAH',
                    description: 'Test payment'
                })
            });
            const data = await resp.json();
            document.getElementById('result').innerHTML =
                `<a href="${data.checkoutUrl}" target="_blank">Перейти до оплати LiqPay</a>`;
        }
        </script>
    </body>
    </html>
    """, "text/html"))
    .ExcludeFromDescription()  // приховати з Swagger
    .WithName("TestPaymentForm");

Крок 8: Тестування в Sandbox

Запуск проєкту

dotnet run

Відкрийте https://localhost:5001/api/payments/test-form

Крок тестової оплати

  1. Натисніть «Оплатити 100 грн» — API поверне checkoutUrl
  2. Перейдіть за посиланням — відкриється сторінка LiqPay
  3. Введіть тестову картку: 4242424242424242, CVV 123, термін 12/28
  4. Підтвердіть оплату

Перевірка webhook

Для прийому webhook локально використовуйте ngrok:

ngrok http 5001

Ngrok надасть публічний URL на кшталт https://abc123.ngrok.io. Вкажіть його як server_url у LiqPay payload:

server_url = "https://abc123.ngrok.io/webhooks/liqpay"

Після успішної оплати в логах побачите:

LiqPay webhook received: order=..., status=sandbox, txId=...

Статуси LiqPay: повна таблиця

Статус LiqPayОписВнутрішній статус
successУспішна оплата (Production)Settled
sandboxУспішна оплата (Sandbox)Settled
processingБанк обробляє платіжPending
hold_waitPre-auth: очікує captureAuthorized
wait_acceptОчікує підтвердження банкуPending
wait_3dsОчікує 3DS аутентифікаціюPending
failureВідхилений банкомFailed
errorТехнічна помилкаFailed
reversedПовернення коштівRefunded
cash_waitОчікує оплату готівкоюPending

Часті помилки та їх вирішення


Підсумок

За цією інструкцією ми реалізували повноцінний платіжний flow з LiqPay: від формування підпису та checkout-посилання до прийому та верифікації webhook. Ключові концепти: Base64(SHA1 signature) для автентифікації, ідемпотентна обробка webhook та маппінг статусів на внутрішню стан-машину. Весь код будується на абстракції IPaymentProvider, що дозволяє у наступних статтях підключити Monobank та Stripe без змін бізнес-логіки.

Практичні завдання

Рівень 1: Початковий (верифікація підпису)

Завдання 1.1: Напишіть unit-тест для LiqPaySignatureHelper.GenerateSignature, що перевіряє: для відомого data = "dGVzdA==" та privateKey = "test_private_key" повертається очікуваний підпис.

Завдання 1.2: Реалізуйте ендпоінт GET /api/payments/sandbox-test-cards, що повертає список тестових карток LiqPay у вигляді JSON (картка, поведінка: success/decline/3ds_required).

Рівень 2: Покращення (retry та логування)

Завдання 2.1: Додайте до LiqPayClient автоматичний retry з exponential backoff при HttpRequestException. Використайте Polly або вбудований HttpClient retry handler. Максимум 3 спроби.

Завдання 2.2: Реалізуйте ідемпотентну обробку webhook: перед оновленням статусу платежу перевіряйте, чи вже не оброблявся цей transaction_id. Зберігайте оброблені ID в таблиці ProcessedWebhooks.

Рівень 3: Повний flow (Production-ready)

Завдання 3.1: Додайте до Webhook-handler queue-based обробку: замість синхронного оновлення БД, публікуйте подію у System.Threading.Channels.Channel<WebhookEvent>, передаючи дані LiqPay webhook. Окремий BackgroundService слухає канал та оновлює БД. Поясніть переваги цього підходу.