Developer documentation
Send email, SMS and WhatsApp
from the system you already run.
One HTTP call, one API key, three channels. Your ERP, CRM, LMS, core banking system or a twelve-line cron script can all use the same endpoint. No SDK to install.
To one number or ten thousand, scheduled or immediate.
From your own authenticated domain, with attachments.
Text, image, audio, video or document.
Quickstart
Three steps. If you have your API key, this takes about a minute.
1. Get your API key
Sign in to your BeSends panel and open Developer → API key. Generate one if you have not already. It is a 36-character string. Treat it like a password: it can send messages and spend your credits.
2. Send a test message
Replace YOUR_API_KEY and the recipient, then run it.
curl -X POST https://besends.com/api/sms/send \
-H "Api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"contact": [
{ "number": "8801712345678", "message": "Your order #4417 is out for delivery." }
]
}'
3. Read the response
Every successful call returns the same envelope. data holds one entry per
message, and each carries an id you can look up later.
{
"success": true,
"message": "Sms dispatch request created successfully",
"data": [
{
"id": 84213,
"status": "pending",
"contact_id": 5591,
"created_at": "2026-09-07 14:02:11"
}
]
}
Accepted is not delivered. A 200 means BeSends has queued the
message. The real outcome arrives on the log record a few seconds later — poll
GET /api/get/sms/{id} or read it in the panel. Nothing about this API
guarantees delivery, and no honest messaging API can.
Authentication
Every request carries your API key. Three ways to supply it, in the order the server checks them:
| Method | How | Use it when |
|---|---|---|
| Header preferred | Api-key: YOUR_API_KEY | Always, unless you cannot set headers. |
| Query string | ?api_key=YOUR_API_KEY | Legacy systems that can only fetch a URL. |
| Request body | "api_key": "YOUR_API_KEY" | Form posts from tools with no header support. |
A key in a URL leaks. It lands in browser history, proxy logs and server access logs. The query and body methods exist so old systems can integrate at all — use the header everywhere you can, and never put a key in front-end JavaScript or a mobile app. Calls must come from your server.
What the server checks
- The key exists and belongs to an account.
- That account has a running subscription. An expired plan returns
403even though the key is valid.
{
"status": "error",
"message": "API key is required. Provide via header (Api-key) or URL parameter (api_key)",
"error": "Invalid Api Key"
}
{
"status": "error",
"error": "Your Subscription Is Expired! Buy A New Plan"
}
Conventions
- Base URL —
https://besends.com/api. HTTPS only. - Content type — send
Content-Type: application/jsonandAccept: application/json. Without the Accept header a validation failure may come back as HTML instead of JSON. - Batching —
contactis always an array. One call can carry many recipients, each with its own message, schedule and gateway. Prefer one call with 500 recipients over 500 calls. - Phone numbers — international format without
+is safest:8801712345678. Local formats are accepted and normalised, but be explicit. - Timestamps —
Y-m-d H:i:sin your account's timezone, e.g.2026-09-08 09:30:00. Any other format is rejected. - Idempotency — there is none. If a call times out, check the log before retrying, or you may send twice.
SMS
Queue one or many SMS. The main endpoint — use this one.
| Field | Type | Notes | |
|---|---|---|---|
contact | required | array | At least one entry. |
contact[].number | required | string | Recipient. Max 255 characters. |
contact[].message | required | string | The text. Unicode is fine; note that non-Latin script costs more segments. |
contact[].schedule_at | optional | string | Y-m-d H:i:s. Omit to send now. |
contact[].gateway_identifier | optional | string | The uid of a specific gateway on your account. Omit to use your default. |
contact[].sms_type | optional | string | Passed through to the route as metadata. |
method | optional | string | Top level, not per contact. api or android. Overrides your account default. |
curl -X POST https://besends.com/api/sms/send \
-H "Api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"contact": [
{
"number": "8801712345678",
"message": "Your fee for September is due on 12 Sep. Pay at the accounts desk."
},
{
"number": "8801812345678",
"message": "Reminder: your appointment is tomorrow at 10:00.",
"schedule_at": "2026-09-08 09:00:00"
}
]
}'
<?php
$payload = [
'contact' => [
[
'number' => '8801712345678',
'message' => 'Your fee for September is due on 12 Sep.',
],
[
'number' => '8801812345678',
'message' => 'Reminder: your appointment is tomorrow at 10:00.',
'schedule_at' => '2026-09-08 09:00:00',
],
],
];
$ch = curl_init('https://besends.com/api/sms/send');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Api-key: ' . getenv('CONNECTS_API_KEY'),
'Content-Type: application/json',
'Accept: application/json',
],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_TIMEOUT => 30,
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$result = json_decode($body, true);
if ($code !== 200 || empty($result['success'])) {
// Log it and move on — never let a failed SMS break the calling process.
error_log('BeSends: ' . $body);
} else {
foreach ($result['data'] as $log) {
echo "queued {$log['id']}\n";
}
}
import os
import requests
BASE = "https://besends.com/api"
KEY = os.environ["CONNECTS_API_KEY"]
def send_sms(messages):
"""messages: list of {"number": str, "message": str, "schedule_at": str|None}"""
response = requests.post(
f"{BASE}/sms/send",
headers={
"Api-key": KEY,
"Content-Type": "application/json",
"Accept": "application/json",
},
json={"contact": messages},
timeout=30,
)
body = response.json()
if response.status_code != 200 or not body.get("success"):
raise RuntimeError(f"BeSends rejected the send: {body}")
return [row["id"] for row in body["data"]]
ids = send_sms([
{"number": "8801712345678",
"message": "Your fee for September is due on 12 Sep."},
{"number": "8801812345678",
"message": "Reminder: your appointment is tomorrow at 10:00.",
"schedule_at": "2026-09-08 09:00:00"},
])
print("queued:", ids)
const BASE = 'https://besends.com/api';
const KEY = process.env.CONNECTS_API_KEY;
async function sendSms(contact) {
const res = await fetch(`${BASE}/sms/send`, {
method: 'POST',
headers: {
'Api-key': KEY,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ contact }),
});
const body = await res.json();
if (!res.ok || !body.success) {
throw new Error(`BeSends rejected the send: ${JSON.stringify(body)}`);
}
return body.data.map((row) => row.id);
}
const ids = await sendSms([
{ number: '8801712345678',
message: 'Your fee for September is due on 12 Sep.' },
{ number: '8801812345678',
message: 'Reminder: your appointment is tomorrow at 10:00.',
schedule_at: '2026-09-08 09:00:00' },
]);
console.log('queued:', ids);
import java.net.URI;
import java.net.http.*;
import java.time.Duration;
public class BeSends {
static final String BASE = "https://besends.com/api";
static final String KEY = System.getenv("CONNECTS_API_KEY");
public static String sendSms(String json) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/sms/send"))
.header("Api-key", KEY)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(30))
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new RuntimeException("BeSends: " + response.body());
}
return response.body();
}
public static void main(String[] args) throws Exception {
String payload = """
{ "contact": [
{ "number": "8801712345678",
"message": "Your fee for September is due on 12 Sep." }
] }
""";
System.out.println(sendSms(payload));
}
}
using System.Net.Http.Json;
using System.Text.Json;
var baseUrl = "https://besends.com/api";
var key = Environment.GetEnvironmentVariable("CONNECTS_API_KEY");
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
http.DefaultRequestHeaders.Add("Api-key", key);
http.DefaultRequestHeaders.Add("Accept", "application/json");
var payload = new
{
contact = new[]
{
new { number = "8801712345678",
message = "Your fee for September is due on 12 Sep." }
}
};
var response = await http.PostAsJsonAsync($"{baseUrl}/sms/send", payload);
var body = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
throw new Exception($"BeSends rejected the send: {body}");
Console.WriteLine(body);
The same send as a plain URL, for systems that cannot POST JSON.
Query parameters: contacts (comma separated), message,
and optionally schedule_at, sms_type,
gateway_identifier, method.
curl -G "https://besends.com/api/sms/send" \
-H "Api-key: YOUR_API_KEY" \
--data-urlencode "contacts=8801712345678,8801812345678" \
--data-urlencode "message=Your order has shipped."
Everything is visible in the URL, including the message. Use POST unless the calling system genuinely cannot.
Queue one or many emails, with optional attachments.
| Field | Type | Notes | |
|---|---|---|---|
contact | required | array | At least one entry. |
contact[].email | required | string | Valid address, max 255. |
contact[].subject | required | string | Max 255. |
contact[].message | required | string | HTML is accepted. |
contact[].sender_name | optional | string | Display name on the From line. |
contact[].reply_to_email | optional | string | Where replies go. |
contact[].schedule_at | optional | string | Y-m-d H:i:s. |
contact[].gateway_identifier | optional | string | Must be an active email gateway uid on your account. |
attachments[] | optional | file | Multipart only. pdf, doc(x), xls(x), csv, txt, png, jpg, jpeg, gif, zip, rar, svg, webp. File count and size caps are set on your plan. |
curl -X POST https://besends.com/api/email/send \
-H "Api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"contact": [
{
"email": "finance@acme.com.bd",
"subject": "Invoice INV-2026-0912",
"message": "<p>Dear Acme,</p><p>Invoice <b>INV-2026-0912</b> for BDT 48,500 is attached and due on 21 September.</p>",
"sender_name": "Acme Accounts",
"reply_to_email": "accounts@yourcompany.com.bd"
}
]
}'
<?php
// With an attachment the request must be multipart, not JSON.
$post = [
'contact[0][email]' => 'finance@acme.com.bd',
'contact[0][subject]' => 'Invoice INV-2026-0912',
'contact[0][message]' => '<p>Invoice attached, due 21 September.</p>',
'contact[0][sender_name]' => 'Acme Accounts',
'attachments[0]' => new CURLFile('/srv/invoices/INV-2026-0912.pdf'),
];
$ch = curl_init('https://besends.com/api/email/send');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Api-key: ' . getenv('CONNECTS_API_KEY'),
'Accept: application/json',
],
CURLOPT_POSTFIELDS => $post, // no json_encode — let curl build multipart
CURLOPT_TIMEOUT => 60,
]);
echo curl_exec($ch);
curl_close($ch);
import os, requests
BASE = "https://besends.com/api"
KEY = os.environ["CONNECTS_API_KEY"]
# Multipart, because we are attaching a file.
data = {
"contact[0][email]": "finance@acme.com.bd",
"contact[0][subject]": "Invoice INV-2026-0912",
"contact[0][message]": "<p>Invoice attached, due 21 September.</p>",
}
with open("/srv/invoices/INV-2026-0912.pdf", "rb") as fh:
response = requests.post(
f"{BASE}/email/send",
headers={"Api-key": KEY, "Accept": "application/json"},
data=data,
files={"attachments[0]": fh},
timeout=60,
)
print(response.status_code, response.json())
Set up your sending domain first. Mail from an unauthenticated domain is filtered before anyone reads it. Add the SPF, DKIM and DMARC records shown in your panel under Email → Sending domains, and verify them, before your first real send.
URL form. No attachments on this route.
Query parameters: contacts, subject, message,
and optionally sender_name, reply_to_email,
schedule_at, gateway_identifier.
Text or media. Media is passed by public URL, not uploaded.
| Field | Type | Notes | |
|---|---|---|---|
contact | required | array | At least one entry. |
contact[].number | required | string | WhatsApp number in international format. |
contact[].message | required | string | Body text, or the caption when sending media. |
contact[].media | optional | string | One of image, audio, video, document. |
contact[].url | optional | string | Public URL of the file. Required when media is set. |
contact[].filename | optional | string | Name the recipient sees. Useful for documents. |
contact[].schedule_at | optional | string | Y-m-d H:i:s. |
contact[].gateway_identifier | optional | string | Which of your connected numbers to send from. |
curl -X POST https://besends.com/api/whatsapp/send \
-H "Api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"contact": [
{
"number": "8801712345678",
"message": "Your parcel is out for delivery. Reply 1 to reschedule."
},
{
"number": "8801812345678",
"message": "Your statement for August is attached.",
"media": "document",
"url": "https://files.yourcompany.com.bd/statements/aug-2026.pdf",
"filename": "Statement-August-2026.pdf"
}
]
}'
The URL must be publicly reachable. BeSends fetches the file itself; a link behind a login or a firewall will fail. If the file is private, publish it at a signed, time-limited URL.
URL form. Text only.
Query parameters: contacts, message, and optionally
schedule_at, gateway_identifier.
pilotOTP
One-time codes with a fallback chain. You give us the customer's phone and/or email and the order to try; we generate the code, deliver it on the first channel that accepts it, and hold the hash for you to verify. One flat price per code that lands, whichever road it took. Nothing is charged when every channel refuses. It is prepaid: top up a balance, then call.
How the fallback decides. WhatsApp and SMS answer synchronously — a number that
is not on WhatsApp, or a route that refuses, is known within seconds and the next channel
is tried at once, inside the same request. Email has no delivery acknowledgement; an SMTP
accept is treated as delivered. That is why the recommended order is
whatsapp → sms → email. Reorder per request if your case is different.
Generate and deliver a code. Responds after the first channel accepts it (typically 1–6 s).
| Field | Type | Notes | |
|---|---|---|---|
phone | one of | string | Recipient's mobile. 01712345678 or 8801712345678. Needed for WhatsApp and SMS. |
email | one of | string | Recipient's email. Needed for the email step. |
order | optional | array | Channels to try, in order: any of whatsapp, sms, email. Default: your panel setting, else ["whatsapp","sms","email"]. Channels with no matching address are skipped. |
length | optional | int | Digits in the code, 4–8. Default 6. |
ttl | optional | int | Seconds until the code expires, 60–900. Default 300. |
sender | optional | string | Name shown in the message. Default: your panel setting, else your company name. |
message | optional | string | Your own text. Must contain {code}; may use {sender} and {minutes}. |
reference | optional | string | Your own id (order number, session id). Echoed back on every response. |
curl -X POST https://besends.com/api/v1/otp/send \
-H "Api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"phone": "01712345678",
"email": "customer@example.com",
"order": ["whatsapp", "sms", "email"],
"length": 6,
"ttl": 300,
"reference": "order-88213"
}'
{
"success": true,
"message": "Code sent.",
"data": {
"otp_id": "otp_k3v9x2m1q8p4z7n6a5b0",
"status": "sent",
"delivered_channel": "sms",
"channel_order": ["whatsapp", "sms", "email"],
"attempts": [
{ "channel": "whatsapp", "ok": false, "error": "number is not on WhatsApp", "ms": 640 },
{ "channel": "sms", "ok": true, "error": null, "ms": 910 }
],
"expires_at": "2026-09-08T12:05:00+06:00",
"charged": 0.5,
"reference": "order-88213",
"balance": 499.5
}
}
Keep otp_id; it is what you verify against. The code itself is never
returned and never stored in clear.
Check the code the customer typed. Five wrong tries lock the code.
| Field | Type | Notes | |
|---|---|---|---|
otp_id | required | string | From the send response. |
code | required | string | What the customer typed. |
curl -X POST https://besends.com/api/v1/otp/verify \
-H "Api-key: YOUR_API_KEY" \
-d "otp_id=otp_k3v9x2m1q8p4z7n6a5b0" -d "code=482913"
# 200 → {"success":true,"message":"Verified.","data":{"status":"verified", ...}}
# 400 → {"success":false,"message":"Wrong code.","data":{"attempts_left":4, ...}}
# 410 → expired or already used
Status and the attempt log for one code.
Same shape as the send response. Statuses: sent, verified, expired, failed.
Prepaid balance, price per code, and your default order.
| HTTP | Meaning |
|---|---|
402 | Balance below the price of one code. Top up. |
422 | No channel can reach the recipient with the addresses given, or a field is invalid. |
429 | More than five codes to the same recipient in ten minutes. |
502 | Every channel refused. Not charged. Check attempts for why. |
Delivery logs
Take the id from a send response and ask what happened to it.
Also /api/get/email/{id} and /api/get/whatsapp/{id}.
curl "https://besends.com/api/get/sms/84213" \
-H "Api-key: YOUR_API_KEY" \
-H "Accept: application/json"
{
"success": true,
"message": "Successfully fetched Sms from Logs",
"data": {
"id": 84213,
"created_at": "2026-09-07 14:02:11",
"status": "delivered",
"message": {
"message": "Your order #4417 is out for delivery."
},
"contact": {
"first_name": "Rina",
"last_name": "Haque",
"email_contact": "8801712345678",
"meta_data": null
}
}
}
Do not poll in a tight loop. You have 60 requests a minute across the whole API. Check a message a few seconds after sending, then back off. For bulk sends, read the report in the panel or export it rather than polling every id.
Status values
The status field on a log record takes one of these:
| Value | Means | Final? |
|---|---|---|
pending | Accepted and waiting for a worker. | No |
schedule | Held until its schedule_at time. | No |
processing | Handed to the route, awaiting its answer. | No |
delivered | The network confirmed delivery. | Yes |
fail | Rejected. The reason is on the record in the panel. | Yes |
cancel | Cancelled before it went out. | Yes |
Errors
| Code | Meaning | What to do |
|---|---|---|
403 | Missing, unknown, or expired-plan API key. | Check the key and that the subscription is running. Do not retry automatically. |
404 | Log id does not exist, or is not yours. | Check the id came from your own send. |
422 | Validation failed. | Read errors — it names the exact field. Fix and resend; retrying unchanged will fail again. |
429 | More than 60 requests in a minute. | Back off and retry. Batch recipients into fewer calls. |
500 | Something broke our side. | Retry once after a short pause. If it persists, send us the timestamp. |
{
"success": false,
"message": "Validation failed",
"errors": {
"contact.0.subject": ["The contact.0.subject field is required."],
"contact.0.message": ["The contact.0.message field is required."]
}
}
Two envelopes, not one. Authentication failures come from middleware and use
{"status":"error","error":"…"}. Everything past authentication uses
{"success":false,"message":"…"}. Handle both: check the HTTP status code
first, and only then read the body.
Rate limits and credits
- 60 requests per minute, counted per IP address. Batch recipients into one call rather than making one call per recipient.
- Credits are per channel. Each message spends one credit from that channel's monthly allowance. A send that fails validation costs nothing; a send that is accepted and then rejected by the network still spends the credit.
- Running out pauses sending until you top up or the next period begins. Watch your balance in the panel if you send in bursts.
- Daily caps apply per channel to protect delivery quality. Your plan's caps are shown in the panel.
Recipes
Fire a message when something happens in your system
The most valuable messages are consequences, not campaigns: an invoice went overdue, a parcel shipped, a fee is unpaid. Call the API at the moment the fact becomes true.
<?php
// Called by your ERP the moment an invoice passes its due date.
function notifyOverdue(array $invoice): void
{
$body = [
'contact' => [[
'number' => $invoice['phone'],
'message' => sprintf(
'Invoice %s for BDT %s was due on %s. Pay at %s',
$invoice['number'],
number_format($invoice['amount']),
$invoice['due_date'],
'https://yourcompany.com.bd/pay'
),
]],
];
$ch = curl_init(getenv('CONNECTS_BASE') . '/sms/send');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Api-key: ' . getenv('CONNECTS_API_KEY'),
'Content-Type: application/json',
'Accept: application/json',
],
CURLOPT_POSTFIELDS => json_encode($body),
CURLOPT_TIMEOUT => 15,
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
// Messaging must never break invoicing. Log and carry on.
if ($status !== 200) {
error_log("BeSends overdue notice failed for {$invoice['number']}: {$response}");
}
}
Send to a large list without hitting the rate limit
One call carries many recipients. Chunk your list and pause between calls.
import os, time, requests
BASE = os.environ["CONNECTS_BASE"] # https://…/api
KEY = os.environ["CONNECTS_API_KEY"]
CHUNK = 500 # recipients per request
PAUSE = 1.2 # seconds between requests
def send_all(recipients, body_for):
"""recipients: iterable of dicts. body_for(r) -> message string."""
queued, failed = [], []
for i in range(0, len(recipients), CHUNK):
batch = recipients[i:i + CHUNK]
payload = {"contact": [
{"number": r["phone"], "message": body_for(r)} for r in batch
]}
for attempt in range(3):
res = requests.post(
f"{BASE}/sms/send",
headers={"Api-key": KEY,
"Content-Type": "application/json",
"Accept": "application/json"},
json=payload, timeout=60,
)
if res.status_code == 429: # rate limited — wait it out
time.sleep(20)
continue
if res.status_code == 422: # our payload is wrong; do not retry
failed.append((i, res.json()))
break
if res.ok and res.json().get("success"):
queued += [row["id"] for row in res.json()["data"]]
break
time.sleep(3 * (attempt + 1)) # 5xx — back off and try again
else:
failed.append((i, "gave up after 3 attempts"))
time.sleep(PAUSE)
return queued, failed
Reach the people an earlier channel missed
Email the detail, then SMS only the people who did not receive it. Because all three channels run off one contact list, this is a filter on your side and two calls.
// 1. Email everyone, and keep the log ids alongside the recipient.
const emailed = await post('/email/send', {
contact: audience.map((p) => ({
email: p.email,
subject: 'Your September statement',
message: renderStatement(p),
})),
});
const pairs = audience.map((p, i) => ({ person: p, logId: emailed.data[i].id }));
// 2. Give the queue time to resolve, then check each one.
await new Promise((r) => setTimeout(r, 30_000));
const missed = [];
for (const { person, logId } of pairs) {
const log = await get(`/get/email/${logId}`);
if (log.data.status === 'fail') missed.push(person);
}
// 3. SMS only those, in one call.
if (missed.length) {
await post('/sms/send', {
contact: missed.map((p) => ({
number: p.phone,
message: `We could not email your September statement. Collect it at ${p.branch}.`,
})),
});
}
Build it with AI
Paste the prompt below into Claude, ChatGPT, Cursor, Copilot or whatever you use. It carries everything the model needs: the real endpoints, the exact payload shapes, both error envelopes, and the failure modes that matter. Fill in the four bracketed lines at the top and it will write an integration that fits your stack.
Never paste your API key into a prompt. The prompt below tells the model to read the key from an environment variable, which is where it belongs anyway.
You are integrating the BeSends messaging API into an existing system.
## Fill these in before you start
- Language / framework: [e.g. Laravel 10, Django 5, Spring Boot 3, .NET 8, Node + Express]
- What triggers a message: [e.g. an invoice passes its due date; a student's fee is unpaid]
- Which channels: [SMS / email / WhatsApp — pick the ones you need]
- Roughly how many messages per send: [e.g. 1, or 40,000 in three days]
## The API
Base URL: https://besends.com/api
Transport: HTTPS, JSON. Send `Content-Type: application/json` and `Accept: application/json`.
Auth: header `Api-key: <key>` on every request. Read the key from the environment
variable CONNECTS_API_KEY. Never hard-code it, never send it to a browser or a mobile app,
never put it in a URL.
Rate limit: 60 requests per minute per IP. `contact` is an array — batch many recipients
into one request rather than looping one request per recipient.
### POST /sms/send
{
"contact": [
{
"number": "8801712345678", // required, string, max 255
"message": "text", // required, string
"schedule_at": "2026-09-08 09:00:00", // optional, exactly Y-m-d H:i:s
"gateway_identifier": "gateway-uid", // optional
"sms_type": "transactional" // optional
}
],
"method": "api" // optional, top level: "api" or "android"
}
### POST /email/send
{
"contact": [
{
"email": "person@example.com", // required, valid email, max 255
"subject": "text", // required, max 255
"message": "<p>HTML allowed</p>", // required
"sender_name": "Acme Accounts", // optional
"reply_to_email": "reply@example.com", // optional
"schedule_at": "2026-09-08 09:00:00", // optional
"gateway_identifier": "gateway-uid" // optional, must be an active email gateway uid
}
]
}
Attachments require a multipart request instead of JSON, with fields named
contact[0][email], contact[0][subject], contact[0][message] and files at attachments[0].
Allowed: pdf, doc, docx, xls, xlsx, csv, txt, png, jpg, jpeg, gif, zip, rar, svg, webp.
### POST /whatsapp/send
{
"contact": [
{
"number": "8801712345678", // required
"message": "text or media caption", // required
"media": "document", // optional: image | audio | video | document
"url": "https://public.example/file.pdf",// required when media is set; must be publicly fetchable
"filename": "Statement.pdf", // optional
"schedule_at": "2026-09-08 09:00:00", // optional
"gateway_identifier": "gateway-uid" // optional
}
]
}
### GET /get/sms/{id} — also /get/email/{id} and /get/whatsapp/{id}
Returns one log record. `status` is one of:
pending | schedule | processing | delivered | fail | cancel
## Responses
Success (HTTP 200):
{ "success": true, "message": "…", "data": [ { "id": 84213, "status": "pending", … } ] }
Validation failure (HTTP 422):
{ "success": false, "message": "Validation failed",
"errors": { "contact.0.message": ["The contact.0.message field is required."] } }
Auth failure (HTTP 403) — NOTE THE DIFFERENT SHAPE, it comes from middleware:
{ "status": "error", "error": "Invalid Api Key" }
or
{ "status": "error", "error": "Your Subscription Is Expired! Buy A New Plan" }
## Write the integration with these rules
1. One reusable client class/module. Base URL and API key come from configuration, never
from literals in the calling code.
2. Handle BOTH response envelopes. Branch on the HTTP status code first, then read the body:
403 -> auth or expired plan, do NOT retry, surface loudly to an operator
422 -> our payload is wrong, do NOT retry, log the `errors` object verbatim
429 -> rate limited, wait and retry with exponential backoff
5xx -> retry up to 3 times with backoff, then give up and log
200 -> read `data[].id` and store each id against the record that caused the message
3. Store the returned log id alongside your own record. That id is the only way to ask later
what happened to the message.
4. Treat a 200 as "accepted", never as "delivered". If the caller needs the real outcome,
poll GET /get/{channel}/{id} after a delay, with backoff — never in a tight loop.
5. Messaging must never break the calling process. Wrap every call so that a failure is
logged and the invoice/order/enrolment still completes.
6. Batch: chunk recipients (about 500 per request is sensible) and pause briefly between
chunks to stay inside 60 requests per minute.
7. Set an explicit HTTP timeout (30s is reasonable, 60s when attaching files).
8. Phone numbers: normalise to international format without a leading +, e.g. 8801712345678.
9. Timestamps for schedule_at must be exactly Y-m-d H:i:s or the request is rejected.
10. Do not invent endpoints, fields or query parameters. Everything available is above; if
something you need is not listed, say so rather than guessing.
Now write the integration, including the error handling and a short usage example.
Before you go live
- The API key is in an environment variable or secret store — not in the repository.
- Calls are made from your server. No key ever reaches a browser or a mobile app.
- Your sending domain has SPF, DKIM and DMARC set up and verified, if you send email.
- Every call has a timeout, and a failure is logged rather than thrown at the user.
403and422are not retried;429and5xxare, with backoff.- Log ids are stored against your own records.
- You have sent one real message to your own handset and seen it arrive.
- Someone gets alerted when sends start failing — silence is the failure mode that costs the most.
Get help
If something here does not match what the server does, tell us — the documentation is wrong until proven otherwise.
- Email hello@pitech.com.bd
- Phone +880 1719 679996, Sunday to Thursday, 10:00–19:00 Bangladesh time
- No account yet? Start the free 7-day trial — it includes API access.