LeMsg API
A small, predictable REST API for sending SMS worldwide and tracking delivery. All requests and responses are JSON (UTF-8).
Overview
| Base URL | https://api.lemsg.com/v1 |
|---|---|
| Format | JSON request bodies with Content-Type: application/json |
| Auth | API key in the Authorization header |
| Currency | USD, amounts with up to 5 decimal places |
| Time | All timestamps are UTC (ISO 8601) |
| Rate limit | 50 requests per second per API key |
/sms/sendSend one text to up to 1,000 numbers/sms/batchSend up to 100 different messages/sms/{msgId}Get a message and its status/balanceCurrent balance/pricingYour price listAuthentication
Create an API key in the console under API keys. The full key (it starts with sk_) is shown only once — store it securely. Send it as a bearer token on every request:
Authorization: Bearer sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxThe header X-API-Key: sk_... is also accepted.
- Each key can have an optional IP whitelist (single IPs or IPv4 CIDR ranges). Requests from other IPs get
401. - Disabled or deleted keys stop working immediately.
- Each key may make up to 50 requests per second; above that you get
429. Use multiple recipients per request or/sms/batchfor high volume. - Never embed keys in browser or mobile apps — call the API from your server.
Responses & errors
Every response uses the same envelope. code is 0 on success.
{
"code": 0,
"message": "ok",
"data": { ... }
} On failure the HTTP status is set and code equals that status; message is a human-readable English description and data is null.
HTTP/1.1 402 Payment Required
{
"code": 402,
"message": "Insufficient balance",
"data": null
}| HTTP status | Meaning |
|---|---|
200 | Success. For send endpoints, individual numbers can still be rejected — check each item. |
400 | Invalid request: missing or malformed fields, empty text, too many numbers, invalid sender ID, etc. |
401 | Missing API key, invalid or disabled key, or the caller IP is not in the key’s whitelist. |
402 | Insufficient balance (balance + credit limit does not cover the accepted messages). Nothing is charged. |
403 | Account is disabled, still pending approval, or sending is suspended for this account. |
404 | Message not found (or it belongs to another account). |
405 | HTTP method not allowed for this endpoint. |
429 | Rate limit exceeded — more than 50 requests per second for this API key — or the account’s daily sending limit is reached. |
500 | Internal server error. Safe to retry with the same clientRef. |
POST /sms/send
Send the same text to one or more numbers. The whole request is charged in one go.
| Field | Type | Description |
|---|---|---|
to required | string | string[] | Recipient(s) in international format with country code, e.g. +6591234567. A single string or an array of up to 1,000 numbers. The leading + (or 00) is optional; spaces, dashes and parentheses are ignored. |
text required | string | Message body, up to 10 segments (see encoding). |
from | string | Sender ID: up to 11 letters/digits (must contain a letter; space . _ &- allowed) or up to 15 digits. Support for custom sender IDs varies by country; omit to use the route default. |
clientRef | string | Your own reference, up to 64 characters, for idempotency. Only allowed with a single number. If a message with the same clientRef already exists, it is not sent or charged again — the original message is returned. Use it to retry safely after timeouts. |
curl -X POST https://api.lemsg.com/v1/sms/send \
-H "Authorization: Bearer $LEMSG_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+6591234567",
"from": "LeMsg",
"text": "Your verification code is 482913",
"clientRef": "otp-7f3a9c"
}'Multiple recipients:
{
"to": ["+6591234567", "+447700900123", "+14155550100"],
"from": "LeMsg",
"text": "Flash sale: 20% off everything today only!"
}Response
Numbers are validated individually. Invalid, duplicate or unsupported numbers are returned as REJECTED items with an error code and are not charged; the rest are accepted and charged. The request still returns 200 when some numbers are rejected.
{
"code": 0,
"message": "ok",
"data": {
"batchId": "BMHC8ZK0W3N8R5T2C6JP",
"accepted": 2,
"rejected": 1,
"segments": 2,
"amount": 0.0630,
"items": [
{
"to": "+6591234567",
"msgId": "MMHC8ZK0W7Q2D9F4K1XA",
"status": "PENDING",
"countryCode": "SG",
"segments": 1,
"amount": 0.0315,
"errorCode": "",
"errorMessage": ""
},
{
"to": "+447700900123",
"msgId": "MMHC8ZK0W9B4L6M1P3QZ",
"status": "PENDING",
"countryCode": "GB",
"segments": 1,
"amount": 0.0315,
"errorCode": "",
"errorMessage": ""
},
{
"to": "12345",
"msgId": null,
"status": "REJECTED",
"countryCode": null,
"segments": null,
"amount": 0,
"errorCode": "INVALID_NUMBER",
"errorMessage": "Invalid phone number (use international format)"
}
]
}
}| Field | Description |
|---|---|
batchId | ID of this request; all accepted messages share it. |
accepted | Messages accepted and charged. |
rejected | Numbers rejected (not charged). |
segments | Total billable segments of accepted messages. |
amount | Total charged, in USD. |
items[] | One per number: to, msgId (null when rejected), status, countryCode (ISO 3166 alpha-2), segments, amount, errorCode, errorMessage. |
If the request as a whole is invalid (e.g. empty text, insufficient balance, account pending approval), nothing is sent or charged and an error status is returned instead.
POST /sms/batch
Send up to 100 messages with different content in one request. Each entry of messages has the same fields as /sms/send (to, text, from, clientRef).
curl -X POST https://api.lemsg.com/v1/sms/batch \
-H "Authorization: Bearer $LEMSG_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"messages": [
{ "to": "+6591234567", "text": "Hi Alice, your order #1001 has shipped." },
{ "to": "+6598765432", "text": "Hi Bob, your order #1002 has shipped.", "from": "MyShop" }
]
}' Entries are processed in order and charged separately. If one entry fails as a whole (for example the balance runs out), its numbers are returned as REJECTED items with that error code (e.g. INSUFFICIENT_BALANCE), and entries already accepted are not affected.
{
"code": 0,
"message": "ok",
"data": {
"accepted": 2,
"rejected": 0,
"amount": 0.0630,
"items": [ { "to": "+6591234567", "msgId": "M...", "status": "PENDING", ... },
{ "to": "+6598765432", "msgId": "M...", "status": "PENDING", ... } ]
}
}GET /sms/{msgId}
Retrieve a message you sent, including its current status.
curl https://api.lemsg.com/v1/sms/MMHC8ZK0W7Q2D9F4K1XA \
-H "Authorization: Bearer $LEMSG_API_KEY"{
"code": 0,
"message": "ok",
"data": {
"msgId": "MMHC8ZK0W7Q2D9F4K1XA",
"batchId": "BMHC8ZK0W3N8R5T2C6JP",
"source": "API",
"clientRef": "otp-7f3a9c",
"to": "+6591234567",
"countryCode": "SG",
"senderId": "LeMsg",
"content": "Your verification code is 482913",
"encoding": "GSM7",
"segments": 1,
"unitPrice": 0.0315,
"amount": 0.0315,
"status": "DELIVERED",
"errorCode": "",
"errorMessage": "",
"submittedAt": "2026-11-10T08:00:01",
"doneAt": "2026-11-10T08:00:05",
"createdAt": "2026-11-10T08:00:00"
}
}submittedAt, doneAt and createdAt are UTC. source is API, WEB (sent from the console), TASK (a console bulk task) or SMPP. Returns 404 if the message does not exist in your account. Prefer webhooks over polling.
GET /balance
curl https://api.lemsg.com/v1/balance \
-H "Authorization: Bearer $LEMSG_API_KEY"{
"code": 0,
"message": "ok",
"data": {
"balance": 125.40210,
"creditLimit": 0,
"currency": "USD"
}
} You can send while balance + creditLimit covers the cost; otherwise send requests fail with 402.
GET /pricing
Your price per segment for each destination. Optional query parameter country (ISO 3166 alpha-2, e.g. SG) filters to one country. Rows with an empty mccmnc are the country default; rows with an operator take precedence for that operator’s numbers.
curl "https://api.lemsg.com/v1/pricing?country=SG" \
-H "Authorization: Bearer $LEMSG_API_KEY"{
"code": 0,
"message": "ok",
"data": [
{ "country": "SG", "countryName": "Singapore", "dialCode": 65,
"mccmnc": "", "operator": "", "price": 0.0315 },
{ "country": "SG", "countryName": "Singapore", "dialCode": 65,
"mccmnc": "52501", "operator": "Singtel", "price": 0.0300 }
]
}Message statuses
| Status | Final | Meaning |
|---|---|---|
PENDING | Accepted and charged, waiting to be sent to a carrier. | |
SENDING | Being submitted to a carrier (transient). | |
SUBMITTED | Accepted by the carrier; waiting for a delivery report. | |
DELIVERED | Yes | Delivered to the handset. |
FAILED | Yes | The carrier reported the message as undelivered. |
EXPIRED | Yes | No delivery report within 48 hours (errorCode NO_DLR). |
REJECTED | Yes | Not sent: rejected by the platform or by every available route. The charge is refunded. |
Typical flow: PENDING → SENDING → SUBMITTED → DELIVERED / FAILED / EXPIRED. A webhook is sent when a message reaches a final status.
Error codes
Per-number errors
Returned in items[].errorCode with status REJECTED. These numbers are not charged.
| errorCode | errorMessage |
|---|---|
INVALID_NUMBER | Invalid phone number (use international format) |
DUPLICATE_NUMBER | Duplicate phone number in the same request |
COUNTRY_NOT_SUPPORTED | Destination country is not enabled for this account |
NO_ROUTE | No route available for this destination |
BLACKLISTED | Number is blacklisted or opted out |
COUNTRY_BLOCKED | Destination country is blocked |
COUNTRY_NOT_ALLOWED | Destination country is not allowed for this account |
NUMBER_RATE_LIMITED | Too many messages to this number |
SENDER_NOT_REGISTERED | This country requires a registered sender ID |
TEMPLATE_REQUIRED | This country requires an approved template |
SENDER_NOT_REGISTERED and TEMPLATE_REQUIRED apply to countries whose regulations require a pre-registered sender ID or pre-approved message templates. Register sender IDs and submit templates in the console under Sender ID & templates. Opted-out numbers are managed in the console under Opt-outs.
Request errors
Returned as the HTTP status and message of the response envelope for /sms/send. In /sms/batch they appear per entry as items[].errorCode.
| HTTP | message | Batch errorCode |
|---|---|---|
400 | Message text is required | CONTENT_EMPTY |
400 | Message text is too long (max 10 segments) | CONTENT_TOO_LONG |
400 | Invalid sender ID: up to 11 alphanumeric characters or 15 digits | INVALID_SENDER_ID |
400 | At least one phone number is required | NO_NUMBERS |
400 | Too many phone numbers in one request | TOO_MANY_NUMBERS |
400 | clientRef can only be used with a single number | CLIENT_REF_SINGLE_ONLY |
400 | clientRef must be at most 64 characters | CLIENT_REF_TOO_LONG |
400 | Message text contains prohibited content | CONTENT_BLOCKED |
402 | Insufficient balance | INSUFFICIENT_BALANCE |
403 | Account is pending approval | ACCOUNT_NOT_APPROVED |
403 | Account is disabled | ACCOUNT_DISABLED |
403 | Sending is suspended for this account; contact support | ACCOUNT_SUSPENDED |
429 | Daily sending limit exceeded | DAILY_LIMIT_EXCEEDED |
Encoding & segments
The encoding is chosen automatically from the text, and you are billed per segment.
| Encoding | When | Single message | Per segment (multipart) |
|---|---|---|---|
GSM7 | All characters are in the GSM 03.38 alphabet | 160 characters | 153 characters |
UCS2 | Any other character (Chinese, Arabic, emoji, …) | 70 characters | 67 characters |
- GSM extension characters
€ [ ] { } \ ^ ~ |and form feed count as 2 characters. - In UCS2, emoji and other characters outside the BMP count as 2 characters and are never split across segments.
- Maximum length is 10 segments; longer text is rejected with
400. - Cost of a message = price per segment × segments.
Webhooks
Set a webhook URL in the console under Status webhook. When a message reaches a final status (DELIVERED, FAILED, EXPIRED or REJECTED) we send a POST with a JSON body:
POST /your/webhook HTTP/1.1
Content-Type: application/json
User-Agent: LeMsg-Webhook/1.0
X-Lemsg-Timestamp: 1794297605
X-Lemsg-Signature: v1=3f1c0e7b5a...e94d
{
"event": "message.status",
"msgId": "MMHC8ZK0W7Q2D9F4K1XA",
"batchId": "BMHC8ZK0W3N8R5T2C6JP",
"clientRef": "otp-7f3a9c",
"to": "+6591234567",
"country": "SG",
"status": "DELIVERED",
"errorCode": "",
"errorMessage": "",
"segments": 1,
"amount": 0.0315,
"submittedAt": "2026-11-10T08:00:01Z",
"doneAt": "2026-11-10T08:00:05Z"
}| Field | Description |
|---|---|
event | Always message.status. |
msgId, batchId | IDs returned when the message was sent. |
clientRef | Your reference, or null. |
to, country | Recipient and ISO country code. |
status, errorCode | Final status and error code (empty when delivered). |
segments, amount | Billable segments and cost in USD. |
submittedAt, doneAt | UTC timestamps (ISO 8601 with Z), or null. |
Verifying signatures
Each request carries X-Lemsg-Timestamp (Unix seconds) and X-Lemsg-Signature, computed with the webhook secret (whsec_...) shown in the console:
signature = "v1=" + hex( HMAC_SHA256( key = webhook_secret,
message = timestamp + "." + raw_body ) )Compute the HMAC over the raw request body exactly as received, compare in constant time, and reject timestamps older than 5 minutes to prevent replays.
Node.js
// Express example — keep the raw body; do not re-serialize parsed JSON.
const crypto = require('crypto')
const express = require('express')
const SECRET = process.env.LEMSG_WEBHOOK_SECRET // whsec_...
const app = express()
app.post('/lemsg/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.get('X-Lemsg-Timestamp') || ''
const signature = req.get('X-Lemsg-Signature') || ''
const body = req.body.toString('utf8')
const expected = 'v1=' + crypto
.createHmac('sha256', SECRET)
.update(timestamp + '.' + body)
.digest('hex')
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300
const valid = signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
if (!fresh || !valid) return res.status(401).end()
const event = JSON.parse(body)
// Deduplicate on event.msgId — the same status may be delivered more than once.
console.log(event.msgId, event.status)
res.status(204).end()
})
app.listen(3000)Python
# Flask example
import hashlib, hmac, os, time
from flask import Flask, request, abort
SECRET = os.environ["LEMSG_WEBHOOK_SECRET"].encode() # whsec_...
app = Flask(__name__)
@app.post("/lemsg/webhook")
def lemsg_webhook():
timestamp = request.headers.get("X-Lemsg-Timestamp", "")
signature = request.headers.get("X-Lemsg-Signature", "")
body = request.get_data(as_text=True) # raw body, exactly as received
expected = "v1=" + hmac.new(SECRET, f"{timestamp}.{body}".encode(), hashlib.sha256).hexdigest()
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
abort(401)
if not hmac.compare_digest(signature, expected):
abort(401)
event = request.get_json()
# Deduplicate on event["msgId"] — the same status may be delivered more than once.
print(event["msgId"], event["status"])
return "", 204Retries
Respond with any 2xx status within 10 seconds to acknowledge. Otherwise we retry after 30 s, 2 min, 10 min, 30 min, 2 h and 6 h, then give up. Deliveries may arrive more than once or out of order — deduplicate on msgId. The URL must be publicly reachable (private and local addresses are refused) and redirects are not followed. Delivery attempts are listed in the console.
Numbers rejected synchronously in the send response (items with msgId: null) never get a webhook — the response is their final result.
SMPP
High-volume senders can connect over SMPP instead of HTTP. Billing, opt-outs, sender ID and template rules are the same as for the REST API.
| Protocol | SMPP 3.4 |
|---|---|
| Host | smpp.lemsg.com |
| Port | 2775 |
| Bind types | Transmitter (TX), Receiver (RX) and Transceiver (TRX) |
| Credentials | system_id and password issued by support |
- SMPP accounts are created by our support team. The password is given to you once — keep it safe; contact support to reset it. Each account has a connection limit, a submit rate (TPS) and an optional IP whitelist, shown in the console under SMPP.
- Send with
submit_sm(other submit commands are not supported). Long messages are supported via UDH concatenation (set the UDHI flag inesm_classand submit each part); parts are reassembled and billed per segment. - The
message_idreturned for a single-part message identifies that message. For a multipart message, each part gets an id of the form<group>-<n>(e.g.MMHC8ZK0W7Q2D9F4K1XA-2), wherenis the part number starting at 1. - Set
registered_deliveryto request delivery receipts. Receipts are sent asdeliver_sm(with the receipt flag inesm_class) on an RX or TRX session, one per submitted part. If no receiving session is bound, receipts are kept for up to 3 days and sent after you bind again. - Exceeding your submit rate, daily limit or per-number limit returns
ESME_RTHROTTLED(0x58). Back off and retry.
data_coding
| data_coding | Encoding |
|---|---|
0 | GSM 7-bit (unpacked) — or Latin-1 if your account is configured for it |
3 | Latin-1 (ISO-8859-1) |
8 | UCS2 (UTF-16BE) — Chinese, Arabic, emoji, … |
Command status codes
| Status | Name | Meaning |
|---|---|---|
0x0000 | ESME_ROK | Accepted |
0x000E | ESME_RINVPASWD | Bind rejected: wrong system_id / password, account disabled, or IP not whitelisted |
0x0005 | ESME_RALYBND | Bind rejected: the account’s connection limit is reached |
0x0003 | ESME_RINVCMDID | Unsupported command (only submit_sm is accepted; no submit_multi, query_sm, data_sm, …) |
0x000B | ESME_RINVDSTADR | Invalid or duplicate destination number |
0x000A | ESME_RINVSRCADR | Invalid sender ID, or the country requires a registered sender ID |
0x0058 | ESME_RTHROTTLED | Throttled: submit rate above your TPS, daily limit or per-number limit reached — retry later |
0x0045 | ESME_RSUBMITFAIL | Other rejection (insufficient balance, country not enabled, blocked content, …) |
0x0008 | ESME_RSYSERR | Temporary system error — retry later |