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.
https://payment.envfri.com/api/v2Setiap 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+)
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+)
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#
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)#
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 + adjustmentspayments.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)#
// 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; setelahSUCCESS, nilainya final.fee_bearer = merchant→ saldo Anda menerimanet_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 wajibcustomer.phone. - Payment link (
method: 'LINK') mengembalikancheckout_url;fee&net_amountmasihnullsampai pelanggan memilih metode di halaman checkout. - Bila kanal pembayaran menolak atau tidak merespons saat menerbitkan tagihan, tagihan langsung ditutup
FAILED(422 payment_method_unavailabledengandetails.reason_code, atau503 provider_unavailable;details.payment_id&details.referencemenunjuk tagihan itu) — buat tagihan baru denganIdempotency-Keybaru.
| 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".
- Server merchant→ZePay
GET /banks?type=ewallet - Server merchant→ZePay
POST /bank-accounts/inquiry{bank_code, account_number} - ZePay→Bank / e-money tujuancek nomor
- Bank / e-money tujuan→ZePaynama pemilik + biaya transfer
- ZePay→Server merchant
{id, account_name, name_matches_merchant, payout_fee, expires_at} - Server merchanttampilkan nama pemilik ke pengguna untuk dikonfirmasi
- Server merchant→ZePay
POST /bank-accounts{inquiry_id}+ Idempotency-Key - ZePay→Server merchant201 status verified | pending_review
- ZePay→Server merchantwebhook bank_account.verified / bank_account.rejected (bila ditinjau)
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→ langsungverified.false→pending_review, ditinjau admin ZePay; hasilnya lewat webhookbank_account.verifiedataubank_account.rejected(alasan direview_note).payout_feedi 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 menahanamount + fee. Bila bank tidak merespons, pencairan tidak dibuat (503 provider_unavailable) — ulangi denganIdempotency-Keyyang sama.- Biaya final ditetapkan setelah transfer selesai. Bila lebih kecil dari yang ditahan, selisihnya kembali ke saldo tersedia (mutasi
payout_release) danfeepada pencairan menjadi biaya final; bila lebih besar, selisihnya ditanggung ZePay. Anda tidak pernah membayar lebih darifeepada responsPOST /payouts.
Bila pencairan gagal, ditolak, atau dibatalkan, nominal dan biaya kembali ke saldo tersedia.
Alur#
// 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 finalStatus 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_destinationoleh 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 denganGET /merchant→payout.any_destination: true. - Cek rekening wajib sebelum setiap pencairan. Tampilkan
account_namedari 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: truedi pratinjau). - Bila respons
POST /payoutstidak sampai (timeout), ulangi denganIdempotency-Keyyang sama — Anda menerima pencairan yang sama, bukan pencairan baru. Jangan cek ulang rekening lalu mencairkan lagi sebelum memastikan lewatGET /payouts.
// 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#
// 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.
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:
{
"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}/simulatehanya menerima kunci uji (kunci live →403 permission_denied) dan tidak memakaiIdempotency-Key. Payment link harus dipilih metodenya di halaman checkout dulu.- Biaya simulasi memakai estimasi:
feetagihan =fee_fixed + ceil(amount × fee_percent / 100)dari kanal diGET /merchant; biaya pencairan =payout_feebank diGET /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 dariPOST /payouts/quoteditampilkan ke pengguna sebelum konfirmasi; biaya final dibaca daripayout.succeeded. - Pembukuan memakai
fee/net_amount/total_debiteddari 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-Signaturediverifikasi (termasuk duav1saat rotasi),idevent disimpan. - Job harian:
GET /events?delivery_status=failed→ kirim ulang, dan rekonsiliasi denganGET /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.