Panduan · Terbit
Integrasi payment gateway di Astro: endpoint yang harus keluar dari mode statis, kunci API di astro:env alih-alih import.meta.env, dan checkOrigin yang menolak tombol bayar di belakang proxy
Astro membangun halaman statis secara bawaan, dan pembayaran butuh kebalikannya: kode yang berjalan di server saat diminta. Integrasinya terdiri dari dua endpoint di src/pages/api, satu yang membuat permintaan pembayaran dan satu yang menerima webhook, dan keduanya hanya bekerja kalau lima hal khas Astro dipenuhi. Proyeknya memakai adapter dan endpoint-nya menulis export const prerender = false. Kunci API dibaca lewat astro:env, bukan import.meta.env. Tombol bayar adalah form HTML biasa yang dijawab redirect 303. Pemeriksaan asal Astro diberi tahu domain aslinya kalau ada proxy di depan. Dan tanda tangan webhook diverifikasi dari request.text().
Contoh di bawah memakai Astro 7.3, adapter @astrojs/node 11.1, Postgres lewat pg, dan SDK JavaScript resmi versi 0.1.0. Seluruh kodenya di-build dan dijalankan dengan Node 22 saat panduan ini ditulis, melawan API tiruan dan Postgres 16, termasuk 40 pengiriman event yang sama secara bersamaan. Angka dan pesan galat di bawah berasal dari pengujian itu.
Alurnya, sebelum menulis kode
Halaman pesanan berisi form yang mengirim id pesanan ke /api/bayar. Endpoint itu membaca total dari tabel orders, membuat permintaan pembayaran, lalu mengalihkan pembeli ke checkout_url. Pembeli membayar dengan QRIS atau Virtual Account delapan bank. Kasera Pay mengirim payment.paid bertanda tangan ke /api/kasera-webhook, dan endpoint itu menandai pesanan lunas. Kepulangan pembeli ke return_url bukan bukti apa pun. Status permintaan pembayaran adalah pending, succeeded, failed, expired, dan canceled; payment.paid adalah nama event-nya, bukan status.
1. Adapter dan prerender: jebakan yang tidak menggagalkan build
npx astro add node
npm install kasera-pay pg
npm install -D @types/pg
# .env, dibaca astro dev. Jangan di-commit.
KASERA_PAY_KEY=kp_test_...
KASERA_PAY_WEBHOOK_SECRET=whsec_...
DATABASE_URL=postgres://...Tanpa adapter, Astro tetap menyelesaikan build. Pada pengujian, endpoint webhook yang hanya punya handler POST menghasilkan satu baris WARN bahwa tidak ada handler untuk GET, lalu build berakhir dengan Complete!. Yang tersimpan di dist/api/kasera-webhook adalah halaman HTML berjudul 404: Not Found. Hosting statis akan menyajikan file itu, dan setiap pengiriman webhook gagal tanpa ada yang terlihat merah di CI. Dengan adapter terpasang, endpoint pembayaran dan halaman pesanan diberi prerender = false, sedangkan halaman lain boleh tetap dibangun statis.
2. Kunci API di astro:env, bukan import.meta.env
// astro.config.mjs
import { defineConfig, envField } from "astro/config";
import node from "@astrojs/node";
export default defineConfig({
adapter: node({ mode: "standalone" }),
// Hanya kalau Astro berjalan di belakang proxy yang memegang TLS (bagian 5).
security: { allowedDomains: [{ hostname: "toko.example", protocol: "https" }] },
env: {
schema: {
KASERA_PAY_KEY: envField.string({ context: "server", access: "secret" }),
KASERA_PAY_WEBHOOK_SECRET: envField.string({ context: "server", access: "secret" }),
DATABASE_URL: envField.string({ context: "server", access: "secret" }),
},
},
});Di kode server Astro, import.meta.env.KASERA_PAY_KEY diganti dengan nilainya saat build. Pada pengujian, build yang dijalankan dengan kunci tes terisi menyimpan kunci itu utuh sebagai string di dalam dist/server/chunks, dan server tetap memakainya walaupun variabelnya kosong saat berjalan. Kunci live jadi ikut ke setiap image Docker dan cache CI. Variabel yang dideklarasikan dengan access: “secret” dan diimpor dari astro:env/server tidak ikut tertanam dan dibaca dari lingkungan proses saat server berjalan.
Kalau satu variabel di skema tidak ada saat server berjalan, server tetap menyala dan halaman biasa dijawab 200, tetapi setiap endpoint yang mengimpor astro:env/server menjawab 500 dengan galat EnvInvalidVariables. Untuk webhook itu kegagalan yang aman: jawaban 500 membuat Kasera Pay mengulang pengiriman, sehingga event tidak hilang selama variabelnya dipasang dalam jendela pengulangan. Kunci kp_test_ dan kp_live_ diganti bersamaan dengan signing secret-nya, karena webhook mode tes dan live adalah endpoint terpisah dengan secret masing-masing.
CREATE TABLE orders (
id text PRIMARY KEY,
total bigint NOT NULL CHECK (total > 0), -- rupiah utuh
status text NOT NULL DEFAULT 'BARU',
attempt int NOT NULL DEFAULT 0,
payment_request_id text,
paid_at timestamptz
);
CREATE TABLE kasera_events (
id text PRIMARY KEY,
type text NOT NULL,
received_at timestamptz NOT NULL DEFAULT now()
);// src/lib/kasera.ts
import { KaseraPay } from "kasera-pay";
import { KASERA_PAY_KEY } from "astro:env/server";
export const kasera = new KaseraPay(KASERA_PAY_KEY);
// src/lib/db.ts
import pg from "pg";
import { DATABASE_URL } from "astro:env/server";
export const db = new pg.Pool({ connectionString: DATABASE_URL, max: 10 });3. Tombol bayar tanpa JavaScript
---
// src/pages/pesanan/[id].astro
export const prerender = false;
const { id } = Astro.params;
---
<form method="post" action="/api/bayar">
<input type="hidden" name="orderId" value={id} />
<button type="submit">Bayar</button>
</form>// src/pages/api/bayar.ts
import type { APIRoute } from "astro";
import { KaseraPayError } from "kasera-pay";
import { kasera } from "../../lib/kasera";
import { db } from "../../lib/db";
export const prerender = false;
export const POST: APIRoute = async ({ request, redirect }) => {
// Memastikan pesanan ini milik pengguna yang login adalah tugas aplikasi.
const form = await request.formData();
const orderId = String(form.get("orderId") ?? "");
const { rows } = await db.query(
"SELECT id, total, status, attempt FROM orders WHERE id = $1",
[orderId],
);
const order = rows[0];
if (!order) return new Response("not_found", { status: 404 });
if (order.status === "LUNAS") return redirect("/pesanan/" + order.id, 303);
try {
const tx = await kasera.createTransaction(
{
amount: Number(order.total), // bigint datang sebagai string
description: "Pesanan " + order.id,
external_id: order.id,
return_url: "https://toko.example/pesanan/" + order.id,
checkout: {},
},
{ idempotencyKey: "order-" + order.id + "-" + order.attempt },
);
await db.query(
"UPDATE orders SET payment_request_id = $1, status = 'MENUNGGU' WHERE id = $2 AND status <> 'LUNAS'",
[tx.id, order.id],
);
return redirect(tx.checkout_url, 303);
} catch (e) {
if (e instanceof KaseraPayError) return new Response(e.code, { status: 502 });
throw e;
}
};Form biasa cocok dengan cara Astro bekerja: halaman pesanan tidak mengirim JavaScript, dan endpoint menjawab redirect 303 sehingga peramban membuka halaman checkout dengan GET. Total dibaca dari database, tidak pernah dari form, supaya pembeli tidak bisa menentukan nominalnya sendiri. pg mengembalikan kolom bigint sebagai string, jadi Number() wajib ada; tanpa itu API menolak permintaan dengan 422 karena amount bukan angka.
Kunci idempotensinya dibentuk dari id pesanan dan nomor percobaan. Pada pengujian, tiga kiriman form yang sama secara bersamaan menghasilkan satu permintaan pembayaran. Nomor percobaan dinaikkan oleh webhook saat payment.expired tiba, supaya pembeli yang kembali setelah tagihan kedaluwarsa mendapat permintaan baru, bukan permintaan lama yang dikembalikan lagi. Hanya header Idempotency-Key yang mencegah duplikat; external_id sekadar label. Alasannya ada di idempotency untuk pembayaran.
4. Endpoint webhook
// src/pages/api/kasera-webhook.ts
import type { APIRoute } from "astro";
import { constructWebhookEvent, SignatureError } from "kasera-pay";
import { KASERA_PAY_WEBHOOK_SECRET } from "astro:env/server";
import { db } from "../../lib/db";
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
let ev;
try {
ev = await constructWebhookEvent(
await request.text(), // byte mentah, bukan JSON.stringify(await request.json())
request.headers.get("kasera-signature-v1") ?? "",
KASERA_PAY_WEBHOOK_SECRET,
);
} catch (e) {
if (e instanceof SignatureError) return new Response("bad signature", { status: 400 });
throw e;
}
// Tipe kasera-pay 0.1.0 baru mengenal payment.paid dan test.ping.
const type: string = ev.type;
const data = ("data" in ev ? ev.data : undefined) as
| { external_id?: string | null; payment_request_id?: string; paid_at?: string | null }
| undefined;
if (!data?.external_id) return new Response("ignored");
const client = await db.connect();
try {
await client.query("BEGIN");
const fresh = await client.query(
"INSERT INTO kasera_events (id, type) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING",
[ev.id, type],
);
if (fresh.rowCount === 1 && type === "payment.paid") {
await client.query(
"UPDATE orders SET status = 'LUNAS', paid_at = coalesce($2, now()) WHERE id = $1",
[data.external_id, data.paid_at ?? null],
);
} else if (fresh.rowCount === 1 && type === "payment.expired") {
await client.query(
`UPDATE orders SET status = 'KEDALUWARSA', attempt = attempt + 1
WHERE id = $1 AND status <> 'LUNAS' AND payment_request_id = $2`,
[data.external_id, data.payment_request_id ?? null],
);
}
await client.query("COMMIT");
} catch (e) {
await client.query("ROLLBACK").catch(() => {});
console.error(e);
return new Response("retry", { status: 500 }); // Kasera Pay mengulang
} finally {
client.release();
}
return new Response("ok");
};request.text() mengembalikan body persis seperti yang dikirim, dan string itulah yang ditandatangani. Pada pengujian dengan body berspasi, varian yang memverifikasi JSON.stringify(await request.json()) menolak pengiriman yang sah dengan 400, sedangkan endpoint di atas menerimanya. constructWebhookEvent memeriksa header Kasera-Signature-V1, toleransi waktu lima menit, dan dua entri v1 selama rotasi signing secret.
Dedupe dan pembaruan pesanan berada dalam satu transaksi, dengan sisipan ke kasera_events sebagai penentunya. Pada pengujian 40 pengiriman payment.expired yang sama secara bersamaan, keempat puluhnya dijawab 200 dan nomor percobaan naik tepat satu kali. Saat tabel kasera_events sengaja diganti namanya, endpoint menjawab 500, dan memang harus begitu: pengiriman bersifat at-least-once, dan jawaban selain 2xx membuat Kasera Pay mencoba lagi sampai total tujuh kali dalam kurang lebih 33 jam. Event kedaluwarsa hanya dikirim ke endpoint yang mencentangnya. Rinciannya ada di referensi webhook.
5. Pemeriksaan asal di belakang proxy
Astro memeriksa header Origin pada setiap POST berformat form, dan form dari situs lain dijawab 403 dengan pesan Cross-site POST form submissions are forbidden. Perlindungan itu berguna, tetapi di belakang Nginx, Caddy, atau load balancer yang memegang TLS, form milik situs sendiri ikut tertolak: pembeli mengirim dari https://toko.example, sedangkan Astro menerima permintaan lewat http. Pada pengujian, form yang sah dijawab 403 dalam tiga susunan header, termasuk dengan X-Forwarded-Host dan X-Forwarded-Proto, selama security.allowedDomains belum diisi.
Setelah domain didaftarkan seperti di konfigurasi bagian 2, form yang sama dijawab 303 ke halaman checkout, asalkan proxy mengirim X-Forwarded-Proto: https. Header Host saja tanpa header protokol masih dijawab 403, dan form dari domain yang tidak terdaftar tetap ditolak. Webhook tidak terpengaruh, karena dikirim sebagai application/json.
6. Garis miring di akhir URL
Dengan trailingSlash bawaan, /api/kasera-webhook dan /api/kasera-webhook/ sama-sama dijawab 200. Situs yang menyetel trailingSlash: “always” menjawab alamat tanpa garis miring dengan redirect 301. Kasera Pay tidak mengikuti pengalihan pada pengiriman webhook, sehingga setiap pengiriman ke alamat itu dihitung gagal dan diulang sampai habis. URL yang didaftarkan di dashboard harus sama persis dengan yang dijawab 200, termasuk garis miring terakhirnya. Membuka URL webhook di peramban menghasilkan 404, dan itu normal karena endpoint-nya hanya punya POST.
7. Deploy dan pengujian
astro build dengan adapter Node menghasilkan dist/server/entry.mjs yang dijalankan dengan node dist/server/entry.mjs, dan ketiga variabel diberikan pada proses itu, bukan pada langkah build. URL webhook didaftarkan di dashboard, menu Developer, dan wajib https ke alamat publik. Dengan kunci kp_test_, halaman checkout menampilkan tombol simulasi, dan hasil berhasil maupun kedaluwarsa bisa dipicu lewat endpoint di dokumentasi mode tes. astro dev berjalan di localhost:4321, jadi webhook ke sana butuh terowongan, misalnya cloudflared tunnel --url http://localhost:4321.
Lima hal yang layak dicoba sebelum kunci live dipasang: grep kunci tes di folder dist setelah build tidak menemukan apa pun; ketukan ganda pada tombol bayar menghasilkan satu permintaan pembayaran; event yang dikirim ulang tidak mengubah apa pun; simulasi kedaluwarsa lalu bayar ulang menghasilkan permintaan baru; dan form bayar dijawab 303, bukan 403, saat dibuka lewat domain asli di belakang proxy. Tim yang masih menimbang Astro melawan Nuxt bisa membandingkan bentuknya dengan panduan integrasi Nuxt.
Pertanyaan yang sering muncul
Situs Astro saya statis dan di-hosting di GitHub Pages. Bisakah menerima pembayaran?
Tidak dengan kode di panduan ini, karena hosting statis tidak menjalankan endpoint apa pun: tidak ada tempat untuk menyimpan kunci API, membuat permintaan pembayaran, atau menerima webhook. Ada dua jalan. Situsnya tetap statis dan setiap produk ditautkan ke halaman pembayaran yang dibuat dari dashboard, tanpa satu baris kode pun. Atau situsnya dipindah ke hosting yang menjalankan server dengan adapter, dan hanya endpoint pembayaran yang diberi prerender = false, sementara halaman lain tetap dibangun statis.
Kenapa kunci API tidak boleh dibaca lewat import.meta.env.KASERA_PAY_KEY?
Karena di kode server Astro, import.meta.env untuk variabel tanpa awalan PUBLIC_ diganti dengan nilainya saat build. Pada pengujian dengan Astro 7.3, build yang dijalankan dengan KASERA_PAY_KEY terisi menghasilkan file di dist/server/chunks yang memuat kunci itu sebagai string, dan server tetap memakainya walaupun variabelnya tidak ada lagi saat berjalan. Kunci ikut ke setiap image dan cache CI. Skema astro:env dengan access secret membaca nilainya saat server berjalan.
Kenapa tombol bayar menjawab 403 setelah situs dipasang di belakang Nginx atau load balancer?
Karena Astro memeriksa header Origin pada setiap POST berformat form. Proxy memegang https, sedangkan Astro menerima http dari proxy, sehingga asal https://toko.example dianggap situs lain dan ditolak dengan pesan Cross-site POST form submissions are forbidden. Daftarkan domain di security.allowedDomains dan pastikan proxy mengirim X-Forwarded-Proto: https. Mematikan checkOrigin bukan perbaikannya, karena perlindungan itulah yang menolak form dari situs lain.
Apakah pemeriksaan asal Astro ikut menolak webhook dari Kasera Pay?
Tidak. Pemeriksaan itu hanya berlaku untuk POST berformat form, sedangkan webhook dikirim sebagai application/json tanpa header Origin. Pada pengujian, pengiriman webhook bertanda tangan sah dijawab 200 dengan checkOrigin tetap menyala.