# Webhook Documentation — Paymet Gateway

Sistem notifikasi real-time untuk pembayaran QRIS.

---

## Overview

Webhook adalah mekanisme **push notification**. Daripada server kamu harus polling, server kami yang mengirim data ke URL kamu saat event terjadi.

### Cara Kerja

1. **Daftarkan Webhook** — Masukkan URL endpoint kamu di Dashboard → Webhook
2. **Pilih Events** — Tentukan event mana yang ingin didengarkan
3. **Terima Notifikasi** — Server kami mengirim POST request ke URL kamu saat event terjadi
4. **Verifikasi Signature** — Pastikan request benar dari server kami

---

## Events

| Event | Deskripsi |
|-------|-----------|
| `payment.created` | Pembayaran baru dibuat |
| `payment.success` | Pembayaran berhasil |
| `payment.cancelled` | Pembayaran dibatalkan |
| `payment.expired` | Pembayaran kedaluwarsa |
| `shop.created` | Pembelian role baru |
| `shop.success` | Role berhasil diaktifkan |
| `shop.expired` | Pembelian role kedaluwarsa |

---

## Payload Format

Setiap webhook dikirim sebagai POST request dengan body JSON:

```json
{
  "event": "payment.success",
  "timestamp": "2025-07-27T10:30:00.000Z",
  "data": {
    "id": "mqd123abc",
    "amount": 50000,
    "status": "succeed",
    "qr_image_url": "https://...",
    "balance_before": 100000,
    "balance_after": 150000,
    "increase": 50000
  }
}
```

### Field yang Dikirim

| Field | Tipe | Deskripsi |
|-------|------|-----------|
| `event` | string | Nama event (contoh: `payment.success`) |
| `timestamp` | string | Waktu event dalam ISO 8601 |
| `data.id` | string | ID payment atau shop payment |
| `data.amount` | number | Nominal dalam Rupiah |
| `data.status` | string | Status: `pending`, `succeed`, `cancelled`, `expired` |
| `data.qr_image_url` | string | URL gambar QR (hanya di event `*.created`) |
| `data.balance_before` | number | Saldo QRIS sebelum transaksi |
| `data.balance_after` | number | Saldo QRIS sesudah transaksi (hanya di `*.success`) |
| `data.increase` | number | Selisih saldo (hanya di `*.success`) |
| `data.role` | string | Nama role (hanya di event `shop.*`) |
| `data.role_expires_at` | string | Waktu role kadaluarsa (hanya di `shop.success`) |

---

## Header

| Header | Deskripsi |
|--------|-----------|
| `Content-Type` | `application/json` |
| `X-Webhook-Signature` | HMAC-SHA256 signature |
| `X-Webhook-Event` | Nama event |
| `User-Agent` | `PaymetGateway-Webhook/1.0` |

---

## Verifikasi Signature

Setiap request dilengkapi header `X-Webhook-Signature` berisi HMAC-SHA256 signature. Verifikasi untuk memastikan request benar dari server kami.

### Cara Verifikasi

```javascript
const crypto = require('crypto');

function verifySignature(secret, body, signature) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(body)
    .digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}
```

> **Penting:** Selalu gunakan `crypto.timingSafeEqual()` untuk perbandingan signature. Jangan gunakan `===` karena rentan timing attack.

---

## Retry Logic

Jika server kamu return error (bukan status 2xx), kami akan retry otomatis:

| Attempt | Delay | Keterangan |
|---------|-------|------------|
| 1 | 0 detik | Langsung kirim |
| 2 | 30 detik | Retry pertama |
| 3 | 2 menit | Retry kedua |
| Gagal | — | Log sebagai failed, stop |

> **Tips:** Return `200 OK` atau `201 Created` untuk menandakan webhook berhasil diterima.

---

## Uji Coba Webhook

Tidak perlu langsung punya server penerima. Ada 3 cara menguji.

### 1. Receiver Bawaan (Tanpa Server Eksternal)

Halaman **Webhook** punya receiver bawaan di `POST /api/webhook/receive-test`.

1. Buka `https://orkut.nickystore.my.id/webhook`
2. Tekan tombol **"Kirim Test ke Receiver"** — sistem mengirim webhook ke receiver-nya sendiri
3. Hasil tampil di section **"Hasil Test Receiver"**: payload, header `X-Webhook-Signature`, dan badge validasi:
   - `SIGNATURE OK` — signature cocok dengan secret config kamu
   - `BELUM ADA CONFIG` — belum ada secret (simpan config dulu untuk verifikasi penuh)
   - `SIGNATURE TIDAK COCOK` — signature bukan dari server kami

Bisa juga mengarahkan URL config ke receiver bawaan agar **event asli** (bukan cuma test) ikut tercatat:
`https://orkut.nickystore.my.id/api/webhook/receive-test`

### 2. Script Test Lokal (Port 5543)

Script bawaan `scripts/webhook-receiver.js` menyediakan server penerima lokal:

```bash
npm run webhook:test
```

- URL tujuan: `http://localhost:5543/webhook`
- Menampilkan event, header, dan payload yang masuk secara real-time
- Verifikasi signature: set secret dulu agar tidak `SKIPPED`

```bash
export WEBHOOK_SECRET=<secret dari halaman webhook>
npm run webhook:test
```

Hasil signature: `VALID ✓` (cocok), `TIDAK COCOK ✗` (salah), `SKIPPED` (secret belum diset).
Ganti port: `node scripts/webhook-receiver.js 9999`

> **Penting:** Server live mengirim webhook dari internet. URL lokal hanya berguna kalau bisa diakses dari luar — gunakan **ngrok**, **tunnel**, atau URL publik (mis. forwarded port Codespaces / GitHub).

### 3. URL Publik (Tunnel)

Pakai URL publik seperti `https://nama-tunnel.app.github.dev/webhook` sebagai URL config di halaman webhook, lalu tekan **Simpan** dan **Kirim Test**. Pastikan script receiver (cara 2) berjalan di port yang terbuka.

---

## API Endpoints

Base URL: `https://orkut.nickystore.my.id`

### GET /api/webhook/configs

Lihat semua konfigurasi webhook milik user.

**Response:**
```json
{
  "success": true,
  "configs": [
    {
      "_id": "...",
      "userId": 123456,
      "url": "https://example.com/webhook",
      "secret": "abc123...",
      "events": ["payment.success", "shop.success"],
      "active": true
    }
  ]
}
```

### POST /api/webhook/config

Buat atau update konfigurasi webhook.

**Request:**
```json
{
  "url": "https://example.com/webhook",
  "events": ["payment.success", "payment.created"]
}
```

**Response:**
```json
{
  "success": true,
  "secret": "a1b2c3d4e5f6..."
}
```

> **Simpan secret!** Secret hanya ditampilkan sekali saat pertama kali membuat config.

### DELETE /api/webhook/config/:id

Hapus konfigurasi webhook.

### POST /api/webhook/config/toggle

Aktifkan atau nonaktifkan webhook.

```json
{
  "id": "...",
  "active": true
}
```

### POST /api/webhook/test/:id

Test kirim webhook test ke URL kamu.

**Response:**
```json
{
  "success": true,
  "result": {
    "status": 200,
    "body": "OK",
    "ok": true
  }
}
```

### GET /api/webhook/logs

Lihat riwayat pengiriman webhook (50 terakhir).

### POST /api/webhook/test-local

Test kirim webhook ke receiver bawaan (`receive-test`) tanpa keluar server.

**Response:**
```json
{
  "success": true,
  "receiptId": "webhook_receipt_id",
  "signature_valid": true,
  "config_found": true
}
```

- `signature_valid: true` — signature dibuat dengan secret config kamu, cocok dengan yang diverifikasi receiver
- `config_found: false` — secret belum ada (config belum pernah disimpan); test tetap jalan tapi tanpa verifikasi penuh

### POST /api/webhook/receive-test

Receiver bawaan publik. Cocokkan `X-Webhook-Signature` (HMAC-SHA256) dengan secret config pemilik, lalu simpan ke `webhook_receipts`.

Bisa dipakai sebagai URL config agar event asli ikut tercatat.

**Response:**
```json
{
  "success": true,
  "receiptId": "webhook_receipt_id",
  "owner": "NICKY",
  "signature_valid": true
}
```

### GET /api/webhook/receipts

Lihat bukti webhook yang masuk ke receiver bawaan (test maupun event asli).

---

## Contoh Implementasi

### Node.js (Express)

```javascript
const express = require('express');
const crypto = require('crypto');
const app = express();

const WEBHOOK_SECRET = 'secret-dari-dashboard';

app.post('/webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    // 1. Verifikasi signature
    const signature = req.headers['x-webhook-signature'];
    const expected = crypto
      .createHmac('sha256', WEBHOOK_SECRET)
      .update(req.body)
      .digest('hex');

    if (signature !== expected) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    // 2. Parse payload
    const event = JSON.parse(req.body);

    // 3. Proses berdasarkan event
    switch (event.event) {
      case 'payment.created':
        console.log('Payment baru:', event.data.id);
        console.log('Nominal:', event.data.amount);
        break;

      case 'payment.success':
        console.log('Payment berhasil:', event.data.id);
        console.log('Saldo naik:', event.data.increase);
        // Kirim notifikasi ke customer, update database, dll
        break;

      case 'payment.cancelled':
        console.log('Payment dibatalkan:', event.data.id);
        break;

      case 'shop.success':
        console.log('Role diaktifkan:', event.data.role);
        break;
    }

    res.json({ received: true });
  }
);

app.listen(3000);
```

### Python (Flask)

```python
from flask import Flask, request, jsonify
import hmac
import hashlib

app = Flask(__name__)
WEBHOOK_SECRET = 'secret-dari-dashboard'

@app.route('/webhook', methods=['POST'])
def webhook():
    # 1. Verifikasi signature
    signature = request.headers.get('X-Webhook-Signature', '')
    expected = hmac.new(
        WEBHOOK_SECRET.encode(),
        request.data,
        hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(signature, expected):
        return jsonify({'error': 'Invalid signature'}), 401

    # 2. Parse payload
    event = request.json

    # 3. Proses berdasarkan event
    if event['event'] == 'payment.created':
        print(f"Payment baru: {event['data']['id']}")
        print(f"Nominal: {event['data']['amount']}")

    elif event['event'] == 'payment.success':
        print(f"Payment berhasil: {event['data']['id']}")
        print(f"Saldo naik: {event['data']['increase']}")

    elif event['event'] == 'payment.cancelled':
        print(f"Payment dibatalkan: {event['data']['id']}")

    elif event['event'] == 'shop.success':
        print(f"Role diaktifkan: {event['data']['role']}")

    return jsonify({'received': True})

if __name__ == '__main__':
    app.run(port=3000)
```

### PHP

```php
<?php
$WEBHOOK_SECRET = 'secret-dari-dashboard';
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';

// 1. Verifikasi signature
$expected = hash_hmac('sha256', $payload, $WEBHOOK_SECRET);

if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    echo json_encode(['error' => 'Invalid signature']);
    exit;
}

// 2. Parse payload
$event = json_decode($payload, true);

// 3. Proses berdasarkan event
switch ($event['event']) {
    case 'payment.created':
        error_log("Payment baru: {$event['data']['id']}");
        error_log("Nominal: {$event['data']['amount']}");
        break;

    case 'payment.success':
        error_log("Payment berhasil: {$event['data']['id']}");
        error_log("Saldo naik: {$event['data']['increase']}");
        break;

    case 'payment.cancelled':
        error_log("Payment dibatalkan: {$event['data']['id']}");
        break;

    case 'shop.success':
        error_log("Role diaktifkan: {$event['data']['role']}");
        break;
}

// 4. Return 200 OK
header('Content-Type: application/json');
echo json_encode(['received' => true]);
?>
```

---

## Dashboard

Akses halaman webhook management di: `https://orkut.nickystore.my.id/webhook`

Fitur:
- Konfigurasi URL & events
- Lihat secret key
- Test webhook (ke URL config kamu)
- **Kirim Test ke Receiver** — uji ke receiver bawaan tanpa server eksternal
- **Hasil Test Receiver** — payload, header signature, badge validasi (OK / belum config / tidak cocok)
- Riwayat pengiriman

---

© 2026 Paymet Gateway — Unofficial OrderKuota API Client. [Beranda](/) - [Dashboard](/dashboard) - [Webhook Docs](/docs/webhook)