Panduan · Terbit
Integrasi payment gateway di Axum (Rust): Bytes sebagai extractor terakhir untuk tanda tangan webhook, Json<Value> yang mengurutkan ulang key, dan layer yang hanya menjaga route di atasnya
Integrasi Kasera Pay di Axum butuh empat keputusan yang khas Axum. Route webhook mengambil body sebagai Bytes, sebagai argumen terakhir handler, dan tidak pernah lewat Json, karena tanda tangan dihitung atas byte persis dan serde_json menyusun ulang byte itu. Middleware sesi dipasang dengan route_layer di Router toko sendiri, karena .layer() menjaga semua route yang ditulis di atasnya, termasuk webhook. Galat dijawab dengan body polos. Dan setiap panic ditangkap CatchPanicLayer, karena tanpa layer itu koneksi diputus tanpa jawaban.
Semua perilaku di bawah diukur untuk panduan ini pada axum 0.8.9, 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, dengan payload yang disusun dan ditandatangani seperti pengirim webhook Kasera Pay. 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]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
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"] }
tower-http = { version = "0.7", features = ["catch-panic"] }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: Bytes, bukan Json<Value>
Empat payload yang sama-sama sah dikirim ke lima 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 |
|---|---|---|---|---|
Json<Value>, lalu to_vec | 400 | 400 | 400 | 400 |
Sama, dengan fitur preserve_order | lolos | gagal | lolos | lolos |
Json<Event> bertipe, lalu to_vec | 400 | 400 | 400 | 422 |
String | 200 | 200 | 200 | 200 |
Bytes | 200 | 200 | 200 | 200 |
Baris pertama gagal pada setiap payload, bukan hanya yang aneh. Tanpa fitur tambahan, serde_json::Value menyimpan object di BTreeMap, jadi key disusun ulang menurut abjad: created_at, data, id, dan seterusnya. Isinya sama, byte-nya berbeda, dan HMAC dihitung atas byte. Baris kedua diuji dengan membandingkan byte hasil serialisasi ulang terhadap byte aslinya: urutan key bertahan, tetapi payload Kasera Pay menulis & sebagai \u0026, sedangkan serde_json menuliskannya kembali apa adanya. Jalan pintas itu lolos di mode tes lalu gagal pada pelanggan pertama yang namanya memuat tanda itu.
Struct bertipe lebih buruk lagi. Serde membuang field yang tidak dideklarasikan, jadi serialisasi ulangnya kehilangan livemode, currency, customer, dan field lain, dan struct yang mewajibkan data menolak test.ping dengan 422 missing field `data`. Json juga menolak permintaan tanpa header Content-Type: application/json dengan 415, sedangkan Bytes tidak peduli header itu. Validasi bentuk payload dilakukan setelah tanda tangan lolos, dengan serde_json::from_slice dari byte yang sama.
3. Extractor yang membaca body wajib paling akhir
Di Axum, extractor yang menghabiskan body (Bytes, String, Json) hanya boleh menjadi argumen terakhir handler, dan hanya satu. Handler async fn bad(body: Bytes, headers: HeaderMap) tidak dikompilasi, dan pesan galatnya tidak menyebut urutan sama sekali:
error[E0277]: the trait bound `fn(Bytes, HeaderMap) -> impl Future<...> {bad}: Handler<_, _>` is not satisfied
= note: Consider using `#[axum::debug_handler]` to improve the error messagePasang #[axum::debug_handler] di atas handler saat pesan itu muncul. Pindahkan HeaderMap dan State ke depan, Bytes ke belakang, dan jangan mencoba mengambil Json dan Bytes sekaligus.
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 axum::{
body::Bytes,
extract::{Path, Request, State},
http::{HeaderMap, StatusCode},
middleware::{self, Next},
response::{IntoResponse, Redirect, Response},
routing::post,
Router,
};
use hmac::{Hmac, KeyInit, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use sqlx::PgPool;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tower_http::catch_panic::CatchPanicLayer;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, dan selisih waktunya bisa dipakai menebak tanda tangan.
#[derive(Clone)]
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,
}
// HeaderMap dulu, Bytes paling akhir: extractor yang membaca body wajib terakhir.
async fn kasera_webhook(
State(s): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> Result<StatusCode, AppError> {
let sig = headers
.get("kasera-signature-v1")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if !verify(&body, sig, &s.webhook_secret) {
return Ok(StatusCode::BAD_REQUEST);
}
// 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(StatusCode::OK)
}
enum AppError {
BadJson,
OrderMismatch(String),
Db(sqlx::Error),
}
impl From<sqlx::Error> for AppError {
fn from(e: sqlx::Error) -> Self {
AppError::Db(e)
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
match self {
AppError::BadJson => (StatusCode::BAD_REQUEST, "bad json").into_response(),
// Galat lengkap ke log sendiri; body 500 tetap polos, karena body
// ini tersimpan di log pengiriman di dashboard Kasera Pay.
AppError::OrderMismatch(id) => {
eprintln!("kasera webhook {id}: pesanan atau nominal tidak cocok");
(StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response()
}
AppError::Db(e) => {
eprintln!("kasera webhook: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response()
}
}
}
}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. 500 berarti pengiriman diulang dengan exponential backoff, sampai tujuh kali dalam kurang lebih 33 jam.
5. Galat dan panic: dua cara membocorkan atau menelan event
Handler Axum yang mengembalikan Result butuh tipe galat yang mengimplementasikan IntoResponse, dan bentuk yang paling sering disalin adalah ini:
// JANGAN: bentuk AppError yang paling sering disalin.
impl IntoResponse for AppError {
fn into_response(self) -> Response {
(StatusCode::INTERNAL_SERVER_ERROR, self.0.to_string()).into_response()
}
}Pada pengukuran, bentuk itu menjawab 500 dengan body error returned from database: relation "no_such_table" does not exist. Body jawaban sampai 2 KB disimpan di log pengiriman webhook di dashboard, jadi nama tabel dan pesan Postgres ikut tersimpan di sana. AppError di bagian 4 mencetak galat lengkap ke log sendiri dan menjawab internal error; saat tabel event diganti nama di tengah pengujian, itulah yang diterima pengirim, dan pengiriman ulang setelah tabelnya kembali diproses normal.
Panic lebih licin. Tanpa layer tambahan, Axum menangkap panic per koneksi: server tetap hidup, tetapi koneksi itu ditutup tanpa jawaban, dan curl melaporkan Empty reply from server. Bagi pengirim webhook itu pengiriman gagal tanpa status, dan di log server tidak ada baris 500 yang bisa dicari. Dengan CatchPanicLayer dari tower-http, panic yang sama dijawab 500.
6. Router: .layer() menjaga semua yang ada di atasnya
#[tokio::main]
async fn main() {
let env = |k: &str| std::env::var(k).unwrap_or_else(|_| panic!("{k} belum diisi"));
let state = 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"),
};
// Route toko punya Router sendiri; route_layer hanya menjaga route di
// atasnya dan tidak mengubah 404 menjadi 401.
let toko = Router::new()
.route("/toko/pesanan/{number}/bayar", post(bayar))
.route_layer(middleware::from_fn(cek_sesi));
let app = Router::new()
.route("/webhooks/kasera-pay", post(kasera_webhook))
.merge(toko)
// Panic di handler mana pun dijawab 500, bukan koneksi yang diputus.
.layer(CatchPanicLayer::new())
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.expect("bind");
axum::serve(listener, app).await.expect("serve");
}cek_sesi adalah middleware sesi milik aplikasi sendiri, yang ditulis dengan middleware::from_fn. .layer() di Axum membungkus route yang sudah ditambahkan sebelum pemanggilannya, tidak yang sesudahnya. Pada pengukuran, middleware sesi yang dipasang dengan .layer() setelah semua route membuat setiap pengiriman webhook dijawab 401, dan path yang tidak ada ikut dijawab 401, bukan 404. Sebaliknya, Router yang diberi .layer() lalu digabung dengan route lain lewat merge meninggalkan route gabungan itu tanpa penjaga: route bayar toko dijawab 200 tanpa sesi. Susunan di atas menghasilkan webhook 200, route toko tanpa sesi 401, dan path yang tidak ada 404.
Path dicocokkan persis. /webhooks/kasera-pay/ dengan garis miring di akhir dan /Webhooks/kasera-pay dijawab 404, dan GET ke route yang hanya punya POST dijawab 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(State(s): State<AppState>, Path(number): Path<String>) -> Response {
// 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 StatusCode::NOT_FOUND.into_response(),
Err(e) => {
eprintln!("bayar {number}: {e}");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
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 {
// Redirect::to = 303, jadi browser membuka checkout dengan GET.
Ok(tx) => Redirect::to(&tx.checkout_url).into_response(),
Err(e) => {
eprintln!("bayar {number}: {e:?}");
StatusCode::BAD_GATEWAY.into_response()
}
}
}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, jadi dua klik yang tiba bersamaan memakai key yang sama, seperti dijelaskan di dokumentasi idempotency. Dengan API key yang salah, API menjawab 401 dengan error.code unauthorized, dan route di atas menjawab 502. reqwest::Client::new() tidak punya batas waktu total, jadi klien dibuat dengan timeout sendiri.
Redirect::to menjawab 303, sehingga browser membuka halaman checkout dengan GET. Redirect::temporary menjawab 307, yang menyuruh browser mengulang POST yang sama ke alamat checkout. 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 Axum yang sedang berjalan. Ketiga puluhnya dijawab 200, tabel webhook_events memuat satu baris, dan trigger penghitung di tabel pesanan mencatat tepat satu pembaruan. Sebagai pembanding, versi “cek dulu, baru tulis” dengan SELECT lalu INSERT terpisah di tabel tanpa primary key memenuhi pesanan yang sama antara satu dan empat kali dalam lima putaran 30 pengiriman. Satu putaran di antaranya hanya memenuhi sekali, jadi satu uji yang hijau tidak membuktikan apa-apa.
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 dalam pustaka standar Go ada di panduan Go, 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 Rust di panduan ini memakai reqwest untuk API dan hmac serta sha2 untuk verifikasi tanda tangan, dua hal yang cukup untuk seluruh alur pembayaran. Daftar endpoint dan field lengkapnya ada di dokumentasi API, dan spesifikasi OpenAPI-nya bisa dipakai untuk membuat klien bertipe.
Bagaimana kalau proyeknya memakai actix-web, bukan Axum?
Panduan ini hanya diukur di Axum. Prinsipnya sama: ambil body sebagai byte mentah (di actix-web, web::Bytes), verifikasi sebelum parse, dan simpan id event lewat primary key. Perilaku extractor, urutan middleware, dan penanganan panic di actix-web belum diukur, jadi uji ulang tiga payload di bagian 2 sebelum go-live.
Proyek ini sudah menyalakan preserve_order di serde_json. Bolehkah Json<Value> dipakai?
Tidak. Dengan preserve_order, urutan key memang bertahan dan payload biasa lolos, tetapi payload webhook menulis tanda & sebagai \u0026 sedangkan serde_json menuliskannya kembali sebagai &. Begitu nama pelanggan memuat tanda itu, tanda tangannya gagal. Fitur Cargo juga disatukan untuk seluruh pohon dependensi, jadi perilakunya bisa berubah hanya karena sebuah crate lain ditambahkan atau dihapus.
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 AppError di bagian 5 menjawab internal error dan mencetak galat lengkapnya ke log server sendiri.