SMS API

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

Authentication

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.

Idempotency

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.

SituationWhat happens
Same key, same bodyThe original response is returned. Nothing is sent or charged again.
Same key, different body422 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 running409 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.

Send a message

POST/api/v1/messages

ParameterTypeDescription
tostring or arrayRequired. 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.
textstringRequired. Message body, up to 1600 characters.
referencestringOptional. 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 a message

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

Per-number delivery

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
}

List messages

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
}

Check balance

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 values

Message status

ValueMeaning
queuedAccepted and charged, not yet handed to the carrier.
sendingBeing submitted. Large batches go out in chunks, so counters climb while in this state.
doneEvery recipient has been processed. Check delivered / failed for the outcome.
failedNothing could be submitted. Anything charged for unsent numbers is refunded automatically.

Recipient status

ValueMeaningCharged?
pendingNot submitted to the carrier yet.Held
sentAccepted by the carrier, no final receipt yet.Yes
deliveredThe carrier confirmed it reached the handset.Yes
undeliveredAccepted by the carrier but never reached the handset — phone off, number no longer in service, blocked.Yes
rejectedWe 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.

Errors

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
  }
}
HTTPcodeWhat to do
400invalid_jsonBody is not a JSON object.
400missing_parameterto is absent. See param.
401missing_api_keyNo key header was sent.
401invalid_api_keyThe key does not exist.
401revoked_api_keyThe key was revoked. Create a new one.
402insufficient_balanceTop up, then retry with the same idempotency key.
403account_disabledContact support.
404not_foundNo message with that id on your account.
405method_not_allowedWrong HTTP method for that path.
409idempotency_key_in_progressRetry in a few seconds.
413request_too_largeBody is over 1 MB. Send at most 5000 recipients per request.
422no_valid_recipientsNone of the numbers is a valid Malaysian mobile number.
422too_many_recipientsOver 5000. Split into batches.
422message_empty / message_too_longFix text.
422content_blockedSee below.
422idempotency_key_reuseSame key, different body. Fix your key generation.
429rate_limit_exceededBack off; honour Retry-After.
500internal_errorOur side. Safe to retry with the same idempotency key.

Blocked content

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.

Rate limits

EndpointLimit
POST /messages60 requests per minute per key
All GET endpoints300 requests per minute per key
Failed authentication20 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.

Java clients: set a User-Agent

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.

Full examples

$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']}");
}

Get started

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.

Open the console See pricing