Send WhatsApp messages from your own systems — order updates, one-time codes, reminders — and track delivery and read receipts. Every message goes through the same number protection as the dashboard: warm-up, human pacing, quiet hours and health checks.
Base URL
https://qonvo.falak.sh/api/v2
Format
JSON in, JSON out
Auth
Bearer API key
Create a key under Settings → API keys and send it in the Authorization header. Keys start with qv_ and are shown once. Your plan must include API access.
curl https://qonvo.falak.sh/api/v2/me \
-H "Authorization: Bearer qv_your_key"
// .env
QONVO_BASE_URL=https://qonvo.falak.sh
QONVO_API_KEY=qv_your_key
const res = await fetch("https://qonvo.falak.sh/api/v2/me", {
headers: { Authorization: "Bearer " + process.env.QONVO_API_KEY },
});
const { data } = await res.json();
import os, requests
res = requests.get(
"https://qonvo.falak.sh/api/v2/me",
headers={"Authorization": f"Bearer {os.environ['QONVO_API_KEY']}"},
)
data = res.json()["data"]
Never put a key in a mobile app or browser code. Revoke any key you think leaked — requests with it stop working immediately.
The qonvo/laravel-qonvo package adds a qonvo notification channel. It retries safely with an idempotency key per notification and turns API errors into exceptions.
composer require qonvo/laravel-qonvo
use Qonvo\Laravel\QonvoMessage;
public function via(object $notifiable): array
{
return ['qonvo'];
}
public function toQonvo(object $notifiable): QonvoMessage
{
return QonvoMessage::text("Your order #{$this->order->id} has shipped.");
}
// On the notifiable model
public function routeNotificationForQonvo(): string
{
return $this->phone; // E.164, e.g. +218910000000
}
Queues a message and returns 202 Accepted. The pacer sends it as soon as the number's warm-up, caps and quiet hours allow, so status starts as queued. Replies to people who just wrote to you go first.
+218910000000) or a group JID (…@g.us).text (default), image, video, audio, document, sticker, location, contact, poll or template. See message types.text.transactional (default) or marketing. Marketing needs the contact's opt-in or a conversation they started, and is never sent at night.2026-10-01T09:00:00Z.curl -X POST https://qonvo.falak.sh/api/v2/messages \
-H "Authorization: Bearer qv_your_key" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042-shipped" \
-d '{"to": "+218920000000", "text": "Your order #1042 has shipped."}'
$user->notify(new OrderShipped($order));
// or without a notification
use Qonvo\Laravel\Facades\Qonvo;
Qonvo::send(
QonvoMessage::text('Your order #1042 has shipped.')->to('+218920000000'),
idempotencyKey: 'order-1042-shipped',
);
const res = await fetch("https://qonvo.falak.sh/api/v2/messages", {
method: "POST",
headers: {
Authorization: "Bearer " + process.env.QONVO_API_KEY,
"Content-Type": "application/json",
"Idempotency-Key": "order-1042-shipped",
},
body: JSON.stringify({ to: "+218920000000", text: "Your order #1042 has shipped." }),
});
const { data } = await res.json(); // data.id, data.status === "queued"
res = requests.post(
"https://qonvo.falak.sh/api/v2/messages",
headers={
"Authorization": f"Bearer {os.environ['QONVO_API_KEY']}",
"Idempotency-Key": "order-1042-shipped",
},
json={"to": "+218920000000", "text": "Your order #1042 has shipped."},
)
message = res.json()["data"]
{
"data": {
"id": "01928c5e-7a8b-7c3d-9e4f-5a6b7c8d9e0f",
"status": "queued",
"from": 3,
"to": "+218920000000",
"type": "text",
"category": "transactional",
"error": null,
"created_at": "2026-09-30T10:15:00Z",
"scheduled_at": null,
"sent_at": null,
"delivered_at": null,
"read_at": null
}
}Set type and add the matching field. Media can be a public URL or base64 data.
{"media": {"url": "https://…"}} or {"media": {"base64": "…", "mimetype": "image/png"}}, with optional filename and caption.{"location": {"latitude": 32.88, "longitude": 13.19, "name": "Our shop"}}{"contact": {"name": "Support", "vcard": "BEGIN:VCARD…"}}{"poll": {"question": "…", "options": ["A", "B"], "selectable_count": 1}} — 2 to 12 options.{"template": {"name": "order_update", "language": "en", "components": […]}}{"otp": {"code": "482913", "expires_in_minutes": 10}} — a verification code with a Copy code button, sent first. Optional text must hold the code on its own line.curl -X POST https://qonvo.falak.sh/api/v2/messages \
-H "Authorization: Bearer qv_your_key" \
-H "Content-Type: application/json" \
-d '{"to": "+218920000000", "type": "image",
"media": {"url": "https://example.com/receipt.png", "caption": "Your receipt"}}'
QonvoMessage::image('https://example.com/receipt.png', 'Your receipt');
QonvoMessage::document('https://example.com/invoice.pdf', 'invoice.pdf');
QonvoMessage::location(32.88, 13.19, 'Our shop');
QonvoMessage::poll('Pick a time', ['Morning', 'Evening']);
QonvoMessage::template('order_update', 'en', $components);
body: JSON.stringify({
to: "+218920000000",
type: "image",
media: { url: "https://example.com/receipt.png", caption: "Your receipt" },
})
json={
"to": "+218920000000",
"type": "image",
"media": {"url": "https://example.com/receipt.png", "caption": "Your receipt"},
}
Returns the message with its current status — queued, sending, sent, delivered, read, failed or cancelled — and the time of each step. Failed messages carry the reason in error.
curl https://qonvo.falak.sh/api/v2/messages/01928c5e-7a8b-7c3d-9e4f-5a6b7c8d9e0f \
-H "Authorization: Bearer qv_your_key"
$message = Qonvo::message('01928c5e-7a8b-7c3d-9e4f-5a6b7c8d9e0f');
const res = await fetch(`https://qonvo.falak.sh/api/v2/messages/${id}`, {
headers: { Authorization: "Bearer " + process.env.QONVO_API_KEY },
});
res = requests.get(f"https://qonvo.falak.sh/api/v2/messages/{id}", headers=headers)
Messages you sent in the last 90 days, newest first. Pass next_cursor from the response as cursor to get the next page; it is null on the last page.
next_cursor of the previous page.curl "https://qonvo.falak.sh/api/v2/messages?status=failed&limit=50" \
-H "Authorization: Bearer qv_your_key"
$page = Qonvo::messages(['status' => 'failed', 'limit' => 50]);
$next = Qonvo::messages(['cursor' => $page->nextCursor]);
const res = await fetch("https://qonvo.falak.sh/api/v2/messages?status=failed&limit=50", { headers });
const { data, next_cursor } = await res.json();
page = requests.get(f"https://qonvo.falak.sh/api/v2/messages", params={"status": "failed", "limit": 50}, headers=headers).json()
messages, cursor = page["data"], page["next_cursor"]
Your plan and how much of today's and this month's quota is used. A null limit means unlimited.
{
"data": {
"id": 42,
"name": "Demo Store",
"plan": {"slug": "business", "name": "Business"},
"quota": {"daily_limit": null, "daily_used": 40, "monthly_limit": null, "monthly_used": 1210}
}
}Your numbers with their provider (whatsmeow for linked phones, cloud for official numbers), connection status and health from 0 to 100. Below 70 Qonvo sends more slowly; below 25 it pauses the number for a few hours.
{
"data": [
{"id": 3, "phone": "+218910000000", "provider": "whatsmeow", "status": "connected", "health": 92}
]
}Record marketing consent collected on your website or at checkout, or stop all marketing to a contact. Both return 204 No Content. People can also opt out themselves by replying STOP.
checkout. Kept as proof.curl -X POST https://qonvo.falak.sh/api/v2/contacts/opt-in \
-H "Authorization: Bearer qv_your_key" \
-H "Content-Type: application/json" \
-d '{"phone": "+218920000000", "source": "checkout"}'
Qonvo::optIn('+218920000000', source: 'checkout');
Qonvo::optOut('+218920000000');
await fetch("https://qonvo.falak.sh/api/v2/contacts/opt-in", {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({ phone: "+218920000000", source: "checkout" }),
});
requests.post(f"https://qonvo.falak.sh/api/v2/contacts/opt-in", headers=headers,
json={"phone": "+218920000000", "source": "checkout"})
Send an Idempotency-Key header (up to 255 characters) on every POST. If you repeat a request with the same key within 24 hours, you get the original response back and nothing is sent twice — so retrying after a timeout is always safe. Reusing a key with a different body returns 409 idempotency_conflict.
Good keys come from your own data: order-1042-shipped, otp-{user}-{attempt}.
GET /me.Over a limit you get 429 with rate_limited and retry_after in seconds, or quota_exceeded with your current quota. Rate limits are separate from number pacing: accepted messages are always queued and sent at a safe speed.
Official numbers use Meta's WhatsApp Cloud API. WhatsApp only allows free-form messages to someone who wrote to you in the last 24 hours; outside that window, send an approved template. A free-form message outside the window returns 422 outside_window.
A linked number can route to an official one automatically when it is unhealthy, out of today's warm-up budget, or messaging a new contact — set it up on the number's page. Routed messages report the official number in from.
Errors share one shape. fields is only present for validation errors.
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"fields": {"to": ["The to field is required."]}
}
}validation_failed, number_not_connected, recipient_suppressed (opted out, or marketing without consent), outside_window.rate_limited or quota_exceeded.Integrations built for the previous Qonvo API keep working unchanged at https://qonvo.falak.sh/api/whatsapp/…, with the same keys, requests and responses. New integrations should use v2 for idempotency, scheduling and consent.