Aplikasi penerima (untuk developer)

Paygate mengirim POST JSON bertanda tangan ke alamat aplikasi tujuan. Aplikasimu cukup menyediakan satu endpoint yang: (1) memverifikasi tanda tangan, (2) mengabaikan event yang sudah diproses, (3) membalas 2xx.

Header

Header Isi
X-Paygate-Signature t=<unix>,v1=<hex> dengan v1 = HMAC_SHA256(kunci_rahasia, "<unix>." + body_mentah)
X-Paygate-Timestamp <unix> yang sama
X-Paygate-Event-Id id event (sama dengan id di body)
X-Paygate-Delivery-Id, X-Paygate-Attempt nomor pengiriman dan percobaan ke-berapa

Gunakan body mentah untuk menghitung HMAC (jangan decode lalu encode ulang). Tolak jika |waktu_sekarang − t| > 300 detik. Bandingkan dengan fungsi constant-time.

Body

{
  "id": "0b8e0c3e-…",
  "provider": "xendit",
  "provider_event_id": "…",
  "event_type": "invoice.paid",
  "reference_id": "IUR-2024-0001",
  "status": "PAID",
  "raw_status": "PAID",
  "amount": "150000",
  "currency": "IDR",
  "payment_method": "BANK_TRANSFER",
  "metadata": {},
  "raw": { "...payload asli dari penyedia..." },
  "test": false,
  "target": "Iurana",
  "matched_by": "rule:12#1",
  "received_at": "2026-10-08T13:59:05+00:00"
}

status: PAID, PENDING, FAILED, EXPIRED, REFUNDED, UNKNOWN. Jika test bernilai true, itu tes dari tombol "Uji koneksi"/"Kirim tes": balas 200 dan jangan ubah data.

Aturan emas

  1. Idempoten — simpan id event yang sudah diproses; jika datang lagi, balas 200 tanpa memproses ulang.
  2. Jangan menurunkan tagihan yang sudah lunas karena event EXPIRED/PENDING yang datang terlambat.
  3. Cocokkan nominal dengan tagihanmu sebelum melunasi.
  4. Balas 2xx cepat (batas waktu default 10 detik); pekerjaan berat masukkan ke antrean.

Balasan dan percobaan ulang

Balasan Hasil
2xx Sukses
4xx/5xx, timeout, tidak tersambung Dicoba lagi: 10 dtk, 30 dtk, 1, 5, 15, 30 mnt, 1, 2, 6 jam; setelah itu ditandai Gagal (bisa dikirim ulang manual)
3xx (redirect) Dianggap gagal — pakai alamat akhir

Contoh PHP (Laravel)

public function __invoke(Request $request)
{
    $body = $request->getContent();
    if (! preg_match('/^t=(\d+),v1=([a-f0-9]{64})$/', (string) $request->header('X-Paygate-Signature'), $m)) abort(401);
    [, $t, $v1] = $m;
    abort_if(abs(time() - (int) $t) > 300, 401);
    abort_unless(hash_equals(hash_hmac('sha256', $t . '.' . $body, config('services.paygate.secret')), $v1), 401);

    $e = $request->json()->all();
    if ($e['test'] ?? false) return response()->json(['ok' => true]);

    if (! DB::table('paygate_events')->insertOrIgnore(['event_id' => $e['id'], 'created_at' => now()])) {
        return response()->json(['ok' => true]); // duplikat
    }
    $invoice = Invoice::where('reference', $e['reference_id'])->first();
    if ($invoice && $e['status'] === 'PAID' && $invoice->status !== 'paid') {
        $invoice->markPaid();
    }
    return response()->json(['ok' => true]);
}

Jangan taruh route ini di grup yang memakai CSRF atau login sesi. Tabel idempotensi: paygate_events(event_id char(36) primary key, created_at).