NawaPayNawaPay Docs

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.

Semua efek samping — termasuk pengiriman webhook — dijadwalkan setelah transaksi basis data selesai. Ketika kamu menerima peristiwa, jurnalnya sudah pasti tercatat.

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" }
  }
}
HeaderIsi
X-NPY-Event-IdIdentitas peristiwa. Tetap sama pada semua percobaan ulang — pakai ini untuk deduplikasi.
X-NPY-Event-TypeJenis peristiwa, sama dengan type di badan.
X-NPY-TimestampWaktu Unix detik saat kiriman dibentuk.
X-NPY-Signaturehmac_sha256(webhook_secret, timestamp + "." + body), heksadesimal huruf kecil.
X-NPY-Delivery-AttemptNomor 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.

PercobaanJeda sejak peristiwa
1segera
230 detik
32 menit
410 menit
51 jam
63 jam
76 jam
812 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

JenisDikirim ketika
payment.createdPembayaran dibuat dan instruksinya siap dipakai pelanggan.
payment.processingPenyedia mulai memproses dana.
payment.succeededDana diterima dan kewajiban ke merchant tercatat di buku besar.
payment.failedPembayaran ditolak penyedia atau ditolak mesin deteksi penipuan.
payment.expiredMasa berlaku habis tanpa pembayaran.
payment.canceledMerchant membatalkan pembayaran yang belum dibayar.
refund.succeededRefund penuh atau parsial selesai dicatat.
refund.failedRefund gagal; tidak ada dana yang bergerak.
settlement.createdBatch settlement dibentuk dan menunggu pembayaran.
settlement.paidDana batch sudah dikirim ke rekening merchant.
dispute.openedSengketa dibuka; dana sebesar nominal sanggahan ditahan.
dispute.updatedStatus atau tenggat bukti berubah.
dispute.resolvedSengketa selesai; dana dilepas atau dipindahkan sesuai hasil.
Daftar ini akan bertambah. Penerima kamu harus mengabaikan jenis peristiwa yang belum dikenal dan tetap menjawab 2xx, bukan melempar galat.

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.

NawaPay berada dalam tahap pengembangan. Seluruh contoh pada dokumentasi ini merujuk pada lingkungan sandbox; sistem belum berizin sebagai penyelenggara jasa pembayaran dan belum memproses transaksi uang sungguhan.

© 2026 NawaPay · docs.nawapay.co.id