SMS API

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

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 10000 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.
send_atstringOptional. 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 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": 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.

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

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
}

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

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.

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.
400invalid_parametersend_at is not a valid timestamp or is outside 2 minutes – 30 days from now, or the Idempotency-Key is longer than 255 characters.
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 10000 recipients per request.
422no_valid_recipientsNone of the numbers is a valid Malaysian mobile number.
422too_many_recipientsOver 10000. 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 — 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.

Rate limits

EndpointLimit
POST /messages60 requests per minute per key by default; can be raised for OTP traffic (see below)
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 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.

Sending OTP / verification codes

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:

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

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

Get started

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.

Open the console See pricing