LiqPay — платіжний сервіс ПриватБанку, найбільшого банку України. За оцінками ринку, LiqPay є найпоширенішим PSP в українському e-commerce: значна частина онлайн-магазинів, маркетплейсів та SaaS-сервісів використовують саме його. Він підтримує картки Visa/Mastercard/ПРОСТІР, Apple Pay, Google Pay, «Оплату частинами» та send-money операції.
У цій статті ми крок за кроком побудуємо повноцінний робочий проєкт — ASP.NET Minimal API з інтеграцією LiqPay через Hosted Checkout з обробкою webhook.
Перш ніж писати код, потрібно зареєструватися в LiqPay та отримати тестові ключі.
Перейдіть на liqpay.ua та зареєструйтесь. Для тестування не потрібно проходити верифікацію бізнесу — Sandbox доступний одразу.
Public Key та Private KeyPrivate Key у код або git-репозиторій. Зберігайте його у appsettings.Development.json (локально) або у Secret Manager / Environment Variables (production).LiqPay автоматично переключається в тестовий режим, якщо у запиті є sandbox: 1. У Sandbox:
4242424242424242, CVV: 123, будь-який термін у майбутньомуМи будуємо реальний проєкт з чіткою структурою, що відповідає архітектурі з попередньої статті.
Перш ніж писати будь-який API-запит, зрозумійте найважливіший концепт LiqPay — підпис (signature).
LiqPay не використовує Bearer токени чи API ключі в заголовках. Замість цього кожен запит супроводжується криптографічним підписом, що доводить автентичність відправника.
signature = Base64(SHA1(private_key + data + private_key))
де data — це Base64-encoded JSON-рядок з параметрами запиту.
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));
}
}
Розберімо ключові моменти:
order_id, а не orderId)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;
}
LiqPayPaymentProviderusing 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.
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
});
}
}
Webhook — це HTTP POST-запит від LiqPay на ваш server_url, що повідомляє про зміну статусу транзакції. Обробка webhook — критична частина будь-якої платіжної інтеграції.
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 не ретраїв нескінченно.Щоб протестувати інтеграцію без окремого фронтенду, додамо просту HTML-сторінку:
// Додайте до 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");
dotnet run
Відкрийте https://localhost:5001/api/payments/test-form
checkoutUrl4242424242424242, CVV 123, термін 12/28Для прийому 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 | Опис | Внутрішній статус |
|---|---|---|
success | Успішна оплата (Production) | Settled |
sandbox | Успішна оплата (Sandbox) | Settled |
processing | Банк обробляє платіж | Pending |
hold_wait | Pre-auth: очікує capture | Authorized |
wait_accept | Очікує підтвердження банку | Pending |
wait_3ds | Очікує 3DS аутентифікацію | Pending |
failure | Відхилений банком | Failed |
error | Технічна помилка | Failed |
reversed | Повернення коштів | Refunded |
cash_wait | Очікує оплату готівкою | Pending |
SHA1(privateKey + data + privateKey). Ключі мають бути без пробілів.server_url недоступний з інтернету (localhost).
Вирішення: Використовуйте ngrok або deployте на публічний хост (навіть безкоштовний Railway/Render для тестування).transaction_id перед оновленням статусу.order_id у payload.
Вирішення: Логуйте весь вхідний payload webhook. Переконайтеся, що order_id у вашому запиті збігається з PaymentId у БД.За цією інструкцією ми реалізували повноцінний платіжний flow з LiqPay: від формування підпису та checkout-посилання до прийому та верифікації webhook. Ключові концепти: Base64(SHA1 signature) для автентифікації, ідемпотентна обробка webhook та маппінг статусів на внутрішню стан-машину. Весь код будується на абстракції IPaymentProvider, що дозволяє у наступних статтях підключити Monobank та Stripe без змін бізнес-логіки.
Завдання 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.1: Додайте до LiqPayClient автоматичний retry з exponential backoff при HttpRequestException. Використайте Polly або вбудований HttpClient retry handler. Максимум 3 спроби.
Завдання 2.2: Реалізуйте ідемпотентну обробку webhook: перед оновленням статусу платежу перевіряйте, чи вже не оброблявся цей transaction_id. Зберігайте оброблені ID в таблиці ProcessedWebhooks.
Завдання 3.1: Додайте до Webhook-handler queue-based обробку: замість синхронного оновлення БД, публікуйте подію у System.Threading.Channels.Channel<WebhookEvent>, передаючи дані LiqPay webhook. Окремий BackgroundService слухає канал та оновлює БД. Поясніть переваги цього підходу.