Panduan · Terbit
Integrasi payment gateway di ASP.NET Core: body webhook dibaca sendiri sebelum System.Text.Json menyentuhnya, decimal yang terkirim sebagai 150000.00, dan HttpClient yang menunggu 100 detik
Panduan ini memasang Kasera Pay di aplikasi ASP.NET Core 8 dengan C# 12, tanpa SDK dan tanpa paket NuGet tambahan: HttpClient dari IHttpClientFactory untuk memanggil API, System.Text.Json untuk JSON, dan System.Security.Cryptography untuk tanda tangan webhook. Contohnya memakai minimal API dan EF Core; bentuk controller MVC disebutkan di tempat perbedaannya penting. Referensi endpoint lengkapnya ada di dokumentasi API Kasera Pay.
Kontraknya sama dengan panduan bahasa lain. Yang membuat ASP.NET Core berbeda adalah empat kebiasaan bawaan kerangka kerjanya yang diam-diam merusak integrasi pembayaran: HttpClient yang menunggu 100 detik, JSON camelCase yang tidak dikenali API, decimal yang terkirim dengan angka di belakang koma, dan binding body yang menghabiskan byte webhook sebelum sempat diverifikasi. Pola yang sama dalam Java dengan jebakan yang berbeda ada di panduan Spring Boot.
1. Kredensial dan metode yang aktif
// appsettings.json: hanya alamatnya. Key dan secret lewat user-secrets saat
// membangun dan variabel lingkungan KaseraPay__Key / KaseraPay__WebhookSecret
// di server, bukan di file yang ikut ter-commit.
{
"KaseraPay": {
"BaseUrl": "https://pay.kasera.id"
}
}API key dibawa sebagai bearer token, berawalan kp_test_ selama membangun dan kp_live_ setelah go-live. Signing secret webhook diambil dari dasbor, menu Developer, dan berbeda per mode: secret tes tidak pernah bisa memverifikasi payload live. Dua garis bawah pada nama variabel lingkungan adalah cara konfigurasi .NET membaca bagian bertingkat, jadi KaseraPay__Key mengisi KaseraPay:Key. Metode yang bisa disebut di payment_methods saat ini adalah qris dan delapan kode Virtual Account: va_bca, va_bri, va_bni, va_mandiri, va_permata, va_cimb, va_danamon, dan va_maybank. QRIS dikenai 0,7% + Rp 250 per transaksi berhasil dan Virtual Account Rp 5.000 tetap.
2. Klien, record, dan opsi JSON yang tidak boleh global
public sealed class KaseraPayOptions
{
public string BaseUrl { get; set; } = "https://pay.kasera.id";
public string Key { get; set; } = "";
public string WebhookSecret { get; set; } = "";
}
// Opsi JSON milik integrasi ini saja. ConfigureHttpJsonOptions atau
// AddJsonOptions akan mengubah JSON setiap endpoint aplikasi sendiri.
public static class KaseraJson
{
public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
}
// Nominal selalu long. Rupiah di API adalah bilangan bulat.
public sealed record CreateRequest(
long Amount,
string? Description = null,
string? ExternalId = null,
Customer? Customer = null,
string? ReturnUrl = null,
IReadOnlyList<string>? PaymentMethods = null);
public sealed record Customer(string? Name = null, string? Email = null);
public sealed record Transaction(string Id, string Status, long Amount, long Fee, long Net,
string CheckoutUrl, DateTimeOffset ExpiresAt);
public sealed class KaseraPayException(int status, string? code, string? requestId)
: Exception($"kasera pay {status} {code} request_id={requestId}")
{
public int Status { get; } = status;
public string? Code { get; } = code;
public string? RequestId { get; } = requestId;
// 429 dan 5xx boleh diulang dengan Idempotency-Key yang sama.
// Sisanya akan ditolak lagi dengan body yang sama.
public bool Retryable => Status == 429 || Status >= 500;
}
public sealed class KaseraPayClient(HttpClient http)
{
public async Task<Transaction> CreateAsync(CreateRequest body, string idempotencyKey,
CancellationToken ct = default)
{
using var req = new HttpRequestMessage(HttpMethod.Post, "/v1/transactions")
{
Content = JsonContent.Create(body, options: KaseraJson.Options),
};
req.Headers.Add("Idempotency-Key", idempotencyKey);
using var res = await http.SendAsync(req, ct);
if (!res.IsSuccessStatusCode)
{
string? code = null;
try
{
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct));
code = doc.RootElement.GetProperty("error").GetProperty("code").GetString();
}
catch (Exception e) when (e is JsonException or KeyNotFoundException
or InvalidOperationException)
{
// Balasan proxy berupa HTML tetap dilaporkan lewat statusnya.
}
res.Headers.TryGetValues("X-Request-Id", out var ids);
throw new KaseraPayException((int)res.StatusCode, code, ids?.FirstOrDefault());
}
return (await res.Content.ReadFromJsonAsync<Transaction>(KaseraJson.Options, ct))!;
}
}snake_case dipasang pada opsi milik integrasi. Bawaan ASP.NET Core adalah camelCase, sedangkan API memakai external_id dan checkout_url. JsonNamingPolicy.SnakeCaseLower tersedia sejak .NET 8, tetapi memasangnya lewat ConfigureHttpJsonOptions mengubah JSON setiap endpoint aplikasi, termasuk API yang sudah dipakai frontend sendiri. Satu JsonSerializerOptions statis membatasi pengaruhnya pada tipe integrasi ini.WhenWritingNull membuat field yang tidak diisi dihilangkan dari body, bukan dikirim sebagai null. Field baru pada response diabaikan secara bawaan oleh System.Text.Json, jadi tambahan di sisi API tidak mematahkan apa pun.
Nominal adalah long, tidak pernah decimal. Kebiasaan C# menyimpan uang sebagai decimal di sini justru merugikan. Nilai dari kolom decimal(18,2) membawa skalanya, System.Text.Json menulisnya apa adanya sebagai 150000.00, dan API yang meminta bilangan bulat menjawab 400 invalid_body dengan pesan yang menyebut amount. Rupiah di API selalu bilangan bulat.
Galat dari API berbentuk error.code, dan keputusan mengulang diambil dari kode itu, bukan dari teks pesannya. Header X-Request-Id ikut disimpan di pengecualian karena nilainya yang disebutkan saat menghubungi dukungan. Daftar kodenya ada di dokumentasi galat.
3. Registrasi: typed client dan batas waktu
// Program.cs
builder.Services.Configure<KaseraPayOptions>(builder.Configuration.GetSection("KaseraPay"));
builder.Services.AddHttpClient<KaseraPayClient>((sp, http) =>
{
var o = sp.GetRequiredService<IOptions<KaseraPayOptions>>().Value;
http.BaseAddress = new Uri(o.BaseUrl);
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", o.Key);
// Bawaannya 100 detik. Selama itu request checkout menggantung.
http.Timeout = TimeSpan.FromSeconds(20);
});
builder.Services.AddSingleton<WebhookVerifier>();
builder.Services.AddScoped<PaymentEventHandler>();AddHttpClient memakai ulang koneksi, sesuatu yang tidak dilakukan new HttpClient() per permintaan. Yang tidak diurusnya adalah batas waktu. HttpClient.Timeout bawaannya 100 detik, dan pada jalur checkout itu berarti request yang menggantung sementara pembeli menekan tombol bayar sekali lagi. Dua puluh detik adalah titik awal yang wajar. Verifier didaftarkan singleton karena hanya memegang secret; handler event scoped karena memakai DbContext.
4. Membuat tagihan dengan key yang lebih tua dari percobaannya
app.MapPost("/pesanan/{number}/bayar", async (string number, AppDb db,
KaseraPayClient kasera, CancellationToken ct) =>
{
var order = await db.Orders.SingleAsync(o => o.Number == number, ct);
// Key dibuat sekali dan disimpan sebelum panggilan pertama. Key baru pada
// tiap percobaan menghapus proteksinya tanpa galat, dan hasilnya dua
// tagihan untuk satu pesanan.
if (order.IdempotencyKey is null)
{
order.IdempotencyKey = $"order-{order.Number}-{Guid.NewGuid():N}";
await db.SaveChangesAsync(ct);
}
var tx = await kasera.CreateAsync(new CreateRequest(
Amount: order.TotalRupiah, // long, bukan decimal
Description: $"Pesanan {order.Number}",
ExternalId: order.Number,
Customer: new Customer(order.CustomerName),
ReturnUrl: $"https://toko.example/pesanan/{order.Number}",
PaymentMethods: ["qris", "va_bca"]), order.IdempotencyKey, ct);
order.PaymentRequestId = tx.Id;
await db.SaveChangesAsync(ct);
return Results.Redirect(tx.CheckoutUrl);
}).RequireAuthorization();Hanya header Idempotency-Key yang mencegah satu pesanan menjadi dua pembayaran. external_id dan merchant_ref hanya label yang disimpan dan bisa difilter, dan dua create dengan nilai yang sama tetap menjadi dua permintaan. Key yang dibangkitkan di dalam blok yang diulang, baik oleh loop sendiri maupun oleh kebijakan retry Polly atau Microsoft.Extensions.Http.Resilience, berganti pada tiap percobaan dan proteksinya hilang tanpa peringatan. Kalau pipeline retry dipasang pada typed client, batasi pada 429 dan 5xx: 422 dan 409 akan ditolak lagi dengan body yang sama. Satu hal yang perlu diingat saat pembeli kembali sejam kemudian: key yang sama mengembalikan tagihan lama yang sudah kedaluwarsa, jadi penerbitan ulang butuh key baru. Latar belakangnya ada di tulisan tentang idempotency.
5. Endpoint webhook yang membaca byte sendiri
app.MapPost("/webhooks/kasera-pay", async (HttpRequest request, WebhookVerifier verifier,
PaymentEventHandler handler, CancellationToken ct) =>
{
// Tidak ada parameter KaseraEvent. Parameter bertipe objek membuat
// System.Text.Json menghabiskan stream body sebelum handler ini berjalan,
// dan byte yang ditandatangani tidak tersisa di mana pun.
using var buffer = new MemoryStream();
await request.Body.CopyToAsync(buffer, ct); // asinkron: Kestrel menolak baca sinkron
var body = buffer.ToArray();
var signature = request.Headers["Kasera-Signature-V1"].ToString();
if (!verifier.Verify(body, signature)) return Results.BadRequest();
var evt = JsonSerializer.Deserialize<KaseraEvent>(body, KaseraJson.Options)!;
await handler.HandleAsync(evt, ct); // pengecualian di sini menjadi 500, dan 500 diulang
return Results.Ok();
})
.AllowAnonymous(); // FallbackPolicy yang mewajibkan login akan menjawab 401
public sealed record KaseraEvent(string Id, string Type, KaseraEventData Data);
public sealed record KaseraEventData(string PaymentRequestId, string? ExternalId,
long Amount, string Status);Kebiasaan yang benar di hampir setiap endpoint ASP.NET Core, yaitu menulis parameter KaseraEvent evt dan membiarkan binding bekerja, adalah satu-satunya bentuk yang pasti salah di sini. HMAC dihitung atas byte, bukan atas isi yang setara, dan byte itu sudah habis dibaca sebelum handler berjalan. Tiga detail lain yang masing-masing berakhir sebagai 500 atau verifikasi gagal:
- Baca secara asinkron.
new StreamReader(request.Body).ReadToEnd()melemparInvalidOperationExceptionkarena Kestrel menolak operasi sinkron secara bawaan.CopyToAsynckeMemoryStreamaman. - Middleware pencatat request. Middleware yang membaca body tanpa
EnableBufferingdan tanpa memutar stream kembali ke awal membuat handler menerima body kosong, dan setiap tanda tangan gagal. - Otorisasi. Kalau aplikasi memasang
FallbackPolicyyang mewajibkan login, setiap endpoint tanpa atribut ikut terkunci dan webhook dijawab 401.AllowAnonymouspada satu endpoint ini saja; keamanannya datang dari tanda tangan, bukan dari sesi.
Aplikasi yang memakai controller MVC dengan filter global AutoValidateAntiforgeryTokenAttribute punya penolakan keempat, yaitu 400 dari antiforgery, karena kiriman server tidak membawa token. Bentuk controller-nya:
[ApiController]
[Route("webhooks/kasera-pay")]
[AllowAnonymous]
[IgnoreAntiforgeryToken] // filter global AutoValidateAntiforgeryToken menjawab 400
public sealed class KaseraWebhookController(WebhookVerifier verifier,
PaymentEventHandler handler) : ControllerBase
{
[HttpPost]
public async Task<IActionResult> Receive(CancellationToken ct)
{
using var buffer = new MemoryStream();
await Request.Body.CopyToAsync(buffer, ct);
// ...sama persis dengan versi minimal API di atas
}
}6. Verifikasi tanda tangan
public sealed class WebhookVerifier(IOptions<KaseraPayOptions> options)
{
private const long ToleranceSeconds = 300;
private readonly byte[] _secret = Encoding.UTF8.GetBytes(options.Value.WebhookSecret);
// Kasera-Signature-V1: t=1723350300,v1=5f4d...[,v1=9a1b...]
public bool Verify(byte[] body, string header)
{
var parts = header.Split(',');
if (parts.Length < 2 || !parts[0].StartsWith("t=", StringComparison.Ordinal)) return false;
var t = parts[0][2..];
if (!long.TryParse(t, NumberStyles.None, CultureInfo.InvariantCulture, out var ts)) return false;
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > ToleranceSeconds) return false;
// Yang ditandatangani: t, satu titik, lalu body mentah.
var prefix = Encoding.UTF8.GetBytes(t + ".");
var signed = new byte[prefix.Length + body.Length];
prefix.CopyTo(signed, 0);
body.CopyTo(signed, prefix.Length);
// HashData statis aman dipakai banyak thread sekaligus. Satu instance
// HMACSHA256 yang disimpan di singleton tidak.
var expected = HMACSHA256.HashData(_secret, signed);
// Selama 24 jam setelah rotasi secret ada dua entri v1.
foreach (var part in parts.AsSpan(1))
{
if (!part.StartsWith("v1=", StringComparison.Ordinal)) continue;
byte[] got;
try { got = Convert.FromHexString(part.AsSpan(3)); }
catch (FormatException) { continue; }
if (CryptographicOperations.FixedTimeEquals(got, expected)) return true;
}
return false;
}
}Yang ditandatangani adalah t, satu titik, dan body mentah, dengan HMAC-SHA256. Timestamp yang melenceng lebih dari lima menit ditolak, dan itulah yang mencegah kiriman yang disadap diputar ulang belakangan. Dua detail khas .NET: HMACSHA256.HashData statis dipakai alih-alih instance HMACSHA256 yang disimpan di field, karena verifier ini singleton yang dipanggil banyak request sekaligus dan instance hash tidak aman dipakai bersamaan; dan perbandingannya memakai CryptographicOperations.FixedTimeEquals, bukan SequenceEqual yang berhenti pada byte pertama yang berbeda. Rotasi secretnya dibahas di rotasi signing secret tanpa kehilangan satu pun event.
7. Dedupe dan pemenuhan pesanan dalam satu transaksi
public sealed class PaymentEventHandler(AppDb db)
{
public async Task HandleAsync(KaseraEvent evt, CancellationToken ct)
{
// Satu transaksi: catatan event dan perubahan pesanan jadi bersama
// atau batal bersama.
await using var tx = await db.Database.BeginTransactionAsync(ct);
// ExecuteSqlAsync dengan string interpolasi menjadi parameter, bukan
// teks SQL. ExecuteSqlRawAsync dengan interpolasi yang sama tidak.
var inserted = await db.Database.ExecuteSqlAsync(
$"INSERT INTO kasera_events (id, type) VALUES ({evt.Id}, {evt.Type}) ON CONFLICT (id) DO NOTHING",
ct);
if (inserted == 0) return; // sudah pernah diproses
if (evt.Type == "payment.paid" && evt.Data.Status == "succeeded")
{
var order = await db.Orders.SingleAsync(o => o.Number == evt.Data.ExternalId, ct);
if (order.TotalRupiah == evt.Data.Amount)
order.PaidAt ??= DateTimeOffset.UtcNow;
else
order.NeedsReview = true; // nominal tidak cocok: periksa, jangan kirim barang
order.PaymentRequestId = evt.Data.PaymentRequestId;
await db.SaveChangesAsync(ct);
}
await tx.CommitAsync(ct);
}
}Pengiriman bersifat at-least-once, jadi event yang sama bisa datang lagi dengan id yang sama. Unique index pada kasera_events.id yang menjadi penjaganya; pada PostgreSQL lewat Npgsql, ON CONFLICT DO NOTHING membuat kiriman kedua yang tiba bersamaan menunggu yang pertama selesai lalu tidak menulis apa pun. SQL Server tidak punya ON CONFLICT: pakai INSERT biasa dan perlakukan pelanggaran unique index, nomor galat 2627 atau 2601, sebagai tanda duplikat. Jangan menggantinya dengan memeriksa AnyAsync lebih dulu, karena dua kiriman yang tiba bersamaan sama-sama membaca “belum ada”.
Catatan event dan perubahan pesanan harus jadi atau batal bersama. Kalau event tercatat sementara perubahan pesanan gagal, pengiriman ulang dianggap duplikat dan pesanan tidak pernah terpenuhi. Nominal event juga dibandingkan dengan total pesanan sebelum ditandai lunas; yang tidak cocok ditandai untuk diperiksa, bukan dilempar sebagai galat yang akan diulang tujuh kali.
Sebelum go-live
URL webhook wajib https dan mengarah ke alamat publik. Empat hal yang layak diuji di mode tes, dengan WebApplicationFactory atau langsung ke aplikasi yang berjalan: kiriman sah diterima dengan 200, body yang diubah satu karakter ditolak, event yang sama dikirim dua kali hanya memenuhi pesanan sekali, dan database yang dimatikan di tengah menghasilkan 500, bukan 200. Daftar pemeriksaan lengkapnya ada di checklist sebelum go-live.
Pertanyaan yang sering muncul
Apakah ada SDK .NET atau paket NuGet resmi untuk Kasera Pay?
Tidak ada, dan panduan ini tidak membutuhkannya. HttpClient, System.Text.Json, dan System.Security.Cryptography yang sudah ikut bersama .NET 8 cukup untuk seluruh integrasinya. Kalau tipe permintaan dan response ingin dibangkitkan dari kontrak API, spesifikasi OpenAPI Kasera Pay bisa diunduh tanpa kunci dan dipakai dengan generator klien seperti NSwag atau Kiota, dengan catatan bahwa verifikasi webhook dan kunci idempotensi tetap ditulis tangan.
Kenapa pembuatan tagihan ditolak 400 padahal nominalnya benar?
Yang paling sering: nominalnya bertipe decimal yang dibaca dari kolom decimal(18,2) di database. System.Text.Json menulis decimal lengkap dengan skalanya, jadi 150000 terkirim sebagai 150000.00, dan API yang meminta bilangan bulat menolaknya dengan kode invalid_body yang menyebut amount. Simpan nominal rupiah sebagai long di model, atau konversi sekali di satu tempat setelah memastikan tidak ada pecahan.
Bolehkah pemenuhan pesanan dijalankan dengan Task.Run supaya webhook cepat dijawab?
Jangan. DbContext terdaftar per request dan sudah dibuang ketika Task.Run berjalan setelah balasan terkirim, sehingga yang muncul adalah ObjectDisposedException di log, bukan pesanan yang terpenuhi. Yang lebih penting, kegagalan sesudah balasan 200 tidak akan pernah diulang. Kerjakan dedupe dan perubahan pesanan sebelum menjawab, dan pindahkan ke antrean hanya pekerjaan yang memang boleh menunggu, seperti email ke pembeli.