Send SMS to Malaysian mobile numbers from your own system. Same routes, same per-number delivery reports and the same price as your console account (standard RM0.20 per segment; lower volume tiers apply automatically).
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 10000 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. |
send_at | string | Optional. Schedule the send instead of sending now: an ISO 8601
timestamp, e.g. 2026-10-01T09:00:00+08:00. Include the time zone; a timestamp without one is read as
Malaysia time (UTC+08:00). Must be between 2 minutes and 30 days from now; the message goes out within 5 minutes after
that time. You are charged when the request succeeds. A scheduled send can only be cancelled in the web console, not over
the API. Misspelling this parameter (for example scheduled_at) is rejected with invalid_parameter
rather than sending immediately; other unknown parameters are ignored and listed in a warnings array. |
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",
"scheduled_at": null,
"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.
rate_cent is your account's current per-segment rate (it drops automatically as you move up a volume tier),
and scheduled_at is the UTC send time for a scheduled message, or null. 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). Our upstream route
adds a 4-character prefix to every message and it counts toward that budget, so one segment holds 156 plain-English
characters, or 66 in Unicode.
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": 1,
"failed": 1,
"rate_cent": 20,
"cost_cent": 40,
"refunded_cent": 0,
"error": null,
"scheduled_at": null,
"created_at": "2026-09-11 09:04:39"
}
cost_cent is what was charged when the message was created. refunded_cent is what has been
credited back since, for numbers we could not submit (rejected). What the message actually cost you is
cost_cent − refunded_cent. Messages in the list endpoint carry the same fields.
Timestamps are UTC. The send response gives created_at as ISO 8601
(2026-09-11T09:04:39.479Z). The read endpoints give created_at and
submitted_at as YYYY-MM-DD HH:MM:SS with no zone marker — that is also UTC, not Malaysia
time. Add 8 hours for Malaysia time, or parse it as UTC (for example append Z before parsing).
scheduled_at is always ISO 8601 UTC.
GET/api/v1/messages/{id}/recipients
The per-number result, not just a total. Supports limit (default 100, max 1000) and offset.
error is a plain-English explanation for anything that did not end as delivered; treat it as
text to log or show, and branch on status instead.
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": "Carrier could not deliver (number off, unreachable, invalid or blocked).", "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
}
rate_cent is your account's current per-segment rate. 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. Some carriers never send a
receipt; a number accepted by the carrier with no receipt and no rejection within 30 minutes (60 minutes for sends to
more than 10 numbers) is also counted as delivered, and its error field says so. | 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.
One more case: if the upstream returns an error or the connection drops while we are submitting, the result is
unknown and the message may already have gone out. That number is charged, not refunded and not retried, and it is
reported as undelivered.
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. |
| 400 | invalid_parameter | send_at is not a valid timestamp or is outside
2 minutes – 30 days from now, or the Idempotency-Key is longer than 255 characters. |
| 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 10000 recipients per request. |
| 422 | no_valid_recipients | None of the numbers is a valid Malaysian mobile number. |
| 422 | too_many_recipients | Over 10000. 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 — and recover the fine from the sender. The platform may block or flag such messages; when a send is blocked, you get this error and nothing 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. Brand-name detection is not guaranteed to catch every case, so keep protected brand names out of your messages yourself — a message that gets through is still fined by the carrier and passed on to you.
| Endpoint | Limit |
|---|---|
POST /messages | 60 requests per minute per key by default; can be raised for OTP traffic (see below) |
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 10000 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. One-code-per-request traffic such as OTP cannot be batched; if your peak is above 60 codes a
minute, contact us through the WhatsApp link in your console and we will raise the limit for your account.
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.
Registration, login and password-reset codes use the same POST /messages endpoint. You generate and check
the code in your own system; we only deliver the SMS (pricing and delivery speed: OTP SMS Malaysia). A few things make a big difference for this kind of traffic:
send_at is submitted to the
carrier straight away instead of waiting in the batch queue, so codes are not held up behind someone's 50,000-number
campaign.Your MyShop code is 482913. Valid for 5 minutes. Do not share it.Idempotency-Key. Use your own ID for the code attempt, for example
otp-7f3a91. If your HTTP client times out and retries, the user still gets one SMS and you pay once.201 response means the
message was accepted and charged. Carrier receipts often arrive minutes later, and some never arrive (see
status values). Show the code-entry screen right away.curl https://hantarsms.com/api/v1/messages \
-H "X-API-Key: $HANTARSMS_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: otp-7f3a91" \
-d '{"to":"60123456789","text":"Your MyShop code is 482913. Valid for 5 minutes. Do not share it.","reference":"otp signup"}'
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_TIMEOUT => 30,
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"])
// Node 18+. Uses top-level await: save as .mjs, or wrap in an async function.
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, message us on WhatsApp to claim RM1 of 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.