Contoh kode

Contoh lengkap untuk lima lingkungan: Laravel, PHP native, Node.js/Express, Go, dan Python/Flask. Semua contoh melakukan hal yang sama:

  1. membuat tagihan untuk sebuah order, lalu mengarahkan pembeli ke payment_url;
  2. menerima webhook: memverifikasi tanda tangan dari body mentah dengan perbandingan waktu-konstan dan toleransi 5 menit, menolak event berulang lewat event_id, lalu membalas JSON ack dalam 10 detik;
  3. menangani payment.paid (termasuk late dan paid_after_cancel), payment.duplicate, payout.sent (termasuk recipient_check), payout.reversed, payout.duplicate, dan account.terminated;
  4. membuat payout dan menampilkan instruksi transfer ke admin.

Aturan di balik kode ini dijelaskan di Webhook dan Konsep.

Variabel lingkungan

Variabel Isi
READER_API_KEY Kunci untuk tagihan (rk_live_… atau rk_test_…), scope payments:read dan payments:write
READER_PAYOUT_API_KEY Kunci terpisah untuk payout, scope payouts:write, dengan allowlist IP
READER_WEBHOOK_SECRET Webhook secret aplikasi (whsec_…)
READER_TEST_MODE true bila server ini memakai kunci uji. Event yang modenya berbeda diterima tanpa diproses
READER_BASE_URL Opsional. Bawaan https://payment-reader.ragh.co.id

Data yang diasumsikan

Contoh memakai dua tabel milik Anda, ditambah satu tabel untuk idempotensi:

Tabel Kolom yang dipakai
orders id (= reference_id tagihan), total, status, reader_payment_id, paid_late, needs_review
withdrawals id (= reference_id payout), amount, bank_code, account_number, account_name, status, reader_payout_id, needs_review
reader_webhook_events event_id (primary key), type, received_at

Ganti bagian "penuhi pesanan" dengan logika bisnis Anda. Bila memenuhi pesanan butuh lebih dari beberapa detik (kirim email, membuat akses, memanggil sistem lain), masukkan ke antrean dan balas ack lebih dulu.


PHP / Laravel

Untuk Laravel 10 ke atas, PHP 8.1+.

1. Konfigurasi di config/services.php:

'reader' => [
    'base_url' => env('READER_BASE_URL', 'https://payment-reader.ragh.co.id'),
    'api_key' => env('READER_API_KEY'),
    'payout_api_key' => env('READER_PAYOUT_API_KEY'),
    'webhook_secret' => env('READER_WEBHOOK_SECRET'),
    'test_mode' => (bool) env('READER_TEST_MODE', false),
],

2. Migration tabel idempotensi:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        // Satu baris per event yang sudah diproses. Primary key = kunci idempotensi.
        Schema::create('reader_webhook_events', function (Blueprint $table) {
            $table->string('event_id', 64)->primary();
            $table->string('type', 64);
            $table->timestamp('received_at');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('reader_webhook_events');
    }
};

3. Klien API di app/Services/ReaderClient.php:

<?php

namespace App\Services;

use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use RuntimeException;

/** Klien kecil untuk API merchant RAGH Reader. */
class ReaderClient
{
    /**
     * Membuat tagihan. Aman dipanggil ulang dengan reference_id yang sama:
     * bila order ternyata sudah lunas, tagihan yang lunas itulah yang dikembalikan.
     */
    public function createPayment(string $orderId, int $amount, array $extra = []): array
    {
        $res = $this->http(config('services.reader.api_key'))
            ->post('payments', ['reference_id' => $orderId, 'amount' => $amount] + $extra);

        if ($res->status() === 409 && $res->json('error.code') === 'order_already_paid') {
            return $res->json('error.payment');
        }

        return $this->ok($res);
    }

    /** Membuat payout. Pakai kunci terpisah (scope payouts:write + allowlist IP). */
    public function createPayout(string $withdrawalId, int $amount, array $recipient, array $extra = []): array
    {
        $res = $this->http(config('services.reader.payout_api_key'))
            ->post('payouts', ['reference_id' => $withdrawalId, 'amount' => $amount, 'recipient' => $recipient] + $extra);

        if ($res->status() === 409 && $res->json('error.code') === 'payout_already_sent') {
            return $res->json('error.payout');
        }

        return $this->ok($res);
    }

    public function getPayment(string $id): array
    {
        return $this->ok($this->http(config('services.reader.api_key'))->get('payments/'.$id));
    }

    private function http(?string $apiKey): PendingRequest
    {
        return Http::baseUrl(rtrim((string) config('services.reader.base_url'), '/').'/api/v1')
            ->withToken((string) $apiKey)
            ->acceptJson()
            ->asJson()
            ->timeout(15);
    }

    private function ok(Response $res): array
    {
        if ($res->successful()) {
            return $res->json();
        }

        throw new RuntimeException(sprintf('Reader API %d %s: %s',
            $res->status(), $res->json('error.code', '-'), $res->json('error.message', $res->body())));
    }
}

4. Membuat tagihan di app/Http/Controllers/CheckoutController.php:

<?php

namespace App\Http\Controllers;

use App\Models\Order;
use App\Services\ReaderClient;
use Illuminate\Http\RedirectResponse;

class CheckoutController extends Controller
{
    /** GET /orders/{order}/pay: buat tagihan, lalu arahkan pembeli ke halaman bayar. */
    public function pay(Order $order, ReaderClient $reader): RedirectResponse
    {
        if ($order->status === 'paid') {
            return redirect()->route('orders.show', $order);
        }

        $payment = $reader->createPayment((string) $order->id, (int) $order->total, [
            'description' => 'Order #'.$order->id,
            'customer' => ['ref' => (string) $order->user_id, 'name' => $order->customer_name],
            'tags' => ['kanal:web'],
            'return_url' => route('orders.show', $order),
        ]);

        if ($payment['status'] === 'paid') {
            // Sudah lunas. Webhook payment.paid sudah (atau akan) memperbarui order.
            return redirect()->route('orders.show', $order);
        }

        $order->forceFill(['reader_payment_id' => $payment['id']])->save();

        return redirect()->away($payment['payment_url']);
    }
}

5. Endpoint webhook di app/Http/Controllers/ReaderWebhookController.php:

<?php

namespace App\Http\Controllers;

use App\Models\Order;
use App\Models\Withdrawal;
use DomainException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

class ReaderWebhookController extends Controller
{
    public function __invoke(Request $request): JsonResponse
    {
        // 1. Verifikasi tanda tangan dari body MENTAH (jangan json_encode ulang).
        $raw = $request->getContent();
        $ts = (string) $request->header('X-Reader-Timestamp', '');
        $sig = (string) $request->header('X-Reader-Signature', '');

        if (! ctype_digit($ts) || abs(time() - (int) $ts) > 300) {
            return response()->json(['error' => 'timestamp tidak valid'], 401);
        }
        $expected = 'sha256='.hash_hmac('sha256', $ts.'.'.$raw, (string) config('services.reader.webhook_secret'));
        if (! hash_equals($expected, $sig)) {
            return response()->json(['error' => 'tanda tangan tidak cocok'], 401);
        }

        $event = json_decode($raw, true);
        $eventId = is_array($event) ? (string) ($event['event_id'] ?? '') : '';
        if ($eventId === '' || ! isset($event['event'])) {
            return response()->json(['error' => 'body tidak valid'], 400);
        }

        // 2. Idempotensi dan perubahan order dalam SATU transaksi.
        try {
            $status = DB::transaction(function () use ($event, $eventId) {
                $isNew = DB::table('reader_webhook_events')->insertOrIgnore([
                    'event_id' => $eventId,
                    'type' => $event['event'],
                    'received_at' => now(),
                ]);
                if ($isNew === 0) {
                    return 'duplicate';
                }
                $this->handle($event); // melempar DomainException untuk menolak

                return 'accepted';
            });
        } catch (DomainException $e) {
            // Transaksi di-rollback, jadi event_id tidak tercatat. Bila event ini
            // dikirim ulang setelah masalahnya Anda perbaiki, event diproses lagi.
            return $this->ack($eventId, 'rejected', $e->getMessage());
        }

        // 3. Balas ack. Exception lain menghasilkan HTTP 500, dan Reader mengirim ulang.
        return $this->ack($eventId, $status);
    }

    private function ack(string $eventId, string $status, ?string $reason = null): JsonResponse
    {
        return response()->json(array_filter(
            ['event_id' => $eventId, 'status' => $status, 'reason' => $reason],
            fn ($v) => $v !== null,
        ));
    }

    private function handle(array $event): void
    {
        $d = $event['data'] ?? [];

        // Event uji tidak boleh menyentuh pesanan sungguhan, dan sebaliknya.
        if ((bool) ($event['test'] ?? false) !== (bool) config('services.reader.test_mode')) {
            return;
        }

        switch ($event['event']) {
            case 'payment.paid':
                $order = Order::whereKey($d['reference_id'] ?? null)->lockForUpdate()->first()
                    ?? throw new DomainException('order '.($d['reference_id'] ?? '-').' tidak ditemukan');
                if ($order->status !== 'paid') {
                    $order->forceFill([
                        'status' => 'paid',
                        'paid_at' => now(),
                        'reader_payment_id' => $d['id'],
                        'paid_amount' => $d['total_amount'],
                        'paid_late' => $d['late'],                 // telat dalam masa jeda: tetap dipenuhi
                        'needs_review' => $d['paid_after_cancel'], // order sempat dibatalkan
                    ])->save();
                    // FulfillOrder::dispatch($order->id)->afterCommit(); // kirim barang/akses lewat antrean
                }
                break;

            case 'payment.duplicate':
                // Uang masuk dua kali. Jangan penuhi lagi; catat untuk refund.
                Order::whereKey($d['reference_id'] ?? null)->update([
                    'needs_review' => true,
                    'review_note' => 'Transfer dobel '.($d['duplicate_mutation']['id'] ?? '').': siapkan refund',
                ]);
                break;

            case 'payout.sent':
                $wd = Withdrawal::whereKey($d['reference_id'] ?? null)->lockForUpdate()->first()
                    ?? throw new DomainException('pencairan '.($d['reference_id'] ?? '-').' tidak ditemukan');
                if ($wd->status !== 'done') {
                    $wd->forceFill([
                        'status' => 'done',
                        'sent_at' => now(),
                        'reader_payout_id' => $d['id'],
                        'needs_review' => $d['recipient_check'] === 'mismatch', // penerima di notifikasi berbeda
                    ])->save();
                }
                break;

            case 'payout.reversed':
                Withdrawal::whereKey($d['reference_id'] ?? null)->update([
                    'status' => 'failed',
                    'needs_review' => true,
                    'review_note' => 'Transfer dikembalikan bank: buat payout baru untuk transfer ulang',
                ]);
                break;

            case 'payout.duplicate':
                Withdrawal::whereKey($d['reference_id'] ?? null)->update([
                    'needs_review' => true,
                    'review_note' => 'Penerima dibayar dua kali: minta dana kembali',
                ]);
                break;

            case 'account.terminated':
                Log::critical('RAGH Reader memutus layanan akun', $d);
                break;

            default:
                // ping, *.verified, *.expired, *.confirmation_delayed, dan event baru: cukup diterima.
                break;
        }
    }
}

6. Rute. Webhook ditaruh di routes/api.php, supaya tidak terkena proteksi CSRF. Di Laravel 11 ke atas, jalankan php artisan install:api bila file itu belum ada.

// routes/api.php → https://toko.contoh.id/api/webhooks/reader
use App\Http\Controllers\ReaderWebhookController;

Route::post('/webhooks/reader', ReaderWebhookController::class);
// routes/web.php
use App\Http\Controllers\CheckoutController;
use App\Http\Controllers\WithdrawalController;

Route::get('/orders/{order}/pay', [CheckoutController::class, 'pay'])->name('orders.pay');
Route::post('/admin/withdrawals/{withdrawal}/process', [WithdrawalController::class, 'process'])
    ->middleware('auth');

7. Membuat payout di app/Http/Controllers/WithdrawalController.php:

<?php

namespace App\Http\Controllers;

use App\Models\Withdrawal;
use App\Services\ReaderClient;
use Illuminate\Http\RedirectResponse;

class WithdrawalController extends Controller
{
    /** POST /admin/withdrawals/{withdrawal}/process: catat payout, lalu tampilkan instruksi transfer. */
    public function process(Withdrawal $withdrawal, ReaderClient $reader): RedirectResponse
    {
        $payout = $reader->createPayout((string) $withdrawal->id, (int) $withdrawal->amount, [
            'bank_code' => $withdrawal->bank_code,          // kode bank penerima, misalnya BCA
            'account_number' => $withdrawal->account_number,
            'account_name' => $withdrawal->account_name,
        ], ['description' => 'Pencairan #'.$withdrawal->id]);

        if ($payout['status'] === 'sent') {
            return back()->with('status', 'Pencairan ini sudah terkirim.');
        }

        $withdrawal->forceFill(['status' => 'processing', 'reader_payout_id' => $payout['id']])->save();

        // Contoh: "Transfer tepat Rp 150.123 ke BCA 9876543210 a.n. BUDI SANTOSO"
        return back()->with('status', $payout['instruction']);
    }
}

PHP native

Tanpa framework. PHP 8.1+, ekstensi curl dan pdo. Contoh webhook memakai SELECT … FOR UPDATE, jadi pakai MySQL atau PostgreSQL. Isi DB_DSN, DB_USER, dan DB_PASS di lingkungan server.

Tabel idempotensi:

CREATE TABLE reader_webhook_events (
    event_id    VARCHAR(64) PRIMARY KEY,
    type        VARCHAR(64) NOT NULL,
    received_at DATETIME    NOT NULL
);

reader.php: helper bersama.

<?php
// reader.php: helper bersama. PHP 8.1+, ekstensi curl dan pdo.
declare(strict_types=1);

const READER_BASE = 'https://payment-reader.ragh.co.id/api/v1';

function db(): PDO
{
    static $pdo = null;

    return $pdo ??= new PDO((string) getenv('DB_DSN'), getenv('DB_USER') ?: null, getenv('DB_PASS') ?: null, [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    ]);
}

/**
 * Memanggil API Reader.
 *
 * @return array{0: int, 1: array} [status HTTP, body JSON]
 */
function reader_request(string $method, string $path, ?array $body = null, ?string $apiKey = null): array
{
    $ch = curl_init(READER_BASE . $path);
    $opts = [
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 15,
        CURLOPT_HTTPHEADER => [
            'Authorization: Bearer ' . ($apiKey ?? (string) getenv('READER_API_KEY')),
            'Accept: application/json',
            'Content-Type: application/json',
        ],
    ];
    if ($body !== null) {
        $opts[CURLOPT_POSTFIELDS] = json_encode($body, JSON_UNESCAPED_SLASHES);
    }
    curl_setopt_array($ch, $opts);

    $res = curl_exec($ch);
    if ($res === false) {
        $error = curl_error($ch);
        curl_close($ch);
        throw new RuntimeException('Koneksi ke Reader gagal: ' . $error);
    }
    $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);

    return [$status, json_decode((string) $res, true) ?? []];
}

pay.php: buat tagihan dan arahkan pembeli.

<?php
// pay.php?order=ORDER-123: buat tagihan, lalu arahkan pembeli ke halaman bayar.
declare(strict_types=1);
require __DIR__ . '/reader.php';

$orderId = (string) ($_GET['order'] ?? '');
$stmt = db()->prepare('SELECT id, total, status FROM orders WHERE id = ?');
$stmt->execute([$orderId]);
$order = $stmt->fetch();

if (!$order) {
    http_response_code(404);
    exit('Order tidak ditemukan');
}
if ($order['status'] === 'paid') {
    header('Location: /order.php?id=' . urlencode($orderId));
    exit;
}

[$status, $json] = reader_request('POST', '/payments', [
    'reference_id' => $orderId, // memanggil ulang dengan reference_id sama selalu aman
    'amount' => (int) $order['total'],
    'description' => 'Order ' . $orderId,
    'return_url' => 'https://toko.contoh.id/order.php?id=' . urlencode($orderId),
]);

if ($status === 409 && ($json['error']['code'] ?? '') === 'order_already_paid') {
    header('Location: /order.php?id=' . urlencode($orderId)); // sudah lunas
    exit;
}
if ($status !== 200 && $status !== 201) {
    http_response_code(502);
    exit('Gagal membuat tagihan: ' . htmlspecialchars((string) ($json['error']['message'] ?? 'HTTP ' . $status)));
}

db()->prepare('UPDATE orders SET reader_payment_id = ? WHERE id = ?')->execute([$json['id'], $orderId]);
header('Location: ' . $json['payment_url'], true, 303);

webhook.php: endpoint webhook. Daftarkan https://toko.contoh.id/webhook.php sebagai callback URL.

<?php
// webhook.php: endpoint webhook RAGH Reader. Contoh ini memakai MySQL/PostgreSQL (SELECT ... FOR UPDATE).
declare(strict_types=1);
require __DIR__ . '/reader.php';

function reply(int $http, array $body): never
{
    http_response_code($http);
    header('Content-Type: application/json');
    echo json_encode($body);
    exit;
}

/** Mengembalikan null bila berhasil, atau alasan penolakan (ack "rejected"). */
function handle_event(PDO $pdo, string $type, array $d): ?string
{
    $ref = (string) ($d['reference_id'] ?? '');

    switch ($type) {
        case 'payment.paid':
            $order = find_for_update($pdo, 'orders', $ref);
            if ($order === null) {
                return "order {$ref} tidak ditemukan";
            }
            if ($order['status'] !== 'paid') {
                $pdo->prepare('UPDATE orders SET status = ?, reader_payment_id = ?, paid_late = ?, needs_review = ? WHERE id = ?')
                    ->execute(['paid', $d['id'], (int) $d['late'], (int) $d['paid_after_cancel'], $ref]);
                // Penuhi pesanan di sini, atau masukkan ke antrean bila butuh lebih dari beberapa detik.
            }

            return null;

        case 'payment.duplicate':
            // Uang masuk dua kali. Jangan penuhi lagi; catat untuk refund.
            $pdo->prepare('UPDATE orders SET needs_review = 1 WHERE id = ?')->execute([$ref]);

            return null;

        case 'payout.sent':
            $wd = find_for_update($pdo, 'withdrawals', $ref);
            if ($wd === null) {
                return "pencairan {$ref} tidak ditemukan";
            }
            if ($wd['status'] !== 'done') {
                $pdo->prepare('UPDATE withdrawals SET status = ?, reader_payout_id = ?, needs_review = ? WHERE id = ?')
                    ->execute(['done', $d['id'], (int) (($d['recipient_check'] ?? '') === 'mismatch'), $ref]);
            }

            return null;

        case 'payout.reversed':
            $pdo->prepare("UPDATE withdrawals SET status = 'failed', needs_review = 1 WHERE id = ?")->execute([$ref]);

            return null;

        case 'payout.duplicate':
            $pdo->prepare('UPDATE withdrawals SET needs_review = 1 WHERE id = ?')->execute([$ref]);

            return null;

        case 'account.terminated':
            error_log('RAGH Reader memutus layanan akun: ' . ($d['reason'] ?? ''));

            return null;

        default:
            return null; // ping, *.verified, *.expired, *.confirmation_delayed, dan event baru
    }
}

function find_for_update(PDO $pdo, string $table, string $id): ?array
{
    $stmt = $pdo->prepare("SELECT * FROM {$table} WHERE id = ? FOR UPDATE");
    $stmt->execute([$id]);

    return $stmt->fetch() ?: null;
}

// 1. Verifikasi tanda tangan dari body MENTAH.
$raw = (string) file_get_contents('php://input');
$ts = (string) ($_SERVER['HTTP_X_READER_TIMESTAMP'] ?? '');
$sig = (string) ($_SERVER['HTTP_X_READER_SIGNATURE'] ?? '');

if (!ctype_digit($ts) || abs(time() - (int) $ts) > 300) {
    reply(401, ['error' => 'timestamp tidak valid']);
}
$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $raw, (string) getenv('READER_WEBHOOK_SECRET'));
if (!hash_equals($expected, $sig)) {
    reply(401, ['error' => 'tanda tangan tidak cocok']);
}

$event = json_decode($raw, true);
if (!is_array($event) || empty($event['event_id']) || empty($event['event'])) {
    reply(400, ['error' => 'body tidak valid']);
}
$eventId = (string) $event['event_id'];
$testMode = getenv('READER_TEST_MODE') === 'true';

// 2. Idempotensi dan perubahan order dalam SATU transaksi.
$pdo = db();
try {
    $pdo->beginTransaction();
    try {
        $pdo->prepare('INSERT INTO reader_webhook_events (event_id, type, received_at) VALUES (?, ?, ?)')
            ->execute([$eventId, $event['event'], date('Y-m-d H:i:s')]);
    } catch (PDOException $e) {
        if (str_starts_with((string) $e->getCode(), '23')) { // pelanggaran unique: sudah pernah diproses
            $pdo->rollBack();
            reply(200, ['event_id' => $eventId, 'status' => 'duplicate']);
        }
        throw $e;
    }

    // Event uji tidak boleh menyentuh pesanan sungguhan, dan sebaliknya.
    $reason = ((bool) ($event['test'] ?? false) === $testMode)
        ? handle_event($pdo, (string) $event['event'], (array) ($event['data'] ?? []))
        : null;

    if ($reason !== null) {
        $pdo->rollBack(); // event_id tidak tercatat, jadi kiriman ulang akan diproses lagi
        reply(200, ['event_id' => $eventId, 'status' => 'rejected', 'reason' => $reason]);
    }
    $pdo->commit();
} catch (Throwable $e) {
    if ($pdo->inTransaction()) {
        $pdo->rollBack();
    }
    error_log('Webhook Reader gagal: ' . $e->getMessage());
    reply(500, ['error' => 'internal']); // Reader akan mengirim ulang sesuai jadwal
}

// 3. Balas ack.
reply(200, ['event_id' => $eventId, 'status' => 'accepted']);

payout.php: buat payout dan tampilkan instruksi transfer.

<?php
// payout.php?id=WD-456: catat payout, lalu tampilkan instruksi transfer ke admin.
declare(strict_types=1);
require __DIR__ . '/reader.php';

$wdId = (string) ($_GET['id'] ?? '');
$stmt = db()->prepare('SELECT * FROM withdrawals WHERE id = ?');
$stmt->execute([$wdId]);
$wd = $stmt->fetch();
if (!$wd) {
    http_response_code(404);
    exit('Pencairan tidak ditemukan');
}

[$status, $json] = reader_request('POST', '/payouts', [
    'reference_id' => $wdId,
    'amount' => (int) $wd['amount'],
    'recipient' => [
        'bank_code' => $wd['bank_code'],          // misalnya BCA
        'account_number' => $wd['account_number'],
        'account_name' => $wd['account_name'],
    ],
], (string) getenv('READER_PAYOUT_API_KEY'));    // kunci terpisah: scope payouts:write + allowlist IP

if ($status === 409 && ($json['error']['code'] ?? '') === 'payout_already_sent') {
    exit('Pencairan ini sudah terkirim.');
}
if ($status !== 200 && $status !== 201) {
    http_response_code(502);
    exit('Gagal membuat payout: ' . htmlspecialchars((string) ($json['error']['code'] ?? 'HTTP ' . $status)));
}

db()->prepare("UPDATE withdrawals SET status = 'processing', reader_payout_id = ? WHERE id = ?")->execute([$json['id'], $wdId]);

// Contoh: "Transfer tepat Rp 150.123 ke BCA 9876543210 a.n. BUDI SANTOSO"
echo htmlspecialchars($json['instruction']);

Node.js / Express

Node.js 18+ (memakai fetch bawaan) dan Express 4. Pasang dengan npm install express@4.

Hal terpenting: rute webhook memakai express.raw(), bukan express.json(), supaya tanda tangan dihitung dari byte yang persis sama dengan yang dikirim Reader.

Contoh ini menyimpan data di memori supaya mudah dicoba. Di produksi, simpan event_id di database dengan indeks unik, dan ubah order dalam transaksi yang sama.

// server.js: Node.js 18+ (fetch bawaan) dan Express 4. Jalankan: node server.js
const express = require('express');
const crypto = require('node:crypto');

const READER_BASE = process.env.READER_BASE_URL || 'https://payment-reader.ragh.co.id';
const API_KEY = process.env.READER_API_KEY;
const PAYOUT_API_KEY = process.env.READER_PAYOUT_API_KEY;
const WEBHOOK_SECRET = process.env.READER_WEBHOOK_SECRET || '';
const TEST_MODE = process.env.READER_TEST_MODE === 'true';

const app = express();

// ---- Contoh penyimpanan di memori. Di produksi, pakai database, dengan
// indeks unik pada event_id, dan ubah order + catat event_id dalam satu transaksi.
const orders = new Map([['ORDER-123', { id: 'ORDER-123', total: 200000, status: 'unpaid' }]]);
const withdrawals = new Map([['WD-456', {
  id: 'WD-456', amount: 150000, status: 'requested',
  bankCode: 'BCA', accountNumber: '9876543210', accountName: 'BUDI SANTOSO',
}]]);
const processedEvents = new Set();

// ---- Klien API ----
async function reader(method, path, body, apiKey = API_KEY) {
  const res = await fetch(`${READER_BASE}/api/v1${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${apiKey}`,
      Accept: 'application/json',
      'Content-Type': 'application/json',
    },
    body: body === undefined ? undefined : JSON.stringify(body),
    signal: AbortSignal.timeout(15000),
  });
  const json = await res.json().catch(() => ({}));
  return { status: res.status, json };
}

// ---- Buat tagihan, lalu arahkan pembeli ke halaman bayar ----
app.get('/orders/:id/pay', async (req, res, next) => {
  try {
    const order = orders.get(req.params.id);
    if (!order) return res.status(404).send('Order tidak ditemukan');
    if (order.status === 'paid') return res.redirect(`/orders/${order.id}`);

    const { status, json } = await reader('POST', '/payments', {
      reference_id: order.id, // memanggil ulang dengan reference_id sama selalu aman
      amount: order.total,
      description: `Order ${order.id}`,
      return_url: `https://toko.contoh.id/orders/${order.id}`,
    });
    if (status === 409 && json.error?.code === 'order_already_paid') {
      return res.redirect(`/orders/${order.id}`); // sudah lunas
    }
    if (status !== 200 && status !== 201) {
      return res.status(502).send(`Gagal membuat tagihan: ${json.error?.message ?? status}`);
    }
    order.readerPaymentId = json.id;
    return res.redirect(303, json.payment_url);
  } catch (err) {
    return next(err);
  }
});

// ---- Buat payout, lalu tampilkan instruksi transfer ke admin ----
app.post('/withdrawals/:id/process', async (req, res, next) => {
  try {
    const wd = withdrawals.get(req.params.id);
    if (!wd) return res.status(404).send('Pencairan tidak ditemukan');

    const { status, json } = await reader('POST', '/payouts', {
      reference_id: wd.id,
      amount: wd.amount,
      recipient: { bank_code: wd.bankCode, account_number: wd.accountNumber, account_name: wd.accountName },
    }, PAYOUT_API_KEY); // kunci terpisah: scope payouts:write + allowlist IP

    if (status === 409 && json.error?.code === 'payout_already_sent') {
      return res.send('Pencairan ini sudah terkirim.');
    }
    if (status !== 200 && status !== 201) {
      return res.status(502).send(`Gagal membuat payout: ${json.error?.code ?? status}`);
    }
    wd.status = 'processing';
    wd.readerPayoutId = json.id;
    // Contoh: "Transfer tepat Rp 150.123 ke BCA 9876543210 a.n. BUDI SANTOSO"
    return res.type('text/plain').send(json.instruction);
  } catch (err) {
    return next(err);
  }
});

// ---- Webhook ----
function verifySignature(rawBody, timestamp, signature) {
  if (!/^\d+$/.test(timestamp || '')) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > 300) return false;
  const expected = 'sha256=' + crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(signature || '');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

/** Mengembalikan null bila berhasil, atau alasan penolakan (ack "rejected"). */
function handleEvent(type, d) {
  switch (type) {
    case 'payment.paid': {
      const order = orders.get(d.reference_id);
      if (!order) return `order ${d.reference_id} tidak ditemukan`;
      if (order.status !== 'paid') {
        order.status = 'paid';
        order.readerPaymentId = d.id;
        order.paidLate = d.late; // telat dalam masa jeda: tetap dipenuhi
        order.needsReview = d.paid_after_cancel; // order sempat dibatalkan
        // Penuhi pesanan di sini, atau masukkan ke antrean bila butuh lebih dari beberapa detik.
      }
      return null;
    }
    case 'payment.duplicate': {
      const order = orders.get(d.reference_id);
      if (order) order.needsReview = true; // uang masuk dua kali: siapkan refund
      return null;
    }
    case 'payout.sent': {
      const wd = withdrawals.get(d.reference_id);
      if (!wd) return `pencairan ${d.reference_id} tidak ditemukan`;
      if (wd.status !== 'done') {
        wd.status = 'done';
        wd.readerPayoutId = d.id;
        wd.needsReview = d.recipient_check === 'mismatch'; // penerima di notifikasi berbeda
      }
      return null;
    }
    case 'payout.reversed': {
      const wd = withdrawals.get(d.reference_id);
      if (wd) Object.assign(wd, { status: 'failed', needsReview: true }); // perlu transfer ulang
      return null;
    }
    case 'payout.duplicate': {
      const wd = withdrawals.get(d.reference_id);
      if (wd) wd.needsReview = true; // penerima dibayar dua kali
      return null;
    }
    case 'account.terminated':
      console.error('RAGH Reader memutus layanan akun:', d.reason);
      return null;
    default:
      return null; // ping, *.verified, *.expired, *.confirmation_delayed, dan event baru
  }
}

// express.raw menjaga body tetap Buffer mentah. JANGAN pasang express.json()
// secara global sebelum rute ini, karena body mentah dibutuhkan untuk tanda tangan.
app.post('/webhooks/reader', express.raw({ type: '*/*', limit: '1mb' }), (req, res) => {
  const raw = req.body;
  if (!Buffer.isBuffer(raw)
    || !verifySignature(raw, req.get('X-Reader-Timestamp'), req.get('X-Reader-Signature'))) {
    return res.status(401).json({ error: 'tanda tangan tidak valid' });
  }

  let event;
  try {
    event = JSON.parse(raw.toString('utf8'));
  } catch {
    return res.status(400).json({ error: 'body tidak valid' });
  }
  const eventId = event.event_id;
  if (!eventId) return res.status(400).json({ error: 'body tidak valid' });

  if (processedEvents.has(eventId)) {
    return res.json({ event_id: eventId, status: 'duplicate' });
  }

  let reason = null;
  try {
    // Event uji tidak boleh menyentuh pesanan sungguhan, dan sebaliknya.
    if (Boolean(event.test) === TEST_MODE) {
      reason = handleEvent(event.event, event.data || {});
    }
  } catch (err) {
    console.error('Webhook Reader gagal', err);
    return res.status(500).json({ error: 'internal' }); // Reader akan mengirim ulang
  }

  if (reason) {
    return res.json({ event_id: eventId, status: 'rejected', reason });
  }
  processedEvents.add(eventId);
  return res.json({ event_id: eventId, status: 'accepted' });
});

app.listen(3000, () => console.log('Siap di http://localhost:3000'));

Go (net/http)

Go 1.22+ (memakai pola rute "POST /webhooks/reader"), hanya pustaka standar. Simpan sebagai main.go, lalu jalankan go run main.go.

Contoh ini menyimpan data di memori supaya mudah dicoba. Di produksi, simpan event_id di database dengan indeks unik, dan ubah order dalam transaksi yang sama.

// main.go: Go 1.22+, hanya pustaka standar. Jalankan: go run main.go
package main

import (
	"bytes"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"strconv"
	"sync"
	"time"
)

var (
	readerBase    = envOr("READER_BASE_URL", "https://payment-reader.ragh.co.id")
	apiKey        = os.Getenv("READER_API_KEY")
	payoutAPIKey  = os.Getenv("READER_PAYOUT_API_KEY")
	webhookSecret = []byte(os.Getenv("READER_WEBHOOK_SECRET"))
	testMode      = os.Getenv("READER_TEST_MODE") == "true"
	client        = &http.Client{Timeout: 15 * time.Second}
)

func envOr(key, def string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return def
}

// ---- Contoh penyimpanan di memori. Di produksi, pakai database, dengan indeks
// unik pada event_id, dan ubah order + catat event_id dalam satu transaksi.

type Order struct {
	ID, Status, ReaderPaymentID string
	Total                       int64
	PaidLate, NeedsReview       bool
}

type Withdrawal struct {
	ID, Status, ReaderPayoutID           string
	Amount                               int64
	BankCode, AccountNumber, AccountName string
	NeedsReview                          bool
}

var (
	mu          sync.Mutex
	orders      = map[string]*Order{"ORDER-123": {ID: "ORDER-123", Total: 200000, Status: "unpaid"}}
	withdrawals = map[string]*Withdrawal{"WD-456": {
		ID: "WD-456", Amount: 150000, Status: "requested",
		BankCode: "BCA", AccountNumber: "9876543210", AccountName: "BUDI SANTOSO",
	}}
	processed = map[string]bool{}
)

// ---- Klien API ----

type apiError struct {
	Error struct {
		Code    string          `json:"code"`
		Message string          `json:"message"`
		Payment json.RawMessage `json:"payment"`
		Payout  json.RawMessage `json:"payout"`
	} `json:"error"`
}

func readerCall(method, path, key string, body any) (int, []byte, error) {
	var buf io.Reader
	if body != nil {
		b, err := json.Marshal(body)
		if err != nil {
			return 0, nil, err
		}
		buf = bytes.NewReader(b)
	}
	req, err := http.NewRequest(method, readerBase+"/api/v1"+path, buf)
	if err != nil {
		return 0, nil, err
	}
	req.Header.Set("Authorization", "Bearer "+key)
	req.Header.Set("Accept", "application/json")
	req.Header.Set("Content-Type", "application/json")
	resp, err := client.Do(req)
	if err != nil {
		return 0, nil, err
	}
	defer resp.Body.Close()
	raw, err := io.ReadAll(resp.Body)
	return resp.StatusCode, raw, err
}

type Payment struct {
	ID          string `json:"id"`
	Status      string `json:"status"`
	TotalAmount int64  `json:"total_amount"`
	PaymentURL  string `json:"payment_url"`
}

type Payout struct {
	ID          string `json:"id"`
	Status      string `json:"status"`
	Instruction string `json:"instruction"`
}

// createPayment aman dipanggil ulang: reference_id yang sama tidak membuat tagihan dobel.
func createPayment(id string, total int64) (*Payment, error) {
	status, raw, err := readerCall("POST", "/payments", apiKey, map[string]any{
		"reference_id": id,
		"amount":       total,
		"description":  "Order " + id,
		"return_url":   "https://toko.contoh.id/orders/" + id,
	})
	if err != nil {
		return nil, err
	}
	var p Payment
	switch status {
	case http.StatusOK, http.StatusCreated:
		return &p, json.Unmarshal(raw, &p)
	case http.StatusConflict:
		var e apiError
		if json.Unmarshal(raw, &e) == nil && e.Error.Code == "order_already_paid" {
			return &p, json.Unmarshal(e.Error.Payment, &p) // sudah lunas
		}
	}
	return nil, fmt.Errorf("reader: HTTP %d: %s", status, raw)
}

// GET /orders/{id}/pay: buat tagihan, lalu arahkan pembeli ke halaman bayar.
func payHandler(w http.ResponseWriter, r *http.Request) {
	mu.Lock()
	o, ok := orders[r.PathValue("id")]
	var id, status string
	var total int64
	if ok {
		id, status, total = o.ID, o.Status, o.Total
	}
	mu.Unlock()
	if !ok {
		http.NotFound(w, r)
		return
	}
	if status == "paid" {
		http.Redirect(w, r, "/orders/"+id, http.StatusSeeOther)
		return
	}

	p, err := createPayment(id, total)
	if err != nil {
		log.Println(err)
		http.Error(w, "Gagal membuat tagihan", http.StatusBadGateway)
		return
	}
	if p.Status == "paid" {
		http.Redirect(w, r, "/orders/"+id, http.StatusSeeOther)
		return
	}
	mu.Lock()
	o.ReaderPaymentID = p.ID
	mu.Unlock()
	http.Redirect(w, r, p.PaymentURL, http.StatusSeeOther)
}

// POST /withdrawals/{id}/process: buat payout, lalu tampilkan instruksi transfer ke admin.
func processWithdrawalHandler(w http.ResponseWriter, r *http.Request) {
	mu.Lock()
	wd, ok := withdrawals[r.PathValue("id")]
	var body map[string]any
	if ok {
		body = map[string]any{
			"reference_id": wd.ID,
			"amount":       wd.Amount,
			"recipient": map[string]string{
				"bank_code":      wd.BankCode,
				"account_number": wd.AccountNumber,
				"account_name":   wd.AccountName,
			},
		}
	}
	mu.Unlock()
	if !ok {
		http.NotFound(w, r)
		return
	}

	// Kunci terpisah: scope payouts:write + allowlist IP.
	status, raw, err := readerCall("POST", "/payouts", payoutAPIKey, body)
	if err != nil {
		log.Println(err)
		http.Error(w, "Gagal menghubungi Reader", http.StatusBadGateway)
		return
	}
	if status == http.StatusConflict {
		var e apiError
		if json.Unmarshal(raw, &e) == nil && e.Error.Code == "payout_already_sent" {
			fmt.Fprintln(w, "Pencairan ini sudah terkirim.")
			return
		}
	}
	if status != http.StatusOK && status != http.StatusCreated {
		http.Error(w, fmt.Sprintf("Gagal membuat payout: HTTP %d %s", status, raw), http.StatusBadGateway)
		return
	}
	var p Payout
	if err := json.Unmarshal(raw, &p); err != nil {
		http.Error(w, "Balasan Reader tidak terbaca", http.StatusBadGateway)
		return
	}
	mu.Lock()
	wd.Status, wd.ReaderPayoutID = "processing", p.ID
	mu.Unlock()
	// Contoh: "Transfer tepat Rp 150.123 ke BCA 9876543210 a.n. BUDI SANTOSO"
	fmt.Fprintln(w, p.Instruction)
}

// ---- Webhook ----

func verifySignature(body []byte, ts, sig string) bool {
	t, err := strconv.ParseInt(ts, 10, 64)
	if err != nil {
		return false
	}
	if d := time.Now().Unix() - t; d > 300 || d < -300 {
		return false
	}
	mac := hmac.New(sha256.New, webhookSecret)
	mac.Write([]byte(ts + "."))
	mac.Write(body)
	expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(expected), []byte(sig))
}

type webhookEvent struct {
	Event   string          `json:"event"`
	EventID string          `json:"event_id"`
	Test    bool            `json:"test"`
	Data    json.RawMessage `json:"data"`
}

type eventData struct {
	ID              string `json:"id"`
	ReferenceID     string `json:"reference_id"`
	Late            bool   `json:"late"`
	PaidAfterCancel bool   `json:"paid_after_cancel"`
	RecipientCheck  string `json:"recipient_check"`
	Reason          string `json:"reason"`
}

func writeAck(w http.ResponseWriter, eventID, status, reason string) {
	ack := map[string]string{"event_id": eventID, "status": status}
	if reason != "" {
		ack["reason"] = reason
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(ack)
}

// handleEvent mengembalikan "" bila berhasil, atau alasan penolakan (ack "rejected").
// Dipanggil dengan mu terkunci.
func handleEvent(event string, d eventData) string {
	switch event {
	case "payment.paid":
		o, ok := orders[d.ReferenceID]
		if !ok {
			return "order " + d.ReferenceID + " tidak ditemukan"
		}
		if o.Status != "paid" {
			o.Status = "paid"
			o.ReaderPaymentID = d.ID
			o.PaidLate = d.Late               // telat dalam masa jeda: tetap dipenuhi
			o.NeedsReview = d.PaidAfterCancel // order sempat dibatalkan
			// Penuhi pesanan di sini, atau masukkan ke antrean bila butuh lebih dari beberapa detik.
		}
	case "payment.duplicate":
		if o, ok := orders[d.ReferenceID]; ok {
			o.NeedsReview = true // uang masuk dua kali: siapkan refund
		}
	case "payout.sent":
		wd, ok := withdrawals[d.ReferenceID]
		if !ok {
			return "pencairan " + d.ReferenceID + " tidak ditemukan"
		}
		if wd.Status != "done" {
			wd.Status = "done"
			wd.ReaderPayoutID = d.ID
			wd.NeedsReview = d.RecipientCheck == "mismatch" // penerima di notifikasi berbeda
		}
	case "payout.reversed":
		if wd, ok := withdrawals[d.ReferenceID]; ok {
			wd.Status, wd.NeedsReview = "failed", true // dikembalikan bank: perlu transfer ulang
		}
	case "payout.duplicate":
		if wd, ok := withdrawals[d.ReferenceID]; ok {
			wd.NeedsReview = true // penerima dibayar dua kali
		}
	case "account.terminated":
		log.Printf("RAGH Reader memutus layanan akun: %s", d.Reason)
	}
	// ping, *.verified, *.expired, *.confirmation_delayed, dan event baru: cukup diterima.
	return ""
}

// POST /webhooks/reader
func webhookHandler(w http.ResponseWriter, r *http.Request) {
	raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) // body MENTAH
	if err != nil {
		http.Error(w, "body terlalu besar", http.StatusRequestEntityTooLarge)
		return
	}
	if !verifySignature(raw, r.Header.Get("X-Reader-Timestamp"), r.Header.Get("X-Reader-Signature")) {
		http.Error(w, "tanda tangan tidak valid", http.StatusUnauthorized)
		return
	}
	var ev webhookEvent
	if err := json.Unmarshal(raw, &ev); err != nil || ev.EventID == "" {
		http.Error(w, "body tidak valid", http.StatusBadRequest)
		return
	}
	var d eventData
	if len(ev.Data) > 0 && ev.Data[0] == '{' {
		if err := json.Unmarshal(ev.Data, &d); err != nil {
			http.Error(w, "data tidak valid", http.StatusBadRequest)
			return
		}
	}

	mu.Lock()
	defer mu.Unlock()
	if processed[ev.EventID] {
		writeAck(w, ev.EventID, "duplicate", "")
		return
	}
	reason := ""
	if ev.Test == testMode { // event uji tidak boleh menyentuh pesanan sungguhan, dan sebaliknya
		reason = handleEvent(ev.Event, d)
	}
	if reason != "" {
		writeAck(w, ev.EventID, "rejected", reason)
		return
	}
	processed[ev.EventID] = true
	writeAck(w, ev.EventID, "accepted", "")
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /orders/{id}/pay", payHandler)
	mux.HandleFunc("POST /withdrawals/{id}/process", processWithdrawalHandler)
	mux.HandleFunc("POST /webhooks/reader", webhookHandler)
	log.Println("Siap di http://localhost:8080")
	log.Fatal(http.ListenAndServe(":8080", mux))
}

Python / Flask

Python 3.10+, Flask 3, dan requests. Pasang dengan pip install flask requests, lalu jalankan flask --app app run.

Contoh ini memakai SQLite bawaan Python, dengan idempotensi dan perubahan order dalam satu transaksi.

# app.py: Python 3.10+, Flask 3, requests. Jalankan: flask --app app run
import hashlib
import hmac
import json
import os
import sqlite3
import time

import requests
from flask import Flask, abort, g, jsonify, redirect, request

READER_BASE = os.environ.get("READER_BASE_URL", "https://payment-reader.ragh.co.id")
API_KEY = os.environ["READER_API_KEY"]
PAYOUT_API_KEY = os.environ.get("READER_PAYOUT_API_KEY", "")
WEBHOOK_SECRET = os.environ["READER_WEBHOOK_SECRET"].encode()
TEST_MODE = os.environ.get("READER_TEST_MODE") == "true"
DB_PATH = os.environ.get("DB_PATH", "toko.db")

app = Flask(__name__)

SCHEMA = """
CREATE TABLE IF NOT EXISTS orders (
    id TEXT PRIMARY KEY, total INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'unpaid',
    reader_payment_id TEXT, paid_late INTEGER NOT NULL DEFAULT 0, needs_review INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS withdrawals (
    id TEXT PRIMARY KEY, amount INTEGER NOT NULL, bank_code TEXT NOT NULL,
    account_number TEXT NOT NULL, account_name TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'requested',
    reader_payout_id TEXT, needs_review INTEGER NOT NULL DEFAULT 0
);
-- Satu baris per event yang sudah diproses. Primary key = kunci idempotensi.
CREATE TABLE IF NOT EXISTS reader_webhook_events (
    event_id TEXT PRIMARY KEY, type TEXT NOT NULL, received_at INTEGER NOT NULL
);
"""


def get_db() -> sqlite3.Connection:
    if "db" not in g:
        g.db = sqlite3.connect(DB_PATH)
        g.db.row_factory = sqlite3.Row
        g.db.executescript(SCHEMA)
    return g.db


@app.teardown_appcontext
def close_db(_exc):
    db = g.pop("db", None)
    if db is not None:
        db.close()


# ---- Klien API ----

def reader(method: str, path: str, body: dict | None = None, api_key: str = API_KEY) -> tuple[int, dict]:
    res = requests.request(
        method,
        f"{READER_BASE}/api/v1{path}",
        json=body,
        headers={"Authorization": f"Bearer {api_key}", "Accept": "application/json"},
        timeout=15,
    )
    try:
        return res.status_code, res.json()
    except ValueError:
        return res.status_code, {}


# ---- Buat tagihan, lalu arahkan pembeli ke halaman bayar ----

@app.get("/orders/<order_id>/pay")
def pay(order_id: str):
    db = get_db()
    order = db.execute("SELECT id, total, status FROM orders WHERE id = ?", (order_id,)).fetchone()
    if order is None:
        abort(404)
    if order["status"] == "paid":
        return redirect(f"/orders/{order_id}")

    status, body = reader("POST", "/payments", {
        "reference_id": order_id,  # memanggil ulang dengan reference_id sama selalu aman
        "amount": order["total"],
        "description": f"Order {order_id}",
        "return_url": f"https://toko.contoh.id/orders/{order_id}",
    })
    error = body.get("error") or {}
    if status == 409 and error.get("code") == "order_already_paid":
        return redirect(f"/orders/{order_id}")  # sudah lunas
    if status not in (200, 201):
        return f"Gagal membuat tagihan: {error.get('message', status)}", 502

    with db:
        db.execute("UPDATE orders SET reader_payment_id = ? WHERE id = ?", (body["id"], order_id))
    return redirect(body["payment_url"], code=303)


# ---- Buat payout, lalu tampilkan instruksi transfer ke admin ----

@app.post("/withdrawals/<wd_id>/process")
def process_withdrawal(wd_id: str):
    db = get_db()
    wd = db.execute("SELECT * FROM withdrawals WHERE id = ?", (wd_id,)).fetchone()
    if wd is None:
        abort(404)

    status, body = reader("POST", "/payouts", {
        "reference_id": wd_id,
        "amount": wd["amount"],
        "recipient": {
            "bank_code": wd["bank_code"],  # misalnya BCA
            "account_number": wd["account_number"],
            "account_name": wd["account_name"],
        },
    }, api_key=PAYOUT_API_KEY)  # kunci terpisah: scope payouts:write + allowlist IP
    error = body.get("error") or {}
    if status == 409 and error.get("code") == "payout_already_sent":
        return "Pencairan ini sudah terkirim."
    if status not in (200, 201):
        return f"Gagal membuat payout: {error.get('code', status)}", 502

    with db:
        db.execute("UPDATE withdrawals SET status = 'processing', reader_payout_id = ? WHERE id = ?",
                   (body["id"], wd_id))
    # Contoh: "Transfer tepat Rp 150.123 ke BCA 9876543210 a.n. BUDI SANTOSO"
    return body["instruction"]


# ---- Webhook ----

class Duplicate(Exception):
    pass


class Reject(Exception):
    pass


def verify_signature(raw: bytes, timestamp: str, signature: str) -> bool:
    if not (timestamp.isascii() and timestamp.isdigit()) or abs(time.time() - int(timestamp)) > 300:
        return False
    digest = hmac.new(WEBHOOK_SECRET, timestamp.encode() + b"." + raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest(("sha256=" + digest).encode(), signature.encode())


def ack(event_id: str, status: str, reason: str | None = None):
    body = {"event_id": event_id, "status": status}
    if reason:
        body["reason"] = reason
    return jsonify(body)


def handle_event(db: sqlite3.Connection, kind: str, d: dict) -> None:
    """Melempar Reject untuk menolak event (ack "rejected")."""
    ref = d.get("reference_id")
    if kind == "payment.paid":
        order = db.execute("SELECT status FROM orders WHERE id = ?", (ref,)).fetchone()
        if order is None:
            raise Reject(f"order {ref} tidak ditemukan")
        if order["status"] != "paid":
            db.execute(
                "UPDATE orders SET status = 'paid', reader_payment_id = ?, paid_late = ?, needs_review = ? WHERE id = ?",
                # late: telat dalam masa jeda, tetap dipenuhi. paid_after_cancel: order sempat dibatalkan.
                (d["id"], int(d["late"]), int(d["paid_after_cancel"]), ref),
            )
            # Penuhi pesanan di sini, atau masukkan ke antrean bila butuh lebih dari beberapa detik.
    elif kind == "payment.duplicate":
        db.execute("UPDATE orders SET needs_review = 1 WHERE id = ?", (ref,))  # uang masuk dua kali: siapkan refund
    elif kind == "payout.sent":
        wd = db.execute("SELECT status FROM withdrawals WHERE id = ?", (ref,)).fetchone()
        if wd is None:
            raise Reject(f"pencairan {ref} tidak ditemukan")
        if wd["status"] != "done":
            db.execute(
                "UPDATE withdrawals SET status = 'done', reader_payout_id = ?, needs_review = ? WHERE id = ?",
                (d["id"], int(d.get("recipient_check") == "mismatch"), ref),
            )
    elif kind == "payout.reversed":
        db.execute("UPDATE withdrawals SET status = 'failed', needs_review = 1 WHERE id = ?", (ref,))
    elif kind == "payout.duplicate":
        db.execute("UPDATE withdrawals SET needs_review = 1 WHERE id = ?", (ref,))
    elif kind == "account.terminated":
        app.logger.critical("RAGH Reader memutus layanan akun: %s", d.get("reason"))
    # ping, *.verified, *.expired, *.confirmation_delayed, dan event baru: cukup diterima.


@app.post("/webhooks/reader")
def reader_webhook():
    raw = request.get_data()  # body MENTAH, sebelum di-parse
    if not verify_signature(raw, request.headers.get("X-Reader-Timestamp", ""),
                            request.headers.get("X-Reader-Signature", "")):
        return jsonify(error="tanda tangan tidak valid"), 401
    try:
        event = json.loads(raw)
        event_id = event["event_id"]
    except (ValueError, KeyError, TypeError):
        return jsonify(error="body tidak valid"), 400

    db = get_db()
    try:
        with db:  # satu transaksi: commit bila sukses, rollback bila ada exception
            try:
                db.execute("INSERT INTO reader_webhook_events (event_id, type, received_at) VALUES (?, ?, ?)",
                           (event_id, event.get("event", ""), int(time.time())))
            except sqlite3.IntegrityError:
                raise Duplicate()
            # Event uji tidak boleh menyentuh pesanan sungguhan, dan sebaliknya.
            if bool(event.get("test")) == TEST_MODE:
                handle_event(db, event.get("event", ""), event.get("data") or {})
    except Duplicate:
        return ack(event_id, "duplicate")
    except Reject as e:
        return ack(event_id, "rejected", str(e))  # rollback: kiriman ulang akan diproses lagi
    # Exception lain menghasilkan HTTP 500, dan Reader mengirim ulang sesuai jadwal.
    return ack(event_id, "accepted")

Menguji endpoint Anda secara lokal

Sebelum memakai Reader sungguhan, Anda bisa mengirim webhook bertanda tangan sendiri ke endpoint lokal. Perintah ini membuat tanda tangan persis seperti Reader (bash, dengan openssl):

SECRET='whsec_GANTI_DENGAN_SECRET_ANDA'
BODY='{"event":"ping","event_id":"evt_uji_lokal_1","api_version":"2026-09-28","created_at":"2026-09-28T09:00:00+07:00","test":true,"data":{"message":"uji lokal"}}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')

curl -i -X POST http://localhost:8000/api/webhooks/reader \
  -H "Content-Type: application/json" \
  -H "X-Reader-Event-Id: evt_uji_lokal_1" \
  -H "X-Reader-Timestamp: $TS" \
  -H "X-Reader-Signature: sha256=$SIG" \
  --data-binary "$BODY"

Hasil yang benar:

  1. Kiriman pertama dibalas {"event_id":"evt_uji_lokal_1","status":"accepted"}.
  2. Kiriman kedua dengan body yang sama dibalas "status":"duplicate".
  3. Mengubah satu karakter di BODY tanpa menghitung ulang SIG menghasilkan 401.

Setelah itu, uji alur lengkap dengan kunci uji dan POST /api/v1/test/simulate: webhook sungguhan dikirim ke callback URL Anda dengan "test": true.