Sistem pengiriman webhook dari penyedia layanan pembayaran (payment gateway) atau bursa eksternal beroperasi di atas prinsip at-least-once delivery. Kegagalan jaringan sesaat (network blip), timeout HTTP 504, atau proses restart internal pada gateway memicu mekanisme retry otomatis. Tanpa mekanisme kontrol konkurensi dan idempotensi yang ketat, satu notifikasi keberhasilan transfer dapat dieksekusi berkali-kali. Kasus double-fill atau duplikasi saldo pada platform seperti Robinhood adalah preseden buruk yang berakar dari kegagalan menangani konkurensi payload ini.

Anatomi Masalah: Network Retry, Duplikasi, dan Out-of-Order Delivery

Saat menangani webhook finansial, ada tiga skenario kegagalan utama yang wajib diantisipasi:

  • Network Retry Acak: Server penerima telah selesai memperbarui balance di database, namun jaringan putus sebelum HTTP response 200 OK terkirim ke gateway. Gateway menganggap request gagal dan mengirimkan ulang request yang identik.
  • Concurrent Duplication: Dua worker webhook dari sisi gateway mengirim event yang sama persis dalam rentang milidetik akibat timeout prematur.
  • Out-of-Order Delivery: Event ORDER_FILLED tiba mendahului ORDER_CREATED karena latensi perutean jaringan atau pemrosesan asinkron multi-partisi di sisi publisher.

Desain Kontrak API dan Idempotency Key

Gateway finansial standar menyertakan identifier unik pada HTTP header atau payload root, misalnya header Idempotency-Key atau field event_id (contoh: Stripe, Xendit). Kontrak API harus menegakkan aturan:

  • Jika request dengan idempotency_key yang sama sedang diproses (in-flight), kembalikan status 409 Conflict atau tahan proses hingga lock selesai.
  • Jika request dengan idempotency_key yang sama telah selesai diproses (PROCESSED), respons wajib mengembalikan payload sukses sebelumnya secara deterministik tanpa mengeksekusi mutasi data ulang.
  • Key memiliki Time-To-Live (TTL). Di sistem perbankan, TTL umumnya dipasang 24 jam hingga 72 jam tergantung jendela retry gateway.

Skema Status Lock di PostgreSQL

Redis efisien untuk distributed locking jangka pendek, namun database relasional ACID tetap menjadi sumber kebenaran (single source of truth) permanen. Gunakan tabel pencatat event idempotensi dengan status eksplisit.

CREATE TABLE webhook_events (
    idempotency_key VARCHAR(128) PRIMARY KEY,
    event_type VARCHAR(64) NOT NULL,
    status VARCHAR(20) NOT NULL, -- PENDING, PROCESSED, FAILED
    payload JSONB NOT NULL,
    response_body JSONB NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_webhook_status ON webhook_events (status);

Implementasi: Redis Distributed Lock dan Transaksi Database

Berikut implementasi backend (Node.js/TypeScript) menggunakan Redis atomic operation (SET NX EX) sebagai gatekeeper lapisan pertama, dilanjutkan oleh transaksi database dengan isolasi record.

import { Request, Response } from 'express';
import Redis from 'ioredis';
import { Pool } from 'pg';

const redis = new Redis(process.env.REDIS_URL);
const db = new Pool({ connectionString: process.env.DATABASE_URL });

export async function handleFinancialWebhook(req: Request, res: Response) {
    const idempotencyKey = req.headers['idempotency-key'] as string;
    const { event_type, amount, account_id } = req.body;

    if (!idempotencyKey) {
        return res.status(400).json({ error: 'Missing Idempotency-Key header' });
    }

    // 1. Redis atomic lock (TTL 30 detik untuk menahan race condition)
    const lockKey = `lock:webhook:${idempotencyKey}`;
    const acquired = await redis.set(lockKey, 'locked', 'EX', 30, 'NX');

    if (!acquired) {
        // Konkurensi terdeteksi: request sedang dieksekusi thread lain
        return res.status(409).json({ error: 'Concurrent request in progress' });
    }

    const client = await db.connect();

    try {
        // 2. Cek apakah event sudah pernah diproses di Postgres
        const existingEvent = await client.query(
            'SELECT status, response_body FROM webhook_events WHERE idempotency_key = $1',
            [idempotencyKey]
        );

        if (existingEvent.rows.length > 0) {
            const event = existingEvent.rows[0];
            if (event.status === 'PROCESSED') {
                // Return cached response deterministik
                return res.status(200).json(event.response_body);
            }
            if (event.status === 'PENDING') {
                return res.status(409).json({ error: 'Event currently being processed' });
            }
        }

        // 3. Eksekusi mutasi saldo dalam satu unit transaksi
        await client.query('BEGIN');

        await client.query(
            `INSERT INTO webhook_events (idempotency_key, event_type, status, payload)
             VALUES ($1, $2, 'PENDING', $3)
             ON CONFLICT (idempotency_key) DO NOTHING`,
            [idempotencyKey, event_type, JSON.stringify(req.body)]
        );

        // Mutasi saldo: Atomic increment
        await client.query(
            'UPDATE accounts SET balance = balance + $1 WHERE id = $2',
            [amount, account_id]
        );

        const responsePayload = { success: true, message: 'Balance updated' };

        // Tandai event selesai diproses
        await client.query(
            `UPDATE webhook_events 
             SET status = 'PROCESSED', response_body = $1, updated_at = NOW() 
             WHERE idempotency_key = $2`,
            [JSON.stringify(responsePayload), idempotencyKey]
        );

        await client.query('COMMIT');

        return res.status(200).json(responsePayload);

    } catch (error) {
        await client.query('ROLLBACK');
        return res.status(500).json({ error: 'Internal transaction error' });
    } finally {
        client.release();
        // Hapus lock Redis agar request retry resmi berikutnya dapat diproses via cache DB
        await redis.del(lockKey);
    }
}

Menangani Out-of-Order Delivery

Jika webhook pembaruan status order tiba tidak berurutan, pendekatan idempotensi berbasis key saja tidak cukup. Dibutuhkan mekanisme sequence numbering atau state-machine verification:

  1. Entity Versioning: Sertakan field integer version pada baris akun/order di database. Query mutasi mewajibkan filter WHERE version = incoming_version - 1. Jika versi tidak cocok, tolak mutasi dan antrikan event ke Dead Letter Queue (DLQ).
  2. Validasi Transisi State: Order dengan status FILLED atau CANCELLED tidak boleh menerima transisi balik ke PENDING. Gunakan explicit finite state machine (FSM) sebelum menerapkan mutasi data finansial.

Trade-off dan Jebakan Implementasi

  • Pelepasan Lock Prematur: Jika waktu eksekusi transaksi database melebihi TTL lock Redis, lock akan kedaluwarsa sebelum transaksi selesai. Solusinya, buat TTL lock secukupnya (misal: 10-30 detik) dan pastikan transaksi DB dirancang sependek mungkin tanpa nested network call.
  • External Call di Dalam Transaksi DB: Jangan pernah memanggil API pihak ketiga (misal: kirim notifikasi push atau email) di dalam blok transaksi PostgreSQL. Jika panggilan eksternal hang, row lock database akan menahan koneksi lain dan menyebabkan connection pool exhaustion.