API Documentation

Authentication

All API endpoints require authentication using a Bearer token. Include your authentication token in the Authorization header with every request.

Getting Your API Token

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.

How to Authenticate

Include the Authorization header in your requests:

Authorization: Bearer [YOUR_API_TOKEN]

IP Allow List

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 SMS

Send an SMS message to a specified phone number using your preferred sender ID.

Endpoint

POST /api/send

Parameters

ParameterTypeRequiredDescription
fromstringYesSender ID. Letters and digits only, must start with a letter (max 15 characters).
tostringYesRecipient 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.
textstringYesMessage content (max 1000 characters)
callback_urlstringNoURL to receive delivery report callbacks for this message (max 2048 characters). Overrides the consumer's default callback URL.

Message Content & Cost

  • For Malaysian numbers, RM0 is prepended to the message text when it is not already there. The prefix counts toward the message length.
  • Long messages are split into multiple SMS segments.
  • The cost is the per-segment rate for the destination country, multiplied by the number of segments. It is deducted from your balance when the message is accepted.

Response Example

{
    "id": 12345,
    "segments": 1,
    "status": "sent",
    "cost": "1.0000",
    "balance": "99.0000"
}

Response Fields

FieldTypeDescription
idintegerUnique identifier of the message
segmentsintegerNumber of SMS segments the message was split into
statusstringsent 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 .
coststringCredit cost of the message with 4 decimal places
balancestringRemaining credit balance with 4 decimal places

Code Examples

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://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://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://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!"
  }'

Check Balance

Get your current account balance (credit value).

Endpoint

GET /api/balance

Parameters

No parameters required.

Response Example

{
    "value": "99.0000"
}

Code Examples

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://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://onsms.opy.la/api/balance');

$balance = $response->json('value');
echo 'Your Balance: ' . $balance;
curl -X GET https://onsms.opy.la/api/balance \
  -H "Authorization: Bearer [YOUR_API_TOKEN]" \
  -H "Content-Type: application/json"

Rate Limiting

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.

Exceeding 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.

Rate Limit Headers

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 minute

Best Practices to Avoid Rate Limits

Here are recommended strategies to ensure your application stays within rate limits:

  • Implement Request Queuing - Queue API requests and process them gradually rather than sending them all at once.
  • Monitor Rate Limit Headers - Check the X-RateLimit-Remaining header and slow down your requests when it approaches zero.
  • Use Exponential Backoff - When you receive a 429 response, wait an increasing amount of time before retrying (e.g., 1s, 2s, 4s, 8s).
  • Cache Results - Cache the response from the /api/balance endpoint locally to avoid unnecessary repeated requests.
  • Space Out Requests - Distribute requests evenly over time rather than sending them in bursts. Aim for no more than 2 requests per second to stay within the 120 requests per minute limit.

Delivery Report Callbacks

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.

Setting Up Your Callback URL

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:

  • Be a valid URL. HTTPS is strongly recommended, since callbacks sent over plain HTTP are not encrypted in transit.
  • Accept POST requests with a JSON body
  • Return a 2xx status code to acknowledge receipt
  • Respond within 15 seconds

Retries

A 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.

Callback Payload

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"
}

Callback Payload Fields

FieldTypeDescription
idintegerUnique identifier of the message (matches the ID returned by /api/send)
segmentsintegerNumber of SMS segments the message was split into
statusstringCurrent message status (e.g., pending , sent , delivered , undelivered , expired , rejected )
coststringCredit cost of the message with 4 decimal places
sent_atstring nullISO 8601 timestamp (UTC) when the network operator accepted the message (null if not yet sent)
delivered_atstring nullISO 8601 timestamp (UTC) when the message was delivered (null if not delivered)

Message Status Values

StatusDescription
pendingMessage is queued and waiting to be processed
sentMessage has been sent to the network operator
deliveredMessage was successfully delivered to the recipient
undeliveredMessage delivery failed
expiredMessage expired before delivery
rejectedMessage was rejected by the network operator

Implementing a Callback Handler

// 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);
    }
}

Best Practices for Callbacks

  • Acknowledge Quickly - Return a 200 OK response immediately. Don't perform heavy processing before responding.
  • Handle Duplicates - Process callbacks asynchronously and idempotently. A message sends one callback per status change, and each callback may be retried; use id together with status to detect duplicates.
  • Log All Callbacks - Keep detailed logs of all received callbacks for debugging and reconciliation.
  • Use HTTPS - Always use HTTPS for your callback URL to ensure data security in transit.
  • Validate Data - Always validate the incoming JSON data before processing to ensure data integrity.
  • Timeout Handling - Ensure your callback handler completes within 15 seconds. If it times out, the callback is retried as described in Retries.

Error Handling

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 CodeDescription
200Success
401Unauthorized - Invalid or missing API token
402Payment Required - Insufficient credits to send the message
403Forbidden - Your account is banned ( user_banned ), or the request IP address is not in the consumer's IP allow list ( ip_address_not_allowed )
422Validation Error - Invalid parameters
429Too Many Requests - Rate limit exceeded
500Server Error

Error Response Examples

{
    "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."
}