# Notifikasi Webhook

> Terima pembaruan status pembayaran dan penarikan secara real-time melalui webhook yang ditandatangani HMAC.

Sistem 2328.io mengirim webhook ke `url_callback` Anda setiap kali status pembayaran berubah. Ini adalah cara yang direkomendasikan untuk mendapatkan notifikasi tentang pembayaran sukses.

## Format permintaan

- **Method:** `POST`
- **Content-Type:** `application/json`
- **Tanda tangan:** field `sign` di body permintaan

## Payload

Body webhook mengikuti format respons `/v1/payment/info` dan menambahkan `tx_explorer_url` serta field `sign` untuk verifikasi tanda tangan.

### Pembayaran sukses

```json
{
  "uuid": "db17d490-15b6-47b9-9015-91d1d8b119f2",
  "order_id": "ORDER-12345",
  "amount": "180.00000000",
  "currency": "RUB",
  "url": "https://go.2328.io/db17d490-15b6-47b9-9015-91d1d8b119f2",
  "expires_at": "2026-05-09T16:56:58+03:00",
  "created_at": "2026-05-09T15:56:58+03:00",
  "payer_currency": "TON",
  "payer_amount": "0.95256917",
  "network": "TON",
  "address": "UQA0RevhkCQx-EltyNgPPeG8dqtnCz7ZslOzMdNQlLxVaNBb",
  "payment_status": "paid",
  "txid": "41c2a327323480af8e705d05deb09c238a41779928832abef4bb77c862357b11",
  "tx_explorer_url": "https://tonviewer.com/transaction/41c2a327323480af8e705d05deb09c238a41779928832abef4bb77c862357b11",
  "payment_amount": "0.95256917",
  "merchant_amount": "0.949711462490000000",
  "amount_usd": "2.41324380",
  "exchange_rate": "0.01340691",
  "sign": "6f8c15b6e53b506d5bfa38ed3fb3b50697af73434262153c02e412541372f04d"
}
```

### Pembayaran dibatalkan / gagal

Saat pembayaran tidak dalam status terminal `paid`, `txid`, `payment_amount`, dan `merchant_amount` adalah `null`:

```json
{
  "uuid": "48edaf2d-2c49-4638-8f86-88636f661c1f",
  "order_id": "ORDER-12345",
  "amount": "2800.00000000",
  "currency": "RUB",
  "url": "https://go.2328.io/48edaf2d-2c49-4638-8f86-88636f661c1f",
  "expires_at": "2026-05-09T06:19:04+03:00",
  "created_at": "2026-05-09T05:19:04+03:00",
  "payer_currency": "ETH",
  "payer_amount": "0.01620968",
  "network": "ETH-ERC20",
  "address": "0x37c20d6d96d130Bc5B33D832e43b8e16aACe0c59",
  "payment_status": "cancel",
  "txid": null,
  "tx_explorer_url": null,
  "payment_amount": null,
  "merchant_amount": null,
  "amount_usd": "37.53934800",
  "exchange_rate": "0.01340691",
  "sign": "40ce68ad9691ad54e684329d75ab5adaf5b01409a2d18d3e0110b8c1be605342"
}
```

### Referensi field

| Field | Tipe | Deskripsi |
|-------|------|-------------|
| `uuid` | string | UUID pembayaran |
| `order_id` | string | ID pesanan Anda |
| `amount` | decimal (8 dp) | Jumlah fiat dalam `currency` |
| `currency` | string | Mata uang fiat yang diminta merchant |
| `url` | string | URL checkout terhosting |
| `expires_at` | string (ISO 8601) | Saat sesi pembayaran kedaluwarsa |
| `created_at` | string (ISO 8601) | Saat sesi pembayaran dibuat |
| `payer_currency` | string | Kripto yang dibayarkan oleh pembayar |
| `payer_amount` | decimal (8 dp) | Jumlah kripto yang diharapkan |
| `network` | string | Jaringan blockchain |
| `address` | string | Alamat deposit |
| `payment_status` | string | Salah satu dari: `pending`, `check`, `paid`, `underpaid_check`, `underpaid`, `overpaid`, `cancel`, `aml_lock` (lihat [References](/docs/references)) |
| `txid` | string \| null | Hash tx blockchain, hadir hanya setelah pembayaran terkonfirmasi |
| `tx_explorer_url` | string \| null | URL transaksi di penjelajah blockchain. Bernilai `null` jika `txid` tidak ada atau transfer merupakan P2P internal. |
| `payment_amount` | decimal \| null | Jumlah aktual yang dibayar, hadir hanya setelah pembayaran |
| `merchant_amount` | decimal (18 dp) \| null | Jumlah yang dikreditkan ke merchant setelah biaya |
| `amount_usd` | decimal (8 dp) | Jumlah dalam USD pada saat pembuatan |
| `exchange_rate` | decimal | Nilai tukar Kripto / fiat yang digunakan |
| `sign` | string (hex) | Tanda tangan HMAC-SHA256 dari payload |

## Memverifikasi tanda tangan

Untuk memverifikasi tanda tangan webhook:

1. Ekstrak field `sign` dari payload
2. Hapus field `sign` dari objek
3. Encode field yang tersisa sebagai JSON
4. Encode JSON tersebut ke Base64
5. Hitung HMAC-SHA256 dari string Base64 menggunakan API_KEY Anda
6. Bandingkan tanda tangan yang dihitung dengan nilai `sign` menggunakan perbandingan constant-time

#### php

```php
<?php
function verifyWebhookSign(array $data, string $apiKey): bool {
    $receivedSign = $data['sign'] ?? '';
    unset($data['sign']);

    $json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    $base64 = base64_encode($json);
    $calculated = hash_hmac('sha256', $base64, $apiKey);

    return hash_equals($calculated, $receivedSign);
}

$apiKey = 'YOUR_API_KEY';
$payload = json_decode(file_get_contents('php://input'), true);

if (!verifyWebhookSign($payload, $apiKey)) {
    http_response_code(401);
    exit;
}

switch ($payload['payment_status']) {
    case 'paid':
    case 'overpaid':
        // Credit the order — check idempotency by order_id first
        break;
    case 'underpaid_check':
    case 'underpaid':
    case 'cancel':
        break;
}

http_response_code(200);
```

#### js

```js
import crypto from "crypto";
import express from "express";

const app = express();
app.use(express.json());

function verifyWebhookSign(payload, apiKey) {
  const { sign, ...rest } = payload;
  const json = JSON.stringify(rest);
  const base64 = Buffer.from(json).toString("base64");
  const calculated = crypto
    .createHmac("sha256", apiKey)
    .update(base64)
    .digest("hex");
  return crypto.timingSafeEqual(
    Buffer.from(calculated),
    Buffer.from(sign || ""),
  );
}

app.post("/webhook", (req, res) => {
  if (!verifyWebhookSign(req.body, process.env.API_KEY)) {
    return res.sendStatus(401);
  }

  const { order_id, payment_status, txid } = req.body;

  if (payment_status === "paid" || payment_status === "overpaid") {
    // Credit the order — check idempotency by order_id first
  }

  res.sendStatus(200);
});
```

#### python

```python
import json
import hmac
import hashlib
import base64
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()
API_KEY = "YOUR_API_KEY"

def verify_webhook_sign(payload: dict, api_key: str) -> bool:
    received = payload.pop("sign", "")
    body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
    b64 = base64.b64encode(body.encode("utf-8")).decode()
    calculated = hmac.new(api_key.encode(), b64.encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(calculated, received)

@app.post("/webhook")
async def webhook(request: Request):
    payload = await request.json()
    if not verify_webhook_sign(payload, API_KEY):
        raise HTTPException(401)

    if payload["payment_status"] in ("paid", "overpaid"):
        # Credit the order — check idempotency by order_id first
        pass

    return {"ok": True}
```

#### go

```go
package main

import (
    "bytes"
    "crypto/hmac"
    "crypto/sha256"
    "encoding/base64"
    "encoding/hex"
    "encoding/json"
    "io"
    "net/http"
)

func verifyWebhookSign(body []byte, apiKey string) (map[string]any, bool) {
    var payload map[string]any
    if err := json.Unmarshal(body, &payload); err != nil {
        return nil, false
    }
    received, _ := payload["sign"].(string)
    delete(payload, "sign")

    var buf bytes.Buffer
    enc := json.NewEncoder(&buf)
    enc.SetEscapeHTML(false)
    enc.Encode(payload)
    reencoded := bytes.TrimRight(buf.Bytes(), "\n")

    b64 := base64.StdEncoding.EncodeToString(reencoded)
    h := hmac.New(sha256.New, []byte(apiKey))
    h.Write([]byte(b64))
    calculated := hex.EncodeToString(h.Sum(nil))

    return payload, hmac.Equal([]byte(calculated), []byte(received))
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)
    payload, ok := verifyWebhookSign(body, apiKey)
    if !ok {
        http.Error(w, "invalid signature", http.StatusUnauthorized)
        return
    }

    status, _ := payload["payment_status"].(string)
    if status == "paid" || status == "overpaid" {
        // Credit the order — check idempotency first
    }
    w.WriteHeader(http.StatusOK)
}
```

#### ruby

```ruby
require "json"
require "openssl"
require "base64"
require "sinatra"

API_KEY = "YOUR_API_KEY"

def verify_webhook_sign(payload, api_key)
  received = payload.delete("sign") || ""
  body = payload.to_json
  b64 = Base64.strict_encode64(body)
  calculated = OpenSSL::HMAC.hexdigest("SHA256", api_key, b64)
  OpenSSL.fixed_length_secure_compare(calculated, received)
end

post "/webhook" do
  payload = JSON.parse(request.body.read)
  halt 401 unless verify_webhook_sign(payload, API_KEY)

  if %w[paid overpaid].include?(payload["payment_status"])
    # Credit the order — check idempotency by order_id first
  end

  status 200
end
```

> **DANGER:** **Selalu verifikasi tanda tangan** sebelum mengkreditkan dana apa pun ke pengguna. Webhook yang tidak ditandatangani atau ditandatangani secara tidak benar dapat menjadi permintaan palsu.

## Webhook penarikan

Saat `status` sebuah penarikan berubah, sistem mengirim webhook `POST` ke URL `url_callback` yang diteruskan saat penarikan dibuat. Jika `url_callback` tidak disediakan, tidak ada webhook yang dikirim untuk penarikan tersebut.

> **WARNING:** Webhook penarikan harus diverifikasi dengan **Payout API key** Anda — bukan API key biasa. Algoritma penandatanganan identik dengan webhook pembayaran (hapus `sign`, encode JSON, base64, HMAC-SHA256), hanya key yang berbeda.

### Payload

```json
{
  "uuid": "019dff1f-0dbd-7277-8d45-271e7775388f",
  "order_id": "4dfdcc84402b1185b71cbe399321533e",
  "status": "completed",
  "currency": "TRX",
  "network": "TRX-TRC20",
  "amount": "3.00",
  "merchant_amount": "3.00",
  "network_amount": "3.00",
  "amount_usd": "1.04",
  "to_address": "THauRv5tcucQRohXg8NiyGTk16DX1XQG5x",
  "memo": null,
  "txid": "9242e533703704ef3eaba840f70b4a26333e72c943377ee375fea17badb53def",
  "tx_explorer_url": "https://tronscan.org/#/transaction/9242e533703704ef3eaba840f70b4a26333e72c943377ee375fea17badb53def",
  "block_number": null,
  "error_type": null,
  "created_at": "2026-05-07T00:08:38+03:00",
  "updated_at": "2026-05-07T00:08:54+03:00",
  "from_currency": "USDT",
  "debited_amount": "1.050735",
  "debited_currency": "USDT",
  "sign": "925ad7bf3d6841864101f7cc2c7e30652e70a06cdb04dbe07a0129480000ce4a"
}
```

### Referensi field

| Field | Tipe | Deskripsi |
|-------|------|-------------|
| `uuid` | string | UUID penarikan |
| `order_id` | string | ID idempotensi / referensi Anda, jika Anda menyediakannya |
| `status` | string | `pending`, `completed`, `failed`, `cancelled` (lihat [References](/docs/references)) |
| `currency` | string | Mata uang penarikan |
| `network` | string | Jaringan blockchain |
| `amount` | decimal | Jumlah penarikan (dalam `currency`) |
| `merchant_amount` | decimal | Jumlah yang dibebankan dari saldo merchant |
| `network_amount` | decimal | Jumlah yang sebenarnya dikirim on-chain |
| `amount_usd` | decimal | Nilai USD pada saat penarikan |
| `to_address` | string | Alamat blockchain penerima |
| `memo` | string \| null | Memo / destination tag, jika digunakan |
| `txid` | string \| null | Hash transaksi blockchain, ditetapkan saat `completed` |
| `tx_explorer_url` | string \| null | URL transaksi di penjelajah blockchain. Bernilai `null` jika `txid` tidak ada atau transfer merupakan P2P internal. |
| `block_number` | integer \| null | Tinggi blok dari transaksi on-chain |
| `error_type` | string \| null | Alasan saat `status = failed` (mis. `aml_risk`, lihat [References](/docs/references)) |
| `created_at` | string (ISO 8601) | Saat penarikan dibuat |
| `updated_at` | string (ISO 8601) | Saat status terakhir berubah |
| `from_currency` | string | Saldo sumber yang didebet untuk penarikan ketika konversi otomatis digunakan (mis. `USDT` untuk penarikan `BTC`) |
| `debited_amount` | decimal | Jumlah yang didebit dari saldo `from_currency` |
| `debited_currency` | string | Mata uang dari debit |
| `sign` | string (hex) | Tanda tangan HMAC-SHA256 dari payload, ditandatangani dengan **Payout API key** |

## Praktik terbaik

- **Idempotensi** — Selalu periksa apakah pembayaran sudah diproses (berdasarkan `order_id` atau `uuid`). Webhook bisa tiba beberapa kali.
- **Respon cepat** — Kembalikan HTTP 200 secepat mungkin. Pindahkan pekerjaan berat ke antrean latar belakang.
- **Retry** — Jika sistem tidak menerima HTTP 200, webhook dikirim ulang setelah 2 menit. Maksimum 5 kali percobaan retry.
- **Pemrosesan async** — Tangani event webhook secara asinkron untuk menghindari pemblokiran respon.
- **Keamanan** — SELALU verifikasi tanda tangan `sign` sebelum mempercayai payload.

> **WARNING:** Webhook bisa tiba dengan urutan yang tidak sesuai. Jangan asumsikan webhook pertama yang Anda terima adalah status final — selalu ambil ulang melalui `/v1/payment/info` (atau `/v1/payout/status/{uuid}`) jika Anda perlu kepastian.

## Kontrak pengiriman dan pemrosesan

Gunakan urutan berikut di dalam endpoint webhook Anda:

1. Baca isi permintaan tanpa mencatat rahasia atau tanda tangan lengkap.
2. Identifikasi apakah itu adalah event pembayaran/dompet statis atau event pembayaran keluar sehingga Anda memilih API key yang benar.
3. Hapus `sign`, reproduksi byte JSON yang didokumentasikan, hitung HMAC-SHA256, dan bandingkan dalam waktu konstan.
4. Validasi pengenal yang diperlukan, string desimal, dan nilai status.
5. Masukkan catatan inbox/idempotensi secara atomik. Jika sudah ada, kembalikan HTTP 200 tanpa mengulangi efek samping.
6. Komit mutasi pesanan/buku besar dan antrekan email, analitik, atau notifikasi yang tidak kritis.
7. Kembalikan HTTP 200 dengan cepat.

Jangan memanggil layanan pihak ketiga yang lambat saat memegang transaksi idempoten. Timeout setelah Anda melakukan komit tetapi sebelum mengembalikan dapat menyebabkan percobaan ulang; duplikat harus memperhatikan kunci inbox yang telah dikomit dan menjadi tanpa operasi.

### Kunci idempoten yang direkomendasikan

| Acara | Identitas utama | Catatan |
|-------|------------------|-------|
| Sesi pembayaran | `uuid` + bukti status/versi | Faktur yang sama dapat menghasilkan beberapa perubahan status yang sah. |
| Isi ulang pembayaran sebagian | faktur `uuid` + `txid` | Lebih dari satu transfer bisa dimiliki oleh faktur yang sama yang kurang dibayar. |
| Deposit dompet statis | jaringan + `txid` | `order_id` digunakan kembali oleh setiap deposit ke dompet itu. |
| Pembayaran | pembayaran `uuid` + status | Jangan pernah membuat pembayaran kedua dari logika percobaan ulang webhook. |

Jika skema Anda tidak memiliki id acara, simpan hash payload yang terverifikasi sebagai bukti audit tambahan, tetapi jangan mengganti identitas bisnis di atas dengan cap waktu.

## Pemesan dan rekonsiliasi

Pengiriman setidaknya-satu-kali dan pesan status dapat bersaing. Terapkan aturan bisnis monotonic daripada “permintaan terakhir menang”:

- jangan pernah memindahkan pesanan yang telah dipenuhi kembali ke `check` karena event yang lebih lama tiba terlambat;
- izinkan `underpaid_check` menerima txid tambahan tanpa mengulang kredit sebelumnya;
- anggap `paid` dan `overpaid` sebagai status penyelesaian yang berhasil, sambil mempertahankan jumlah yang berbeda;
- tetap pertahankan `underpaid` sebagai hasil pembayaran parsial final kecuali API yang berwenang kemudian melaporkan status lain;
- arahkan `aml_lock` ke peninjauan dan jangan biarkan pekerja ulang generik memenuhinya;
- meminta info pembayaran/pencairan kapan pun transisi tidak mungkin, konteks hilang, atau secara finansial ambigu.

Jalankan rekonsiliasi terjadwal bahkan ketika pengiriman webhook tampak sehat. Bandingkan status terminal lokal dan jumlah yang dikreditkan dengan `/v1/payment/info`, `/v1/static-wallet/transactions`, atau `/v1/payout/status/{uuid}` dan beri peringatan pada perbedaan alih-alih menimpa riwayat pembukuan secara diam-diam.

## Keamanan endpoint webhook

- Mewajibkan HTTPS dan menjaga callback tetap bisa diakses publik; target callback privat/loopback ditolak saat pembuatan pembayaran.
- Terapkan batas kecil pada body permintaan dan tipe konten JSON.
- Batasi kecepatan sebelum pekerjaan yang mahal, tetapi berikan ruang cukup untuk lonjakan sah dan percobaan ulang.
- Jangan pernah mengizinkan webhook hanya berdasarkan IP sumber. Daftar izin jaringan adalah pertahanan berlapis; verifikasi HMAC wajib.
- Sunting `sign`, kunci API, alamat bila diperlukan oleh kebijakan, dan metadata pribadi dari log aplikasi.
- Simpan baik kunci saat ini maupun kunci pengganti yang dijadwalkan secara eksplisit selama jendela rotasi terkontrol; jangan pernah menebak kunci mana yang menandatangani sebuah kejadian.
- Kembalikan isi kesalahan umum pada tanda tangan yang tidak valid agar endpoint tidak menjadi oracle kunci atau akun.