Referensi API¶
Semua endpoint di halaman ini dipakai untuk mengirim dari sistem Anda sendiri — bukan untuk dashboard. Autentikasinya memakai API Key akun Anda.
Autentikasi¶
Kirim API Key di header Authorization. Kunci ini berbeda dari token sesi
dashboard, dan tidak boleh ditempel di kode frontend karena siapa pun yang
memegangnya bisa mengirim WhatsApp dari nomor Anda.
Nilai API Key dan Subdomain akun Anda bisa disalin dari
Akun → tab API. Tipe Authorization yang sama juga dipakai sistem internal
untuk kunci tenant, jadi jangan mengirim token dashboard ke jalur ini.
Mana saja yang menerima API Key¶
Halaman ini hanya memuat endpoint yang bisa dipanggil dengan API Key. Sisanya milik dashboard dan dijaga sesi login:
| Endpoint | API Key |
|---|---|
POST pengiriman — send-text, send-otp, send-button, send-list, blast |
ya |
GET /send/api/history, /send/api/status, /send/api/wallet, /send/api/wallet/ledger |
ya |
POST /send/api/auth/magic/link |
ya — lihat Tautan Masuk |
GET /send/api/webhook, GET /send/api/ip-allowlist |
ya — baca saja |
POST /send/api/webhook, POST /send/api/webhook/test, POST /send/api/ip-allowlist |
tidak — sengaja, lihat Webhook Keluar |
GET /send/api/templates |
tidak |
GET /send/api/waha/session, GET /send/qr/status |
tidak |
POST /send/api/wallet/topup |
tidak — butuh browser |
POST /send/api/client/rotate-key |
tidak — sengaja, lihat bagiannya di bawah |
Yang tidak menerima API Key menjawab 401 dengan code: no_session. Kalau Anda
butuh daftar template atau kondisi perangkat, ambil dari GET /send/api/status.
Pengecualiannya baris yang ditandai "butuh browser" dan "sengaja": tiga endpoint
penulisan webhook/daftar-IP dan wallet/topup menjawab 403 +
code: session_required. Bedanya disengaja — 403 berarti "kunci Anda sah,
hanya endpoint ini bukan untuk mesin", dan itu jawaban yang lebih berguna bagi
integrasi daripada 401.
no_session juga berarti “kunci tidak dikenali”
Kunci yang salah tulis, sudah di-rotate, atau milik klien yang tidak aktif
sama-sama dijawab 401 + code: no_session — bukan kode khusus. Jadi
no_session di sini artinya tidak ada kredensial yang sah, bukan
“Anda butuh login”. Saat integrasi menabrak kode ini, periksa dulu nilai
header Authorization Anda sebelum menyimpulkan masalah sesi.
Akses API adalah fitur paket
API key hanya bisa dipakai pada paket yang mencantumkan akses API
(Starter dan Bisnis). Pada paket yang tidak termasuk, permintaan dengan API
key ditolak 403 dengan code: api_access_required — termasuk bila dashboard
Anda sendiri masih bisa dipakai lewat sesi login. Tab API tetap menampilkan
kredensialnya agar Anda bisa menyiapkan integrasi sebelum menaikkan paket.
API key itu rahasia
Kalau kunci Anda bocor, ganti sendiri lewat Akun → tab API → Ganti API Key. Kunci lama langsung berhenti bekerja, jadi sistem yang masih memakainya harus segera diperbarui. Kunci yang bocor memungkinkan orang lain memakai kuota Anda.
Base URL¶
Seluruh jalur di bawah ini relatif terhadap domain layanan:
Endpoint¶
POST /send/api/send-text¶
Kirim satu pesan teks. Ini endpoint yang paling sering dipakai untuk notifikasi transaksional.
curl -X POST https://send.yolked.id/send/api/send-text \
-H "Authorization: Bearer <API_KEY_ANDA>" \
-H "Content-Type: application/json" \
-d '{"phone":"6281234567890","text":"Halo dari API"}'
| Field | Tipe | Wajib | Keterangan |
|---|---|---|---|
phone |
string | ya | Nomor format internasional tanpa +, tanpa spasi |
text |
string | ya | Isi pesan; mendukung {name}, {phone}, {otp} |
scheduled_at |
string | tidak | Waktu ISO 8601; kalau diisi pesan masuk status scheduled |
Balasan sukses — status bernilai sent atau failed, jadi periksa nilainya,
bukan hanya kode HTTP-nya:
{otp} menghasilkan kode sungguhan
Kalau text memuat {otp}, server membuat kode 6 digit dan mengisinya, lalu
menambahkan field otp pada balasan supaya backend Anda bisa memverifikasinya
sendiri. Pesan seperti ini dihitung sebagai satu pesan teks biasa, bukan
pesan OTP. Kalau {otp} tidak dipakai, field otp tidak muncul dan bentuk
balasannya tidak berubah.
Field otp hanya untuk komunikasi server ke server. Jangan pernah
meneruskannya ke browser atau menyimpannya di log.
POST /send/api/send-otp¶
Buat dan kirim kode OTP. Kodenya dibuat di sisi server dan dicatat sebagai pesan
bertipe otp, sehingga kuota OTP yang terpakai — bukan kuota pesan biasa.
| Field | Tipe | Wajib | Keterangan |
|---|---|---|---|
phone |
string | ya | Nomor tujuan |
template |
string | tidak | Nama template OTP; bawaannya otp-default |
otp_length |
integer | tidak | Panjang kode, bawaan 6; nilai di luar 4–8 dipangkas |
copy_button |
boolean | tidak | Kirim sebagai pesan tombol dengan tombol salin |
idempotency_key |
string | tidak | Kunci anti-pengiriman ganda; bisa juga lewat header |
Template yang tidak ditemukan tidak menggagalkan pengiriman
Kalau nama di template tidak ada di akun Anda, server tetap mengirim —
isinya diganti teks bawaan Yolked (Kode OTP Anda: …) dan balasannya tetap
200 dengan status: sent. Jadi jangan memakai 404 untuk mendeteksi salah
tulis nama template. Periksa daftar templates dari GET /send/api/status
lebih dulu kalau isi pesan harus persis seperti yang Anda rancang.
OTP tidak bisa dijadwalkan
Endpoint ini menolak scheduled_at dengan 400 +
code: otp_not_schedulable. Alasannya dua: kode yang dibuat sekarang lalu
dikirim dua jam kemudian adalah kredensial yang jauh lebih lemah, dan
backend Anda juga tidak akan pernah menerima kodenya untuk disimpan — jadi
tidak ada yang bisa memverifikasinya. Kirim OTP tanpa scheduled_at. Untuk
pesan yang memang perlu dijadwalkan, pakai send-text, send-button,
send-list, atau blast.
curl -X POST https://send.yolked.id/send/api/send-otp \
-H "Authorization: Bearer <API_KEY_ANDA>" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: otp-9f2c1a" \
-d '{"phone":"6281234567890","otp_length":6}'
Balasan sukses menyertakan kodenya supaya backend Anda bisa menyimpannya dan membandingkannya sendiri:
otp hanya untuk backend Anda
Yolked membuat dan mengirim kode; Yolked tidak memverifikasinya.
Verifikasi adalah tanggung jawab sistem Anda, jadi field otp hanya boleh
dibaca di server. Mengirimkannya ke browser, aplikasi mobile, atau log
sama saja dengan menyerahkan kode itu kepada pihak yang justru ingin
dikecualikan oleh OTP. Pola yang disarankan ada di
Panduan Integrasi.
Rate limit¶
Untuk melindungi nomor penerima sekaligus kuota akun Anda, endpoint ini dibatasi:
- 1 OTP per 60 detik untuk nomor yang sama.
- Maksimum 3 OTP per 15 menit untuk nomor yang sama.
62812…, +62812…, dan 0812… dihitung sebagai satu nomor, jadi mengubah
format penulisan tidak menembus batas ini. Hanya pengiriman yang benar-benar
terkirim yang dihitung — kalau pengiriman gagal karena gangguan, Anda tetap bisa
mencoba lagi nomor yang sama.
Pelanggaran mengembalikan 429 beserta header Retry-After:
{
"error": "Terlalu banyak permintaan OTP untuk nomor ini. Coba lagi dalam 47 detik.",
"code": "otp_rate_limited",
"reason": "cooldown",
"retry_after": 47
}
Idempotency¶
Kirim Idempotency-Key (header) atau idempotency_key (body) yang unik untuk
setiap permintaan. Kalau permintaan yang sama diulang dalam 15 menit, server
tidak mengirim pesan kedua dan tidak memotong kuota lagi — balasan asli
dikembalikan apa adanya, ditandai idempotent_replay: true dan header
Idempotent-Replay: true.
Ini penting karena pengiriman bersifat sinkron dengan batas tunggu 15 detik: klien yang kehabisan waktu lalu mencoba ulang tanpa kunci idempotency akan membuat penerima mendapat dua kode dan kuota Anda terpotong dua kali.
{
"status": "sent",
"otp": "481920",
"message_id": 1234,
"to": "6281234567890",
"idempotent_replay": true
}
Pakai kunci yang benar-benar unik
Kunci idempotency yang dipakai ulang untuk nomor berbeda akan
mengembalikan balasan lama, bukan mengirim pesan baru. Bentuk yang aman:
gabungan id transaksi dan nomor, misalnya otp-order-7781-6281234567890.
Kapasitas penampung batas¶
Kedua catatan di atas (stempel rate limit dan kunci idempotency) disimpan per
API key, di dalam satu baris config berbentuk JSON. Artinya ada batas
kapasitas, dan batas itu dihitung untuk satu usaha, bukan untuk jaringan
cabang:
| Penampung | Bawaan | Isi |
|---|---|---|
| Rentang jendela rate limit | 2.000 nomor | Nomor yang dikirimi OTP dalam 15 menit terakhir |
| Kunci idempotency | 1.000 kunci | Kunci yang masih bisa dijawab ulang dalam 15 menit |
Urutan pembuangannya disengaja. Entri yang sudah kedaluwarsa dibuang lebih dulu — entri seperti itu tidak bisa lagi menolak apa pun, jadi membuangnya tidak mengubah satu pun keputusan. Baru kalau nomor yang benar-benar aktif dalam satu jendela melebihi kapasitas, server terpaksa membuang entri yang masih hidup (nomor yang paling lama tidak dipakai), dan mencatatnya di log:
[send] warning: OTP rate-limit store is over capacity (2140 numbers active
within the window, cap 2000); dropping the 140 least recently used. ...
Konsekuensinya harus dipahami: selama kapasitas cukup, batas 3 OTP per 15 menit per nomor berlaku mutlak. Kalau kapasitas terlampaui, nomor yang dibuang bisa dikirimi melebihi batas itu, dan kunci idempotency yang dibuang membuat percobaan ulang mengirim — dan menagih — OTP kedua.
Untuk operator
Dua variabel lingkungan berikut menaikkan kapasitas dan dibaca sekali saat aplikasi dijalankan:
| Variabel | Bawaan |
|---|---|
SEND_OTP_RL_PHONES_MAX |
2000 |
SEND_OTP_IDEM_MAX |
1000 |
Nilainya harus bilangan bulat positif; nilai yang salah ketik dilaporkan di log dan diabaikan, tidak menggagalkan proses boot. Ukurannya sebaiknya sebesar jumlah nomor berbeda dalam sehari untuk klien itu.
Perhatikan harga menaikkannya: seluruh isi penampung ditulis ulang setiap kali OTP terkirim, jadi kapasitas besar berarti tulisan besar setiap saat.
Kalau satu API key dipakai untuk banyak cabang, memisahkan API key per cabang lebih baik daripada menaikkan kapasitas: batas 3-per-15-menit lalu berlaku di dalam tiap cabang, dan pemakaian kuota tiap cabang terbaca terpisah di halaman Riwayat.
POST /send/api/send-button¶
Kirim pesan dengan tombol. Cocok untuk konfirmasi atau menu sederhana.
curl -X POST https://send.yolked.id/send/api/send-button \
-H "Authorization: Bearer <API_KEY_ANDA>" \
-H "Content-Type: application/json" \
-d '{
"phone": "6281234567890",
"text": "Pesanan Anda sudah siap.",
"title": "Status Pesanan",
"footer": "Yolked",
"buttons": [{"text": "Lacak"}]
}'
Alih-alih menulis seluruh isi pesan, Anda bisa mengirim template_id (ID
template yang sudah dibuat di dashboard) dan server akan mengambil text,
footer, serta buttons dari template itu. Field yang Anda kirim tetap menang
atas nilai dari template.
| Field | Tipe | Wajib | Keterangan |
|---|---|---|---|
phone |
string | ya | Nomor tujuan |
text |
string | tidak | Isi pesan; boleh kosong bila template_id diisi |
title |
string | tidak | Judul di atas isi pesan |
footer |
string | tidak | Teks kecil di bawah pesan |
buttons |
array | tidak | Daftar tombol |
template_id |
integer | tidak | Ambil isi dari template ini |
scheduled_at |
string | tidak | Waktu ISO 8601 |
POST /send/api/send-list¶
Kirim list message — pesan dengan daftar pilihan yang bisa dibuka penerima.
| Field | Tipe | Wajib | Keterangan |
|---|---|---|---|
phone |
string | ya | Nomor tujuan |
text |
string | ya | Kalimat pembuka |
title |
string | tidak | Judul pesan |
footer |
string | tidak | Teks kaki pesan |
button_text |
string | tidak | Label tombol pembuka daftar |
sections |
array | tidak | Bagian dan baris pilihannya |
template_id |
integer | tidak | Ambil isi dari template ini |
scheduled_at |
string | tidak | Waktu ISO 8601 |
POST /send/api/blast¶
Kirim pesan yang sama ke banyak nomor sekaligus. Nomor dikirim sebagai larik,
dan hasilnya dikelompokkan dalam satu batch_id sehingga bisa dipantau sebagai
satu siklus pengiriman.
curl -X POST https://send.yolked.id/send/api/blast \
-H "Authorization: Bearer <API_KEY_ANDA>" \
-H "Content-Type: application/json" \
-d '{"phones":["6281234567890","6281234567891"],"text":"Promo bulan ini"}'
| Field | Tipe | Wajib | Keterangan |
|---|---|---|---|
phones |
array | ya | Daftar nomor tujuan; tidak boleh kosong |
text |
string | ya* | Isi pesan |
template |
string | ya* | Nama template tipe blast |
cycle_name |
string | tidak | Nama siklus, untuk pengelompokan di dashboard |
group_filter |
string | tidak | Batasi ke satu grup kontak |
scheduled_at |
string | tidak | Waktu ISO 8601 |
* Salah satu dari text atau template wajib ada. Kalau template diisi,
isinya diambil dari template tersebut.
Blast terjadwal menolak kelebihan kuota
Pengiriman blast yang dijadwalkan harus masuk kuota paket. Kalau kuotanya
habis, permintaan ditolak dengan code: quota_exhausted. Kirim tanpa
scheduled_at kalau ingin memakai saldo E-Wallet untuk kelebihannya.
GET /send/api/history¶
Ambil riwayat pengiriman akun Anda, terbaru lebih dulu.
| Query | Tipe | Bawaan | Keterangan |
|---|---|---|---|
limit |
integer | 50 |
Jumlah maksimum baris yang dikembalikan |
type |
string | kosong | Filter tipe pesan, misalnya otp, text, atau blast |
curl -H "Authorization: Bearer <API_KEY_ANDA>" \
"https://send.yolked.id/send/api/history?limit=20&type=otp"
Balasannya larik objek pesan:
[
{
"id": 1234,
"type": "text",
"to": "6281234567890",
"message": "Halo dari API",
"status": "sent",
"created_at": "2026-09-22T04:15:02",
"batch_id": "",
"delivered_at": null,
"read_at": null,
"replied_at": null,
"reply_message": ""
}
]
GET /send/api/status¶
Mulai dari sini. Satu panggilan yang menjawab "apakah saya boleh mengirim sekarang?", tanpa perlu dashboard. Cocok untuk tombol Uji koneksi di halaman pengaturan Anda, atau untuk pemeriksaan berkala sebelum mengirim.
{
"status": "ok",
"server_time": "2026-09-22T04:15:02Z",
"client": { "subdomain": "toko-anda", "status": "active", "expires_at": "2026-10-01T00:00:00" },
"plan": { "name": "Starter", "slug": "starter", "api_access": true, "webhook": false, "custom_sender": false },
"api_access": true,
"device": {
"session": "toko-anda",
"status": "WORKING",
"connected": true,
"reachable": true,
"number": "6281234567890",
"push_name": "Toko Anda"
},
"quota": {
"otp": { "used": 120, "limit": 1000, "remaining": 880 },
"text": { "used": 40, "limit": 1000, "remaining": 960 },
"blast": { "used": 0, "limit": 500, "remaining": 500 }
},
"wallet": { "balance": 50000, "currency": "IDR" },
"templates": [{ "name": "otp-default", "type": "otp", "has_buttons": false }],
"webhook": { "allowed": true, "configured": true, "events": ["message.sent", "message.failed"], "recent_failures": 0 },
"ip_allowlist": { "enforced": true, "entries": 2, "current_ip": "203.0.113.10" },
"pos": {
"connected": true,
"app": "POS Yolked",
"client_id": "pos.yolked.id",
"connected_at": "2026-09-24T06:00:00Z",
"last_exchange_at": "2026-09-24T06:00:00Z",
"exchanges": 1,
"last_seen_at": "2026-09-24T06:12:00Z",
"active": true,
"stale": false
},
"warnings": []
}
| Field | Keterangan |
|---|---|
server_time |
Waktu server dalam UTC. Pakai untuk mengukur selisih jam mesin Anda — jendela OTP dan pembatasan laju diukur memakai jam ini |
api_access |
false berarti paket tidak mencantumkan akses API; endpoint pengiriman akan menolak dengan 403 |
device.connected |
true hanya kalau sesi WhatsApp berstatus WORKING. Kalau false, pesan akan tercatat failed walaupun balasannya 200 |
device.reachable |
false berarti WAHA tidak bisa dihubungi dari server, jadi status belum tentu mencerminkan keadaan sebenarnya |
device.number |
Nomor WhatsApp yang tersambung, tanpa akhiran @c.us |
quota.<jenis>.remaining |
null berarti kuota tidak dibatasi; angka 0 berarti kuota jenis itu benar-benar habis |
templates |
Template yang benar-benar ada di akun Anda — pakai daftar ini untuk memvalidasi nama template sebelum mengirim |
webhook.allowed |
false berarti paket tidak mencantumkan fitur Webhook |
webhook.configured |
true hanya kalau URL dan secret webhook sudah tersimpan |
webhook.events |
Event yang benar-benar dikirim; daftar kosong berarti semua event |
webhook.recent_failures |
Berapa dari 8 pengiriman terakhir yang gagal — angka selain 0 berarti masalah ada di sisi penerima |
ip_allowlist.enforced |
true berarti pembatasan IP aktif untuk API key |
ip_allowlist.entries |
Jumlah entri di daftar |
ip_allowlist.current_ip |
Alamat yang kami lihat untuk permintaan ini. Bandingkan dengan daftarnya — inilah cara mendiagnosis ip_not_allowed tanpa membuka browser |
pos.connected |
true berarti akun ini pernah menukar kode otorisasi POS menjadi API key — ada aplikasi POS yang memegang kunci akun ini |
pos.app |
Nama aplikasi yang menyambung, seperti yang tertulis di halaman persetujuan saat koneksi dibuat |
pos.client_id |
Identitas client_id yang dipakai saat menukar kode |
pos.exchanges |
Berapa kali koneksi diselesaikan. Bertambah kalau POS menyambung ulang; tanggal connected_at tetap yang pertama |
pos.last_exchange_at |
Kapan penukaran kode terakhir berhasil |
pos.last_seen_at |
Panggilan API terakhir yang memakai API key akun ini — null berarti belum pernah |
pos.active |
true berarti tersambung, kuncinya masih yang berlaku, dan ada panggilan dalam 24 jam terakhir |
pos.stale |
true berarti kunci yang dipegang POS sudah tidak berlaku, biasanya karena rotate key di dashboard. Sambungkan ulang dari sisi POS; tidak ada yang perlu diubah di sini |
warnings |
Daftar masalah yang perlu ditindaklanjuti, lihat di bawah |
Tambah ?live=0 untuk melewati pemeriksaan ke WhatsApp dan hanya melaporkan
status terakhir yang tercatat. Lebih cepat, tapi device.connected bisa sudah
tidak akurat — jangan pakai untuk memutuskan pengiriman.
Kode di dalam warnings sengaja sama dengan kode error pada endpoint
pengiriman, jadi satu penanganan error bisa dipakai untuk keduanya:
| Kode | Arti |
|---|---|
api_access_required |
Paket tidak mencantumkan akses API |
device_offline |
Sesi WhatsApp belum WORKING; pindai QR di dashboard |
insufficient_balance |
Ada jenis pesan yang kuotanya sudah 0 dan saldo E-Wallet juga habis |
webhook_unconfigured |
Paket mencantumkan fitur Webhook tapi URL webhook belum diisi — event tidak akan sampai ke mana pun |
Periksa ini sebelum mengirim massal
Pola yang disarankan: panggil /send/api/status, hentikan proses kalau
warnings tidak kosong, baru kirim. Ini mencegah ribuan pesan tercatat
failed gara-gara sesi WhatsApp terputus di tengah malam.
GET /send/api/wallet¶
Saldo E-Wallet akun Anda, dipakai untuk kelebihan pengiriman di luar kuota.
Bentuk balasannya mengikuti halaman E-Wallet di dashboard: balance beserta
ringkasan pemakaian. Mutasinya ada di GET /send/api/wallet/ledger.
Top up tidak bisa dari API
POST /send/api/wallet/topup harus diselesaikan di browser karena berakhir
di halaman pembayaran Doku. Kalau dipanggil dengan API key, balasannya 403 +
code: session_required — bukan 401 — supaya jelas bahwa kuncinya benar
dan hanya jalurnya yang tidak sesuai.
POST /send/api/client/rotate-key¶
Buat API key baru untuk akun Anda dan matikan yang lama.
Ini endpoint dashboard, bukan endpoint integrasi
Jalur ini sengaja menolak API key dan hanya menerima sesi login browser. Alasannya keamanan: kunci yang bocor harus mustahil dipakai untuk membuat penggantinya sendiri. Di dashboard, tombolnya ada di Akun → tab API → Ganti API Key.
| Field | Tipe | Wajib | Keterangan |
|---|---|---|---|
confirm |
boolean | ya | Harus true; tanpa ini balasannya 400 + code: confirmation_required |
Balasan sukses:
{
"status": "ok",
"api_key": "send_xxxxxxxxxxxxxxxx",
"subdomain": "toko-anda",
"rotated_at": "2026-09-22T04:15:02Z",
"previous_key_prefix": "send_A1"
}
| Field | Keterangan |
|---|---|
api_key |
Kunci baru. Ditampilkan sekali di sini dan di dashboard; simpan langsung |
rotated_at |
Waktu penggantian, UTC |
previous_key_prefix |
Enam karakter pertama kunci lama, untuk jejak audit — bukan kunci lama |
Tidak ada masa tenggang: kunci lama berhenti berlaku begitu permintaan ini
selesai. Perbarui konfigurasi integrasi Anda, lalu uji dengan
GET /send/api/status sebelum mengirim sungguhan.
POST /send/api/auth/magic/link¶
Kirim tautan masuk sekali pakai ke email salah satu pengguna aplikasi Anda — tanpa password, dan tanpa kode yang harus diketik. Cocok untuk kasir yang perlu membuka POS, atau pengguna aplikasi Anda yang sudah Anda autentikasi sendiri dan ingin Anda pindahkan ke sesi Send.
Balasannya tidak pernah memuat tautannya. Tautan itu kredensial: kalau ikut dikembalikan, siapa pun yang memegang API key Anda bisa masuk ke akun mana pun yang alamat emailnya ditebak. Tautannya hanya dikirim ke email penerimanya.
curl -X POST "https://send.yolked.id/send/api/auth/magic/link" \
-H "Authorization: Bearer <API_KEY_ANDA>" \
-H "Content-Type: application/json" \
-d '{
"email": "kasir@toko-anda.com",
"app": "POS Toko Anda",
"redirect_uri": "https://pos.toko-anda.com/auth/send/callback",
"state": "sesi-8f21"
}'
| Field | Tipe | Wajib | Keterangan |
|---|---|---|---|
email |
string | ya | Alamat penerima — ini yang menentukan siapa yang masuk |
name |
string | tidak | Nama untuk akun yang baru dibuat; kalau kosong dipakai bagian sebelum @ |
phone |
string | tidak | Nomor untuk akun yang baru dibuat |
app |
string | tidak | Nama aplikasi Anda. Muncul di halaman konfirmasi dan di email supaya penerima tahu tautannya dari mana. Kalau kosong dipakai nama klien |
redirect_uri |
string | tidak | Ke mana penerima dikembalikan setelah tautan dipakai. Harus terdaftar lebih dulu — lihat di bawah |
state |
string | tidak | Nilai yang ingin Anda terima kembali; dikembalikan apa adanya bersama token |
create_account |
boolean | tidak | Default true: akun dibuat kalau alamatnya belum ada. Kirim false kalau Anda hanya mau melayani pengguna yang sudah terdaftar |
Balasan sukses — perhatikan tidak ada url maupun token di dalamnya:
| Field | Keterangan |
|---|---|
created |
true kalau akunnya baru dibuat oleh panggilan ini |
expires_in_minutes |
Masa berlaku tautan. Diatur admin di dashboard, default 15 menit |
create_account: false tidak membocorkan siapa yang sudah terdaftar
Kalau alamatnya belum punya akun dan create_account bernilai false,
balasannya sama dengan sukses biasa meskipun tidak ada email yang
terkirim. Kalau kedua kasus dibedakan, endpoint ini bisa dipakai menebak
alamat email mana yang sudah terdaftar.
redirect_uri dan daftar izin¶
redirect_uri hanya diizinkan kalau terdaftar di variabel lingkungan
SEND_MAGIC_REDIRECT_URIS di server (dipisah koma), atau kalau hostnya
localhost / 127.0.0.1 — yang terakhir supaya pengembangan lokal bisa jalan
tanpa konfigurasi tambahan. Selama belum terdaftar, permintaan yang menyertakan
redirect_uri dijawab 400 + code: invalid_redirect_uri.
Daftar izin ini ada karena redirect_uri yang diterima begitu saja adalah celah
pengalihan terbuka: penyerang bisa minta tautan resmi kami yang, setelah dipakai,
melempar token sesi ke domainnya sendiri.
Kalau redirect_uri tidak diisi, penerima hanya melihat halaman konfirmasi Send
dan berakhir di dashboard Send — bukan di aplikasi Anda.
Batas pengiriman¶
Sama seperti jalur OTP: 3 permintaan per 10 menit untuk alamat yang sama, dan
jeda 60 detik sebelum boleh minta lagi. Melewatinya dijawab 429 +
code: magic_rate_limited, atau magic_cooldown khusus untuk jeda 60 detik.
Jumlah tautan aktif dibatasi per akun, dan tautan lama tidak dibatalkan saat Anda minta yang baru: setiap tautan berdiri sendiri sampai dipakai atau kedaluwarsa.
Memakai tautannya¶
Tautannya berbentuk https://send.yolked.id/send/magic-login?t=<token>. Membuka
alamat itu belum memakai tokennya — halaman yang muncul hanya meminta satu
konfirmasi, lalu mengirim POST dari halaman itu juga. Pemisahan ini disengaja:
pemindai tautan di email korporat (Outlook SafeLinks dan sejenisnya) membuka
setiap URL di kotak masuk lebih dulu, jadi tautan yang habis begitu dibuka akan
selalu mati sebelum pemiliknya sempat mengklik.
Setelah dipakai, tautannya langsung tidak berlaku — termasuk untuk penekanan
kedua di perangkat yang sama. Yang diterima pengguna adalah sesi Send biasa:
token yang bentuknya sama dengan hasil login, dan bisa dipakai di mana saja
seperti token dari POST /send/api/auth/otp/verify.
Kalau redirect_uri diisi, penerima diarahkan ke sana dengan token (dan
state bila Anda mengirimnya) ditambahkan ke query string-nya. Simpan token itu
seperti Anda menyimpan token hasil login — lihat
Panduan Integrasi untuk contoh lengkapnya.
Endpoint ini juga menerima sesi login pemilik klien sebagai ganti API key.
Cabang sesi diperiksa lewat menu Akun milik pengguna itu, bukan lewat kolom
akses API pada paketnya — jadi pemilik klien bisa memakainya dari dashboard
walaupun paketnya tidak mencantumkan akses API. Tanpa kredensial yang dikenali,
balasannya 403 + client_required.
Webhook Keluar¶
Kalau Anda tidak mau menebak status pesan dengan polling GET /send/api/history,
aktifkan webhook: kami yang memanggil server Anda setiap kali status sebuah
pesan berubah. Arahnya terbalik dari seluruh endpoint di halaman ini — di sini
Anda yang menyediakan URL, kami yang jadi klien HTTP.
Webhook adalah fitur paket (Bisnis dan ke atas). Di paket yang tidak
mencantumkannya, GET /send/api/status menjawab "webhook": {"allowed": false}
dan tidak ada event yang dikirim.
Event yang tersedia¶
| Event | Dikirim saat |
|---|---|
message.sent |
Pesan diterima WhatsApp dan mulai diproses |
message.delivered |
WhatsApp melaporkan pesan sampai ke perangkat penerima |
message.read |
Penerima membaca pesan (hanya kalau pengaturannya aktif di ponselnya) |
message.failed |
Pengiriman gagal — termasuk saat sesi WhatsApp sedang tidak WORKING |
message.incoming |
Ada pesan masuk ke nomor Anda. Satu-satunya event yang memuat isi pesan |
blast.completed |
Satu batch blast selesai diproses. Diringkas per batch, bukan per nomor |
ping |
Hanya dari POST /send/api/webhook/test. Tidak pernah dikirim otomatis |
Pilih event mana saja yang ingin dikirim dari dashboard. Daftar kosong berarti
semua event dikirim. blast.completed sengaja satu event per batch supaya
blast 5.000 nomor tidak berubah menjadi 5.000 permintaan ke server Anda.
Bentuk permintaan¶
POST ke URL Anda, Content-Type: application/json, badan berupa JSON padat
(tanpa spasi) dengan urutan field tetap:
POST /hooks/yolked-send HTTP/1.1
Content-Type: application/json
User-Agent: YolkedSend-Webhook/1.0
X-Send-Event: message.sent
X-Send-Delivery: 9f1c4a8e2b7d4f0c8a3e6b1d5c9f2a7e
X-Send-Attempt: 1
X-Send-Timestamp: 1789000000
X-Send-Signature: sha256=5c1f...e29a
{"delivery_id":"9f1c4a8e2b7d4f0c8a3e6b1d5c9f2a7e","event":"message.sent","created_at":"2026-09-22T04:15:02Z","data":{"message_id":4821,"type":"text","to":"6281234567890","status":"sent","created_at":"2026-09-22T04:15:02Z","delivered_at":null,"read_at":null,"error":""}}
| Header | Keterangan |
|---|---|
X-Send-Event |
Nama event. Sama dengan event di badan |
X-Send-Delivery |
ID unik percobaan pengiriman ini. Pakai ini sebagai kunci idempotensi — percobaan ulang memakai ID yang sama |
X-Send-Attempt |
Nomor percobaan, mulai dari 1 |
X-Send-Timestamp |
Unix time (detik) saat permintaan dibuat |
X-Send-Signature |
Tanda tangan, lihat di bawah |
blast.completed membawa data yang berbeda — {"batch_id", "cycle_name", "group_filter", "total", "sent", "failed"}. Pakai batch_id untuk mengaitkannya dengan hasil POST /send/api/blast.
Isi pesan tidak pernah dikirim lewat webhook
Payload hanya memuat metadata: nomor, tipe, status, waktu, dan pesan error.
Satu-satunya pengecualian adalah message.incoming, yang memang membawa isi
balasan pelanggan di data.message (dipotong 4.096 karakter) — karena itu
gunanya. Kalau Anda butuh isi pesan keluar, simpan sendiri saat mengirim.
Memverifikasi tanda tangan¶
Setiap permintaan ditandatangani dengan HMAC-SHA256 memakai secret akun Anda. Secret dibuat otomatis saat URL webhook pertama kali disimpan dan hanya ditampilkan satu kali di dashboard — kalau hilang, ganti dengan Akun → tab API → Ganti secret.
Teks yang ditandatangani adalah <X-Send-Timestamp> + "." + badan permintaan
mentah, bukan JSON yang sudah di-parse:
import hashlib, hmac
def verify(secret: str, raw_body: bytes, timestamp: str, signature: str) -> bool:
expected = "sha256=" + hmac.new(
secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
Tiga hal yang gampang salah di sini:
- Pakai badan mentah. Tanda tangan dihitung atas byte yang dikirim. Kalau
middleware Anda mengubah JSON lebih dulu (re-serialisasi, normalisasi spasi),
verifikasinya gagal. Baca
request.get_data()sebelum di-parse. - Timestamp ikut ditandatangani, bukan hanya ditempel di sebelahnya. Jadi batas umur yang Anda terapkan tidak bisa dilewati dengan memindahkan timestamp baru ke badan lama — mengubahnya merusak digest.
- Bandingkan dengan
compare_digest, bukan==, supaya tidak ada kebocoran waktu.
Tolak permintaan yang umurnya lebih dari beberapa menit, dan simpan
X-Send-Delivery yang sudah diproses supaya percobaan ulang tidak menghasilkan
pesan ganda di sistem Anda.
Kebijakan percobaan ulang¶
Balasan HTTP 2xx dianggap berhasil. Selain itu (termasuk 3xx) dianggap
gagal dan dicoba lagi:
| Percobaan | Jeda sebelum percobaan berikutnya |
|---|---|
| 1 | 2 detik |
| 2 | 8 detik |
| 3 | menyerah |
Timeout setiap percobaan 5 detik untuk koneksi dan 10 detik untuk keseluruhan,
redirect tidak diikuti (3xx dihitung gagal). Jadi satu event paling lama
memakan sekitar 25 detik sebelum dinyatakan gagal. Balas 200 secepat mungkin
dan kerjakan sisanya di latar belakang.
Delapan pengiriman terakhir — berhasil maupun gagal — terlihat di Akun → tab API, lengkap dengan kode HTTP, jumlah percobaan, dan pesan errornya. Ini alat pertama untuk menjawab "kok webhook saya tidak masuk?".
Webhook hanya boleh menuju alamat publik
Server kami yang melakukan permintaan, jadi URL diarahkan ke alamat
internal akan berubah menjadi cara mengetuk jaringan kami sendiri:
http://127.0.0.1:5432, alamat metadata cloud, port admin. Karena itu hanya
http:// dan https:// ke nama host yang semua alamatnya publik yang
diterima, tanpa kredensial di URL (user:pass@host ditolak). Nama host
diperiksa ulang sebelum setiap pengiriman, jadi domain yang tadinya publik
lalu menunjuk ke IP privat tetap ditolak. Kalau integrator Anda memang berada
di jaringan privat yang sama, hanya administrator server yang bisa
mengendurkan aturan ini lewat variabel lingkungan
SEND_WEBHOOK_ALLOW_PRIVATE=1 — dan itu berlaku untuk semua tenant,
bukan untuk satu akun.
GET /send/api/webhook¶
Lihat target webhook dan riwayat pengiriman terakhir. Bisa dipanggil dengan API key supaya integrasi bisa memastikan sendiri URL yang terpasang memang URL-nya — ketidakcocokan di sini biasanya hanya terlihat sebagai "webhook tidak pernah datang".
{
"status": "ok",
"allowed": true,
"enabled": true,
"url": "https://pos.contoh.id/hooks/yolked-send",
"events": ["message.sent", "message.failed", "message.incoming"],
"available_events": ["message.sent", "message.delivered", "message.read", "message.failed", "message.incoming", "blast.completed"],
"secret_set": true,
"secret_hint": "K7pQ2vXa...",
"updated_at": "2026-09-22T04:15:02Z",
"recent": [
{ "at": "2026-09-22T04:16:10Z", "event": "message.sent", "delivery_id": "9f1c4a8e...", "ok": true, "status": 200, "attempts": 1, "error": "" }
]
}
secret_hint hanya 8 karakter pertama — cukup untuk memastikan secret mana yang
tersimpan di POS Anda, tidak cukup untuk menjadi secretnya. secret_set adalah
satu-satunya cara memeriksa keberadaan secretnya dari integrasi.
POST /send/api/webhook¶
Simpan URL dan event yang dikirim. Sesi browser saja — lihat alasannya di Pembatasan IP, alasannya sama.
| Field | Tipe | Keterangan |
|---|---|---|
url |
string | Wajib. Maksimal 500 karakter, harus lolos pemeriksaan alamat publik |
enabled |
boolean | Kalau false, pengaturan tetap tersimpan tapi tidak ada event yang dikirim |
events |
array string | Nama event yang diinginkan. Dihilangkan berarti semua; kosong berarti 400 |
rotate_secret |
boolean | true untuk membuat secret baru, membatalkan yang lama seketika |
Balasan sukses berbentuk sama dengan GET, dengan tambahan secret (nilai utuh,
ditampilkan sekali ini saja) dan secret_created: true hanya pada
penyimpanan yang membuat secret baru. Setelah itu secret tidak pernah muncul
lagi.
POST /send/api/webhook/test¶
Kirim satu event ping ke URL yang tersimpan dan laporkan apa yang dijawab
endpoint Anda. Sengaja sinkron — uji coba yang hasilnya baru terlihat di log
satu jam kemudian bukan uji coba. Balasannya 200 kalau endpoint Anda menjawab
2xx dan 502 kalau tidak, keduanya dengan bentuk:
{ "status": "ok", "ok": true, "http_status": 200, "error": "", "delivery_id": "…", "url": "https://pos.contoh.id/hooks/yolked-send" }
Hasil uji tidak ditulis ke riwayat pengiriman, supaya riwayat tetap bisa dipakai menjawab pertanyaan tentang lalu lintas sungguhan.
Pembatasan IP¶
Kalau API key Anda hanya dipakai dari satu server, mengikatnya ke alamat IP itu membuat kunci yang bocor tidak ada gunanya bagi pencurinya. Ini lapisan kedua setelah rencana "ganti kunci kalau bocor", untuk kasus ketika kebocoran baru diketahui belakangan.
Hanya berlaku untuk API key — dashboard tidak pernah dibatasi
Sesi login browser selalu lolos. Itu bukan kelalaian: kalau Anda mengunci diri sendiri keluar (IP kantor berubah, salah ketik/CIDR, server pindah), Anda masih bisa masuk lewat browser dan memperbaiki daftarnya. Karena alasan yang sama, mengubah daftar ini hanya bisa dari sesi browser — kalau API key boleh menulis ke daftarnya sendiri, kunci yang bocor tinggal menambahkan alamat pencurinya dan pembatasannya tidak menjaga apa pun.
Alamat yang tidak diizinkan dijawab:
dengan status 403. Kode ini berbeda dari no_session: artinya kredensial
Anda dikenali dan sah, hanya saja dipakai dari alamat yang tidak ada di daftar.
Bedakan keduanya di penanganan error integrasi Anda — no_session berarti
perbaiki kuncinya, ip_not_allowed berarti perbaiki atau perluas daftarnya.
Tidak ada endpoint API yang dikecualikan
Termasuk endpoint yang hanya membaca, GET /send/api/ip-allowlist dan
GET /send/api/status — keduanya juga menjawab 403 + ip_not_allowed dari
alamat yang tidak ada di daftar. Itu disengaja: pemeriksaannya berada di satu
dekorator yang dilalui semua endpoint API key, dan daftar pengecualian
adalah hal yang gampang terlupa diperbarui.
Yang dibutuhkan integrasi tetap tersedia, karena balasan penolakannya sendiri
membawa code dan ip — "kunci Anda ditolak karena permintaan datang dari
X". Daftar lengkapnya bisa dibaca dari dashboard, dan GET /send/api/status
di sisi browser mencantumkan jumlah entri serta apakah alamat Anda lolos.
Dari alamat mana IP dinilai¶
Kalau layanan Anda berada di belakang reverse proxy, X-Forwarded-For adalah
satu-satunya cara mengetahui alamat asli pemanggil — tapi header itu juga bisa
ditulis siapa saja. Aturannya karena itu gagal-tertutup:
- Header
X-Forwarded-For/X-Real-IPhanya dibaca kalau koneksi datang dari proxy yang dipercaya (bawaan:127.0.0.1dan::1). - Dari
X-Forwarded-For, entri yang dipakai adalah entri terakhir — entri yang ditulis proxy terdekat, bukan yang diklaim pemanggil. - Kalau koneksi datang langsung, yang dinilai
remote_addritu sendiri dan header diabaikan sepenuhnya.
Daftar proxy yang dipercaya diatur administrator lewat variabel lingkungan
SEND_TRUSTED_PROXIES (dipisah koma, boleh CIDR). Jangan pernah menambahkan
0.0.0.0/0 — itu mengembalikan pembatasan ini menjadi hiasan, karena setiap
pemanggil lalu bebas mengaku berada di alamat mana pun.
GET /send/api/ip-allowlist¶
{
"status": "ok",
"enabled": true,
"ips": ["203.0.113.10", "10.0.0.0/24"],
"max_entries": 20,
"current_ip": "203.0.113.10",
"current_ip_source": "peer",
"current_ip_allowed": true,
"updated_at": "2026-09-22T04:15:02Z"
}
| Field | Keterangan |
|---|---|
ips |
Daftar yang tersimpan, sudah dinormalkan (10.0.0.1 menjadi 10.0.0.1/32) |
current_ip |
Alamat yang kami lihat untuk permintaan ini — pakai ini saat menulis daftarnya, bukan hasil whatismyip |
current_ip_source |
peer kalau dari koneksi langsung, x-forwarded-for atau x-real-ip kalau lewat proxy tepercaya |
current_ip_allowed |
Apakah current_ip lolos daftar saat ini |
POST /send/api/ip-allowlist¶
Sesi browser saja. Daftar diganti seluruhnya, bukan ditambal, supaya apa yang Anda lihat di formulir persis sama dengan yang tersimpan.
| Field | Tipe | Keterangan |
|---|---|---|
enabled |
boolean | true untuk mulai menolak alamat di luar daftar |
ips |
array string | Alamat IP tunggal atau CIDR. 10.0.0.1 dan 10.0.0.1/32 dianggap satu entri |
Batasannya 20 entri per akun, cukup untuk rantai beberapa toko. Mengaktifkan
pembatasan dengan daftar kosong ditolak 400 + code: no_ips — mengaktifkan
sesuatu yang mengunci semuanya hampir pasti bukan yang Anda maksud.
Format nomor¶
Pakai format internasional tanpa tanda +, tanpa spasi, dan tanpa tanda
hubung — contoh 6281234567890. Pastikan kode negaranya benar; nomor Indonesia
diawali 62.
Kode error¶
Semua kegagalan mengembalikan JSON dengan field error, dan sebagian menyertakan
field code yang stabil untuk dibaca program:
| Kode | Arti |
|---|---|
401 |
API key tidak dikenali, klien tidak aktif, atau langganannya kedaluwarsa |
401 + no_session |
Permintaan tidak punya kredensial yang dikenali. Muncul di endpoint jalur API maupun dashboard: menu_required mencari klien dari API key lebih dulu, dan kalau tidak ketemu ia jatuh ke cabang sesi. Jadi kunci yang salah tulis pun menghasilkan no_session, bukan kode tersendiri — pembedaannya memang tidak dilakukan supaya pemanggil tidak bisa menebak kunci mana yang pernah ada |
400 |
Isi permintaan kurang atau tidak valid |
400 + otp_not_schedulable |
send-otp dipanggil dengan scheduled_at; OTP tidak bisa dijadwalkan |
400 + confirmation_required |
client/rotate-key dipanggil tanpa {"confirm": true} |
400 + invalid_email |
auth/magic/link dipanggil dengan alamat email yang bentuknya tidak sah |
400 + invalid_redirect_uri |
auth/magic/link meminta pengembalian ke alamat yang belum terdaftar di SEND_MAGIC_REDIRECT_URIS |
402 + insufficient_balance |
Kuota habis dan saldo E-Wallet tidak cukup. Balasannya menyertakan required, balance, dan shortfall |
403 + api_access_required |
Paket akun tidak mencantumkan akses API |
403 + client_required |
auth/magic/link dipanggil tanpa API key klien maupun sesi pemilik klien |
403 + magic_disabled |
Masuk lewat tautan sedang dimatikan admin di dashboard |
403 + ip_not_allowed |
Pembatasan IP aktif dan permintaan datang dari alamat di luar daftar. Kredensialnya sah — bedakan dari no_session. Balasannya menyertakan ip yang kami lihat |
403 + webhook_not_in_plan |
POST /send/api/webhook dipanggil pada paket yang tidak mencantumkan fitur Webhook |
403 + quota_exhausted |
Kuota habis untuk pengiriman yang dijadwalkan |
403 + menu_forbidden |
Jalur ini tidak diaktifkan untuk akun Anda |
403 + session_required |
Endpoint yang harus diselesaikan di browser (mis. wallet/topup) dipanggil dengan API key |
400 + url_invalid |
URL webhook bukan http(s), memuat kredensial, atau menunjuk ke alamat yang tidak publik |
400 + bad_ip |
Daftar IP memuat entri yang bukan alamat IP/CIDR yang sah. Field invalid memuat entri yang ditolak |
400 + no_ips |
Pembatasan IP diaktifkan dengan daftar kosong |
400 + too_many_ips |
Daftar IP melebihi batas entri per akun |
400 + no_events |
Daftar event webhook dikirim dalam keadaan kosong |
404 |
template_id tidak ditemukan atau bukan milik akun Anda. Field template pada send-otp tidak menghasilkan 404 — lihat catatan di bagiannya |
404 + no_client |
client/rotate-key dijalankan oleh akun yang belum punya klien Send |
429 + otp_rate_limited |
Terlalu sering mengirim OTP ke nomor yang sama; lihat Retry-After |
429 + magic_rate_limited |
Terlalu sering meminta tautan masuk untuk alamat yang sama; lihat Retry-After |
429 + magic_cooldown |
Masih dalam jeda 60 detik setelah permintaan tautan masuk sebelumnya |
502 + smtp_failed |
Tautan masuk gagal dikirim karena SMTP bermasalah. Kuota kirimnya tidak terpakai — baris tautannya dibatalkan, jadi tidak ada tautan hidup yang tidak sampai ke mana pun |
502 + store_failed |
Tautan masuk gagal disimpan di server, jadi tidak ada yang dikirim |
Kuota dulu, saldo kemudian
Kuota paket dipakai lebih dulu. Setelah kuota habis, kelebihan pengiriman —
untuk pengiriman yang dikirim saat itu juga — ditagih ke saldo E-Wallet
selama saldonya mencukupi. Kalau saldo tidak cukup, permintaan ditolak dengan
402 + insufficient_balance dan tidak ada pesan yang dikirim. Periksa
wallet.balance dan quota.*.remaining dari GET /send/api/status untuk
mengetahui posisi Anda tanpa mengirim apa pun.
Sudah siap mengintegrasikan?¶
Contoh kode lengkap untuk Python/Flask, Node.js/Express, dan PHP/Laravel — termasuk cara menyimpan dan memverifikasi kode OTP di database Anda sendiri — ada di Panduan Integrasi.