Lewati ke konten
ZePayDokumentasi API
API v2.6.0OpenAPI
Menu dokumentasi
Panduan · API v2.6.0

Panduan integrasi merchant — ZePay API v2

Untuk developer merchant. Merchant hanya mengakses ZePay lewat API ini — tidak ada login web untuk merchant. Konsol web ZePay khusus admin ZePay.

Base URL https://payment.envfri.com/api/v2

Setiap merchant berdiri sendiri: kunci API, saldo, rekening terdaftar, pencairan, dan webhook milik merchant itu saja. Platform yang mengintegrasikan banyak bisnis memakai kunci masing-masing merchant.

Biaya: ada dua jenis biaya — biaya transaksi per kanal pembayaran (tarif kanal pembayaran) dan biaya transfer per pencairan (tarif transfer ke bank/e-money tujuan). Rincian di bagian 4 dan 6.

Referensi lengkap: openapi-merchant-v2.yaml (versi 2.6.0) · Base URL: https://payment.envfri.com/api/v2

Yang bisa dilakukan Endpoint
Info merchant, keterangan biaya, kanal & tarif, aturan pencairan GET /merchant
Saldo & nominal yang bisa dicairkan GET /balance
Mutasi saldo (termasuk baris biaya transfer) GET /balance/transactions
Ringkasan periode untuk rekonsiliasi GET /balance/summary?from=…&to=…
Transaksi: buat, cari, cek status, batalkan POST /payments, GET /payments, GET /payments/{id}, POST /payments/{id}/cancel
Simulasi hasil pembayaran (khusus mode uji) POST /payments/{id}/simulate
Daftar bank & e-money tujuan GET /banks?type=bank|ewallet
Cek rekening / e-money (nama pemilik) POST /bank-accounts/inquiry
Daftarkan rekening dari hasil cek POST /bank-accounts
Rekening terdaftar GET /bank-accounts, GET/PATCH/DELETE /bank-accounts/{id}
Pratinjau pencairan (biaya transfer & total dipotong) POST /payouts/quote
Cairkan, status, riwayat, batalkan POST /payouts, GET /payouts/{id}, GET /payouts, POST /payouts/{id}/cancel
Endpoint webhook (tambah, ubah, rotasi secret, uji) GET/POST /webhook-endpoints, GET/PATCH/DELETE /webhook-endpoints/{id}, POST …/rotate-secret, POST …/test
Riwayat event & kirim ulang webhook yang terlewat GET /events, GET /events/{id}, POST /events/{id}/resend

Yang hanya bisa dilakukan admin ZePay (ajukan ke tim ZePay, karena merchant tidak punya akses web): membuat atau merotasi kunci API, membatasi scope & IP kunci, memberi izin khusus pencairan tanpa daftar rekening, mengubah limit harian & ambang persetujuan, mengisi nama pemilik rekening sesuai KYC, meninjau rekening pending_review, menyetujui pencairan besar, pencairan gabungan, serta mengatur kanal & tarif.


1. Kunci API & mode uji#

  • Admin ZePay membuat kunci untuk merchant Anda: Key ID (zpk_live_… / zpk_test_…) dan secret (zsk_live_… / zsk_test_…), dikirim lewat kanal aman satu kali. Tidak ada halaman web untuk melihatnya lagi — simpan baik-baik; bila hilang, minta rotasi.
  • zpk_test_… memakai saldo, rekening, tagihan, pencairan, dan webhook terpisah dari live, dan tidak pernah menghubungi bank atau kanal pembayaran sungguhan (simulator, bagian 10). Uji seluruh alur di mode uji dulu.
  • Simpan secret di server (env/secret manager). Jangan pernah di aplikasi HP atau browser.
  • Rotasi kunci: kunci lama tetap berlaku selama masa transisi (langsung berhenti, 24 jam, atau 7 hari).
  • Admin bisa membatasi kunci ke scope dan daftar IP tertentu. Tanpa scope yang dibutuhkan, atau dari IP di luar daftar → 403 permission_denied. Kunci baru mendapat semua scope:
Scope Untuk
(tanpa scope) GET /merchant, GET /banks
balance:read saldo, mutasi, ringkasan periode
payments:read / payments:write membaca tagihan / membuat, membatalkan, dan mensimulasikan tagihan
bank_accounts:read / bank_accounts:write membaca rekening / cek, daftar, ubah, nonaktifkan rekening
payouts:read / payouts:write membaca pencairan / pratinjau, cairkan, batalkan
webhooks:read / webhooks:write membaca endpoint & event / mengelola endpoint, event uji, kirim ulang
payouts:any_destination izin khusus, tidak otomatis: cairkan ke rekening tanpa daftar lewat inquiry_id (bagian 6, "Pencairan langsung"). Hanya untuk kunci yang dibatasi IP publik spesifik server Anda (IPv4 /24–/32, IPv6 /48–/128), bersama payouts:write & bank_accounts:write.

2. Menandatangani request#

Setiap request membawa:

Header Isi
X-ZePay-Key-Id Key ID
X-ZePay-Timestamp Unix detik sekarang (toleransi 300 detik)
X-ZePay-Signature hex(HMAC_SHA256(secret, timestamp + "\n" + METHOD + "\n" + path_dan_query + "\n" + hex(SHA256(body)) [+ "\n" + Idempotency-Key])) — baris terakhir hanya bila header Idempotency-Key dikirim
Idempotency-Key Wajib (16–64 karakter: huruf, angka, _ . : -; mis. UUID) untuk POST /payments, POST /payments/{id}/cancel, POST /bank-accounts, POST /payouts, POST /payouts/{id}/cancel, POST /webhook-endpoints, dan POST /webhook-endpoints/{id}/rotate-secret. Pakai ulang kunci yang sama saat mengulang request (timeout/jaringan putus) agar tidak terjadi tagihan atau pencairan ganda.

path_dan_query termasuk prefix dan persis seperti dikirim (termasuk encoding), mis. /api/v2/payouts?limit=20. Body kosong di-hash sebagai string kosong. Bila request membawa Idempotency-Key, nilainya ikut ditandatangani (ditambahkan sebagai baris terakhir) — request yang tersadap tidak bisa dikirim ulang dengan kunci lain untuk menggandakan tagihan atau pencairan. Saat mengulang request dengan kunci yang sama, tanda tangani ulang dengan timestamp baru seperti biasa. Body JSON maks. 64 KB; field yang tidak dikenal ditolak (400 invalid_request).

Idempotensi: kirim ulang dengan kunci & request yang sama → respons pertama dikembalikan apa adanya dengan header Idempotent-Replayed: true (respons 4xx ikut disimpan; 5xx tidak, jadi aman diulang). Kunci sama dengan request berbeda → 409 idempotency_conflict. Kunci disimpan 24 jam per merchant (live & uji berbagi ruang kunci — jangan pakai ulang antar-mode). POST /bank-accounts/inquiry, POST /payouts/quote, POST /payments/{id}/simulate, POST …/test, dan POST /events/{id}/resend tidak memakai Idempotency-Key.

Batas request: bawaan 60 request/menit per kunci API (bisa diatur ZePay) → 429 rate_limited dengan header Retry-After. Setiap respons membawa X-Request-Id.

Node.js (18+)

JavaScript
import crypto from 'node:crypto';

const BASE = 'https://payment.envfri.com';

export async function zepay(method, pathAndQuery, body, idempotencyKey) {
  const ts = Math.floor(Date.now() / 1000).toString();
  const raw = body === undefined ? '' : JSON.stringify(body);
  const bodyHash = crypto.createHash('sha256').update(raw).digest('hex');
  const signature = crypto.createHmac('sha256', process.env.ZEPAY_SECRET)
    .update(`${ts}\n${method}\n${pathAndQuery}\n${bodyHash}${idempotencyKey ? `\n${idempotencyKey}` : ''}`).digest('hex');
  const headers = { 'X-ZePay-Key-Id': process.env.ZEPAY_KEY_ID, 'X-ZePay-Timestamp': ts, 'X-ZePay-Signature': signature };
  if (raw) headers['Content-Type'] = 'application/json';
  if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;
  const res = await fetch(BASE + pathAndQuery, { method, headers, body: raw || undefined });
  const text = await res.text();                       // 204 (hapus endpoint webhook) tidak punya body
  const json = text ? JSON.parse(text) : null;
  if (!res.ok) throw Object.assign(new Error(json.error.message), { code: json.error.code, status: res.status, details: json.error.details });
  return json;
}

PHP (7.4+)

PHP
function zepay(string $method, string $pathAndQuery, ?array $body = null, ?string $idempotencyKey = null): ?array {
    $ts = (string) time();
    $raw = $body === null ? '' : json_encode($body, JSON_UNESCAPED_SLASHES);
    $toSign = $ts . "\n" . $method . "\n" . $pathAndQuery . "\n" . hash('sha256', $raw) . ($idempotencyKey !== null ? "\n" . $idempotencyKey : '');
    $sig = hash_hmac('sha256', $toSign, getenv('ZEPAY_SECRET'));
    $headers = ['X-ZePay-Key-Id: ' . getenv('ZEPAY_KEY_ID'), 'X-ZePay-Timestamp: ' . $ts, 'X-ZePay-Signature: ' . $sig];
    if ($raw !== '') $headers[] = 'Content-Type: application/json';
    if ($idempotencyKey !== null) $headers[] = 'Idempotency-Key: ' . $idempotencyKey;
    $ch = curl_init('https://payment.envfri.com' . $pathAndQuery);
    curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30]);
    if ($raw !== '') curl_setopt($ch, CURLOPT_POSTFIELDS, $raw);
    $res = json_decode((string) curl_exec($ch), true);   // null untuk 204 tanpa body
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    if ($status >= 400) throw new RuntimeException($res['error']['code'] . ': ' . $res['error']['message'], $status);
    return $res;
}

3. Info merchant & saldo#

JavaScript
const me = await zepay('GET', '/api/v2/merchant');
// me.fees   → { description: "Biaya transaksi mengikuti tarif kanal pembayaran; biaya pencairan mengikuti tarif transfer ke
//               bank/e-money tujuan. Angka pasti tampil saat tagihan dibuat dan saat pratinjau pencairan." }
// me.payout → { fee: 2500, fee_type: "per_transfer", fee_is_estimate: true, fee_charged_to: "balance", min: 10000,
//               daily_limit: 50000000, daily_limit_remaining: 49950000, approval_threshold: 10000000 }
// me.payment_methods[1] → { method: "VA", enabled: true, channels: [{ channel: "014", name: "BCA Virtual Account",
//               fee_fixed: 4000, fee_percent: 0, fee_paid_by: "merchant", min_amount: 10000, max_amount: 50000000,
//               expiry_minutes: 1440 }, …] }
// me.account_holder_name → "Toko Batik Sekar"  (rekening dengan nama ini langsung terverifikasi)

const bal = await zepay('GET', '/api/v2/balance');
// { currency: "IDR", pending: 496000, available: 195250, reserved: 0, withdrawable: 192750,
//   payout_fee: 2500, payout_fee_source: "estimate", payout_min: 10000,
//   daily_limit: 50000000, daily_limit_remaining: 49950000, livemode: true, as_of: "2026-10-31T10:42:00+07:00" }
Field Arti
pending Sudah dibayar pelanggan, menunggu settlement; waktunya bergantung kanal pembayaran (QRIS & e-wallet umumnya 2–4 hari, Alfamart H+3, Indomaret H+2). Belum bisa dicairkan. Pembayaran VA langsung masuk available karena VA dikreditkan secara realtime.
available Saldo tersedia.
reserved Ditahan untuk pencairan yang sedang diproses / menunggu persetujuan (nominal + biaya transfer).
withdrawable Maksimum amount sekarang = available − payout_fee, dibatasi sisa limit harian.
payout_fee Estimasi biaya transfer (payout_fee_source = estimate) ke rekening utama Anda. Biaya pasti dicek ke bank tujuan saat pratinjau & pencairan (bagian 6).

Tarif di GET /merchant (payment_methods[].channels[] dan payout.fee, dengan payout.fee_is_estimate = true) juga estimasi — gunakan untuk tampilan, bukan untuk pembukuan. Angka biaya untuk pembukuan selalu diambil dari objek transaksi/pencairan (fee, net_amount, total_debited).

Ringkasan periode (rekonsiliasi)#

JavaScript
const sum = await zepay('GET', '/api/v2/balance/summary?from=2026-10-01T00:00:00%2B07:00&to=2026-11-01T00:00:00%2B07:00');
// { opening_balance: 0, payments_net: 248250, payouts: 100000, payout_fees: 2500, adjustments: 0, closing_balance: 145750,
//   payments: { count: 1, gross: 250000, fees: 1750, net: 248250 }, payouts_count: 1, period: { from, to } }
// closing_balance = opening_balance + payments_net − payouts − payout_fees + adjustments

payments.fees = total biaya transaksi atas pembayaran sukses; payout_fees = total biaya transfer final. Maksimal 93 hari per permintaan. Cocokkan dengan pembukuan Anda setiap hari.

4. Transaksi (pembayaran)#

JavaScript
// buat tagihan — simpan Idempotency-Key per pesanan (16–64 karakter)
const pay = await zepay('POST', '/api/v2/payments', { amount: 150000, method: 'VA', channel: 'BCA',
  description: 'Pesanan #1042', metadata: { order_id: '1042' } }, 'order-1042-payment');
// { id, reference: "SKR-01JBQ7X4M2RKD8T5W9C3HZPN6E", status: "PENDING", amount: 150000,
//   fee: 4000, fee_bearer: "merchant", net_amount: 146000, method: "VA", channel: "014",
//   va_number: "8923278209078580", expires_at: "2026-11-01T10:30:00+07:00", metadata: { order_id: "1042" }, … }

// cari transaksi
await zepay('GET', '/api/v2/payments?status=SUCCESS&created_from=2026-10-31T00:00:00%2B07:00&limit=50');
await zepay('GET', '/api/v2/payments?reference=SKR-01JBQ7X4M2RKD8T5W9C3HZPN6E');

// cek status satu transaksi (cadangan bila webhook terlambat)
await zepay('GET', `/api/v2/payments/${pay.id}`);

// batalkan tagihan yang belum dibayar
await zepay('POST', `/api/v2/payments/${pay.id}/cancel`, { reason: 'Pesanan dibatalkan pembeli' }, 'cancel-' + pay.id);

Biaya transaksi:

  • fee = biaya transaksi menurut tarif kanal pembayaran. Nilainya sudah tampil saat tagihan dibuat; setelah SUCCESS, nilainya final.
  • fee_bearer = merchant → saldo Anda menerima net_amount = amount − fee. fee_bearer = customer → biaya dibayar pelanggan, net_amount = amount. Penanggung biaya diatur per kanal oleh admin ZePay (GET /merchant → fee_paid_by).
  • VA bisa disebut dengan kode bank (014) atau nama (BCA); respons selalu memakai kode bank. OVO wajib customer.phone.
  • Payment link (method: 'LINK') mengembalikan checkout_url; fee & net_amount masih null sampai pelanggan memilih metode di halaman checkout.
  • Bila kanal pembayaran menolak atau tidak merespons saat menerbitkan tagihan, tagihan langsung ditutup FAILED (422 payment_method_unavailable dengan details.reason_code, atau 503 provider_unavailable; details.payment_id & details.reference menunjuk tagihan itu) — buat tagihan baru dengan Idempotency-Key baru.
Status Arti
PENDING Menunggu pembayaran
SUCCESS Dibayar; dana masuk saldo tertunda, lalu tersedia setelah settlement (balance.settled, settled_at terisi). VA: settlement langsung bersamaan dengan SUCCESS.
EXPIRED Lewat batas waktu tanpa pembayaran (ditutup otomatis ±2 menit setelah expires_at)
FAILED Ditolak penerbit (mis. saldo e-wallet kurang) atau gagal diterbitkan kanal pembayaran
CANCELLED Dibatalkan merchant. Bila dana ternyata tetap masuk (mis. VA dibayar sebelum nomornya ditutup bank), status menjadi SUCCESS dan payment.succeeded dikirim — dana tidak hilang.

Di mode uji, tagihan tetap PENDING sampai Anda mensimulasikan hasilnya: POST /api/v2/payments/{id}/simulate dengan { "status": "SUCCESS" } (atau FAILED / EXPIRED) — lihat bagian 10.

5. Daftarkan rekening (wajib sebelum mencairkan)#

Pencairan ke rekening/e-money yang sudah terdaftar dan berstatus verified. Pengecualian: kunci berizin khusus payouts:any_destination boleh mencairkan langsung ke hasil cek rekening tanpa mendaftarkannya — lihat bagian 6, "Pencairan langsung tanpa daftar".

AlurServer merchantZePayBank / e-money tujuan
  1. Server merchant→ZePayGET /banks?type=ewallet
  2. Server merchant→ZePayPOST /bank-accounts/inquiry {bank_code, account_number}
  3. ZePay→Bank / e-money tujuancek nomor
  4. Bank / e-money tujuan→ZePaynama pemilik + biaya transfer
  5. ZePay→Server merchant{id, account_name, name_matches_merchant, payout_fee, expires_at}
  6. Server merchanttampilkan nama pemilik ke pengguna untuk dikonfirmasi
  7. Server merchant→ZePayPOST /bank-accounts {inquiry_id} + Idempotency-Key
  8. ZePay→Server merchant201 status verified | pending_review
  9. ZePay→Server merchantwebhook bank_account.verified / bank_account.rejected (bila ditinjau)
JavaScript
const banks = await zepay('GET', '/api/v2/banks?type=ewallet');
// { data: [{ code: "DANA", name: "DANA", type: "ewallet", available: true, payout_fee: 2000,
//   payout_fee_source: "estimate", payout_min: 20000, payout_max: 10000000,
//   account_number_pattern: "^08[0-9]{8,11}$", account_number_hint: "Nomor HP terdaftar di DANA, awalan 08" }, …] }

const inq = await zepay('POST', '/api/v2/bank-accounts/inquiry', { bank_code: 'DANA', account_number: '081234567788' });
// { id: "3b1d…", bank_name: "DANA", type: "ewallet", account_number_masked: "•••• 7788",
//   account_name: "TOKO BATIK SEKAR", name_matches_merchant: true, payout_fee: 2000, expires_at: "…+15 menit" }

const acc = await zepay('POST', '/api/v2/bank-accounts', { inquiry_id: inq.id, label: 'DANA operasional' }, 'reg-' + inq.id);
// { id: "6f1c…", status: "verified", is_default: true, account_number_masked: "•••• 7788", … }

Aturan:

  • Nama pemilik selalu dari bank/e-money, tidak bisa diisi sendiri.
  • name_matches_merchant = true → langsung verified. false → pending_review, ditinjau admin ZePay; hasilnya lewat webhook bank_account.verified atau bank_account.rejected (alasan di review_note).
  • payout_fee di hasil cek = perkiraan biaya transfer ke rekening itu; biaya yang dipotong dicek ulang ke bank tujuan saat pencairan.
  • Hasil cek berlaku 15 menit dan sekali pakai. Rekening yang sama tidak bisa didaftarkan dua kali (bank_account_exists). Cek rekening tidak memotong saldo.
  • Rekening terverifikasi pertama otomatis jadi rekening utama. Ganti dengan PATCH /bank-accounts/{id} { "is_default": true }.
  • DELETE /bank-accounts/{id} menonaktifkan rekening. Untuk memakainya lagi, cek & daftarkan ulang.
  • Cek rekening dibatasi 30/menit dan 300/24 jam per merchant (live & uji dihitung bersama).

6. Cairkan saldo#

Biaya: biaya transfer, dipotong dari saldo#

Biaya pencairan = biaya transfer menurut tarif transfer ke bank/e-money tujuan untuk tujuan & nominal itu (saat ini Rp2.500 per transfer ke bank dan Rp2.000 ke e-money), satu kali per pencairan, di luar nominal yang diterima rekening.

Anda minta (amount) Diterima rekening Biaya transfer ke bank (tarif saat ini) Saldo tersedia berkurang (total_debited)
Rp10.000 (minimum) Rp10.000 Rp2.500 Rp12.500
Rp100.000 Rp100.000 Rp2.500 Rp102.500
withdraw_all saat tersedia Rp195.250 Rp192.750 Rp2.500 Rp195.250 (saldo jadi 0)

Dari mana angka biaya:

  • GET /merchant (payout.fee, fee_is_estimate: true), GET /balance (payout_fee), GET /banks (payout_fee) → estimasi tarif transfer, untuk tampilan.
  • POST /payouts/quote → ZePay memastikan biaya dengan pengecekan ke bank tujuan untuk rekening & nominal itu. fee_source: "confirmed" = biaya sudah dipastikan lewat pengecekan ke bank tujuan; "estimate" = pengecekan sementara tidak tersedia, sehingga pratinjau memakai estimasi tarif dan biaya dihitung ulang saat pencairan dibuat.
  • POST /payouts → ZePay mengecek ulang biaya ke bank tujuan lalu menahan amount + fee. Bila bank tidak merespons, pencairan tidak dibuat (503 provider_unavailable) — ulangi dengan Idempotency-Key yang sama.
  • Biaya final ditetapkan setelah transfer selesai. Bila lebih kecil dari yang ditahan, selisihnya kembali ke saldo tersedia (mutasi payout_release) dan fee pada pencairan menjadi biaya final; bila lebih besar, selisihnya ditanggung ZePay. Anda tidak pernah membayar lebih dari fee pada respons POST /payouts.

Bila pencairan gagal, ditolak, atau dibatalkan, nominal dan biaya kembali ke saldo tersedia.

Alur#

JavaScript
// 1) pratinjau: tampilkan biaya transfer & total dipotong ke pengguna sebelum konfirmasi
const quote = await zepay('POST', '/api/v2/payouts/quote', { bank_account_id: acc.id, amount: 100000 });
// { amount: 100000, fee: 2000, fee_source: "confirmed", total_debited: 102000,
//   available_before: 195250, available_after: 93250, withdrawable: 193250,
//   daily_limit: 50000000, daily_limit_remaining: 49950000, requires_approval: false,
//   destination: { type: "merchant_account", account_type: "ewallet", bank_account_id: "6f1c…", bank_code: "DANA",
//                  bank_name: "DANA", account_name: "TOKO BATIK SEKAR", account_number_masked: "•••• 7788" } }

// 2) cairkan — simpan Idempotency-Key bersama pesanan Anda, pakai ulang bila mengulang request
const payout = await zepay('POST', '/api/v2/payouts', { bank_account_id: acc.id, amount: 100000, note: 'Tarik mingguan' }, 'wd-20261031-0001');
// { id, reference: "WD-01JBQ9S2ZK7MX4D8E2R5T0WQ3N", status: "PROCESSING", amount: 100000, fee: 2000,
//   total_debited: 102000, combined: false, destination: { … }, note: "Tarik mingguan", … }

// 3) tunggu webhook payout.succeeded / payout.failed (atau GET /api/v2/payouts/{id});
//    `fee` di payout.succeeded = biaya transfer final

Status pencairan:

Status Arti Saldo
PENDING_APPROVAL Nominal di atas approval_threshold (default Rp10.000.000), menunggu admin ZePay. Bisa dibatalkan: POST /payouts/{id}/cancel. nominal + biaya ditahan
PROCESSING Sedang dikirim ke bank tujuan. Rekening dicek ulang ke bank; bila tidak ditemukan atau nama pemilik berbeda dari yang terdaftar → FAILED (failure_reason mis. Rekening tujuan tidak ditemukan di BCA saat diproses, Nama pemilik rekening di bank berbeda dari yang terdaftar). ditahan
SUCCESS Diterima rekening tujuan. nominal + biaya transfer final keluar; kelebihan biaya yang ditahan kembali ke tersedia
FAILED / CANCELLED / REJECTED Gagal (mis. Transfer ditolak bank tujuan atau jaringan pembayaran (kode …), Transfer gagal diproses bank tujuan) / dibatalkan Anda / ditolak admin. nominal + biaya kembali ke tersedia

Batas: minimum = terbesar dari payout.min merchant dan payout_min bank; maksimum = payout_max bank; limit harian dihitung dari nominal (bukan biaya) sejak 00.00 WIB.

Pencairan gabungan yang dibuat admin ZePay dari beberapa merchant juga muncul di GET /payouts dengan combined: true; yang ditampilkan hanya porsi merchant Anda (porsi biaya dibagi proporsional, atau 0 bila ditanggung ZePay).

Pencairan langsung tanpa daftar (izin khusus)#

Untuk mengirim dana ke rekening atas nama siapa pun (mis. pembayaran ke vendor/mitra) tanpa mendaftarkan rekening dan tanpa menunggu tinjauan admin. Syarat:

  • Kunci API diberi scope khusus payouts:any_destination oleh tim ZePay (tidak pernah otomatis), dan kunci itu wajib dibatasi IP publik server Anda yang spesifik (IPv4 /24–/32, IPv6 /48–/128; IP privat/lokal atau rentang lebar ditolak). Kunci yang bocor tidak bisa dipakai dari IP lain. Cek dengan GET /merchant → payout.any_destination: true.
  • Cek rekening wajib sebelum setiap pencairan. Tampilkan account_name dari hasil cek ke pengguna untuk dikonfirmasi — nama pemilik selalu dari bank tujuan.
  • Satu hasil cek = satu pencairan: berlaku 15 menit dan sekali pakai (422 inquiry_used / inquiry_expired → cek ulang).
  • Biaya, minimum/maksimum, limit harian, dan cek ulang rekening saat diproses tetap berlaku sama seperti pencairan biasa. Ambang persetujuan dihitung kumulatif per hari untuk pencairan langsung: bila total pencairan langsung hari ini (termasuk yang baru) melewati payout.approval_threshold, pencairan menunggu persetujuan admin ZePay (PENDING_APPROVAL; requires_approval: true di pratinjau).
  • Bila respons POST /payouts tidak sampai (timeout), ulangi dengan Idempotency-Key yang sama — Anda menerima pencairan yang sama, bukan pencairan baru. Jangan cek ulang rekening lalu mencairkan lagi sebelum memastikan lewat GET /payouts.
JavaScript
// 1) cek rekening tujuan (wajib) — tampilkan nama pemilik ke pengguna
const inq = await zepay('POST', '/api/v2/bank-accounts/inquiry', { bank_code: '014', account_number: '8730121111' });
// { id: "3b8e…", bank_name: "BCA", account_name: "BUDI SANTOSO", name_matches_merchant: false, payout_fee: 2500, expires_at: "…+15 menit" }

// 2) pratinjau & cairkan dengan inquiry_id (bukan bank_account_id)
const quote = await zepay('POST', '/api/v2/payouts/quote', { inquiry_id: inq.id, amount: 100000 });
const payout = await zepay('POST', '/api/v2/payouts', { inquiry_id: inq.id, amount: 100000, note: 'Bayar vendor' }, 'wd-vendor-0001');
// { status: "PROCESSING", amount: 100000, fee: 2500, …,
//   destination: { type: "inquiry_account", bank_account_id: null, inquiry_id: "3b8e…", bank_name: "BCA",
//                  account_name: "BUDI SANTOSO", account_number_masked: "•••• 1111" } }

Rekening tujuan tidak tersimpan sebagai rekening terdaftar (tidak muncul di GET /bank-accounts). Untuk tujuan yang sering dipakai, tetap lebih praktis didaftarkan (bagian 5). Tanpa izin khusus, inquiry_id pada pencairan ditolak 403 permission_denied.

7. Mutasi saldo#

GET /balance/transactions — terbaru dulu; setiap baris punya category:

category Arti net_change
payment Pembayaran pelanggan masuk saldo tertunda (net_amount) +
settlement Tertunda → tersedia 0
payout_hold Tersedia → ditahan saat pencairan dibuat (nominal + biaya) 0
payout Nominal keluar ke rekening −
payout_fee Biaya transfer (final) −
payout_release Ditahan → tersedia: pencairan gagal/batal/ditolak, atau kelebihan biaya transfer dikembalikan 0
opening_balance Saldo awal hasil migrasi dari ZePay v1 +
adjustment Koreksi oleh admin (dengan catatan) ±

Contoh setelah pencairan Rp100.000 ke rekening bank (biaya transfer Rp2.500), terbaru dulu: payout_fee −2.500 · payout −100.000 · payout_hold 0 · settlement 0 · payment +248.250.

8. Webhook#

Atur endpoint lewat API#

JavaScript
// tambah endpoint (maks. 3 per mode); secret hanya muncul sekali di respons ini
const hook = await zepay('POST', '/api/v2/webhook-endpoints',
  { url: 'https://tokobatik.id/hooks/zepay', events: ['payment.*', 'payout.*', 'balance.*', 'bank_account.*'] }, 'webhook-setup-tokobatik-1');
// hook.secret → "whsec_…" (simpan sebagai ZEPAY_WEBHOOK_SECRET)

await zepay('POST', `/api/v2/webhook-endpoints/${hook.id}/test`);                     // kirim event webhook.test
await zepay('POST', `/api/v2/webhook-endpoints/${hook.id}/rotate-secret`,            // secret lama tetap ikut
  { previous_secret_valid_hours: 24 }, 'rotate-' + Date.now());                          // ditandatangani 24 jam

// kejar webhook yang terlewat (mis. server Anda sempat mati)
const missed = await zepay('GET', '/api/v2/events?delivery_status=failed&limit=100');
for (const ev of missed.data) await zepay('POST', `/api/v2/events/${ev.id}/resend`);

Tanpa events, endpoint berlangganan payment.*, payout.*, balance.*, dan bank_account.*. URL wajib https:// dan mengarah ke alamat publik.

Pengiriman & verifikasi tanda tangan#

Setiap event dikirim sebagai POST JSON { id, type, created, livemode, data } dengan header ZePay-Event-Id (= id), ZePay-Event-Type, ZePay-Signature, dan User-Agent: ZePay-Webhook/2.0.

Header ZePay-Signature: t=<unix>,v1=<hex>; v1 = HMAC_SHA256(webhook_secret, t + "." + body_mentah). Hitung dari body mentah (byte persis yang diterima), bukan dari JSON yang di-parse lalu di-stringify ulang. Selama masa transisi rotasi ada dua v1 (secret baru & lama) — terima bila salah satu cocok. Tolak bila selisih waktu > 300 detik (t dibuat ulang di setiap percobaan kirim). Simpan id event agar idempoten; balas 2xx dalam 10 detik — redirect tidak diikuti. Gagal → dicoba ulang 1 mnt, 5 mnt, 15 mnt, 30 mnt, 1 jam, 2 jam, 4 jam, 8 jam, dan 8 jam kemudian (10 percobaan, ±24 jam), lalu berhenti (dead); event 30 hari terakhir ada di GET /events dan bisa dikirim ulang.

JavaScript
app.post('/hooks/zepay', express.raw({ type: 'application/json' }), (req, res) => {
  const pairs = req.get('ZePay-Signature').split(',').map(p => p.split('='));
  const t = pairs.find(([k]) => k === 't')?.[1];
  const sigs = pairs.filter(([k]) => k === 'v1').map(([, v]) => v);   // 2 nilai saat rotasi secret
  if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.sendStatus(400);
  const expected = crypto.createHmac('sha256', process.env.ZEPAY_WEBHOOK_SECRET).update(`${t}.${req.body}`).digest('hex');
  const ok = sigs.some(v => v.length === expected.length && crypto.timingSafeEqual(Buffer.from(v), Buffer.from(expected)));
  if (!ok) return res.sendStatus(401);
  const event = JSON.parse(req.body);
  // simpan event.id; proses event.type
  res.sendStatus(200);
});
Event Kapan data
payment.succeeded Pembayaran berhasil, dana masuk saldo tertunda payment_id, reference, amount, fee (biaya transaksi final), fee_bearer, net_amount, method, channel, paid_at, metadata
payment.expired / payment.failed / payment.cancelled Tagihan kedaluwarsa / ditolak penerbit atau gagal diterbitkan / dibatalkan payment_id, reference, amount, status, reason (mis. Tagihan kedaluwarsa, Ditolak penerbit, Gagal diterbitkan kanal pembayaran (kode …)), metadata
balance.settled Dana pindah ke saldo tersedia payment_id, reference, amount (= net_amount)
payout.created Pencairan dibuat; nominal + biaya transfer ditahan payout_id, reference, amount, fee (ditahan), total_debited, status, combined
payout.succeeded Diterima rekening tujuan payout_id, reference, amount, fee (final), total_debited
payout.failed Gagal; nominal + biaya kembali payout_id, reference, reason
payout.cancelled Dibatalkan / ditolak; nominal + biaya kembali payout_id, reference, reason
bank_account.created Rekening terdaftar (verified / pending_review) objek rekening
bank_account.verified Rekening bisa menerima pencairan objek rekening
bank_account.rejected Rekening ditolak (alasan di review_note) objek rekening
webhook.test Event uji dari POST /webhook-endpoints/{id}/test endpoint_id, message

Waktu di dalam data (mis. paid_at, verified_at) ditulis server dalam ISO 8601 dengan offset lain (mis. +00:00); selalu parse sebagai ISO 8601 lengkap. Field waktu lain di API memakai +07:00.

9. Kode error#

Format: { "error": { "code", "message", "request_id", "details" } }. Sertakan request_id (juga di header X-Request-Id) saat menghubungi tim ZePay. Contoh saldo kurang saat mencairkan:

JSON
{
  "error": {
    "code": "insufficient_balance",
    "message": "Saldo tersedia tidak cukup: perlu 102500 (nominal 100000 + biaya transfer 2500), tersedia 80000.",
    "request_id": "req_01jbqa9t2v5y8b1d",
    "details": { "amount": 100000, "fee": 2500, "total_debited": 102500, "available": 80000 }
  }
}
Kode HTTP Arti Yang perlu dilakukan
invalid_request 400 Body/parameter salah, mis. format nomor tidak cocok account_number_pattern, field tidak dikenal, amount dan withdraw_all (atau bank_account_id dan inquiry_id) diisi bersamaan, atau Idempotency-Key tidak ada; rincian di details.errors Perbaiki request
authentication_failed, signature_invalid, timestamp_out_of_range 401 Kunci/tanda tangan/waktu salah (details.server_time = jam server) Periksa secret & jam server (NTP)
permission_denied 403 Kunci tidak punya scope untuk operasi ini (termasuk pencairan dengan inquiry_id tanpa izin payouts:any_destination), IP tidak ada di daftar kunci, atau simulasi pembayaran dengan kunci live Minta scope/IP ke tim ZePay; simulasi hanya dengan zpk_test_…
not_found 404 Objek tidak ada / bukan milik Anda / beda mode —
idempotency_conflict 409 Idempotency-Key sama dengan request berbeda, atau request pertama masih diproses Pakai kunci baru untuk operasi baru; tunggu lalu ulangi
rate_limited 429 Terlalu banyak request Tunggu Retry-After
merchant_inactive 422 Merchant ditangguhkan/nonaktif Hubungi tim ZePay
payment_method_unavailable 422 Kanal tidak ada/nonaktif, tidak ada metode aktif yang menerima nominal itu (payment link), atau kanal pembayaran menolak membuat tagihan (details: payment_id, reference, reason_code) Pakai kanal lain (lihat GET /merchant)
bank_unavailable 422 Bank/e-money sedang gangguan Coba lagi nanti atau pakai rekening lain
provider_unavailable 503 Bank atau kanal pembayaran sedang tidak merespons Ulangi dengan Idempotency-Key yang sama (tagihan yang gagal diterbitkan sudah ditutup FAILED — buat baru)
account_not_found 422 Nomor tidak ditemukan di bank/e-money (saat cek rekening atau saat cek biaya pencairan) Periksa nomor & bank; daftarkan ulang
inquiry_not_found 404 inquiry_id tidak ada / milik merchant lain / beda mode Cek rekening ulang
inquiry_expired, inquiry_used 422 Hasil cek > 15 menit / sudah dipakai Cek rekening ulang
bank_account_exists 409 Rekening sudah terdaftar Pakai rekening yang ada
bank_account_rejected 422 Rekening pernah ditolak admin Hubungi tim ZePay
bank_account_not_found 404 Rekening tidak terdaftar untuk merchant ini Daftarkan dulu (bagian 5)
bank_account_not_verified 422 Rekening masih pending_review / nonaktif Tunggu bank_account.verified atau pakai rekening lain
amount_below_minimum, amount_above_maximum 422 Di luar batas kanal (tagihan, details.min_amount/max_amount) atau batas pencairan (min. Rp10.000; maks. per bank) Sesuaikan nominal
payout_limit_exceeded 422 Melebihi sisa limit harian Cairkan sisa limit atau tunggu besok (00.00 WIB)
insufficient_balance 422 amount + biaya transfer > available; details berisi amount, fee, total_debited, available (tanpa amount/total_debited bila withdraw_all) Kurangi nominal atau pakai withdraw_all
payout_not_cancellable 409 Bukan PENDING_APPROVAL, atau pencairan gabungan dari konsol admin —
payment_not_cancellable 409 Tagihan sudah bukan PENDING Cek status terbaru
webhook_endpoint_limit 409 Sudah ada 3 endpoint di mode ini Hapus/ubah endpoint lama
no_webhook_endpoint 409 Tidak ada endpoint aktif yang berlangganan event itu Tambah/aktifkan endpoint
internal_error 500 Kesalahan di ZePay Ulangi; bila berlanjut kirim request_id ke tim ZePay

10. Mode uji (kunci zpk_test_…)#

Kunci uji tidak pernah menghubungi bank atau kanal pembayaran sungguhan; semua jawabannya disimulasikan.

Skenario Cara
Cek rekening: nama cocok → langsung verified nomor apa pun selain akhiran di bawah (nama pemilik = account_holder_name merchant)
Cek rekening: nama berbeda (BUDI SANTOSO) → pending_review nomor berakhiran 1111
Cek rekening: tidak ditemukan (422) nomor berakhiran 0000
Cek rekening: bank gangguan (503) nomor berakhiran 9999
Pembayaran sukses POST /payments/{id}/simulate { "status": "SUCCESS" } → payment.succeeded; settlement ±1 menit kemudian (balance.settled)
Pembayaran gagal / kedaluwarsa POST /payments/{id}/simulate { "status": "FAILED" } / { "status": "EXPIRED" }
Dana masuk setelah dibatalkan batalkan tagihan, lalu simulasikan SUCCESS
Pencairan gagal (dana kembali) amount berakhiran 13, mis. 10013
Pencairan sukses nominal lain (±10 detik setelah dikirim)

Catatan mode uji:

  • POST /payments/{id}/simulate hanya menerima kunci uji (kunci live → 403 permission_denied) dan tidak memakai Idempotency-Key. Payment link harus dipilih metodenya di halaman checkout dulu.
  • Biaya simulasi memakai estimasi: fee tagihan = fee_fixed + ceil(amount × fee_percent / 100) dari kanal di GET /merchant; biaya pencairan = payout_fee bank di GET /banks (biaya final = biaya yang ditahan).
  • QRIS, nomor VA, tautan e-wallet, dan kode bayar retail di mode uji hanya contoh dan tidak bisa dibayar sungguhan.

11. Checklist go-live#

  • Tanda tangan HMAC lolos di mode uji; jam server tersinkron (NTP).
  • Idempotency-Key (16–64 karakter) disimpan per operasi dan dipakai ulang saat retry.
  • Biaya transfer (fee, fee_source) & total dipotong dari POST /payouts/quote ditampilkan ke pengguna sebelum konfirmasi; biaya final dibaca dari payout.succeeded.
  • Pembukuan memakai fee / net_amount / total_debited dari objek transaksi & pencairan ZePay, bukan tarif tetap.
  • Rekening live sudah didaftarkan lewat cek rekening dan berstatus verified.
  • Endpoint webhook dibuat lewat API, event uji diterima, ZePay-Signature diverifikasi (termasuk dua v1 saat rotasi), id event disimpan.
  • Job harian: GET /events?delivery_status=failed → kirim ulang, dan rekonsiliasi dengan GET /balance/summary.
  • Penanganan semua kode error di bagian 9, terutama insufficient_balance, bank_account_not_verified, payout_limit_exceeded, provider_unavailable.
  • Secret disimpan di server saja; rotasi kunci sudah dicoba.