Panduan · Terbit
Integrasi payment gateway di actix-web (Rust): web::Bytes untuk tanda tangan webhook, state yang berlipat per worker, dan Redirect yang mengulang POST
Integrasi Kasera Pay di actix-web butuh empat keputusan yang khas actix-web. Route webhook mengambil body sebagai web::Bytes dan tidak pernah lewat web::Json, karena tanda tangan dihitung atas byte persis. State bersama dibuat sekali di luar closure HttpServer::new, karena closure itu dijalankan sekali per worker. Middleware sesi dipasang dengan wrap pada scope toko, tidak pada App. Dan pembeli diarahkan ke checkout dengan 303, karena web::Redirect bawaannya menjawab 307.
Semua perilaku di bawah diukur untuk panduan ini pada actix-web 4.18.1, actix-rt 2.15.0, tokio 1.54.1, sqlx 0.9.0, hmac 0.13.0, sha2 0.11.1, reqwest 0.13.5, Rust 1.97, dan Postgres 16, di mesin dengan empat CPU. Payload webhook disusun dan ditandatangani seperti pengirim webhook Kasera Pay, dan pembuatan tagihan diuji terhadap tiruan API di mesin yang sama, bukan terhadap API sungguhan. Kontrak lengkapnya ada di dokumentasi webhook.
1. Crate, kredensial, dan tabel dedupe
Belum ada crate Rust resmi; SDK resmi saat ini untuk PHP dan JavaScript. Yang dibutuhkan hanya klien HTTP dan HMAC-SHA256:
[dependencies]
actix-web = "4"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
hmac = "0.13"
sha2 = "0.11"
hex = "0.4"
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "postgres"] }
reqwest = { version = "0.13", features = ["json"] }
uuid = { version = "1", features = ["v4"] }API key berawalan kp_test_ selama membangun dan kp_live_ setelah go-live. Signing secret webhook diambil dari dashboard, menu Developer, dan berbeda per mode.
# kp_test_ selama membangun, kp_live_ setelah go-live.
KASERA_PAY_KEY=kp_test_...
KASERA_PAY_WEBHOOK_SECRET=whsec_...
DATABASE_URL=postgres://...Pengiriman webhook bersifat at-least-once: event yang sama bisa datang lebih dari sekali dengan id yang sama. Penjaganya primary key, bukan pemeriksaan “sudah ada atau belum” sebelum menulis.
CREATE TABLE webhook_events (
id text PRIMARY KEY,
type text NOT NULL,
received_at timestamptz NOT NULL DEFAULT now()
);2. Body mentah: web::Bytes, dan hanya satu extractor yang membaca body
Empat payload yang sama-sama sah dikirim ke empat cara membaca body: payload biasa, payload dengan nama pelanggan CV Maju & Jaya, payload dengan external_id INV/2026/10/001, dan test.ping dari tombol uji di dashboard.
| Cara membaca | Biasa | Tanda & | Garis miring | test.ping |
|---|---|---|---|---|
web::Json<Value>, lalu to_vec | 400 | 400 | 400 | 400 |
web::Json<Event> bertipe, lalu to_vec | 400 | 400 | 400 | 400 |
String | 200 | 200 | 200 | 200 |
web::Bytes | 200 | 200 | 200 | 200 |
Kedua baris Json gagal pada setiap payload. serde_json::Value menyusun ulang key menurut abjad, struct bertipe membuang field yang tidak dideklarasikan, dan keduanya menulis ulang \u0026 sebagai &. Isinya sama, byte-nya berbeda, dan HMAC dihitung atas byte. web::Json juga menolak permintaan tanpa header Content-Type: application/json dengan 400 Content type error, sedangkan web::Bytes tidak peduli header itu.
Jebakan yang khas actix-web: urutan argumen handler bebas, dan compiler tidak menolak handler yang mengambil web::Json dan web::Bytes sekaligus. Body hanya bisa dibaca satu kali. Dengan Json lebih dulu, Bytes yang diterima handler berisi 0 byte, sehingga setiap tanda tangan gagal 400. Dengan Bytes lebih dulu, Json yang gagal dan menjawab 400 Json deserialize error: EOF while parsing a value at line 1 column 0. Keduanya hanya terlihat saat berjalan. Ambil web::Bytes saja, lalu baca JSON dengan serde_json::from_slice dari byte yang sama setelah tanda tangan lolos.
3. State dibuat di luar closure HttpServer::new
HttpServer::new menerima closure yang membangun App, dan closure itu dijalankan sekali untuk setiap worker. Jumlah worker bawaannya mengikuti jumlah CPU yang tersedia. Apa pun yang dibuat di dalam closure ikut berlipat, termasuk penanda event yang disimpan di memori:
// JANGAN: closure ini dijalankan sekali per worker.
HttpServer::new(move || {
App::new()
.app_data(web::Data::new(Mutex::new(HashSet::<String>::new())))
.route("/webhooks/kasera-pay", web::post().to(kasera_webhook))
})Pada pengukuran dengan empat worker, event yang sama dikirim 30 kali bersamaan ke bentuk di atas, lima putaran dengan id berbeda. Setiap putaran memproses event-nya empat kali, sekali per worker, karena tiap worker memegang HashSet-nya sendiri; dibuat di luar closure, satu kali per putaran. Penjaga yang tetap benar setelah aplikasi dijalankan di dua server sekaligus hanyalah primary key di database, jadi panduan ini memakai itu, dan PgPool serta klien HTTP tetap dibuat di luar closure supaya tidak ada empat pool.
Kesalahan pendaftaran state juga baru terlihat saat berjalan. Handler yang meminta web::Data<AppState> padahal tidak ada web::Data<AppState> yang didaftarkan tetap dikompilasi, lalu setiap pengiriman dijawab 500 dengan body Requested application data is not configured correctly. View/enable debug logs for more details., dan pengirim mengulangnya sampai tujuh kali.
4. Verifikasi dan route webhook
Seluruh potongan kode di bawah berada di satu src/main.rs dengan use berikut, dan dikompilasi sebagai satu program saat pengukuran.
// src/main.rs
use actix_web::{
body::MessageBody,
dev::{ServiceRequest, ServiceResponse},
http::StatusCode,
middleware::{from_fn, Next},
web, App, HttpRequest, HttpResponse, HttpServer, ResponseError,
};
use hmac::{Hmac, KeyInit, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use sqlx::PgPool;
use std::time::{Duration, SystemTime, UNIX_EPOCH};const TOLERANSI_DETIK: i64 = 300;
// Kasera-Signature-V1: t=1723350300,v1=5f4d...[,v1=9a1b...]
pub fn verify(raw: &[u8], header: &str, secret: &str) -> bool {
let mut parts = header.split(',');
let Some(t) = parts.next().and_then(|p| p.strip_prefix("t=")) else {
return false;
};
let Ok(t) = t.parse::<i64>() else { return false };
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
if (now - t).abs() > TOLERANSI_DETIK {
return false;
}
// Dua entri v1 muncul selama 24 jam setelah rotasi signing secret.
for p in parts {
let Some(sig) = p.strip_prefix("v1=") else { continue };
let Ok(got) = hex::decode(sig) else { continue };
let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(secret.as_bytes()) else {
return false;
};
mac.update(t.to_string().as_bytes());
mac.update(b".");
mac.update(raw);
// verify_slice membandingkan dalam waktu konstan; jangan pakai ==.
if mac.verify_slice(&got).is_ok() {
return true;
}
}
false
}verify_slice dari trait Mac membandingkan dalam waktu konstan. Perbandingan == atas string hex berhenti pada byte pertama yang berbeda.
struct AppState {
db: PgPool,
http: reqwest::Client,
webhook_secret: String,
api_key: String,
}
#[derive(Deserialize)]
struct Event {
id: String,
#[serde(rename = "type")]
kind: String,
// Option: test.ping dari dashboard tidak membawa data.
data: Option<EventData>,
}
#[derive(Deserialize)]
struct EventData {
payment_request_id: String,
external_id: Option<String>,
amount: i64,
}
// Urutan argumen bebas di actix-web, tetapi body hanya bisa dibaca satu extractor.
async fn kasera_webhook(
s: web::Data<AppState>,
req: HttpRequest,
body: web::Bytes,
) -> Result<HttpResponse, AppError> {
let sig = req
.headers()
.get("kasera-signature-v1")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if !verify(&body, sig, &s.webhook_secret) {
return Ok(HttpResponse::BadRequest().finish());
}
// Baru sekarang JSON dibaca, dari byte yang sama.
let event: Event = serde_json::from_slice(&body).map_err(|_| AppError::BadJson)?;
let mut tx = s.db.begin().await?;
// Primary key yang menjadi penjaga dedupe, bukan SELECT lebih dulu.
let fresh = sqlx::query(
"INSERT INTO webhook_events (id, type) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING",
)
.bind(&event.id)
.bind(&event.kind)
.execute(&mut *tx)
.await?
.rows_affected()
== 1;
if fresh && event.kind == "payment.paid" {
let d = event.data.as_ref().ok_or(AppError::BadJson)?;
let updated = sqlx::query(
"UPDATE orders SET status = 'dibayar', payment_request_id = $1
WHERE number = $2 AND total = $3",
)
.bind(&d.payment_request_id)
.bind(&d.external_id)
.bind(d.amount)
.execute(&mut *tx)
.await?
.rows_affected();
// Pesanan tidak ada atau nominal berbeda: tx di-drop tanpa commit,
// jadi id event ikut batal dan pengiriman ulang diproses lagi.
if updated != 1 {
return Err(AppError::OrderMismatch(event.id));
}
}
tx.commit().await?;
// Tipe lain, termasuk test.ping, cukup dijawab 200.
Ok(HttpResponse::Ok().finish())
}
#[derive(Debug)]
enum AppError {
BadJson,
OrderMismatch(String),
Db(sqlx::Error),
}
impl From<sqlx::Error> for AppError {
fn from(e: sqlx::Error) -> Self {
AppError::Db(e)
}
}
// ResponseError mewajibkan Display; teks ini tidak pernah dikirim sebagai body.
impl std::fmt::Display for AppError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AppError::BadJson => write!(f, "bad json"),
AppError::OrderMismatch(id) => write!(f, "event {id}: pesanan atau nominal tidak cocok"),
AppError::Db(e) => write!(f, "database: {e}"),
}
}
}
impl ResponseError for AppError {
fn status_code(&self) -> StatusCode {
match self {
AppError::BadJson => StatusCode::BAD_REQUEST,
_ => StatusCode::INTERNAL_SERVER_ERROR,
}
}
// Galat lengkap ke log sendiri; body tetap polos, karena body jawaban
// tersimpan di log pengiriman di dashboard Kasera Pay.
fn error_response(&self) -> HttpResponse {
eprintln!("kasera webhook: {self}");
match self {
AppError::BadJson => HttpResponse::BadRequest().body("bad json"),
_ => HttpResponse::InternalServerError().body("internal error"),
}
}
}Yang terukur pada kode ini. Keempat payload di bagian 2 dijawab 200. Header dengan dua entri v1, yang muncul selama 24 jam setelah rotasi signing secret, diterima kalau salah satunya cocok. Stempel waktu 400 detik yang lalu ditolak 400 karena toleransinya lima menit, dan tanda tangan dengan secret lain juga 400. Event untuk external_id yang belum ada dijawab 500 dan id event-nya tidak tersimpan, karena transaksi yang di-drop tanpa commit di-rollback oleh sqlx; pengiriman ulang setelah pesanannya dibuat dijawab 200 dan pesanannya lunas. Nominal yang berbeda dari total pesanan juga dijawab 500 dan pesanannya tetap menunggu. 500 berarti pengiriman diulang dengan exponential backoff, sampai tujuh kali dalam kurang lebih 33 jam.
5. Galat dan panic
Cara tercepat mengubah galat sqlx menjadi jawaban di actix-web adalah ini:
// JANGAN: bentuk yang paling sering disalin.
sqlx::query("...")
.execute(pool.get_ref())
.await
.map_err(actix_web::error::ErrorInternalServerError)?;Pada pengukuran, bentuk itu menjawab 500 dengan body error returned from database: relation "no_such_table" does not exist. ErrorInternalServerError memakai teks Display galatnya sebagai body, dan body jawaban sampai 2 KB disimpan di log pengiriman webhook di dashboard. AppError di bagian 4 mengisi error_response sendiri: saat tabel event diganti nama di tengah pengujian, pengirim hanya menerima internal error, pesan lengkapnya tercetak di log server, dan pengiriman ulang setelah tabelnya kembali dijawab 200.
Panic di handler tidak menjatuhkan server: dua belas panic berturut-turut tetap meninggalkan server yang menjawab permintaan berikutnya. Tetapi koneksi yang panic ditutup tanpa jawaban, curl melaporkan Empty reply from server, dan di log akses tidak ada baris 500 yang bisa dicari. Karena itu handler webhook di atas tidak memakai unwrap atau expect sama sekali; setiap kegagalan menjadi AppError yang dijawab dengan status.
6. Middleware sesi hanya di scope toko
async fn cek_sesi(
req: ServiceRequest,
next: Next<impl MessageBody + 'static>,
) -> Result<ServiceResponse<impl MessageBody>, actix_web::Error> {
// Contoh saja: ganti dengan pemeriksaan sesi aplikasi sendiri.
if req.cookie("sesi").is_none() {
return Err(actix_web::error::ErrorUnauthorized("login dulu"));
}
next.call(req).await
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let env = |k: &str| std::env::var(k).unwrap_or_else(|_| panic!("{k} belum diisi"));
// Dibuat SEKALI, di luar closure: closure HttpServer::new dijalankan
// sekali per worker, dan apa pun yang dibuat di dalamnya ikut berlipat.
let state = web::Data::new(AppState {
db: PgPool::connect(&env("DATABASE_URL")).await.expect("database"),
// reqwest::Client::new() tidak punya batas waktu total.
http: reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.build()
.expect("http client"),
webhook_secret: env("KASERA_PAY_WEBHOOK_SECRET"),
api_key: env("KASERA_PAY_KEY"),
});
HttpServer::new(move || {
App::new()
.app_data(state.clone())
.route("/webhooks/kasera-pay", web::post().to(kasera_webhook))
// Middleware sesi hanya membungkus scope toko, bukan webhook.
.service(
web::scope("/toko")
.wrap(from_fn(cek_sesi))
.route("/pesanan/{number}/bayar", web::post().to(bayar)),
)
})
.bind(("0.0.0.0", 3000))?
.run()
.await
}middleware::from_fn tersedia sejak actix-web 4.9. Tiga susunan diukur:
| Susunan | Webhook | Route toko tanpa sesi | Path yang tidak ada |
|---|---|---|---|
.wrap(from_fn(cek_sesi)) di App | 401 | 401 | 401 |
web::scope("") ber-wrap didaftarkan sebelum route webhook | 401 | 401 | 401 |
web::scope("/toko").wrap(...), seperti di atas | 200 | 401 | 404 |
Baris kedua yang sering tidak disangka: scope dengan prefix kosong cocok dengan setiap path, dan actix-web mencocokkan service menurut urutan pendaftarannya, jadi route webhook yang didaftarkan sesudahnya tidak pernah tercapai. Beri scope toko prefix sendiri. Path juga dicocokkan persis: /webhooks/kasera-pay/ dengan garis miring di akhir, /Webhooks/kasera-pay, dan GET ke route yang hanya punya POST semuanya dijawab 404, bukan 405. Daftarkan URL di dashboard persis seperti route-nya.
7. Membuat tagihan dan mengarahkan pembeli
#[derive(Serialize)]
struct CreateRequest<'a> {
amount: i64, // rupiah, bilangan bulat
description: &'a str,
external_id: &'a str,
return_url: &'a str,
// Object kosong = create Checkout: pembeli membuka checkout_url.
checkout: Checkout,
}
#[derive(Serialize)]
struct Checkout {}
#[derive(Deserialize)]
struct Transaction {
checkout_url: String,
}
#[derive(Deserialize)]
struct ApiErrorBody {
error: ApiErrorInner,
}
#[derive(Deserialize)]
struct ApiErrorInner {
code: String,
}
#[derive(Debug)]
enum KaseraError {
Api { status: u16, code: String },
Http(reqwest::Error),
}
async fn create_transaction(
s: &AppState,
req: &CreateRequest<'_>,
idempotency_key: &str,
) -> Result<Transaction, KaseraError> {
let res = s
.http
.post("https://pay.kasera.id/v1/transactions")
.bearer_auth(&s.api_key)
.header("Idempotency-Key", idempotency_key)
.json(req)
.send()
.await
.map_err(KaseraError::Http)?;
let status = res.status();
if !status.is_success() {
let code = res
.json::<ApiErrorBody>()
.await
.map(|b| b.error.code)
.unwrap_or_default();
return Err(KaseraError::Api { status: status.as_u16(), code });
}
res.json::<Transaction>().await.map_err(KaseraError::Http)
}async fn bayar(s: web::Data<AppState>, number: web::Path<String>) -> HttpResponse {
let number = number.into_inner();
// Nominal dari database, tidak pernah dari body permintaan. Key diklaim
// dalam satu pernyataan, jadi dua klik bersamaan mendapat key yang sama.
let row: Result<Option<(i64, String)>, _> = sqlx::query_as(
"UPDATE orders SET idempotency_key = COALESCE(idempotency_key, $2)
WHERE number = $1 AND status = 'menunggu'
RETURNING total, idempotency_key",
)
.bind(&number)
.bind(uuid::Uuid::new_v4().to_string())
.fetch_optional(&s.db)
.await;
let (total, key) = match row {
Ok(Some(r)) => r,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(e) => {
eprintln!("bayar {number}: {e}");
return HttpResponse::InternalServerError().finish();
}
};
let return_url = format!("https://toko.example/pesanan/{number}");
let req = CreateRequest {
amount: total,
description: &format!("Pesanan {number}"),
external_id: &number,
return_url: &return_url,
checkout: Checkout {},
};
match create_transaction(&s, &req, &key).await {
// 303: browser membuka checkout dengan GET.
Ok(tx) => HttpResponse::SeeOther()
.insert_header(("Location", tx.checkout_url))
.finish(),
Err(e) => {
eprintln!("bayar {number}: {e:?}");
HttpResponse::BadGateway().finish()
}
}
}Idempotency-Key adalah satu-satunya hal yang mencegah satu pesanan menjadi dua pembayaran, dan header itu opsional. Key di atas disimpan bersama pesanannya dan diklaim dalam satu UPDATE. Pada pengukuran, sepuluh klik bersamaan ke tombol bayar pesanan yang sama menghasilkan sepuluh jawaban 303 dan sepuluh panggilan API yang semuanya membawa key yang sama, seperti dijelaskan di dokumentasi idempotency. Dengan API key yang salah, tiruan API menjawab 401 dengan error.code unauthorized, log mencatat kodenya, dan route di atas menjawab 502.
Redirect adalah jebakan terakhir. web::Redirect::to menjawab 307 Temporary Redirect, yang menyuruh browser mengulang POST yang sama ke alamat checkout. Jawab 303 dengan HttpResponse::SeeOther() seperti di atas, atau tambahkan .see_other() pada web::Redirect, supaya checkout dibuka dengan GET. Mulai 26 Oktober 2026, create untuk pembayaran di atas Rp 1.000.000 juga wajib membawa delivery_date, delivery_terms_shown, nama pembeli, dan kontaknya; daftar field-nya ada di dokumentasi membuat transaksi.
8. Dedupe di bawah pengiriman kembar bersamaan
Event yang sama dikirim 30 kali bersamaan ke server yang sedang berjalan dengan empat worker, tiga putaran dengan event dan pesanan berbeda. Di setiap putaran ketiga puluhnya dijawab 200, tabel webhook_events memuat satu baris, dan trigger penghitung di tabel pesanan mencatat tepat satu pembaruan.
9. Sebelum go-live
URL webhook wajib https dan mengarah ke alamat publik, dan pengirimnya tidak mengikuti redirect. Kepulangan pembeli ke return_url bukan bukti bayar; penanda lunas hanya payment.paid bertanda tangan atau pembacaan GET /v1/transactions/{id} dari server. Kalau stok dilepas saat payment.expired atau payment.failed, siapkan jalur untuk menariknya kembali karena payment.paid masih bisa menyusul. Bentuk yang sama di framework Rust lain ada di panduan Axum, dan urutan pemeriksaan lainnya di checklist sebelum go-live.
Pertanyaan yang sering muncul
Apakah Kasera Pay punya crate Rust resmi?
Belum. SDK resmi saat ini hanya untuk PHP dan JavaScript/TypeScript. Integrasi di panduan ini memakai reqwest untuk API serta hmac dan sha2 untuk verifikasi tanda tangan, dan itu cukup untuk seluruh alur pembayaran. Daftar endpoint dan field-nya ada di dokumentasi API.
Bolehkah memakai awc, klien HTTP bawaan ekosistem actix, alih-alih reqwest?
Boleh, tetapi panduan ini hanya mengukur reqwest. Yang wajib sama: header Authorization Bearer, Idempotency-Key yang disimpan bersama pesanan, batas waktu total pada klien, dan pembacaan error.code dari body jawaban yang bukan 2xx.
Proyeknya memakai Axum, bukan actix-web. Apa yang berbeda?
Prinsipnya sama, perilakunya tidak. Di Axum urutan extractor yang salah tidak dikompilasi, sedangkan di actix-web dikompilasi lalu gagal saat berjalan; Axum menjawab 405 untuk metode yang salah, actix-web 404; dan redirect bawaan keduanya berbeda. Langkah yang diukur di Axum ada di panduan Axum.
Di mana body jawaban 500 dari endpoint terlihat?
Body jawaban endpoint, sampai 2 KB, disimpan di log pengiriman webhook dan tampil di detail log di dashboard, menu Developer. Karena itu ResponseError di bagian 4 menjawab internal error dan mencetak galat lengkapnya ke log server sendiri.