API Distributor
Antarmuka host-to-host untuk melakukan pembelian produk digital secara otomatis dari sistem Anda sendiri. Ditujukan untuk mitra distributor yang sudah memiliki saldo di Pulsakonter.
Base URL
Produksi https://api.pulsakonter.com/v1 Sandbox — belum tersedia, nama sudah dicadangkan https://sandbox-api.pulsakonter.com/v1
Seluruh endpoint memakai metode POST dan bertukar data JSON — termasuk endpoint yang sifatnya hanya membaca, seperti cek saldo. Ini disengaja: tanda tangan permintaan menyertakan ringkasan isi body, sehingga aturannya jadi seragam dan tidak ada kasus khusus yang bisa salah diterapkan.
Prasyarat
Empat hal berikut harus terpenuhi sebelum permintaan pertama Anda diterima.
| Syarat | Keterangan |
|---|---|
| Role Distributor | Akun Anda harus berrole Distributor. Harga yang berlaku di API adalah harga tier Distributor, bukan harga etalase. |
| Saldo | Order hanya bisa dibayar dengan saldo. Tidak ada metode pembayaran lain di API — isi saldo lebih dulu melalui halaman member. |
| PIN | Akun harus sudah memiliki PIN. PIN dikirim pada setiap permintaan order. |
| Whitelist IP | Permintaan hanya diterima dari alamat IP yang Anda daftarkan di halaman API. IP di luar daftar ditolak sebelum tanda tangan sempat diperiksa. |
Kredensial, whitelist IP, dan URL callback diatur sendiri lewat menu API di area member Anda. Mengubah salah satunya meminta PIN.
Kredensial
Anda memegang dua nilai dengan peran yang berbeda. Membedakan keduanya penting: satu boleh terlihat orang lain, satu lagi tidak boleh sama sekali.
Menyatakan siapa yang memanggil. Dikirim apa adanya di header X-Client-Id pada setiap permintaan, jadi nilainya memang terlihat — perannya seperti username, bukan kata sandi. Aman bila tercatat di log Anda sendiri.
Dipakai hanya untuk menghitung X-Signature. Nilainya tidak pernah ikut dalam permintaan — yang terkirim hanyalah hasil perhitungannya. Perannya seperti kata sandi: siapa pun yang memegangnya bisa berpura-pura menjadi Anda.
Inilah alasan tanda tangan lebih aman daripada sekadar mengirim kunci rahasia di header: kalaupun sebuah permintaan berhasil disadap, penyadap hanya mendapat API Key dan satu tanda tangan yang hanya berlaku untuk permintaan itu saja — bukan kunci yang bisa dipakai membuat permintaan baru.
Mendapatkan dan mengganti kredensial
Keduanya diterbitkan otomatis saat akses API akun Anda diaktifkan, dan bisa dilihat di menu API pada area member. Secret Key hanya ditampilkan penuh sesaat setelah dibuat; setelah itu tersimpan terenkripsi dan tidak dapat ditampilkan lagi — bila terlupa, generate ulang.
Generate ulang membuat Secret Key lama langsung tidak berlaku, sehingga integrasi yang masih memakainya akan gagal dengan rc 11. Siapkan penggantian di sisi Anda sebelum menekan tombolnya.
Autentikasi
Setiap permintaan membawa tiga header. Tidak ada token yang perlu disimpan atau diperbarui — tanda tangan dihitung ulang per permintaan.
| Header | Isi |
|---|---|
| X-Client-Id | API Key Anda — lihat Kredensial. |
| X-Request-Time | Waktu permintaan dalam format ISO 8601, contoh 2026-08-05T09:14:22+07:00 atau 2026-08-05T02:14:22Z. |
| X-Signature | HMAC-SHA256 dalam huruf kecil heksadesimal, dihitung dengan Secret Key. Cara membentuknya di bawah. |
Membentuk tanda tangan
- Susun body JSON, lalu buang seluruh spasi dan baris baru sehingga menjadi satu baris rapat.
- Hitung
MD5dari string body tersebut. - Rangkai empat bagian dipisah titik dua: metode, path tanpa garis miring depan, hasil MD5, dan
X-Request-Time. - Hitung
HMAC-SHA256atas rangkaian itu menggunakan Secret Key Anda sebagai kunci.
// 1 — body dirapatkan {"service_code":"ML10","reference_number":"INV-0001",...} // 2 — md5 dari body di atas ca06c494d590574127263feeed50bae5 // 3 — string yang ditandatangani POST:v1/order:ca06c494d590574127263feeed50bae5:2026-08-05T09:14:22+07:00 // 4 — hasil akhir, masuk ke header X-Signature hmac_sha256(secret, string_di_atas) // hex, huruf kecil
X-Request-Time ditolak bila selisihnya lebih dari 5 menit dari jam server kami. Pastikan jam server Anda tersinkron NTP — jam yang meleset adalah penyebab kegagalan integrasi yang paling sering terjadi dan paling membingungkan, karena tanda tangannya sendiri sudah benar.
Secret Key hanya dipakai untuk menghitung tanda tangan dan tidak pernah dikirim dalam permintaan. Jangan menaruhnya di kode sisi klien, repositori publik, atau log. Bila bocor, generate ulang dari halaman API — tanda tangan lama langsung tidak berlaku.
Nilai X-Request-Time di header harus sama persis dengan yang Anda masukkan ke rangkaian tanda tangan. Hitung sekali, simpan di variabel, lalu pakai di kedua tempat. Membangkitkannya dua kali secara terpisah adalah penyebab rc 11 yang paling sulit dilacak, karena selisih satu detik saja sudah membuat tanda tangan berbeda.
Contoh kode
Fungsi penanda tangan beserta contoh pemanggilan endpoint order. Semua contoh menghasilkan tanda tangan yang identik untuk masukan yang sama.
// Node.js 18+ — tanpa dependensi eksternal const crypto = require('crypto'); const BASE_URL = 'https://api.pulsakonter.com'; const API_KEY = process.env.PK_API_KEY; // pk_live_… const SECRET_KEY = process.env.PK_SECRET_KEY; // sk_live_… jangan di-hardcode function buatSignature(pathname, payload, requestTime) { const md5 = crypto.createHash('md5').update(payload).digest('hex'); const raw = `POST:${pathname}:${md5}:${requestTime}`; return crypto.createHmac('sha256', SECRET_KEY).update(raw).digest('hex'); } async function panggil(pathname, body) { // JSON.stringify sudah menghasilkan string rapat tanpa spasi/baris baru. // Payload yang ditandatangani HARUS string yang sama dengan yang dikirim. const payload = JSON.stringify(body); const requestTime = new Date().toISOString(); const res = await fetch(`${BASE_URL}/${pathname}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Client-Id': API_KEY, 'X-Request-Time': requestTime, 'X-Signature': buatSignature(pathname, payload, requestTime), }, body: payload, }); return res.json(); } // Contoh: buat order panggil('v1/order', { service_code: 'ML10', reference_number: 'INV-0001', inputs: { user_id: '12345678', zone_id: '2201' }, price: 3000, qty: 1, pin: process.env.PK_PIN, }).then(console.log);
// TypeScript — Node.js 18+ import { createHash, createHmac } from 'node:crypto'; const BASE_URL = 'https://api.pulsakonter.com'; const API_KEY = process.env.PK_API_KEY!; const SECRET_KEY = process.env.PK_SECRET_KEY!; function buatSignature(pathname: string, payload: string, requestTime: string): string { const md5 = createHash('md5').update(payload).digest('hex'); const raw = `POST:${pathname}:${md5}:${requestTime}`; return createHmac('sha256', SECRET_KEY).update(raw).digest('hex'); } interface ApiResponse<T> { rc: string; message: string; data?: T; } async function panggil<T>(pathname: string, body: unknown): Promise<ApiResponse<T>> { const payload = JSON.stringify(body); const requestTime = new Date().toISOString(); const res = await fetch(`${BASE_URL}/${pathname}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Client-Id': API_KEY, 'X-Request-Time': requestTime, 'X-Signature': buatSignature(pathname, payload, requestTime), }, body: payload, }); return res.json() as Promise<ApiResponse<T>>; } interface Order { order_id: string; reference_number: string; status: 'PROCESSING' | 'SUCCESS' | 'FAILED' | 'REFUNDED'; sn: string; total: number; } const hasil = await panggil<Order>('v1/order', { service_code: 'ML10', reference_number: 'INV-0001', inputs: { user_id: '12345678', zone_id: '2201' }, price: 3000, qty: 1, pin: process.env.PK_PIN!, });
<?php const BASE_URL = 'https://api.pulsakonter.com'; function buatSignature(string $pathname, string $payload, string $requestTime, string $secretKey): string { $md5 = md5($payload); $raw = "POST:{$pathname}:{$md5}:{$requestTime}"; return hash_hmac('sha256', $raw, $secretKey); } function panggil(string $pathname, array $body): array { $apiKey = getenv('PK_API_KEY'); $secretKey = getenv('PK_SECRET_KEY'); // JSON_UNESCAPED_SLASHES agar payload tidak berubah bentuk; // string inilah yang ditandatangani DAN yang dikirim. $payload = json_encode($body, JSON_UNESCAPED_SLASHES); $requestTime = date('c'); // contoh: 2026-08-05T09:14:22+07:00 $ch = curl_init(BASE_URL . '/' . $pathname); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'X-Client-Id: ' . $apiKey, 'X-Request-Time: ' . $requestTime, 'X-Signature: ' . buatSignature($pathname, $payload, $requestTime, $secretKey), ], ]); $res = curl_exec($ch); curl_close($ch); return json_decode($res, true); } // Contoh: buat order $hasil = panggil('v1/order', [ 'service_code' => 'ML10', 'reference_number' => 'INV-0001', 'inputs' => ['user_id' => '12345678', 'zone_id' => '2201'], 'price' => 3000, 'qty' => 1, 'pin' => getenv('PK_PIN'), ]); print_r($hasil);
package main import ( "bytes" "crypto/hmac" "crypto/md5" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "os" "time" ) const baseURL = "https://api.pulsakonter.com" func buatSignature(pathname, payload, requestTime, secretKey string) string { sum := md5.Sum([]byte(payload)) raw := fmt.Sprintf("POST:%s:%s:%s", pathname, hex.EncodeToString(sum[:]), requestTime) mac := hmac.New(sha256.New, []byte(secretKey)) mac.Write([]byte(raw)) return hex.EncodeToString(mac.Sum(nil)) } func panggil(pathname string, body any) (map[string]any, error) { // json.Marshal menghasilkan string rapat; string inilah yang // ditandatangani DAN yang dikirim sebagai body. payload, err := json.Marshal(body) if err != nil { return nil, err } requestTime := time.Now().Format(time.RFC3339) // 2026-08-05T09:14:22+07:00 req, err := http.NewRequest("POST", baseURL+"/"+pathname, bytes.NewReader(payload)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Client-Id", os.Getenv("PK_API_KEY")) req.Header.Set("X-Request-Time", requestTime) req.Header.Set("X-Signature", buatSignature(pathname, string(payload), requestTime, os.Getenv("PK_SECRET_KEY"))) res, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer res.Body.Close() raw, _ := io.ReadAll(res.Body) var out map[string]any return out, json.Unmarshal(raw, &out) } func main() { hasil, err := panggil("v1/order", map[string]any{ "service_code": "ML10", "reference_number": "INV-0001", "inputs": map[string]string{"user_id": "12345678", "zone_id": "2201"}, "price": 3000, "qty": 1, "pin": os.Getenv("PK_PIN"), }) if err != nil { panic(err) } fmt.Println(hasil) }
Tandatangani string body yang sama persis dengan yang Anda kirimkan — jangan menyusun ulang JSON untuk keperluan tanda tangan. Urutan kunci, spasi, dan cara meng-escape karakter bisa berbeda antar pemanggilan, dan perbedaan satu karakter saja membuat MD5 — dan seluruh tanda tangannya — tidak cocok. Pada semua contoh di atas, satu variabel payload dipakai untuk keduanya.
Memverifikasi tanda tangan callback
Perhitungannya sama, hanya pathname-nya diganti path endpoint callback Anda sendiri.
// Node.js — verifikasi callback masuk const diterima = req.headers['x-signature']; const harusnya = buatSignature('webhook/pulsakonter', rawBody, req.headers['x-request-time']); // Bandingkan dengan timingSafeEqual, bukan === , agar waktu perbandingan // tidak membocorkan informasi tentang tanda tangan yang benar. const cocok = diterima?.length === harusnya.length && crypto.timingSafeEqual(Buffer.from(diterima), Buffer.from(harusnya)); if (!cocok) return res.status(401).end();
rawBody harus berupa body mentah yang diterima, bukan hasil parse lalu stringify ulang. Di Express, gunakan express.raw() atau simpan body mentahnya lewat opsi verify sebelum JSON di-parse.
Kode respons
Setiap respons membawa rc dan message. HTTP status selalu 200 selama permintaan berhasil diterima dan diautentikasi — hasil bisnisnya dibaca dari rc, bukan dari HTTP status.
| rc | Arti | Tindakan yang disarankan |
|---|---|---|
| 00 | Sukses | — |
| 01 | Transaksi sedang diproses | Tunggu callback. Jangan kirim ulang order. |
| 02 | Transaksi gagal | Saldo sedang dikembalikan otomatis. |
| 03 | Saldo sudah dikembalikan | Status akhir. Aman untuk order ulang. |
| 10 | Client ID tidak dikenal | Periksa header X-Client-Id. |
| 11 | Tanda tangan tidak valid | Periksa perapatan body dan urutan rangkaian. |
| 12 | Waktu permintaan kedaluwarsa | Sinkronkan jam server Anda. |
| 13 | IP tidak diizinkan | Tambahkan IP di halaman API. |
| 14 | PIN salah | Hentikan pengiriman ulang. Lihat catatan di bawah. |
| 15 | Akses API nonaktif | Hubungi kami. |
| 20 | Produk tidak ditemukan | Perbarui katalog Anda. |
| 21 | Produk sedang nonaktif | Coba lagi nanti atau pakai produk lain. |
| 22 | Harga tidak cocok | Ambil ulang harga dari daftar produk. |
| 30 | Saldo tidak cukup | Isi saldo. |
| 31 | Data akun tidak lengkap | Lengkapi inputs sesuai inputs_schema. |
| 40 | Nomor referensi sudah dipakai untuk order berbeda | Gunakan nomor referensi baru. |
| 99 | Kesalahan sistem | Coba lagi beberapa saat kemudian. |
PIN yang salah dikirim berkali-kali akan mengunci akses API Anda selama 15 menit. Penguncian ini khusus API dan tidak memengaruhi PIN Anda di area member. Bila mendapat rc 14, hentikan pengiriman dan perbaiki konfigurasi — mengulang otomatis hanya memperpanjang penguncian.
Saldo
Mengembalikan sisa saldo akun Anda dalam Rupiah.
Permintaan
{}
Body kosong, tetapi tetap harus dikirim sebagai {} dan ikut ditandatangani.
Respons
{
"rc": "00",
"message": "Sukses",
"data": {
"balance": 1250000,
"currency": "IDR"
}
}
Daftar produk
Mengembalikan katalog beserta harga tier Distributor Anda dan bentuk data akun yang dibutuhkan tiap produk.
Permintaan
| Field | Tipe | Keterangan |
|---|---|---|
| updated_sinceopsional | string | ISO 8601. Bila diisi, hanya produk yang berubah sejak waktu itu yang dikembalikan. Gunakan untuk sinkronisasi berkala tanpa menarik seluruh katalog. |
| category_codeopsional | string | Batasi ke satu kategori. |
Respons
{
"rc": "00",
"message": "Sukses",
"data": [
{
"service_code": "ML10",
"service_name": "10 Diamonds",
"category_code": "MLBB",
"category_name": "Mobile Legends",
"price": 3000,
"status": "active",
"updated_at": "2026-08-05T09:00:00+07:00",
"inputs_schema": [
{ "key": "user_id", "label": "User ID", "type": "number", "required": true },
{ "key": "zone_id", "label": "Zone ID", "type": "number", "required": true }
]
}
]
}
inputs_schema adalah kontrak untuk field inputs saat membuat order. Produk pulsa umumnya hanya punya satu entri; game biasanya dua. Simpan skema ini bersama katalog Anda agar validasi bisa dilakukan di sisi Anda sebelum order dikirim.
Buat order
Permintaan
| Field | Tipe | Keterangan |
|---|---|---|
| service_codewajib | string | Kode produk dari daftar produk. |
| reference_numberwajib | string | Nomor referensi milik Anda, unik per akun. Maks. 64 karakter. |
| inputswajib | object | Data akun tujuan, berkunci sesuai inputs_schema produk. |
| pricewajib | number | Harga satuan yang Anda yakini berlaku. Bila tidak cocok, order ditolak dengan rc 22. |
| pinwajib | string | PIN 6 digit akun Anda. |
| qtyopsional | number | Jumlah pembelian. Default 1. |
{
"service_code": "ML10",
"reference_number": "INV-0001",
"inputs": {
"user_id": "12345678",
"zone_id": "2201"
},
"price": 3000,
"qty": 1,
"pin": "123456"
}
Respons
{
"rc": "01",
"message": "Transaksi sedang diproses",
"data": {
"order_id": "TRX-20260805-A1B2C",
"reference_number": "INV-0001",
"service_code": "ML10",
"service_name": "10 Diamonds",
"status": "PROCESSING",
"price": 3000,
"qty": 1,
"total": 3000,
"sn": "",
"balance_after": 1247000,
"created_at": "2026-08-05T09:14:22+07:00"
}
}
Mengirim ulang reference_number yang sama tidak membuat order kedua — Anda menerima kembali status order yang sudah ada. Ini berlaku juga ketika koneksi Anda terputus sebelum respons diterima: cukup kirim ulang permintaan yang sama persis, saldo tidak akan terpotong dua kali.
Bila reference_number yang sama dikirim dengan isi berbeda, permintaan ditolak dengan rc 40.
Saldo dipotong saat order diterima. Bila order berakhir gagal, saldo dikembalikan otomatis dan status menjadi REFUNDED — Anda tidak perlu mengajukan apa pun.
Cek status
Gunakan sebagai jaring pengaman bila callback tidak diterima. Bukan pengganti callback — jangan melakukan polling ketat.
Permintaan
Isi salah satu dari dua field berikut.
| Field | Tipe | Keterangan |
|---|---|---|
| reference_number | string | Nomor referensi Anda. |
| order_id | string | Nomor order dari sistem kami. |
{ "reference_number": "INV-0001" }
Respons
Bentuknya sama persis dengan respons buat order, dengan status dan sn terkini.
Callback status transaksi
Dikirim ke URL callback transaksi Anda setiap kali sebuah order mencapai status akhir. Kami mengirim POST JSON dengan header tanda tangan yang sama seperti permintaan Anda ke kami — sehingga Anda bisa memverifikasinya dengan kode yang cara kerjanya identik.
{
"event": "transaction.status",
"data": {
"order_id": "TRX-20260805-A1B2C",
"reference_number": "INV-0001",
"service_code": "ML10",
"status": "SUCCESS",
"rc": "00",
"message": "Transaksi berhasil",
"sn": "SN-8842190",
"price": 3000,
"qty": 1,
"total": 3000,
"balance_after": 1247000,
"updated_at": "2026-08-05T09:15:04+07:00"
}
}
Balas dengan HTTP 200 untuk menandakan callback sudah diterima. Respons selain 200, atau tidak ada respons dalam 10 detik, dianggap gagal dan akan diulang dengan jeda bertambah: 1, 5, 15, 60, lalu 300 detik. Setelah lima kali gagal, callback berhenti dan Anda perlu mengambil statusnya lewat cek status.
Callback yang sama bisa terkirim lebih dari sekali — misalnya bila respons 200 Anda hilang di jalan. Pastikan pemrosesan di sisi Anda aman diulang, dengan berpatokan pada reference_number.
Callback perubahan produk
Dikirim ke URL callback produk Anda ketika ada perubahan harga atau status produk. Perubahan dikumpulkan lebih dulu selama ±20 detik, lalu dikirim sebagai satu callback berisi array.
Satu tindakan admin — misalnya mengubah margin sebuah kategori — dapat mengubah harga ratusan produk sekaligus. Bila tiap produk mengirim callback sendiri, endpoint Anda akan menerima ratusan permintaan dalam sekejap. Dengan pengumpulan, satu tindakan admin menghasilkan satu callback, dan Anda cukup memprosesnya dalam satu transaksi basis data.
{
"event": "product.changed",
"data": [
{
"service_code": "ML10",
"price": 3100,
"status": "active",
"changed": ["price"],
"updated_at": "2026-08-05T09:20:00+07:00"
},
{
"service_code": "ML86",
"price": 21000,
"status": "inactive",
"changed": ["status"],
"updated_at": "2026-08-05T09:20:00+07:00"
}
]
}
Field changed menyebutkan apa saja yang berubah, sehingga Anda bisa memperbarui seperlunya. Aturan percobaan ulang sama dengan callback transaksi.
Verifikasi tanda tangan callback
Callback dari kami membawa header X-Client-Id, X-Request-Time, dan X-Signature. Rangkaian yang ditandatangani memakai path callback Anda sendiri, tanpa garis miring depan.
// URL callback Anda: https://server-anda.com/webhook/pulsakonter
POST:webhook/pulsakonter:<md5(body dirapatkan)>:<X-Request-Time>
Tanpa verifikasi, siapa pun yang mengetahui URL callback Anda bisa mengirim notifikasi palsu — misalnya mengaku sebuah order sudah SUCCESS padahal belum. Bandingkan tanda tangan sebelum memproses isinya, dan tolak bila tidak cocok.
Status transaksi
| Status | rc | Arti |
|---|---|---|
| PROCESSING | 01 | Order diterima dan sedang diproses. Saldo sudah dipotong. Status belum final. |
| SUCCESS | 00 | Produk terkirim. sn berisi serial number bila produk memilikinya. Status akhir. |
| FAILED | 02 | Produk gagal terkirim. Pengembalian saldo sedang berjalan — status ini bersifat sementara. |
| REFUNDED | 03 | Saldo sudah kembali ke akun Anda. Status akhir. |
Anda akan menerima callback pada SUCCESS dan REFUNDED. Bila sebuah order tertahan di PROCESSING lebih lama dari biasanya, itu berarti sedang menunggu penanganan manual — jangan mengirim order pengganti, karena order aslinya masih bisa berakhir sukses.
Aturan penting
Harga diperiksa saat order
Field price yang Anda kirim dibandingkan dengan harga tier Distributor yang berlaku saat itu. Bila berbeda, order ditolak — tidak diproses diam-diam dengan harga lain. Ini melindungi kedua pihak ketika katalog Anda tertinggal dari perubahan harga kami.
Satu nomor referensi, satu order
reference_number harus unik di akun Anda. Ini yang membuat pengiriman ulang aman saat jaringan bermasalah.
Kegagalan selalu dikembalikan
Tidak ada order gagal yang menahan saldo Anda. Pengembalian berjalan otomatis dan tercatat di mutasi saldo.
Batas laju
Maksimal 10 permintaan per detik per akun. Melebihi batas akan mendapat rc 99 sementara.