Send SMS to Malaysian mobile numbers from your own system. Same routes, same RM0.20 per segment pricing and same per-number delivery reports as the web console.
Base URL — all endpoints are relative to it, and HTTPS is required:
https://hantarsms.com/api/v1
Create an API key in the console on the API keys tab. The key is shown once, at creation time. We store only a hash of it, so we cannot show it to you again — if you lose it, revoke it and create a new one.
Send the key on every request, either header works:
X-API-Key: sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # or Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Keep the key server-side. It can spend your balance. Never put it in a mobile app, a browser page, or anything you ship to end users — anyone who reads it can send SMS on your account until you revoke it. The API deliberately does not send CORS headers, so a browser page cannot call it directly.
You can hold several keys at once (one per environment, for example) and revoke any of them independently. Revoking takes effect immediately.
Send an Idempotency-Key header on every send. It is optional, but without it a request that times out
on your side is genuinely ambiguous: the message may or may not have been created, and retrying may charge you twice
and deliver two SMS to every recipient.
Idempotency-Key: order-48213-reminder
Use any string up to 255 characters that is unique to that one send — an order ID, a UUID, a job ID. With the header
set, you can retry the exact same request as many times as you like: the first call does the work, and every later
call returns the same response, including the same message id. Replays carry an
Idempotent-Replay: true response header.
| Situation | What happens |
|---|---|
| Same key, same body | The original response is returned. Nothing is sent or charged again. |
| Same key, different body | 422 idempotency_key_reuse. This means your key generation
has a bug — if we returned the first response, you would think the second message was sent when it never was. |
| Same key, first call still running | 409 idempotency_key_in_progress. Retry in a few seconds. |
| Request failed before anything was created (bad parameters, insufficient balance, blocked content) |
The key is released. Fix the problem and retry with the same key. |
Keys are scoped to your account and kept for 24 hours.
POST/api/v1/messages
| Parameter | Type | Description |
|---|---|---|
to | string or array | Required. One Malaysian mobile number, or an array of
them, up to 5000 per request. Accepts 0123456789, 60123456789 and
+60123456789; all are normalised to 60XXXXXXXXX. Duplicates are removed and charged once. |
text | string | Required. Message body, up to 1600 characters. |
reference | string | Optional. Your own label for this send, up to 200 characters. Returned on every read and shown in the console. |
curl https://hantarsms.com/api/v1/messages \
-H "X-API-Key: $HANTARSMS_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-48213-reminder" \
-d '{
"to": ["60123456789", "0198765432"],
"text": "Your order #48213 is ready for pickup.",
"reference": "order-48213"
}'
201 Created:
{
"id": 4821,
"status": "queued",
"reference": "order-48213",
"text": "Your order #48213 is ready for pickup.",
"encoding": "gsm",
"segments": 1,
"recipients": 2,
"invalid_recipients": [],
"rate_cent": 20,
"cost_cent": 40,
"balance_cent": 158360,
"created_at": "2026-09-11T09:04:39.479Z"
}
Money is in cents, always as integers. cost_cent: 40 is RM0.40. We never send RM as a decimal —
floating point turns 23.10 into 23.099999999999998 somewhere down the line and your reconciliation stops matching.
Divide by 100 for display.
You are charged segments × recipients × rate_cent the moment the request succeeds. Segments are counted
the way the carrier counts them: 160 GSM characters for a single-segment message, 153 per segment once it has to be
concatenated (70 / 67 if the text contains any non-ASCII character, which switches it to Unicode). A 4-character
carrier prefix is added upstream and counts toward that budget, so plan for 156 usable GSM characters in one segment.
Numbers that are not valid Malaysian mobile numbers are dropped, listed in invalid_recipients (first 20)
and never charged. If none of them is valid you get 422 no_valid_recipients instead.
GET/api/v1/messages/{id}
Poll this to follow a send. Delivery counters are refreshed from the carrier when you call it (at most once every 30 seconds per message; more frequent polling returns the last known state rather than an error).
curl https://hantarsms.com/api/v1/messages/4821 \ -H "X-API-Key: $HANTARSMS_KEY"
{
"id": 4821,
"status": "done",
"reference": "order-48213",
"text": "Your order #48213 is ready for pickup.",
"encoding": "gsm",
"segments": 1,
"recipients": 2,
"submitted": 2,
"delivered": 2,
"failed": 0,
"rate_cent": 20,
"cost_cent": 40,
"error": null,
"created_at": "2026-09-11 09:04:39"
}
GET/api/v1/messages/{id}/recipients
The per-number result, not just a total. Supports limit (default 100, max 1000) and offset.
curl "https://hantarsms.com/api/v1/messages/4821/recipients?limit=2" \ -H "X-API-Key: $HANTARSMS_KEY"
{
"message_id": 4821,
"data": [
{ "to": "60123456789", "status": "delivered", "error": null, "submitted_at": "2026-09-11 09:04:41" },
{ "to": "60198765432", "status": "undelivered", "error": "Absent subscriber", "submitted_at": "2026-09-11 09:04:41" }
],
"total": 2,
"limit": 2,
"offset": 0,
"has_more": false
}
GET/api/v1/messages
Newest first. Supports limit (default 20, max 100) and offset.
{
"data": [ { "id": 4821, "status": "done", … } ],
"total": 137,
"limit": 20,
"offset": 0,
"has_more": true
}
GET/api/v1/balance
{
"balance_cent": 158360,
"rate_cent": 20,
"currency": "MYR",
"segments_remaining": 7918
}
Cheap enough to call before a large batch, or on a schedule to alert yourself before you run out.
status| Value | Meaning |
|---|---|
queued | Accepted and charged, not yet handed to the carrier. |
sending | Being submitted. Large batches go out in chunks, so counters climb while in this state. |
done | Every recipient has been processed. Check delivered / failed for the outcome. |
failed | Nothing could be submitted. Anything charged for unsent numbers is refunded automatically. |
status| Value | Meaning | Charged? |
|---|---|---|
pending | Not submitted to the carrier yet. | Held |
sent | Accepted by the carrier, no final receipt yet. | Yes |
delivered | The carrier confirmed it reached the handset. | Yes |
undelivered | Accepted by the carrier but never reached the handset — phone off, number no longer in service, blocked. | Yes |
rejected | We could not submit it at all. | No — refunded |
rejected and undelivered are different on purpose. Carriers bill on submission, not
on delivery, so a message that was accepted and then failed to reach a switched-off phone is still charged.
A rejected number was never submitted and is refunded. Collapsing both into one "failed" value is what
makes API invoices impossible to reconcile.
Every error returns the same envelope. Branch on code — it is a stable contract. The
message is for humans and may be reworded at any time.
{
"error": {
"type": "insufficient_funds",
"code": "insufficient_balance",
"message": "This request costs 100000 cent (RM1000.00) but the account balance is 99940 cent (RM999.40).",
"required_cent": 100000,
"balance_cent": 99940
}
}
| HTTP | code | What to do |
|---|---|---|
| 400 | invalid_json | Body is not a JSON object. |
| 400 | missing_parameter | to is absent. See param. |
| 401 | missing_api_key | No key header was sent. |
| 401 | invalid_api_key | The key does not exist. |
| 401 | revoked_api_key | The key was revoked. Create a new one. |
| 402 | insufficient_balance | Top up, then retry with the same idempotency key. |
| 403 | account_disabled | Contact support. |
| 404 | not_found | No message with that id on your account. |
| 405 | method_not_allowed | Wrong HTTP method for that path. |
| 409 | idempotency_key_in_progress | Retry in a few seconds. |
| 413 | request_too_large | Body is over 1 MB. Send at most 5000 recipients per request. |
| 422 | no_valid_recipients | None of the numbers is a valid Malaysian mobile number. |
| 422 | too_many_recipients | Over 5000. Split into batches. |
| 422 | message_empty / message_too_long | Fix text. |
| 422 | content_blocked | See below. |
| 422 | idempotency_key_reuse | Same key, different body. Fix your key generation. |
| 429 | rate_limit_exceeded | Back off; honour Retry-After. |
| 500 | internal_error | Our side. Safe to retry with the same idempotency key. |
Malaysian carriers fine RM1.50 per segment for messages containing protected international brand names — more than seven times the price of the SMS itself. Those sends are rejected before anything is charged:
{
"error": {
"type": "content_blocked",
"code": "content_blocked",
"message": "Message contains protected international brand keywords (1xBet). …",
"param": "text",
"brands": ["1xBet"],
"max_penalty_cent": 150
}
}
Remove the brand name and resend. The web console can warn-and-confirm instead of blocking, because a person is there to make that call; over the API there is nobody to ask, so it always blocks.
| Endpoint | Limit |
|---|---|
POST /messages | 60 requests per minute per key |
All GET endpoints | 300 requests per minute per key |
| Failed authentication | 20 per 5 minutes per IP address |
The first two limits are per API key, not per IP, so your other traffic never competes with itself. A
429 carries a Retry-After header in seconds. Each request can carry up to 5000 recipients,
so the send limit is well clear of any normal batch workload — if you are hitting it, batch more numbers per request
rather than sending faster.
The failed-authentication limit is per IP, and it does not distinguish which key failed. It has to be: when the key is not recognised we have no idea who is calling, so the address is the only thing left to count. Twenty consecutive failures from one address and every request from that address is refused for the rest of the window — including requests carrying a valid key.
In practice this only bites during a broken deploy: a wrong or revoked key in a tight retry loop burns through
twenty attempts in seconds. Treat 401 as fatal, not as something to retry — fix the key and try again,
or wait out the Retry-After.
Requests whose User-Agent starts with Java/1. — the default for
java.net.HttpURLConnection on Java 8 and older — are rejected at the network edge with
HTTP 403 and an HTML body, before they reach the API. This is a platform-wide bot signature rule, not something
specific to your account.
Set any User-Agent of your own and it goes away:
HttpURLConnection c = (HttpURLConnection) url.openConnection();
c.setRequestProperty("User-Agent", "my-app/1.0");
Apache HttpClient, OkHttp, Java 9+, and every non-Java client we tested (PHP/Guzzle, python-requests, Node, Go, curl) are unaffected. It is good practice to send a descriptive User-Agent regardless — it is what we look at first when you ask us to trace a request.
$ch = curl_init('https://hantarsms.com/api/v1/messages');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'X-API-Key: ' . getenv('HANTARSMS_KEY'),
'Content-Type: application/json',
'Idempotency-Key: order-' . $orderId,
],
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode([
'to' => ['60123456789'],
'text' => "Your order #$orderId is ready.",
'reference' => "order-$orderId",
]),
]);
$res = json_decode(curl_exec($ch), true);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($code === 201) {
echo "Queued as message {$res['id']}\n";
} elseif ($res['error']['code'] === 'insufficient_balance') {
// top up, then retry with the SAME Idempotency-Key
} else {
error_log("SMS failed: {$res['error']['code']}");
}
import os, requests
r = requests.post(
"https://hantarsms.com/api/v1/messages",
headers={
"X-API-Key": os.environ["HANTARSMS_KEY"],
"Idempotency-Key": f"order-{order_id}",
},
json={
"to": ["60123456789"],
"text": f"Your order #{order_id} is ready.",
"reference": f"order-{order_id}",
},
timeout=30,
)
if r.status_code == 201:
print("Queued as message", r.json()["id"])
else:
err = r.json()["error"]
# err["code"] is the stable value to branch on
print("Failed:", err["code"], err["message"])
const res = await fetch('https://hantarsms.com/api/v1/messages', {
method: 'POST',
headers: {
'X-API-Key': process.env.HANTARSMS_KEY,
'Content-Type': 'application/json',
'Idempotency-Key': `order-${orderId}`,
},
body: JSON.stringify({
to: ['60123456789'],
text: `Your order #${orderId} is ready.`,
reference: `order-${orderId}`,
}),
});
const body = await res.json();
if (res.status === 201) {
console.log('Queued as message', body.id);
} else {
console.error('Failed:', body.error.code, body.error.message);
}
HttpClient client = HttpClient.newHttpClient(); // Java 11+
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://hantarsms.com/api/v1/messages"))
.header("X-API-Key", System.getenv("HANTARSMS_KEY"))
.header("Content-Type", "application/json")
.header("Idempotency-Key", "order-" + orderId)
.header("User-Agent", "my-app/1.0") // see the Java note above
.POST(HttpRequest.BodyPublishers.ofString("""
{"to":["60123456789"],"text":"Your order is ready.","reference":"order-%s"}
""".formatted(orderId)))
.build();
HttpResponse<String> res = client.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(res.statusCode() + " " + res.body());
Create an account, ask support for trial credit, then generate a key on the API keys tab in the console. Trial credit works on the API exactly like it does on the web console, so you can send a real SMS to your own phone before writing any integration code.