API Documentation

Referensi lengkap REST API Waslah. Authentication, endpoints, error codes, rate limit per plan.

Kelola API Keys
Auto-fill token di semua contoh

Paste token dari API Keys, semua snippet code di bawah akan otomatis terisi token-mu.

Token diterapkan ke semua contoh

Getting Started

Waslah REST API memungkinkan integrasi WhatsApp dengan sistem apa pun — e-commerce, CRM, ERP, atau script internal. Cocok untuk notifikasi transaksional, OTP, reminder, dan auto reply.

1. Buat API Key

Dari halaman API Keys, klik Buat API Key.

2. Connect Instance

Dari Instances, scan QR sampai status `connected`.

3. Kirim Pesan

POST ke /api/v1/messages dengan instance_key, to, text (atau url untuk media).

Authentication

Semua endpoint (kecuali webhook receiver) butuh API key. Kirim via header Authorization Bearer atau X-API-Key:

curl -H "Authorization: Bearer wsl_YOUR_TOKEN_HERE" \
     https://waslah.id/api/v1/me
fetch('https://waslah.id/api/v1/me', {
  headers: { 'Authorization': 'Bearer wsl_YOUR_TOKEN_HERE' }
})
  .then(r => r.json())
  .then(console.log);
<?php
$ch = curl_init('https://waslah.id/api/v1/me');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Authorization: Bearer wsl_YOUR_TOKEN_HERE',
    'Accept: application/json',
]);
$response = curl_exec($ch);
echo $response;
import requests

r = requests.get(
    'https://waslah.id/api/v1/me',
    headers={'Authorization': 'Bearer wsl_YOUR_TOKEN_HERE'}
)
print(r.json())
Token disimpan dalam bentuk hash. Plain token hanya ditampilkan sekali saat dibuat. Kalau lupa/hilang, revoke dan buat baru.
Plan gating. Seluruh endpoint REST API butuh plan dengan feature REST API access (Pro ke atas). User Free akan dapat 403 plan_feature_required walau API key valid. Beberapa feature spesifik juga gated: scheduled_at (Basic+ → Schedule message).

Base URL

Semua endpoint dimulai dengan base URL berikut:

https://waslah.id/api/v1
GET
https://waslah.id/api/v1/me

Dapatkan info user pemilik API key + metadata key (request count, last used, scopes). Tidak butuh scope khusus.

Response 200
{
  "ok": true,
  "data": {
    "user": {
      "id": 1,
      "name": "Admin",
      "email": "admin@waslah.id"
    },
    "api_key": {
      "id": 2,
      "name": "Production Server",
      "prefix": "wsl_a3f29d4b",
      "scopes": ["messages:send", "messages:read", "instances:read"],
      "request_count": 42,
      "last_used_at": "2026-05-11T17:24:02+00:00",
      "expires_at": null
    }
  }
}
curl -H "Authorization: Bearer wsl_YOUR_TOKEN_HERE" \
     https://waslah.id/api/v1/me
const res = await fetch('https://waslah.id/api/v1/me', {
  headers: { 'Authorization': 'Bearer wsl_YOUR_TOKEN_HERE' }
});
const { data } = await res.json();
console.log(data.user.email);
$client = new \GuzzleHttp\Client();
$res = $client->get('https://waslah.id/api/v1/me', [
    'headers' => ['Authorization' => 'Bearer wsl_YOUR_TOKEN_HERE'],
]);
$data = json_decode($res->getBody(), true);
import requests

r = requests.get(
    'https://waslah.id/api/v1/me',
    headers={'Authorization': 'Bearer wsl_YOUR_TOKEN_HERE'}
)
data = r.json()['data']
print(data['user']['email'])
GET
https://waslah.id/api/v1/instances
scope: instances:read

List semua instance WhatsApp milik user.

Response 200
{
  "ok": true,
  "data": [
    {
      "id": 1,
      "key": "fathur-utama",
      "name": "fathur romadhon",
      "status": "connected",
      "phone_number": "6285815004099",
      "connected_at": "2026-05-11T16:32:49+00:00",
      "last_seen_at": "2026-05-11T17:30:00+00:00",
      "created_at": "2026-05-10T...",
      "disconnected_at": null
    }
  ]
}
curl -H "Authorization: Bearer wsl_YOUR_TOKEN_HERE" \
     https://waslah.id/api/v1/instances
const res = await fetch('https://waslah.id/api/v1/instances', {
  headers: { 'Authorization': 'Bearer wsl_YOUR_TOKEN_HERE' }
});
const { data } = await res.json();
data.forEach(i => console.log(i.key, i.status));
$res = (new \GuzzleHttp\Client())->get('https://waslah.id/api/v1/instances', [
    'headers' => ['Authorization' => 'Bearer wsl_YOUR_TOKEN_HERE'],
]);
$instances = json_decode($res->getBody(), true)['data'];
r = requests.get(
    'https://waslah.id/api/v1/instances',
    headers={'Authorization': 'Bearer wsl_YOUR_TOKEN_HERE'}
)
for inst in r.json()['data']:
    print(inst['key'], inst['status'])
GET
https://waslah.id/api/v1/instances/{key}
scope: instances:read

Detail satu instance berdasarkan key.

Path Parameter
ParamTipeDeskripsi
keystringKey instance, hanya huruf/angka/dash/underscore.
curl -H "Authorization: Bearer wsl_YOUR_TOKEN_HERE" \
     https://waslah.id/api/v1/instances/fathur-utama
const res = await fetch('https://waslah.id/api/v1/instances/fathur-utama', {
  headers: { 'Authorization': 'Bearer wsl_YOUR_TOKEN_HERE' }
});
$res = (new \GuzzleHttp\Client())->get('https://waslah.id/api/v1/instances/fathur-utama', [
    'headers' => ['Authorization' => 'Bearer wsl_YOUR_TOKEN_HERE'],
]);
r = requests.get(
    'https://waslah.id/api/v1/instances/fathur-utama',
    headers={'Authorization': 'Bearer wsl_YOUR_TOKEN_HERE'}
)
POST
https://waslah.id/api/v1/messages
RECOMMENDED scope: messages:send

Unified endpoint — auto-detect tipe pesan berdasarkan body. Endpoint paling fleksibel untuk integrasi external app.

Auto-routing logic
Kalau body punya...Routed ke
cuma textSend Text (lihat /messages/text)
url atau media_url atau typeSend Media (lihat /messages/media) — default type=image kalau gak di-set
tidak ada text/url422 validation error
Request Examples

Text message:

{
  "instance_key": "fathur-utama",
  "to": "6285815004099",
  "text": "Halo dari API"
}

Media (image):

{
  "instance_key": "fathur-utama",
  "to": "6285815004099",
  "url": "https://picsum.photos/600/400",
  "caption": "Foto promo"
}

Document with explicit type:

{
  "instance_key": "fathur-utama",
  "to": "6285815004099",
  "type": "document",
  "url": "https://example.com/invoice.pdf",
  "filename": "Invoice.pdf"
}

Field params, response shape, dan error codes identik dengan /messages/text / /messages/media tergantung yang ke-route.

Reply ke conversation LID (WhatsApp privacy): field to juga menerima JID utuh (62812xxx@s.whatsapp.net, 12345@lid, 120363xxx@g.us). Pakai ini untuk reply ke customer yang chat pakai mode privasi 2024+ (LID) — kirim ke phone biasa tidak akan sampai. Ambil remote_jid dari webhook message.received lalu pakai persis di field to.
{
  "instance_key": "fathur-utama",
  "to": "123456789012345@lid",
  "text": "Halo, terima kasih sudah menghubungi kami."
}
curl -X POST https://waslah.id/api/v1/messages \
  -H "Authorization: Bearer wsl_YOUR_TOKEN_HERE" \
  -H "Content-Type: application/json" \
  -d '{
    "instance_key": "fathur-utama",
    "to": "6285815004099",
    "text": "Halo dari API"
  }'
// Text
await fetch('https://waslah.id/api/v1/messages', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer wsl_YOUR_TOKEN_HERE',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    instance_key: 'fathur-utama',
    to: '6285815004099',
    text: 'Halo',
  }),
});

// Atau dengan media — sistem auto-detect dari url
await fetch('https://waslah.id/api/v1/messages', {
  method: 'POST',
  headers: { /* sama */ },
  body: JSON.stringify({
    instance_key: 'fathur-utama',
    to: '6285815004099',
    url: 'https://picsum.photos/600/400',
    caption: 'Foto promo',
  }),
});
$client = new \GuzzleHttp\Client();

// Text
$client->post('https://waslah.id/api/v1/messages', [
    'headers' => ['Authorization' => 'Bearer wsl_YOUR_TOKEN_HERE'],
    'json' => [
        'instance_key' => 'fathur-utama',
        'to' => '6285815004099',
        'text' => 'Halo',
    ],
]);

// Media — auto-detect karena ada url
$client->post('https://waslah.id/api/v1/messages', [
    'headers' => ['Authorization' => 'Bearer wsl_YOUR_TOKEN_HERE'],
    'json' => [
        'instance_key' => 'fathur-utama',
        'to' => '6285815004099',
        'url' => 'https://picsum.photos/600/400',
    ],
]);
import requests

h = {'Authorization': 'Bearer wsl_YOUR_TOKEN_HERE'}

# Text
requests.post('https://waslah.id/api/v1/messages', headers=h, json={
    'instance_key': 'fathur-utama',
    'to': '6285815004099',
    'text': 'Halo',
})

# Media — auto-detect
requests.post('https://waslah.id/api/v1/messages', headers=h, json={
    'instance_key': 'fathur-utama',
    'to': '6285815004099',
    'url': 'https://picsum.photos/600/400',
})
POST
https://waslah.id/api/v1/messages/text
scope: messages:send

Kirim pesan teks explicit (text-only endpoint). Endpoint return 202 Accepted langsung; kerjaan kirim ke WhatsApp dijalankan via queue. Cek status final di GET /messages/{id}.

Lebih flexible? Pakai POST /messages — auto-detect text vs media.
Request Body
FieldTipeWajibKeterangan
instance_keystringKey instance yang sudah connected.
tostringNomor tujuan (62..., 08..., +62...) — auto dinormalisasi.
Atau JID utuh untuk reply ke conversation existing: 62812xxx@s.whatsapp.net, 12345@lid (WhatsApp privacy), 120363xxx@g.us (group). Pakai remote_jid dari webhook message.received persis untuk reply LID — kirim ke phone biasa tidak akan sampai ke LID user.
textstringIsi pesan (max 4000 karakter).
scheduled_atISO 8601 datetimeJadwalkan pesan untuk dikirim di masa depan (min 30 detik dari sekarang). Butuh plan Basic+ (feature Schedule message). Format: 2026-05-21T09:00:00+07:00.
Request Example
{
  "instance_key": "fathur-utama",
  "to": "6285815004099",
  "text": "Halo, ini notifikasi dari sistem kami.",
  "scheduled_at": "2026-05-21T09:00:00+07:00"
}
Response 202 (Accepted, queued)
{
  "ok": true,
  "data": {
    "id": 5,
    "status": "pending",
    "instance_key": "fathur-utama",
    "to": "6285815004099",
    "preview": "Halo, ini notifikasi...",
    "scheduled_at": "2026-05-21T09:00:00+07:00",
    "check_status_url": "https://waslah.id/api/v1/messages/5",
    "created_at": "2026-05-19T..."
  }
}
Response 409 (Instance not connected)
{
  "ok": false,
  "error": "instance_not_connected",
  "message": "Instance `xxx` sedang `disconnected`. Hanya bisa kirim saat status `connected`.",
  "instance_status": "disconnected"
}
Response 403 (Schedule butuh plan upgrade)
{
  "ok": false,
  "error": "plan_feature_required",
  "message": "Schedule message butuh plan Basic atau lebih tinggi.",
  "required_feature": "Schedule message",
  "active_plan": "Free"
}
curl -X POST https://waslah.id/api/v1/messages/text \
  -H "Authorization: Bearer wsl_YOUR_TOKEN_HERE" \
  -H "Content-Type: application/json" \
  -d '{
    "instance_key": "fathur-utama",
    "to": "6285815004099",
    "text": "Halo dari API"
  }'
const res = await fetch('https://waslah.id/api/v1/messages/text', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer wsl_YOUR_TOKEN_HERE',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    instance_key: 'fathur-utama',
    to: '6285815004099',
    text: 'Halo dari API',
  }),
});
const { data } = await res.json();
console.log('Queued:', data.id);
$client = new \GuzzleHttp\Client();
$res = $client->post('https://waslah.id/api/v1/messages/text', [
    'headers' => [
        'Authorization' => 'Bearer wsl_YOUR_TOKEN_HERE',
        'Accept' => 'application/json',
    ],
    'json' => [
        'instance_key' => 'fathur-utama',
        'to' => '6285815004099',
        'text' => 'Halo dari API',
    ],
]);
$message = json_decode($res->getBody(), true)['data'];
import requests

r = requests.post(
    'https://waslah.id/api/v1/messages/text',
    headers={
        'Authorization': 'Bearer wsl_YOUR_TOKEN_HERE',
        'Content-Type': 'application/json',
    },
    json={
        'instance_key': 'fathur-utama',
        'to': '6285815004099',
        'text': 'Halo dari API',
    }
)
print(r.json()['data']['id'])
POST
https://waslah.id/api/v1/messages/media
scope: messages:send

Kirim gambar, dokumen, video, atau audio via URL. Engine yang fetch URL + upload ke WhatsApp.

Request Body
FieldTipeWajibKeterangan
instance_keystringKey instance connected.
tostringNomor tujuan, ATAU JID utuh (...@s.whatsapp.net, ...@lid, ...@g.us) untuk reply ke conversation LID/group — sama seperti /messages/text.
typeenumimage, document, video, audio.
urlurlURL public file media (max 2048 char). Tidak boleh localhost / 127.0.0.1 / private IP — engine akan reject. Pakai POST /uploads kalau gak punya host public.
captionstringTeks di bawah media (max 1024 char).
filenamestringNama file untuk type document.
mimetypestringMIME type override.
scheduled_atISO 8601Jadwalkan untuk dikirim nanti (min 30 detik dari sekarang). Butuh plan Basic+.
Request Example
{
  "instance_key": "fathur-utama",
  "to": "6285815004099",
  "type": "image",
  "url": "https://picsum.photos/600/400",
  "caption": "Foto promo bulan ini"
}
Response 202 (Accepted, queued)
{
  "ok": true,
  "data": {
    "id": 12,
    "status": "pending",
    "instance_key": "fathur-utama",
    "to": "6285815004099",
    "type": "image",
    "media_url": "https://picsum.photos/600/400",
    "caption": "Foto promo bulan ini",
    "scheduled_at": null,
    "check_status_url": "https://waslah.id/api/v1/messages/12",
    "created_at": "2026-05-19T..."
  }
}
curl -X POST https://waslah.id/api/v1/messages/media \
  -H "Authorization: Bearer wsl_YOUR_TOKEN_HERE" \
  -H "Content-Type: application/json" \
  -d '{
    "instance_key": "fathur-utama",
    "to": "6285815004099",
    "type": "image",
    "url": "https://picsum.photos/600/400",
    "caption": "Foto promo"
  }'
await fetch('https://waslah.id/api/v1/messages/media', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer wsl_YOUR_TOKEN_HERE',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    instance_key: 'fathur-utama',
    to: '6285815004099',
    type: 'image',
    url: 'https://picsum.photos/600/400',
    caption: 'Foto promo',
  }),
});
$client = new \GuzzleHttp\Client();
$client->post('https://waslah.id/api/v1/messages/media', [
    'headers' => ['Authorization' => 'Bearer wsl_YOUR_TOKEN_HERE'],
    'json' => [
        'instance_key' => 'fathur-utama',
        'to' => '6285815004099',
        'type' => 'image',
        'url' => 'https://picsum.photos/600/400',
        'caption' => 'Foto promo',
    ],
]);
requests.post(
    'https://waslah.id/api/v1/messages/media',
    headers={'Authorization': 'Bearer wsl_YOUR_TOKEN_HERE'},
    json={
        'instance_key': 'fathur-utama',
        'to': '6285815004099',
        'type': 'image',
        'url': 'https://picsum.photos/600/400',
        'caption': 'Foto promo',
    }
)
POST
https://waslah.id/api/v1/messages/bulk
scope: messages:send

Broadcast pesan ke max 100 nomor sekaligus. Buat 1 broadcast record + N pesan, dispatch ke queue paralel. Cek progress via GET /broadcasts/{id}.

Request Body
FieldTipeWajibKeterangan
instance_keystringKey instance connected.
recipientsstring[]Array nomor tujuan (max 100). Duplikat auto-deduped.
namestringNama broadcast untuk identifikasi (max 200 char).
typeenumtext, image, document, video, audio.
textstring✓¹Wajib kalau type=text.
urlurl✓²Wajib kalau type ≠ text.
caption, filename, mimetypestringUntuk media (lihat /messages/media).
delay_min_secintJeda minimum antar pesan (0–300 detik). Default 3.
delay_max_secintJeda maksimum. Sama dengan min = fixed. Beda = random range. Default 8.
Anti-ban WhatsApp. Tanpa delay (0/0) = pesan dikirim paralel cepat, risiko ban tinggi. Rekomendasi: delay_min_sec=3, delay_max_sec=8 untuk jeda random natural.
Response 202
{
  "ok": true,
  "data": {
    "id": 3,
    "name": "Promo Akhir Bulan",
    "message_type": "text",
    "instance_key": "fathur-utama",
    "status": "processing",
    "total": 50,
    "sent": 0,
    "failed": 0,
    "pending": 50,
    "progress_percent": 0,
    "delay_min_sec": 3,
    "delay_max_sec": 8,
    "delay_label": "3–8 detik",
    "created_at": "2026-05-19T...",
    "completed_at": null,
    "check_status_url": "https://waslah.id/api/v1/broadcasts/3"
  }
}
Response 422 (Tidak ada nomor valid)
{
  "ok": false,
  "error": "no_valid_recipients",
  "message": "Tidak ada nomor valid (minimal 8 digit, max 15 digit)."
}
curl -X POST https://waslah.id/api/v1/messages/bulk \
  -H "Authorization: Bearer wsl_YOUR_TOKEN_HERE" \
  -H "Content-Type: application/json" \
  -d '{
    "instance_key": "fathur-utama",
    "name": "Promo Akhir Bulan",
    "type": "text",
    "text": "Halo, ada promo nih!",
    "recipients": ["62812xxx", "62813xxx", "62814xxx"]
  }'
await fetch('https://waslah.id/api/v1/messages/bulk', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer wsl_YOUR_TOKEN_HERE',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    instance_key: 'fathur-utama',
    name: 'Promo Akhir Bulan',
    type: 'text',
    text: 'Halo, ada promo nih!',
    recipients: ['62812xxx', '62813xxx', '62814xxx'],
  }),
});
$client = new \GuzzleHttp\Client();
$res = $client->post('https://waslah.id/api/v1/messages/bulk', [
    'headers' => ['Authorization' => 'Bearer wsl_YOUR_TOKEN_HERE'],
    'json' => [
        'instance_key' => 'fathur-utama',
        'name' => 'Promo Akhir Bulan',
        'type' => 'text',
        'text' => 'Halo, ada promo nih!',
        'recipients' => ['62812xxx', '62813xxx', '62814xxx'],
    ],
]);
$broadcastId = json_decode($res->getBody(), true)['data']['id'];
r = requests.post(
    'https://waslah.id/api/v1/messages/bulk',
    headers={'Authorization': 'Bearer wsl_YOUR_TOKEN_HERE'},
    json={
        'instance_key': 'fathur-utama',
        'name': 'Promo Akhir Bulan',
        'type': 'text',
        'text': 'Halo, ada promo nih!',
        'recipients': ['62812xxx', '62813xxx', '62814xxx'],
    }
)
broadcast_id = r.json()['data']['id']
POST
https://waslah.id/api/v1/uploads
scope: messages:send

Upload file (multipart/form-data). Return public URL yang bisa langsung dipakai untuk /messages/media atau /messages/bulk. Max 16MB. Tidak konsumsi quota.

Allowed MIME types

Image: jpeg, png, webp, gif  ·  Video: mp4, quicktime  ·  Audio: mpeg, mp4, ogg, wav, aac  ·  Document: pdf, doc/docx, xls/xlsx, ppt/pptx, txt, csv, zip

Response 200
{
  "ok": true,
  "data": {
    "url": "https://waslah.id/storage/uploads/2026-05/9c5b...-uuid.jpg",
    "path": "uploads/2026-05/9c5b...-uuid.jpg",
    "filename": "kucing.jpg",
    "size": 245870,
    "mime_type": "image/jpeg",
    "type": "image"
  }
}

url = public URL siap pakai (paste ke /messages/media.url). path = relative storage path (untuk delete/admin use). type = auto-detect dari MIME (image|video|audio|document).

Response 422 (MIME tidak didukung)
{
  "ok": false,
  "error": "unsupported_media_type",
  "message": "MIME type `application/x-foo` tidak didukung. Cek dokumentasi untuk format yang diterima.",
  "mime_type": "application/x-foo"
}
Response 422 (Validation gagal, mis. file kosong / >16MB)
{
  "ok": false,
  "error": "validation_failed",
  "message": "File tidak valid: The file field is required.",
  "errors": {
    "file": ["The file field is required."]
  }
}
curl -X POST https://waslah.id/api/v1/uploads \
  -H "Authorization: Bearer wsl_YOUR_TOKEN_HERE" \
  -F "file=@/path/to/image.jpg"
const fd = new FormData();
fd.append('file', fileInput.files[0]);

const res = await fetch('https://waslah.id/api/v1/uploads', {
  method: 'POST',
  headers: { 'Authorization': 'Bearer wsl_YOUR_TOKEN_HERE' },
  body: fd,
});
const { data } = await res.json();
console.log('Uploaded URL:', data.url);
$client = new \GuzzleHttp\Client();
$res = $client->post('https://waslah.id/api/v1/uploads', [
    'headers' => ['Authorization' => 'Bearer wsl_YOUR_TOKEN_HERE'],
    'multipart' => [
        ['name' => 'file', 'contents' => fopen('/path/to/image.jpg', 'r')],
    ],
]);
$url = json_decode($res->getBody(), true)['data']['url'];
with open('/path/to/image.jpg', 'rb') as f:
    r = requests.post(
        'https://waslah.id/api/v1/uploads',
        headers={'Authorization': 'Bearer wsl_YOUR_TOKEN_HERE'},
        files={'file': f},
    )
url = r.json()['data']['url']
GET
https://waslah.id/api/v1/messages/{id}
scope: messages:read

Cek status pesan. Status bisa pending, sent, delivered, read, failed.

Response 200
{
  "ok": true,
  "data": {
    "id": 5,
    "instance_key": "fathur-utama",
    "direction": "outbound",
    "status": "sent",
    "message_type": "text",
    "phone_number": "6285815004099",
    "remote_jid": "6285815004099@s.whatsapp.net",
    "body": "Halo dari API",
    "media_url": null,
    "media_filename": null,
    "media_mime_type": null,
    "provider_message_id": "BAE5...",
    "error_message": null,
    "sent_at": "2026-05-19T...",
    "received_at": null,
    "scheduled_at": null,
    "created_at": "2026-05-19T...",
    "updated_at": "2026-05-19T..."
  }
}

Untuk pesan media: message_type = image|video|audio|document|sticker, media_url berisi URL file. Untuk reply ke conversation LID (privacy mode), remote_jid akan berakhiran @lid alih-alih @s.whatsapp.net — pakai value remote_jid persis di field to saat call /messages/text / /messages/media untuk reply ke customer LID.

Response 404
{
  "ok": false,
  "error": "not_found",
  "message": "Message dengan ID `99999` tidak ditemukan atau bukan milik Anda."
}
curl -H "Authorization: Bearer wsl_YOUR_TOKEN_HERE" \
     https://waslah.id/api/v1/messages/5
// Poll status sampai final (sent/delivered/failed)
async function pollStatus(id, token) {
  while (true) {
    const r = await fetch(`https://waslah.id/api/v1/messages/${id}`, {
      headers: { 'Authorization': `Bearer ${token}` }
    });
    const { data } = await r.json();
    if (data.status !== 'pending') return data;
    await new Promise(r => setTimeout(r, 2000));
  }
}
function pollStatus(int $id, string $token): array {
    $client = new \GuzzleHttp\Client();
    while (true) {
        $res = $client->get("https://waslah.id/api/v1/messages/$id", [
            'headers' => ['Authorization' => "Bearer $token"]
        ]);
        $data = json_decode($res->getBody(), true)['data'];
        if ($data['status'] !== 'pending') return $data;
        sleep(2);
    }
}
import requests, time

def poll_status(id, token):
    while True:
        r = requests.get(
            f'https://waslah.id/api/v1/messages/{id}',
            headers={'Authorization': f'Bearer {token}'}
        )
        data = r.json()['data']
        if data['status'] != 'pending':
            return data
        time.sleep(2)
GET
https://waslah.id/api/v1/broadcasts
scope: messages:read

List 50 broadcast terakhir milik user dengan progress real-time.

curl -H "Authorization: Bearer wsl_YOUR_TOKEN_HERE" \
     https://waslah.id/api/v1/broadcasts
const res = await fetch('https://waslah.id/api/v1/broadcasts', {
  headers: { 'Authorization': 'Bearer wsl_YOUR_TOKEN_HERE' }
});
const { data } = await res.json();
data.forEach(b => console.log(b.id, b.status, `${b.sent}/${b.total}`));
$res = (new \GuzzleHttp\Client())->get('https://waslah.id/api/v1/broadcasts', [
    'headers' => ['Authorization' => 'Bearer wsl_YOUR_TOKEN_HERE'],
]);
r = requests.get('https://waslah.id/api/v1/broadcasts',
    headers={'Authorization': 'Bearer wsl_YOUR_TOKEN_HERE'}
)
for b in r.json()['data']:
    print(b['id'], b['status'], f"{b['sent']}/{b['total']}")
GET
https://waslah.id/api/v1/broadcasts/{id}
scope: messages:read

Cek progress real-time broadcast. Poll endpoint ini sampai status jadi completed atau failed.

Response 200
{
  "ok": true,
  "data": {
    "id": 3,
    "name": "Promo Akhir Bulan",
    "message_type": "text",
    "instance_key": "fathur-utama",
    "status": "processing",
    "total": 50,
    "sent": 32,
    "failed": 1,
    "pending": 17,
    "progress_percent": 66,
    "delay_min_sec": 3,
    "delay_max_sec": 8,
    "delay_label": "3–8 detik",
    "created_at": "2026-05-19T...",
    "completed_at": null
  }
}

statusqueued|processing|completed|failed. delay_label human-readable (mis. 3 detik (fixed) kalau min=max). completed_at diisi saat status final.

curl -H "Authorization: Bearer wsl_YOUR_TOKEN_HERE" \
     https://waslah.id/api/v1/broadcasts/3
// Poll progress sampai selesai
async function trackBroadcast(id, token) {
  while (true) {
    const r = await fetch(`https://waslah.id/api/v1/broadcasts/${id}`, {
      headers: { 'Authorization': `Bearer ${token}` }
    });
    const { data } = await r.json();
    console.log(`${data.progress_percent}% — sent ${data.sent}/${data.total}`);
    if (data.status !== 'processing') return data;
    await new Promise(r => setTimeout(r, 3000));
  }
}
function trackBroadcast(int $id, string $token): array {
    $client = new \GuzzleHttp\Client();
    while (true) {
        $res = $client->get("https://waslah.id/api/v1/broadcasts/$id", [
            'headers' => ['Authorization' => "Bearer $token"]
        ]);
        $data = json_decode($res->getBody(), true)['data'];
        echo $data['progress_percent'] . "% — sent {$data['sent']}/{$data['total']}\n";
        if ($data['status'] !== 'processing') return $data;
        sleep(3);
    }
}
import time

def track_broadcast(id, token):
    while True:
        r = requests.get(f'https://waslah.id/api/v1/broadcasts/{id}',
            headers={'Authorization': f'Bearer {token}'}
        )
        d = r.json()['data']
        print(f"{d['progress_percent']}% — sent {d['sent']}/{d['total']}")
        if d['status'] != 'processing':
            return d
        time.sleep(3)

Error Codes

Semua error response punya format konsisten:

{
  "ok": false,
  "error": "error_code_string",
  "message": "Human-readable message",
  ...additional context...
}
HTTPError CodePenyebab
401unauthorizedToken tidak ada, salah, atau di-revoke.
403forbidden_scopeAPI key tidak punya scope yang dibutuhkan endpoint.
403plan_feature_requiredPlan kamu belum punya fitur yang dibutuhkan (mis. Schedule message, REST API access). Response include required_feature + active_plan.
404not_found / instance_not_foundResource tidak ada atau bukan milik kamu.
409instance_not_connectedMencoba kirim pesan saat instance tidak `connected`.
422validation_failedBody request tidak lolos validasi. Cek field errors.
422no_valid_recipientsBroadcast: tidak ada nomor valid setelah normalize (min 8 digit, max 15).
422unsupported_media_typeUpload: MIME type tidak ada di whitelist.
429Rate limit terlampaui. Tunggu sampai X-RateLimit-Reset.
500Server error. Lapor ke support kalau berulang.
502Engine down / tidak respon. Coba lagi.

Rate Limits per Plan

Limit di-scope per API key, di-track per menit. Kalau exceed, response 429 Too Many Requests.

PlanRequest / menit
Free 60
Basic 300
Pro 1,000
Enterprise 5,000
Response Headers
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 47
X-RateLimit-Reset: 1699999999
Retry-After: 12
Butuh limit lebih tinggi? Upgrade plan via Subscription. Bulk send (1 request kirim ke banyak nomor) pakai endpoint POST /messages/bulk supaya hemat quota request.

Scopes

Scope membatasi apa yang bisa dilakukan API key. Set seminimal mungkin (principle of least privilege).

ScopeAkses
messages:send Kirim pesan
messages:read Baca histori pesan
instances:read Lihat status instance
instances:write Kelola instance (start/disconnect)

Outbound Webhooks

Daftarkan URL endpoint kamu — Waslah akan POST event real-time saat ada pesan masuk, status update, dst. Butuh plan Basic+.

Setup
  1. Buka Developer → Webhooks → klik Tambah Webhook
  2. Isi URL endpoint (HTTPS recommended), pilih event yang di-subscribe
  3. Catat secret yang ter-generate — dipakai untuk verify signature
Event types
EventTrigger
message.receivedPesan masuk dari customer
message.sentPesan outbound berhasil dikirim ke WA (status: queued)
message.statusUpdate status: delivered, read, played
Headers yang Waslah kirim

Setiap webhook POST mengirim headers berikut. Signature dikirim di 5 alias header sekaligus untuk maximum compatibility dengan berbagai framework — pilih salah satu di kode validator kamu.

HeaderContoh ValueCatatan
X-Waslah-Signaturesha256=<hex>Primary — recommended
X-Signaturesha256=<hex>Alias generic
Signaturesha256=<hex>Alias bare
X-Hub-Signature-256sha256=<hex>GitHub convention
X-Webhook-Signaturesha256=<hex>Common convention
X-Waslah-Timestamp1748036400Unix timestamp — dipakai di HMAC payload
X-Waslah-Eventmessage.receivedEvent type
X-Waslah-Event-IdUUIDUnique per delivery (anti duplicate processing)
User-AgentWaslah-Webhook/1.0Identifier
Formula HMAC SHA-256
payload_to_sign = X-Waslah-Timestamp + "." + raw_request_body
signature       = HMAC-SHA256(payload_to_sign, webhook_secret)
header_value    = "sha256=" + bin2hex(signature)
Contoh Validator — PHP (vanilla)
$timestamp = $_SERVER['HTTP_X_WASLAH_TIMESTAMP'] ?? '';
$header = $_SERVER['HTTP_X_WASLAH_SIGNATURE'] ?? '';
$body = file_get_contents('php://input');
$secret = 'YOUR_WEBHOOK_SECRET'; // dari dashboard

$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $body, $secret);

if (! hash_equals($expected, $header)) {
    http_response_code(401);
    exit(json_encode(['ok' => false, 'error' => 'Invalid signature']));
}

// Anti-replay: reject kalau timestamp > 5 menit lalu
if (abs(time() - (int) $timestamp) > 300) {
    http_response_code(401);
    exit(json_encode(['ok' => false, 'error' => 'Timestamp too old']));
}

$data = json_decode($body, true);
// ... process event ...
http_response_code(200);
echo json_encode(['ok' => true]);
Contoh Validator — Node.js / Express
const crypto = require('crypto');
const express = require('express');
const app = express();

// PENTING: raw body required untuk signature verify
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
    const timestamp = req.header('X-Waslah-Timestamp');
    const received = req.header('X-Waslah-Signature');
    const body = req.body.toString('utf8');
    const secret = process.env.WASLAH_WEBHOOK_SECRET;

    const expected = 'sha256=' + crypto
        .createHmac('sha256', secret)
        .update(timestamp + '.' + body)
        .digest('hex');

    if (! crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received))) {
        return res.status(401).json({ ok: false, error: 'Invalid signature' });
    }

    if (Math.abs(Date.now() / 1000 - parseInt(timestamp)) > 300) {
        return res.status(401).json({ ok: false, error: 'Timestamp too old' });
    }

    const data = JSON.parse(body);
    console.log('Event:', data.event, data.data);
    res.json({ ok: true });
});
Contoh Validator — Python / Flask
import hmac, hashlib, time
from flask import Flask, request, jsonify

app = Flask(__name__)
SECRET = b'YOUR_WEBHOOK_SECRET'

@app.route('/webhook', methods=['POST'])
def webhook():
    timestamp = request.headers.get('X-Waslah-Timestamp', '')
    received = request.headers.get('X-Waslah-Signature', '')
    body = request.get_data()

    expected = 'sha256=' + hmac.new(
        SECRET, (timestamp + '.').encode() + body, hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(expected, received):
        return jsonify(ok=False, error='Invalid signature'), 401

    if abs(time.time() - int(timestamp)) > 300:
        return jsonify(ok=False, error='Timestamp too old'), 401

    data = request.get_json()
    print('Event:', data['event'], data['data'])
    return jsonify(ok=True)
Penting: Selalu pakai hash_equals / timingSafeEqual / compare_digest — bukan === atau ==. Anti timing attack yang bisa leak signature byte-by-byte.
Example payload (message.received)
{
  "event": "message.received",
  "timestamp": 1715000000,
  "data": {
    "message_id": 1234,
    "instance_key": "fathur-utama",
    "from": "6281234567890",
    "remote_jid": "6281234567890@s.whatsapp.net",
    "body": "Halo, info produk",
    "message_type": "text",
    "received_at": "2026-05-12T11:05:23+07:00"
  }
}
Reply otomatis ke customer: ambil data.remote_jid dari payload webhook lalu pakai persis sebagai field to saat call POST /messages/text. Untuk customer dengan WhatsApp privacy mode 2024+ (LID), remote_jid berakhiran @lidharus pakai full JID, karena reply ke from (digit phone) tidak sampai ke nomor LID.
// Contoh auto-reply Node.js
app.post('/webhook', (req, res) => {
  const { event, data } = req.body;
  if (event === 'message.received') {
    fetch('https://waslah.id/api/v1/messages/text', {
      method: 'POST',
      headers: { 'Authorization': 'Bearer wsl_xxx', 'Content-Type': 'application/json' },
      body: JSON.stringify({
        instance_key: data.instance_key,
        to: data.remote_jid, // ← pakai JID utuh, bukan data.from
        text: 'Terima kasih, pesan kamu sudah kami terima.'
      })
    });
  }
  res.json({ ok: true });
});
Engine retry 3x dengan backoff kalau endpoint kamu return non-2xx. Setelah max retry, event di-queue di disk + retry tiap 30 detik sampai sukses. Response harus 2xx dalam 10 detik — kalau lebih lama, considered failed.