Panduan · Terbit
Integrasi payment gateway di NestJS: rawBody yang harus dinyalakan di main.ts, guard global yang menolak setiap webhook, dan DTO yang tidak boleh dipasang di route-nya
Integrasi Kasera Pay di NestJS terdiri dari satu service yang membuat permintaan pembayaran dan satu controller yang menerima webhook. Tiga hal khas NestJS menentukan berhasil tidaknya, dan tidak satu pun soal kriptografi: opsi rawBody: true di main.ts, route webhook yang tidak memakai @Body() maupun DTO, dan pengecualian route itu dari guard autentikasi global. Tanpa yang pertama, setiap tanda tangan gagal diverifikasi. Tanpa yang ketiga, setiap pengiriman dijawab 401.
Tidak ada SDK atau module NestJS resmi, dan tidak diperlukan: yang dipakai hanya fetch bawaan Node dan node:crypto. Contoh di bawah ditulis untuk NestJS 10 dan 11 dengan adapter Express, Prisma sebagai ORM, dan @nestjs/config untuk kredensial. Referensi endpoint lengkapnya ada di dokumentasi API Kasera Pay. Kalau yang dipakai Express tanpa NestJS, urutan middleware yang menjadi pokok soalnya dibahas di panduan integrasi Node.js dan Express.
Alurnya, sebelum menulis kode
Server membuat permintaan pembayaran dan mengarahkan pembeli ke checkout_url. Pembeli membayar dengan QRIS atau Virtual Account delapan bank. Lalu Kasera Pay mengirim payment.paid bertanda tangan ke route webhook, dan hanya event itu yang menandai uang masuk. Kepulangan pembeli ke return_url bukan bukti apa pun, karena halaman itu bisa dibuka tanpa membayar. Permintaan yang lunas berstatus succeeded, yang masih menunggu pending, dan yang lewat batas waktu expired; payment.paid adalah nama event-nya, bukan nama status.
1. Kredensial
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. Simpan keduanya di .env dan baca lewat ConfigService, bukan process.env yang tersebar di banyak berkas.
# .env: kp_test_ selama membangun, kp_live_ setelah go-live.
KASERA_PAY_KEY=kp_test_...
KASERA_PAY_WEBHOOK_SECRET=whsec_...
KASERA_PAY_BASE_URL=https://pay.kasera.id2. main.ts: tiga baris yang mengubah perilaku route webhook
// src/main.ts
import { NestFactory } from "@nestjs/core";
import { ValidationPipe } from "@nestjs/common";
import { AppModule } from "./app.module";
async function bootstrap() {
// rawBody: true membuat body parser bawaan Nest menyimpan salinan Buffer
// mentah di req.rawBody, di samping req.body yang sudah di-parse.
// Tanpa opsi ini, req.rawBody undefined dan byte aslinya hilang.
const app = await NestFactory.create(AppModule, { rawBody: true });
// Prefix ini ikut menjadi bagian URL webhook yang didaftarkan:
// https://api.toko.example/api/webhooks/kasera-pay, bukan tanpa /api.
app.setGlobalPrefix("api");
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }));
await app.listen(3000);
}
bootstrap();rawBody: true adalah baris yang paling menentukan. NestJS memasang body parser JSON secara bawaan, jadi saat controller dipanggil body permintaan sudah menjadi objek dan stream aslinya sudah habis terbaca. Kasera Pay menandatangani byte persis seperti yang dikirim. Menyusun ulang objek itu dengan JSON.stringify menghasilkan isi yang sama dengan byte yang berbeda, sehingga HMAC-nya tidak pernah cocok dan setiap pengiriman yang sah ditolak. Dengan opsi ini, parser bawaan tetap bekerja untuk semua route dan menyimpan salinan Buffer mentah di req.rawBody. Opsi itu hanya bekerja selama parser bawaan menyala, jadi jangan gabungkan dengan bodyParser: false.
setGlobalPrefix tidak merusak apa pun, tetapi mengubah URL yang harus didaftarkan di dashboard. Controller webhooks dengan route kasera-pay di bawah prefix api hidup di /api/webhooks/kasera-pay. URL yang didaftarkan tanpa /api dijawab 404, dan 404 berarti pengiriman diulang sampai tujuh percobaan dalam kurang lebih 33 jam tanpa satu pun sampai ke controller.
3. Service yang membungkus dua endpoint
// src/kasera-pay/kasera-pay.service.ts
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
export class KaseraPayError extends Error {
constructor(readonly status: number, readonly code?: string) {
super("Kasera Pay " + status + " " + (code ?? "unknown"));
}
}
@Injectable()
export class KaseraPayService {
private readonly baseUrl: string;
private readonly key: string;
constructor(config: ConfigService) {
this.baseUrl = config.get("KASERA_PAY_BASE_URL", "https://pay.kasera.id");
this.key = config.getOrThrow("KASERA_PAY_KEY");
}
async createTransaction(input: Record<string, unknown>, idempotencyKey: string) {
const res = await fetch(this.baseUrl + "/v1/transactions", {
method: "POST",
headers: {
Authorization: "Bearer " + this.key,
"Content-Type": "application/json",
// Satu-satunya yang mencegah satu pesanan menjadi dua pembayaran.
// Dibuat sekali, dipakai apa adanya pada setiap percobaan ulang.
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(input),
// fetch tidak membatasi waktu seluruh permintaan dengan angka yang
// pendek. Tanpa ini, satu koneksi yang macet menahan request pembeli.
signal: AbortSignal.timeout(15_000),
});
const body = await res.json();
if (!res.ok) throw new KaseraPayError(res.status, body?.error?.code);
return body as { id: string; status: string; checkout_url: string };
}
async getTransaction(id: string) {
const res = await fetch(this.baseUrl + "/v1/transactions/" + id, {
headers: { Authorization: "Bearer " + this.key },
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) throw new KaseraPayError(res.status);
return res.json();
}
}Dua hal di service ini bukan hiasan. Pertama, AbortSignal.timeout: fetch di Node tidak memotong permintaan yang menggantung dalam hitungan detik, dan request pembeli ikut menunggu selama itu. Kalau batas waktunya habis padahal permintaan pembayarannya sempat dibuat, percobaan ulang dengan Idempotency-Key yang sama mengembalikan yang asli dengan status 200, bukan membuat yang kedua. Key yang sama dengan body yang berbeda ditolak 409 idempotency_conflict.
Kedua, Idempotency-Key adalah satu-satunya yang mencegah pembayaran ganda. Header itu opsional, dan tanpa header itu setiap percobaan menjadi permintaan pembayaran baru. external_id dan merchant_ref hanya label yang disimpan dan bisa difilter, tidak pernah penanda bahwa dua permintaan adalah satu pembayaran. Latar belakangnya ada di idempotency untuk pembayaran.
// src/orders/orders.controller.ts (potongan)
@Post(":id/bayar")
async bayar(@Param("id") id: string, @Res() res: Response) {
const order = await this.orders.find(id);
// Key disimpan bersama pesanannya. Key baru pada setiap percobaan
// menghapus proteksinya tanpa galat apa pun yang memberi tahu.
const key = order.idempotencyKey ?? (await this.orders.assignIdempotencyKey(id));
const tx = await this.kaseraPay.createTransaction(
{
amount: order.total, // rupiah utuh, integer
description: "Pesanan " + order.number,
external_id: order.number,
customer: { name: order.customerName, email: order.customerEmail },
return_url: this.siteUrl + "/pesanan/" + order.number,
payment_methods: ["qris", "va_bca"],
},
key,
);
await this.orders.setPaymentRequest(id, tx.id);
return res.redirect(303, tx.checkout_url);
}amount dikirim sebagai integer rupiah utuh. Kolom Prisma bertipe Decimal tiba sebagai objek Decimal, bukan number, dan harus dikonversi dengan Number() sebelum masuk ke body. Kalau tidak, JSON.stringify menuliskannya sebagai string dan permintaannya ditolak.
4. Guard global yang menolak setiap webhook
Banyak proyek NestJS memasang JwtAuthGuard sebagai APP_GUARD supaya setiap route otomatis mewajibkan login. Route webhook ikut terkena. Pengirim webhook tidak membawa token, jadi setiap pengiriman dijawab 401, diulang, dan akhirnya habis jatahnya, sementara dari sisi aplikasi tidak ada galat yang tercatat karena guard menolaknya sebelum controller dipanggil. Pola yang lazim adalah dekorator @Public() yang dibaca guard lewat Reflector:
// src/auth/public.decorator.ts
import { SetMetadata } from "@nestjs/common";
export const IS_PUBLIC = "isPublic";
export const Public = () => SetMetadata(IS_PUBLIC, true);
// Di dalam JwtAuthGuard yang terdaftar sebagai APP_GUARD:
canActivate(ctx: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC, [
ctx.getHandler(),
ctx.getClass(),
]);
if (isPublic) return true;
return super.canActivate(ctx);
}Hal yang sama berlaku untuk guard lain yang dipasang global, misalnya pembatas laju dari @nestjs/throttler yang secara bawaan menghitung per IP. Semua pengiriman webhook datang dari sisi Kasera Pay, bukan dari ribuan pembeli, jadi lonjakan pesanan bisa membuatnya terkena batas yang dirancang untuk satu pengguna. Pengecualian yang sama layak diberikan di sana.
5. Controller webhook
// src/kasera-pay/webhook.controller.ts
import {
BadRequestException, Controller, Headers, HttpCode, Post, Req,
} from "@nestjs/common";
import type { RawBodyRequest } from "@nestjs/common";
import type { Request } from "express";
import { ConfigService } from "@nestjs/config";
import { Prisma } from "@prisma/client";
import { createHmac, timingSafeEqual } from "node:crypto";
import { Public } from "../auth/public.decorator";
import { PrismaService } from "../prisma.service";
const TOLERANCE_SECONDS = 300;
@Controller("webhooks")
export class KaseraPayWebhookController {
constructor(
private readonly config: ConfigService,
private readonly prisma: PrismaService,
) {}
@Public() // lolos dari guard global; tanda tangan yang menjadi autentikasinya
@Post("kasera-pay")
@HttpCode(200) // bawaan @Post() adalah 201; keduanya 2xx, 200 lebih jujur
async handle(
@Req() req: RawBodyRequest<Request>,
@Headers("kasera-signature-v1") signature?: string,
) {
// Sengaja tanpa @Body() dan tanpa DTO: yang ditandatangani adalah byte,
// dan req.body hasil parse tidak bisa dikembalikan menjadi byte yang sama.
const raw = req.rawBody;
if (!raw) throw new BadRequestException("raw body missing");
const secret = this.config.getOrThrow<string>("KASERA_PAY_WEBHOOK_SECRET");
if (!signature || !verify(raw.toString("utf8"), signature, secret)) {
// Selain 2xx berarti pengiriman diulang, dan itu benar kalau yang
// salah adalah secret yang terpasang di server ini.
throw new BadRequestException("invalid signature");
}
const event = JSON.parse(raw.toString("utf8"));
try {
await this.prisma.$transaction(async (tx) => {
// Unique pada id: pengiriman kedua dari event yang sama gagal di sini
// dan seluruh transaksinya batal, termasuk pemenuhan pesanannya.
await tx.webhookEvent.create({ data: { id: event.id, type: event.type } });
if (event.type === "payment.paid") {
await tx.order.update({
where: { number: event.data.external_id },
data: { paidAt: new Date(event.data.paid_at), status: "LUNAS" },
});
} else if (event.type === "payment.expired") {
// Lepas stok di sini, tetapi siapkan jalur menariknya kembali:
// payment.paid masih bisa menyusul event ini. Percobaan ulang
// event ini juga bisa tiba SETELAH paid, jadi pesanan yang sudah
// lunas tidak boleh ditimpa.
await tx.order.updateMany({
where: { number: event.data.external_id, status: { not: "LUNAS" } },
data: { status: "KEDALUWARSA" },
});
}
});
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
return { ok: true }; // sudah pernah diproses
}
throw err; // menjadi 500, dan pengirimannya diulang
}
return { ok: true };
}
}
function verify(rawBody: string, header: string, secret: string): boolean {
// Kasera-Signature-V1: t=1723350300,v1=5f4d...[,v1=9a1b...]
const parts = header.split(",");
const t = Number(parts[0].slice(2));
if (!Number.isFinite(t)) return false;
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = Buffer.from(
createHmac("sha256", secret).update(t + "." + rawBody).digest("hex"),
);
// Selama rotasi signing secret ada dua entri v1; cocok dengan salah satu cukup.
return parts
.slice(1)
.filter((p) => p.startsWith("v1="))
.some((p) => {
const sig = Buffer.from(p.slice(3));
return sig.length === expected.length && timingSafeEqual(sig, expected);
});
}Route ini sengaja tidak memakai @Body() dengan DTO. Selain karena objek hasil parse tidak bisa dipakai untuk verifikasi, ValidationPipe global dengan forbidNonWhitelisted akan menolak payload begitu Kasera Pay menambahkan satu field baru yang belum ada di DTO, dan penolakan itu terjadi sebelum tanda tangan sempat diperiksa. Payload dibaca dari byte mentah setelah verifikasi, dan hanya field yang dipakai yang disentuh.
Header Kasera-Signature-V1 berisi t=<unix>,v1=<hex>. Yang ditandatangani adalah t, satu titik, lalu body mentah. Toleransi waktunya lima menit, dan selama rotasi signing secret header membawa dua entri v1 selama 24 jam, sehingga cocok dengan salah satunya sudah cukup. @Headers() di NestJS membaca nama header dalam huruf kecil, karena Node menormalkan semua nama header begitu.
Pengiriman bersifat at-least-once, jadi event yang sama bisa datang lebih dari sekali dengan id yang sama. Unique index pada tabel event menjadi penjaganya, dan karena pencatatan event serta pembaruan pesanan berada dalam satu $transaction, pengiriman kedua gagal dengan P2002 tanpa menyentuh pesanan. Galat lain dilempar ulang, dan exception filter bawaan NestJS mengubahnya menjadi 500 sehingga pengirimannya diulang. Itu perilaku yang benar untuk database yang sedang tidak bisa dihubungi.
payment.expired dan payment.failed dikirim ke endpoint yang mencentangnya; endpoint yang dibuat sebelum pilihan event ada hanya menerima payment.paid sampai diubah. Kalau stok atau kursi dilepas saat payment.expired, payment.paid masih bisa menyusul untuk uang yang sudah diterima sebelum batas waktunya, dan pada keadaan itu uangnya yang menang. Rinciannya ada di referensi webhook.
6. Sebelum go-live
URL webhook wajib https dan mengarah ke alamat publik, jadi selama membangun di laptop diperlukan terowongan; caranya ada di webhook di localhost dan mode tes. Empat hal yang layak dicoba di mode tes: pengiriman yang sah harus diterima dan tercatat, event yang sama dikirim dua kali harus menghasilkan satu pesanan terpenuhi, route webhook harus lolos tanpa token walaupun guard global menyala, dan database yang dimatikan sesaat harus menghasilkan 500, bukan 200. Kalau pengiriman yang sah tetap ditolak, periksa req.rawBody lebih dulu: nilai undefined berarti rawBody: true belum terpasang di main.ts, atau aplikasinya dibuat oleh NestFactory.create yang lain, misalnya di berkas pengujian end-to-end.
Pertanyaan yang sering muncul
Kenapa verifikasi tanda tangan selalu gagal di NestJS padahal secret-nya benar?
Hampir selalu karena yang di-hash adalah hasil JSON.stringify atas @Body(), bukan byte yang benar-benar dikirim. Body parser bawaan Nest sudah mengubah body menjadi objek sebelum controller dipanggil, dan menyusunnya kembali menghasilkan teks yang isinya sama tetapi byte-nya berbeda, sehingga HMAC-nya tidak pernah cocok. Nyalakan rawBody: true di NestFactory.create, lalu baca req.rawBody lewat RawBodyRequest.
Kenapa setiap pengiriman webhook dijawab 401?
Karena aplikasi memasang guard autentikasi global lewat APP_GUARD atau useGlobalGuards, dan guard itu juga berlaku untuk route webhook. Pengirim webhook tidak membawa token login apa pun, jadi setiap pengiriman ditolak dan diulang sampai jatah percobaannya habis. Tandai route itu dengan dekorator @Public() yang dibaca guard lewat Reflector. Keamanan route itu datang dari verifikasi Kasera-Signature-V1, bukan dari guard.
Apakah Kasera Pay punya SDK atau module NestJS resmi?
Tidak ada, dan tidak diperlukan. Seluruh integrasi di panduan ini memakai fetch yang sudah ada di Node sejak versi 18 dan modul node:crypto bawaan, dibungkus satu service yang bisa di-inject seperti provider lain. Tidak ada package yang perlu dipasang di luar yang biasanya sudah ada di proyek NestJS.
Apakah rawBody juga bekerja kalau aplikasinya memakai Fastify?
Ya. Opsi rawBody: true yang sama diberikan di NestFactory.create bersama FastifyAdapter, dan tipe requestnya menjadi RawBodyRequest<FastifyRequest>. Yang tidak boleh dilakukan di kedua adapter adalah mematikan body parser bawaan dengan bodyParser: false, karena rawBody bergantung pada parser itu.
Perlukah webhook menjawab 200 kalau @Post() di NestJS menjawab 201?
Tidak wajib. Kasera Pay menganggap 2xx apa pun sebagai diterima, jadi 201 bawaan @Post() tidak memicu percobaan ulang. @HttpCode(200) dipasang supaya log lebih mudah dibaca, karena tidak ada yang dibuat oleh sebuah pengiriman webhook.