Webhook
Webhook adalah cara utama sistem kamu mengetahui hasil akhir sebuah pembayaran. Jangan menggantungkan alur bisnis pada polling: status dapat berubah beberapa menit setelah pembayaran dibuat, dan hanya webhook yang memberitahumu segera setelah perubahan itu tercatat di buku besar.
Menyiapkan alamat penerima
- Daftarkan satu URL https:// per lingkungan pada portal merchant. Alamat http:// ditolak.
- Alamat harus dapat dijangkau dari internet dan menjawab dalam 10 detik.
- Kamu akan menerima signing secret khusus webhook. Nilainya berbeda dari secret API.
- Balas 2xx secepatnya, lalu proses isinya secara asinkron. Balasan lambat dianggap gagal dan memicu percobaan ulang.
Format payload
Setiap kiriman adalah satu objek JSON dengan amplop yang seragam. Bidang data berisi objek yang sama persis dengan yang dikembalikan API untuk sumber daya tersebut.
POST /webhook/npy HTTP/1.1
Content-Type: application/json
X-NPY-Event-Id: evt_01J9Q5R8W2D4KM
X-NPY-Event-Type: payment.succeeded
X-NPY-Timestamp: 1785200284
X-NPY-Signature: 9d4c0b7e5f1a2c8b6e3d0f9a7c5b4e2d1f0a9c8b7e6d5f4a3c2b1e0d9f8a7c6b
X-NPY-Delivery-Attempt: 1
{
"id": "evt_01J9Q5R8W2D4KM",
"type": "payment.succeeded",
"created_at": "2026-08-04T02:18:04Z",
"environment": "sandbox",
"api_version": "v1",
"data": {
"reference": "pay_01J9Q4M2T7K3XW",
"status": "succeeded",
"amount_minor": 150000,
"currency": "IDR",
"exponent": 0,
"fee_minor": 2100,
"net_minor": 147900,
"payment_method": "qris",
"reference_id": "ORD-10427",
"gateway_code": "qris_sandbox",
"paid_at": "2026-08-04T02:18:04Z",
"metadata": { "channel": "web", "plan": "pro" }
}
}
| Header | Isi |
|---|---|
| X-NPY-Event-Id | Identitas peristiwa. Tetap sama pada semua percobaan ulang — pakai ini untuk deduplikasi. |
| X-NPY-Event-Type | Jenis peristiwa, sama dengan type di badan. |
| X-NPY-Timestamp | Waktu Unix detik saat kiriman dibentuk. |
| X-NPY-Signature | hmac_sha256(webhook_secret, timestamp + "." + body), heksadesimal huruf kecil. |
| X-NPY-Delivery-Attempt | Nomor percobaan, mulai dari 1. |
Memverifikasi tanda tangan
Skema tanda tangannya identik dengan autentikasi API, hanya kuncinya yang berbeda. Tolak kiriman yang tanda tangannya tidak cocok atau stempel waktunya di luar 300 detik — jangan memprosesnya lebih dulu lalu memeriksa belakangan.
import hashlib
import hmac
import os
import time
from fastapi import APIRouter, Header, HTTPException, Request
router = APIRouter()
WEBHOOK_SECRET = os.environ["NPY_WEBHOOK_SECRET"].encode("utf-8")
TOLERANCE = 300
@router.post("/webhook/npy")
async def receive(
request: Request,
x_npy_timestamp: str = Header(...),
x_npy_signature: str = Header(...),
x_npy_event_id: str = Header(...),
):
raw = await request.body() # byte mentah, sebelum diparsing
try:
skew = abs(int(time.time()) - int(x_npy_timestamp))
except ValueError:
raise HTTPException(400, "stempel waktu tidak valid")
if skew > TOLERANCE:
raise HTTPException(400, "stempel waktu di luar jendela")
expected = hmac.new(
WEBHOOK_SECRET,
x_npy_timestamp.encode("utf-8") + b"." + raw,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, x_npy_signature):
raise HTTPException(401, "tanda tangan tidak cocok")
# Deduplikasi: satu event_id hanya diproses satu kali.
if already_processed(x_npy_event_id):
return {"received": True}
enqueue_for_processing(raw, x_npy_event_id) # proses di luar permintaan ini
return {"received": True}
<?php
declare(strict_types=1);
$secret = getenv('NPY_WEBHOOK_SECRET');
$raw = file_get_contents('php://input'); // byte mentah
$timestamp = $_SERVER['HTTP_X_NPY_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_NPY_SIGNATURE'] ?? '';
$eventId = $_SERVER['HTTP_X_NPY_EVENT_ID'] ?? '';
if ($timestamp === '' || abs(time() - (int) $timestamp) > 300) {
http_response_code(400);
exit('stempel waktu di luar jendela');
}
$expected = hash_hmac('sha256', $timestamp . '.' . $raw, $secret);
// hash_equals = perbandingan waktu tetap
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit('tanda tangan tidak cocok');
}
if (already_processed($eventId)) {
http_response_code(200);
exit('ok');
}
enqueue_for_processing($raw, $eventId); // proses asinkron
http_response_code(200);
echo 'ok';
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.NPY_WEBHOOK_SECRET;
// Penting: ambil badan MENTAH, bukan hasil parsing JSON.
app.post('/webhook/npy', express.raw({ type: 'application/json' }), (req, res) => {
const ts = req.get('X-NPY-Timestamp') || '';
const sig = req.get('X-NPY-Signature') || '';
const eventId = req.get('X-NPY-Event-Id') || '';
if (Math.abs(Math.floor(Date.now() / 1000) - Number(ts)) > 300) {
return res.status(400).send('stempel waktu di luar jendela');
}
const expected = crypto
.createHmac('sha256', SECRET)
.update(Buffer.concat([Buffer.from(ts + '.', 'utf8'), req.body]))
.digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(sig, 'utf8');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send('tanda tangan tidak cocok');
}
if (alreadyProcessed(eventId)) return res.status(200).send('ok');
enqueueForProcessing(req.body, eventId); // proses asinkron
res.status(200).send('ok');
});
Kebijakan percobaan ulang
Kiriman dianggap berhasil bila penerima menjawab kode 2xx dalam 10 detik. Selain itu — termasuk 3xx, batas waktu, dan galat TLS — dianggap gagal dan dicoba ulang dengan jeda menaik.
| Percobaan | Jeda sejak peristiwa |
|---|---|
| 1 | segera |
| 2 | 30 detik |
| 3 | 2 menit |
| 4 | 10 menit |
| 5 | 1 jam |
| 6 | 3 jam |
| 7 | 6 jam |
| 8 | 12 jam — percobaan terakhir |
- Setelah percobaan ke-8 gagal, peristiwa ditandai undelivered dan dapat dikirim ulang manual dari portal merchant.
- Urutan kedatangan tidak dijamin. Bandingkan created_at peristiwa dengan status yang sudah kamu simpan, dan abaikan peristiwa yang lebih lama.
- Peristiwa yang sama dapat datang lebih dari satu kali. Simpan X-NPY-Event-Id dan tolak duplikatnya — penerima kamu harus idempoten.
Jenis peristiwa
| Jenis | Dikirim ketika |
|---|---|
| payment.created | Pembayaran dibuat dan instruksinya siap dipakai pelanggan. |
| payment.processing | Penyedia mulai memproses dana. |
| payment.succeeded | Dana diterima dan kewajiban ke merchant tercatat di buku besar. |
| payment.failed | Pembayaran ditolak penyedia atau ditolak mesin deteksi penipuan. |
| payment.expired | Masa berlaku habis tanpa pembayaran. |
| payment.canceled | Merchant membatalkan pembayaran yang belum dibayar. |
| refund.succeeded | Refund penuh atau parsial selesai dicatat. |
| refund.failed | Refund gagal; tidak ada dana yang bergerak. |
| settlement.created | Batch settlement dibentuk dan menunggu pembayaran. |
| settlement.paid | Dana batch sudah dikirim ke rekening merchant. |
| dispute.opened | Sengketa dibuka; dana sebesar nominal sanggahan ditahan. |
| dispute.updated | Status atau tenggat bukti berubah. |
| dispute.resolved | Sengketa selesai; dana dilepas atau dipindahkan sesuai hasil. |
Menguji di sandbox
- Portal merchant menyediakan tombol untuk memicu setiap jenis peristiwa dengan data contoh.
- Riwayat pengiriman menampilkan badan permintaan, header, kode balasan, dan waktu tempuh setiap percobaan.
- Saat mengembangkan di komputer lokal, arahkan alamat webhook ke terowongan HTTPS. Alamat localhost tidak dapat kami jangkau.

