
All API endpoints require authentication using a Bearer token. Include your authentication token in the Authorization header with every request.
To obtain your API token, log in to your account and navigate to Consumers where you can view and manage your consumer tokens. Each consumer has a unique API token that you can show, hide, copy, or regenerate. Keep your token secure and never share it publicly.
Include the Authorization header in your requests:
Authorization: Bearer [YOUR_API_TOKEN]
For extra security, you can restrict each consumer to a list of allowed IP addresses in the Consumers section. When the list is set, requests from any other IP address are rejected with 403 Forbidden . When the list is empty, requests from any IP address are accepted.
Send an SMS message to a specified phone number using your preferred sender ID.
POST /api/send
| Parameter | Type | Required | Description |
|---|---|---|---|
| from | string | Yes | Sender ID. Letters and digits only, must start with a letter (max 15 characters). |
| to | string | Yes | Recipient phone number, preferably in E.164 format (e.g., +60123456789, max 20 characters). A number without a country code is treated as a Malaysian number. |
| text | string | Yes | Message content (max 1000 characters) |
| callback_url | string | No | URL to receive delivery report callbacks for this message (max 2048 characters). Overrides the consumer's default callback URL. |
RM0 is prepended to the message text when it is not already there. The prefix counts toward the message length.
{
"id": 12345,
"segments": 1,
"status": "sent",
"cost": "1.0000",
"balance": "99.0000"
}
| Field | Type | Description |
|---|---|---|
| id | integer | Unique identifier of the message |
| segments | integer | Number of SMS segments the message was split into |
| status | string | sent when the network operator accepted the message, rejected when it refused it, or pending when sending is being retried. Later status changes are sent to your callback URL . |
| cost | string | Credit cost of the message with 4 decimal places |
| balance | string | Remaining credit balance with 4 decimal places |
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://dr.onsms.opy.la/api/send');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer [YOUR_API_TOKEN]',
'Content-Type: application/json',
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
'from' => 'SENDER123',
'to' => '+60123456789',
'text' => 'Hello, this is a test message!',
]));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
$data = json_decode($response, true);
echo 'Message ID: ' . $data['id'];
echo 'Cost: ' . $data['cost'];
echo 'New Balance: ' . $data['balance'];
use Illuminate\Support\Facades\Http;
$response = Http::withToken('[YOUR_API_TOKEN]')
->post('https://dr.onsms.opy.la/api/send', [
'from' => 'SENDER123',
'to' => '+60123456789',
'text' => 'Hello, this is a test message!',
]);
$data = $response->json();
echo 'Message ID: ' . $data['id'];
echo 'Cost: ' . $data['cost'];
echo 'New Balance: ' . $data['balance'];
curl -X POST https://dr.onsms.opy.la/api/send \
-H "Authorization: Bearer [YOUR_API_TOKEN]" \
-H "Content-Type: application/json" \
-d '{
"from": "SENDER123",
"to": "+60123456789",
"text": "Hello, this is a test message!"
}'
Get your current account balance (credit value).
GET /api/balance
No parameters required.
{
"value": "99.0000"
}
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://dr.onsms.opy.la/api/balance');
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer [YOUR_API_TOKEN]',
'Content-Type: application/json',
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
$data = json_decode($response, true);
echo 'Your Balance: ' . $data['value'];
use Illuminate\Support\Facades\Http;
$response = Http::withToken('[YOUR_API_TOKEN]')
->get('https://dr.onsms.opy.la/api/balance');
$balance = $response->json('value');
echo 'Your Balance: ' . $balance;
curl -X GET https://dr.onsms.opy.la/api/balance \ -H "Authorization: Bearer [YOUR_API_TOKEN]" \ -H "Content-Type: application/json"
To ensure fair usage and maintain service stability, API requests are rate limited to 120 requests per minute per consumer . Each consumer account has its own independent rate limit bucket, so requests from different API tokens do not affect each other's rate limits.
When you exceed the rate limit, the API will return a 429 Too Many Requests response. Your request will be rejected and you should retry after the number of seconds given in the Retry-After response header. We recommend implementing exponential backoff in your application to handle rate limiting gracefully.
Each API response includes rate limit information in the response headers:
X-RateLimit-Limit - Maximum requests allowed per minute (120)X-RateLimit-Remaining - Number of requests remaining in the current minuteHere are recommended strategies to ensure your application stays within rate limits:
X-RateLimit-Remaining header and slow down your requests when it approaches zero.429 response, wait an increasing amount of time before retrying (e.g., 1s, 2s, 4s, 8s)./api/balance endpoint locally to avoid unnecessary repeated requests.Delivery report callbacks allow you to receive real-time updates about the status of your sent SMS messages. Whenever the status of a message changes, the system will send an HTTP POST request to your callback URL with the delivery status details.
To enable delivery report callbacks, configure a default callback URL in the Consumers section of your account, or pass callback_url when sending a message to use a different URL for that message. The callback URL must:
2xx status code to acknowledge receiptA callback that times out, fails to connect, or returns a non- 2xx status code is retried up to 2 more times: 5 minutes after the first attempt, then 15 minutes after the second. After 3 failed attempts, the callback is dropped.
When a message status changes, we will POST the following JSON data to your callback URL:
{
"id": 12345,
"segments": 1,
"status": "delivered",
"cost": "0.5000",
"sent_at": "2024-01-23T15:00:00.000000Z",
"delivered_at": "2024-01-23T15:00:30.000000Z"
}
| Field | Type | Description |
|---|---|---|
| id | integer | Unique identifier of the message (matches the ID returned by /api/send) |
| segments | integer | Number of SMS segments the message was split into |
| status | string | Current message status (e.g., pending , sent , delivered , undelivered , expired , rejected ) |
| cost | string | Credit cost of the message with 4 decimal places |
| sent_at | string null | ISO 8601 timestamp (UTC) when the network operator accepted the message (null if not yet sent) |
| delivered_at | string null | ISO 8601 timestamp (UTC) when the message was delivered (null if not delivered) |
| Status | Description |
|---|---|
| pending | Message is queued and waiting to be processed |
| sent | Message has been sent to the network operator |
| delivered | Message was successfully delivered to the recipient |
| undelivered | Message delivery failed |
| expired | Message expired before delivery |
| rejected | Message was rejected by the network operator |
// Get the raw POST data
$input = file_get_contents('php://input');
$data = json_decode($input, true);
// Extract callback data
$message_id = $data['id'];
$status = $data['status'];
$segments = $data['segments'];
$cost = $data['cost'];
$sent_at = $data['sent_at'];
$delivered_at = $data['delivered_at'];
// Log the delivery report
$log_message = sprintf(
"[%s] Message %d: %s (segments: %d, cost: %s)\n",
$delivered_at ?? $sent_at,
$message_id,
$status,
$segments,
$cost
);
file_put_contents('delivery_reports.log', $log_message, FILE_APPEND);
// Update your database with the delivery status
// UPDATE messages SET status = ?, delivered_at = ? WHERE id = ?
// Always return 200 OK to acknowledge receipt
http_response_code(200);
echo json_encode(['success' => true]);
namespace App\Http\Controllers\Webhooks;
use App\Models\Message;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class DeliveryReportController extends Controller
{
public function handle(Request $request)
{
// Validate the incoming callback data
$validated = $request->validate([
'id' => 'required|integer',
'segments' => 'required|integer',
'status' => 'required|string',
'cost' => 'required|string',
'sent_at' => 'nullable|string',
'delivered_at' => 'nullable|string',
]);
// Update the message status in database
Message::find($validated['id'])?->update([
'status' => $validated['status'],
'delivered_at' => $validated['delivered_at'],
]);
// Log for monitoring
Log::info('Delivery report received', $validated);
// Return 200 OK to acknowledge receipt
return response()->json(['success' => true], 200);
}
}
200 OK response immediately. Don't perform heavy processing before responding.id together with status to detect duplicates.The API returns appropriate HTTP status codes. Error responses are JSON, with a human-readable message field and additional fields depending on the error.
| Status Code | Description |
|---|---|
| 200 | Success |
| 401 | Unauthorized - Invalid or missing API token |
| 402 | Payment Required - Insufficient credits to send the message |
| 403 | Forbidden - Your account is banned ( user_banned ), or the request IP address is not in the consumer's IP allow list ( ip_address_not_allowed ) |
| 422 | Validation Error - Invalid parameters |
| 429 | Too Many Requests - Rate limit exceeded |
| 500 | Server Error |
{
"message": "Unauthenticated."
}
{
"error": "insufficient_credits",
"message": "Insufficient credits to complete this request.",
"required": "2.0000",
"available": "1.5000"
}
{
"error": "ip_address_not_allowed",
"message": "Your IP address is not allowed to access this API.",
"ip": "203.0.113.10"
}
{
"error": "user_banned",
"message": "Your account has been banned from accessing this API."
}
{
"message": "The sender field is required. (and 1 more error)",
"errors": {
"from": ["The sender field is required."],
"text": ["The message field is required."]
}
}
{
"message": "Too Many Attempts."
}