Panduan · Terbit
Integrasi payment gateway di AdonisJS: request.raw() untuk tanda tangan webhook, CSRF Shield yang menjawab webhook dengan 302, dan Env.schema.secret() yang harus di-release()
Integrasi Kasera Pay di AdonisJS gagal di tiga tempat sebelum kode pembayaran mana pun sempat berjalan. Shield menjawab setiap pengiriman webhook dengan 302 sampai route-nya dimasukkan ke exceptRoutes. Tanda tangan harus diverifikasi atas request.raw(), bukan atas request.body() yang disusun ulang. Dan secret yang divalidasi dengan Env.schema.secret() adalah objek, bukan string: tanpa .release(), HMAC dihitung dengan kunci [redacted] dan semua pengiriman ditolak 400.
Semua perilaku di bawah diukur untuk panduan ini pada AdonisJS 7 (@adonisjs/core 7.6, Lucid 22.4, Shield 9, Auth 10) dari starter kit hypermedia, SDK kasera-pay 0.1.0, Node 24, dan Postgres 16, baik di node ace serve maupun di build produksi, dengan pengiriman yang meniru header pengirim Kasera Pay. AdonisJS 7 mensyaratkan Node 24. Kontrak lengkapnya ada di dokumentasi webhook, dan klien yang dipakai adalah SDK JavaScript resmi.
1. Paket, kredensial, dan tabel dedupe
npm install kasera-payStarter kit hypermedia sudah membawa Lucid, Shield, dan Auth, jadi yang ditambahkan hanya SDK. 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: secret tes tidak pernah bisa memverifikasi payload live.
# .env: kp_test_ selama membangun, kp_live_ setelah go-live.
KASERA_PAY_KEY=kp_test_...
KASERA_PAY_WEBHOOK_SECRET=whsec_...// start/env.ts, di dalam Env.create(...)
KASERA_PAY_KEY: Env.schema.string(),
KASERA_PAY_WEBHOOK_SECRET: Env.schema.secret(),Pengiriman webhook bersifat at-least-once, jadi event yang sama bisa datang lebih dari sekali dengan id yang sama. Penjaganya primary key, bukan pemeriksaan “sudah ada atau belum” sebelum menulis.
// database/migrations/..._create_webhook_events_table.ts
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
async up() {
this.schema.createTable('webhook_events', (table) => {
table.text('id').primary()
table.text('type').notNullable()
table.timestamp('received_at', { useTz: true }).notNullable().defaultTo(this.now())
})
}
async down() {
this.schema.dropTable('webhook_events')
}
}2. CSRF: 302 ke / untuk setiap pengiriman
Shield memeriksa token CSRF pada setiap POST. Pengiriman webhook tidak membawa token apa pun, dan pada pengukuran Shield menjawabnya 302 dengan Location: /, baik di mode pengembangan maupun di build produksi. Log aplikasi hanya mencatat Invalid or expired CSRF token pada level warning. Pengirim Kasera Pay menolak mengikuti redirect, jadi pengiriman itu tercatat gagal dan diulang sampai jatahnya habis, sementara controller-nya tidak pernah dipanggil.
// config/shield.ts
csrf: {
enabled: true,
// Kasera Pay tidak membawa token CSRF. Pola route, bukan URL lengkap.
exceptRoutes: ['/webhooks/kasera-pay'],
enableXsrfCookie: false,
methods: ['POST', 'PUT', 'PATCH', 'DELETE'],
},exceptRoutes berisi pola route, dan pengecualiannya ikut berlaku untuk /webhooks/kasera-pay/ dengan garis miring di akhir (diterima 200). Satu jebakan kecil: pada pengukuran dengan node ace serve, perubahan config/shield.ts baru berlaku setelah server dijalankan ulang, jadi pengujian yang langsung diulang setelah menyunting config masih mendapat 302.
3. Body mentah: request.raw(), bukan request.body()
Jalan pintas yang paling sering dicoba adalah memverifikasi JSON.stringify(request.body()). Pada pengukuran, jalan pintas itu lolos untuk payload biasa dan gagal 400 untuk payload yang sama dengan nama pelanggan CV Maju & Jaya. Payload webhook Kasera Pay menulis &, <, dan > sebagai \u0026, \u003c, dan \u003e, sedangkan JSON.stringify menuliskannya kembali apa adanya. Isinya sama, byte-nya berbeda, dan HMAC dihitung atas byte. Di AdonisJS ada lapisan kedua: konfigurasi bawaan config/bodyparser.ts menyalakan trimWhitespaces dan convertEmptyStringsToNull untuk JSON, jadi request.body() memang bukan salinan dari yang dikirim.
request.raw() menyimpan string aslinya. Dengan itu payload berisi \u0026 lolos 200, termasuk dengan Content-Type: application/json; charset=utf-8.
4. Env.schema.secret(): objek yang tercetak [redacted]
start/env.ts menyediakan Env.schema.secret() untuk nilai rahasia, dan itu pilihan yang tepat untuk signing secret, karena nilainya tidak bocor ke log. Akibatnya, env.get('KASERA_PAY_WEBHOOK_SECRET') mengembalikan objek Secret, bukan string. Diteruskan begitu saja ke SDK, objek itu diubah menjadi teks [redacted], dan HMAC dihitung dengan kunci sepuluh karakter itu.
Pada pengukuran, kesalahan ini muncul dengan dua wajah. node ace serve menjalankan TypeScript tanpa pemeriksaan tipe, jadi aplikasi berjalan dan setiap pengiriman yang sah ditolak 400 invalid signature. node ace build justru berhenti dengan TS2345 (Secret<string> tidak bisa diberikan ke string) dan menolak membangun. Pesan build itu sendiri menawarkan --ignore-ts-errors; memakainya agar deploy lolos berarti mengirim kode yang sama ke produksi, yang menolak semua webhook persis seperti di mode pengembangan. Jawabannya satu kata: .release().
5. Controller webhook
// app/controllers/kasera_webhooks_controller.ts
import type { HttpContext } from '@adonisjs/core/http'
import db from '@adonisjs/lucid/services/db'
import { constructWebhookEvent, SignatureError } from 'kasera-pay'
import env from '#start/env'
export default class KaseraWebhooksController {
async handle({ request, response }: HttpContext) {
let event
try {
// request.raw(): byte persis yang dikirim. Jangan JSON.stringify(request.body()).
event = await constructWebhookEvent(
request.raw() ?? '',
request.header('kasera-signature-v1') ?? '',
// Env.schema.secret() mengembalikan objek Secret; .release() mengambil string-nya.
env.get('KASERA_PAY_WEBHOOK_SECRET').release()
)
} catch (err) {
// return: tanpa return, baris di bawahnya tetap berjalan.
if (err instanceof SignatureError) return response.badRequest('invalid signature')
throw err
}
await db.transaction(async (trx) => {
// Primary key yang menjadi penjaga dedupe. Pengiriman kembar yang
// tiba bersamaan menunggu di baris ini, lalu tidak mendapat baris.
const inserted = await trx
.insertQuery()
.table('webhook_events')
.insert({ id: event.id, type: event.type })
.onConflict('id')
.ignore()
.returning('id')
if (inserted.length === 0) return
if (event.type !== 'payment.paid') return
const updated = await trx
.from('orders')
.where('number', event.data.external_id ?? '')
.update({ status: 'dibayar', payment_request_id: event.data.payment_request_id })
// UPDATE yang tidak menemukan pesanan tetap sukses (0 baris). Lempar galat
// agar id event ikut di-rollback dan pengiriman diulang.
if (Number(updated) !== 1) throw new Error('pesanan untuk event ini tidak ditemukan')
})
return 'ok'
}
}Yang terukur pada kode ini, di build produksi. Payload dengan \u0026 diterima 200 dan pesanannya menjadi dibayar. 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. Tanda tangan yang dibuat dengan secret lain ditolak 400 dengan body invalid signature, dan pesanannya tidak berubah.
SDK mengetik event sebagai gabungan beberapa jenis event, termasuk test.ping yang tidak membawa data. Itu sebabnya pemeriksaan event.type !== 'payment.paid' berada sebelum event.data dibaca: tanpa pemeriksaan itu, tsc menolak kodenya. Untuk event selain payment.paid, controller ini mencatat id-nya lalu menjawab 200.
6. response.badRequest() tanpa return
Di AdonisJS, response.badRequest() hanya mengisi respons. Fungsinya tidak melempar galat, jadi controller tetap berjalan sampai baris terakhirnya. Bentuk yang lazim ditulis orang yang memverifikasi tanpa SDK:
// JANGAN: badRequest() tanpa return.
const ok = await verifyWebhook(request.raw() ?? '', header, secret)
if (!ok) {
response.badRequest('invalid signature') // hanya mengisi respons
}
await tandaiLunas(request.body()) // tetap jalan
return 'ok'Pada pengukuran, event payment.paid bertanda tangan palsu yang dikirim ke controller seperti ini dijawab 400 invalid signature, dan pesanannya tetap menjadi dibayar. Nilai return 'ok' di akhir diabaikan karena respons sudah diisi, sehingga semua yang terlihat dari luar tampak benar: log pengirim mencatat penolakan, dan pesanan palsunya sudah lunas. Karena 400 membuat pengiriman diulang, pengirim palsu bahkan mendapat beberapa kesempatan. Controller di bagian 5 menulis return response.badRequest(...), dan dengan bentuk itu event palsu dijawab 400 tanpa mengubah pesanan.
7. Route, auth, dan redirect ke checkout
// start/routes.ts
const KaseraWebhooksController = () => import('#controllers/kasera_webhooks_controller')
const OrdersController = () => import('#controllers/orders_controller')
// Di luar grup mana pun yang memakai middleware.auth().
router.post('/webhooks/kasera-pay', [KaseraWebhooksController, 'handle'])
router
.group(() => {
router.post('/pesanan/:number/bayar', [OrdersController, 'pay'])
})
.use(middleware.auth())Pada pengukuran, route webhook yang ditaruh di dalam grup .use(middleware.auth()) dijawab 302 ke /login, sama seperti pengunjung yang belum masuk. Pengirim tidak mengikuti redirect itu, jadi gejalanya sama dengan masalah CSRF di bagian 2. Middleware silent_auth_middleware yang terpasang global di starter kit tidak mengganggu: webhook di luar grup dijawab 200. Pencocokan route peka huruf besar: /Webhooks/kasera-pay dijawab 404, begitu juga GET ke route yang hanya punya POST.
// app/controllers/orders_controller.ts
import type { HttpContext } from '@adonisjs/core/http'
import { randomUUID } from 'node:crypto'
import db from '@adonisjs/lucid/services/db'
import { KaseraPay, KaseraPayError } from 'kasera-pay'
import env from '#start/env'
const kasera = new KaseraPay(env.get('KASERA_PAY_KEY'))
export default class OrdersController {
async pay({ params, response }: HttpContext) {
// Key dibuat sekali per pesanan dan dipakai ulang pada setiap percobaan.
// COALESCE dalam satu UPDATE: dua klik bersamaan tetap mendapat key yang sama.
const [order] = await db
.from('orders')
.where('number', params.number)
.update({ idempotency_key: db.raw('COALESCE(idempotency_key, ?)', [randomUUID()]) })
.returning(['number', 'total', 'status', 'idempotency_key'])
if (!order || order.status !== 'baru') return response.notFound()
try {
const tx = await kasera.createTransaction(
{
amount: order.total, // dari database, tidak pernah dari body permintaan
description: 'Pesanan ' + order.number,
external_id: order.number,
return_url: env.get('APP_URL') + '/pesanan/' + order.number,
checkout: {},
},
{ idempotencyKey: order.idempotency_key }
)
return response.redirect().status(303).toPath(tx.checkout_url)
} catch (err) {
if (err instanceof KaseraPayError) return response.status(502).json({ error: err.code })
throw err
}
}
}response.redirect(url) dan response.redirect().toPath(url) sama-sama menjawab 302 secara bawaan. Setelah POST, 303 adalah kode yang memaksa browser membuka halaman checkout dengan GET, dan response.redirect().status(303).toPath(url) menghasilkannya. Pada pengukuran terhadap API tiruan, sepuluh permintaan bayar bersamaan untuk pesanan yang sama mengirim satu Idempotency-Key yang sama, pesanan yang sudah dibayar dijawab 404, dan penolakan API dengan validation_failed diteruskan sebagai 502. Header Idempotency-Key adalah satu-satunya yang mencegah tagihan ganda; external_id hanya label. Bentuk yang sama untuk Express ada di panduan Node.js dan Express.
8. Dedupe di bawah pengiriman kembar bersamaan
Event baru yang sama dikirim 30 kali bersamaan ke build produksi. Ketiga puluhnya dijawab 200, tabel webhook_events memuat satu baris untuk event itu, dan trigger penghitung di tabel pesanan mencatat tepat satu pembaruan. Saat pemeriksaan inserted.length === 0 dimatikan untuk pengujian, pengiriman yang sama menghasilkan 30 pembaruan. Catatan event dan pembaruan pesanan berada dalam satu db.transaction: saat tabel pesanan dibuat hilang di tengah pengujian, pengiriman dijawab 500 dan tidak ada baris event yang tersimpan, lalu pengiriman ulang setelah tabelnya kembali dijawab 200 dan memenuhi pesanannya.
Pemeriksaan Number(updated) !== 1 menjaga pesanan yang tidak ditemukan. Pada pengukuran, update Lucid tanpa returning mengembalikan jumlah baris, dan untuk external_id yang tidak ada nilainya 0, perintahnya tetap sukses. Tanpa pemeriksaan itu, id event tercatat, tidak ada pesanan yang lunas, controller menjawab 200, dan setiap pengiriman ulang terbaca sebagai duplikat. Dengan galat yang dilempar, pengiriman dijawab 500, tidak ada baris event yang tersimpan, dan pengiriman berikutnya diproses normal setelah pesanannya ada.
9. Sebelum go-live
URL webhook wajib https dan mengarah ke alamat publik, dan pengirimnya tidak mengikuti redirect, jadi daftarkan URL persis seperti route-nya. 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. Urutan pemeriksaan lainnya ada di checklist sebelum go-live, dan cara menguji webhook dari mesin lokal ada di webhook di localhost dan mode tes.
Pertanyaan yang sering muncul
Proyek memakai starter kit API, bukan hypermedia. Apakah CSRF tetap perlu diurus?
Starter kit yang memasang @adonisjs/shield dengan csrf.enabled: true, seperti hypermedia yang dipakai untuk pengukuran ini, wajib memasukkan route webhook ke exceptRoutes. Periksa start/kernel.ts: kalau shield_middleware tidak ada di router.use(), CSRF tidak berjalan dan langkah itu tidak diperlukan. Yang tetap berlaku di semua starter kit adalah request.raw(), .release() untuk secret, dan return di depan response.badRequest().
Kenapa webhook dijawab 302 ke / padahal route-nya benar?
Itu jawaban Shield untuk token CSRF yang tidak ada: ia mengalihkan ke halaman sebelumnya, dan karena pengirim webhook tidak membawa header Referer, tujuannya /. Log aplikasi mencatat Invalid or expired CSRF token. Pengirim Kasera Pay tidak mengikuti redirect, jadi setiap pengiriman tercatat gagal dan diulang. Masukkan pola route webhook ke exceptRoutes di config/shield.ts lalu jalankan ulang server, karena perubahan file config tidak dimuat ulang oleh node ace serve.
Apakah request.raw() terisi kalau Content-Type bukan application/json?
Pada pengukuran, request.raw() terisi untuk application/json, untuk application/json; charset=utf-8, dan juga untuk text/plain. Tanpa header Content-Type sama sekali, request.raw() bernilai null dan verifikasi gagal 400. Pengiriman webhook Kasera Pay selalu membawa Content-Type application/json, jadi jalur yang dipakai adalah jalur JSON.
Apa yang dilihat pengirim kalau database sedang mati?
Galat yang dilempar dari controller dijawab 500. Di build produksi, body-nya adalah halaman galat dari resources/views/pages/errors, tanpa pesan Postgres; pesan lengkapnya tercatat di log aplikasi. 500 berarti pengiriman diulang dengan exponential backoff sampai tujuh kali dalam kurang lebih 33 jam.