Event
Webhook
Terima payment.paid ber-signature begitu pembayaran terkonfirmasi.
Tambahkan webhook endpoint di dashboard (menu Developer) — maksimal lima per mode, masing-masing dengan URL, nama, dan signing secret sendiri, jadi toko dan ERP bisa menerima kiriman masing-masing. Saat pembayaran terkonfirmasi, kami mengirim POST ke setiap endpoint yang aktif, dengan payload:
{
"id": "evt_...",
"type": "payment.paid",
"livemode": false,
"created_at": "2026-08-11T12:05:00+07:00",
"data": {
"payment_request_id": "payreq_9b2f...",
"external_id": "ORD-1234",
"merchant_ref": "INV-2026-001",
"amount": 150000,
"currency": "IDR",
"customer": { "name": "Budi", "email": "budi@toko.dev" },
"paid_at": "2026-08-11T12:04:58+07:00",
"status": "succeeded"
}
}Event yang diterima
Ada tiga event pembayaran. Setiap endpoint memilih sendiri mana yang ingin diterima, lewat dashboard; endpoint yang dibuat sebelum pilihan ini ada hanya menerima payment.paid, dan tetap begitu sampai diubah.
payment.paid— pembeli sudah membayar. Ini yang berarti uang masuk.payment.expired— permintaan lewat batas waktu tanpa dibayar.payment.failed— rail menolak permintaannya. Ini bukan percobaan yang gagal: pembeli yang scan-nya gagal sekali masih bisa scan lagi, dan permintaannya tetap terbuka. Hanya permintaan yang benar-benar mati yang mengirim ini.
Langganan mengirim event invoice dan langganannya sendiri, misalnya invoice.paid, lewat endpoint dan signature yang sama; daftar dan payload-nya ada di halaman Langganan.
Dua event terminal ini bentuknya sama dengan yang paid, dengan paid_at: null:
{
"id": "evt_...",
"type": "payment.expired",
"livemode": true,
"created_at": "2026-08-11T13:00:00+07:00",
"data": {
"payment_request_id": "payreq_9b2f...",
"external_id": "ORD-1234",
"amount": 150000,
"currency": "IDR",
"paid_at": null,
"status": "expired"
}
}Expired bisa disusul pembayaran. Kalau uang pembeli sudah diterima sebelum timer expiry kami jalan, uang yang menang: permintaan jadi succeeded dan payment.paid menyusul payment.expired yang sudah dikirim. Baca data.status — itu status pembayaran saat event dibuat, bukan saat diantrekan — atau ambil ulang permintaannya untuk memastikan. Kalau stok sudah dilepas saat expired, event paid adalah sinyal untuk menariknya kembali atau melakukan refund.
Failed juga bisa disusul pembayaran. Sejak 13 Oktober 2026 (PAY-849), permintaan yang berstatus failed karena penyedia melaporkan QR-nya dibatalkan saat expiry kami tetap berubah menjadi succeeded kalau pembeli masih membayarnya, dan payment.paid menyusul payment.failed yang sudah dikirim. Perlakukan pesanan yang sudah dilepas sama seperti setelah expired.
Pengiriman bersifat at-least-once: event yang sama bisa terkirim lebih dari sekali dan selalu membawa id yang sama — dedupe berdasarkan id. Kalau ada beberapa endpoint, masing-masing menerima event satu kali dan di-retry sendiri-sendiri: satu endpoint yang menolak tidak akan menunda yang lain. Endpoint Anda harus menjawab 2xx; kalau tidak, kami retry dengan exponential backoff maksimal 7 kali dalam ±33 jam. URL webhook wajib https dan mengarah ke alamat publik. Live dan test adalah endpoint terpisah dengan signing secret masing-masing: atur masing-masing di mode dashboard-nya sendiri. Event test mode hanya dikirim ke endpoint test dan membawa livemode: false; event live hanya ke endpoint live. Secret test tidak akan pernah bisa memverifikasi payload live.
Verifikasi signature
Setiap pengiriman membawa signature dengan timestamp di header Fintar-Signature-V1: t=<unix>,v1=<hex>, dengan v1 adalah HMAC-SHA256 atas t + "." + rawBody. Verifikasi terhadap t dan tolak pengiriman yang timestamp-nya melenceng lebih dari 5 menit dari jam server Anda — ini yang mencegah pengiriman yang disadap di-replay belakangan. Setelah rotasi secret, header membawa dua entri v1 selama 24 jam — satu per secret — jadi terima pengiriman kalau salah satu entri cocok. Id event juga ada di Fintar-Event-Id.
// Node.js
const crypto = require("crypto");
// Fintar-Signature-V1: t=1723350300,v1=5f4d...[,v1=9a1b...]
function verify(rawBody, v1Header, secret, toleranceSeconds = 300) {
const parts = v1Header.split(",");
const t = Number(parts[0].slice(2)); // "t=<unix>"
if (!Number.isFinite(t)) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(t + "." + rawBody)
.digest("hex");
return parts
.slice(1)
.filter((p) => p.startsWith("v1="))
.some((p) => {
const sig = Buffer.from(p.slice(3));
return (
sig.length === expected.length &&
crypto.timingSafeEqual(sig, Buffer.from(expected))
);
});
}Di PHP atau JavaScript/TypeScript Anda tidak perlu kode ini: SDK resmi sudah menyediakannya sebagai Webhook::constructEvent dan constructWebhookEvent, lengkap dengan toleransi timestamp dan masa transisi rotasi secret.
Header lama (deprecated)
Pengiriman masih membawa header Fintar-Signature yang asli — hex HMAC-SHA256 atas raw body, tanpa timestamp — jadi kode verifikasi yang sudah ada tetap jalan tanpa perubahan. Header ini deprecated: tidak melindungi dari replay, dan hanya di-sign dengan secret saat ini (tanpa grace period rotasi). Pindahlah ke Fintar-Signature-V1; header lama akan dihapus setelah masa deprecation yang diumumkan lebih dulu.
// Node.js — legacy Fintar-Signature (deprecated)
const crypto = require("crypto");
function verifyLegacy(rawBody, signatureHeader, secret) {
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(signatureHeader),
Buffer.from(expected)
);
}Secret bisa dilihat kapan saja di dashboard, dan hanya berubah saat Anda merotasinya — memindahkan URL webhook ke domain baru tidak mengganti secret, jadi migrasi tidak perlu deploy ulang kode verifikasi Anda. Rotasi punya grace period 24 jam: secret lama tetap men-sign entri v1 kedua di samping yang baru, jadi deploy secret baru tanpa terburu-buru — retry yang sedang berjalan tetap lolos verifikasi. Anda juga bisa menambah endpoint tanpa URL untuk dapat secret-nya dulu: buat dan deploy handler-nya, arahkan domain belakangan. Endpoint yang dinonaktifkan menahan event-nya tanpa menghabiskan jatah retry sampai Anda aktifkan lagi.
Verifikasi signature sebelum memproses payload. Pakai raw body, bukan JSON yang di-parse ulang.