LeMsg API

A small, predictable REST API for sending SMS worldwide and tracking delivery. All requests and responses are JSON (UTF-8).

Overview

Base URLhttps://api.lemsg.com/v1
FormatJSON request bodies with Content-Type: application/json
AuthAPI key in the Authorization header
CurrencyUSD, amounts with up to 5 decimal places
TimeAll timestamps are UTC (ISO 8601)
Rate limit50 requests per second per API key
POST/sms/sendSend one text to up to 1,000 numbers
POST/sms/batchSend up to 100 different messages
GET/sms/{msgId}Get a message and its status
GET/balanceCurrent balance
GET/pricingYour price list

Authentication

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_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

The 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/batch for 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 statusMeaning
200Success. For send endpoints, individual numbers can still be rejected — check each item.
400Invalid request: missing or malformed fields, empty text, too many numbers, invalid sender ID, etc.
401Missing API key, invalid or disabled key, or the caller IP is not in the key’s whitelist.
402Insufficient balance (balance + credit limit does not cover the accepted messages). Nothing is charged.
403Account is disabled, still pending approval, or sending is suspended for this account.
404Message not found (or it belongs to another account).
405HTTP method not allowed for this endpoint.
429Rate limit exceeded — more than 50 requests per second for this API key — or the account’s daily sending limit is reached.
500Internal 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.

FieldTypeDescription
to requiredstring | 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 requiredstringMessage body, up to 10 segments (see encoding).
fromstring 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.
clientRefstring 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)"
      }
    ]
  }
}
FieldDescription
batchIdID of this request; all accepted messages share it.
acceptedMessages accepted and charged.
rejectedNumbers rejected (not charged).
segmentsTotal billable segments of accepted messages.
amountTotal 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

StatusFinalMeaning
PENDINGAccepted and charged, waiting to be sent to a carrier.
SENDINGBeing submitted to a carrier (transient).
SUBMITTEDAccepted by the carrier; waiting for a delivery report.
DELIVEREDYesDelivered to the handset.
FAILEDYesThe carrier reported the message as undelivered.
EXPIREDYesNo delivery report within 48 hours (errorCode NO_DLR).
REJECTEDYesNot 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.

errorCodeerrorMessage
INVALID_NUMBERInvalid phone number (use international format)
DUPLICATE_NUMBERDuplicate phone number in the same request
COUNTRY_NOT_SUPPORTEDDestination country is not enabled for this account
NO_ROUTENo route available for this destination
BLACKLISTEDNumber is blacklisted or opted out
COUNTRY_BLOCKEDDestination country is blocked
COUNTRY_NOT_ALLOWEDDestination country is not allowed for this account
NUMBER_RATE_LIMITEDToo many messages to this number
SENDER_NOT_REGISTEREDThis country requires a registered sender ID
TEMPLATE_REQUIREDThis 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.

HTTPmessageBatch errorCode
400Message text is requiredCONTENT_EMPTY
400Message text is too long (max 10 segments)CONTENT_TOO_LONG
400Invalid sender ID: up to 11 alphanumeric characters or 15 digitsINVALID_SENDER_ID
400At least one phone number is requiredNO_NUMBERS
400Too many phone numbers in one requestTOO_MANY_NUMBERS
400clientRef can only be used with a single numberCLIENT_REF_SINGLE_ONLY
400clientRef must be at most 64 charactersCLIENT_REF_TOO_LONG
400Message text contains prohibited contentCONTENT_BLOCKED
402Insufficient balanceINSUFFICIENT_BALANCE
403Account is pending approvalACCOUNT_NOT_APPROVED
403Account is disabledACCOUNT_DISABLED
403Sending is suspended for this account; contact supportACCOUNT_SUSPENDED
429Daily sending limit exceededDAILY_LIMIT_EXCEEDED

Encoding & segments

The encoding is chosen automatically from the text, and you are billed per segment.

EncodingWhenSingle messagePer segment (multipart)
GSM7All characters are in the GSM 03.38 alphabet160 characters153 characters
UCS2Any other character (Chinese, Arabic, emoji, …)70 characters67 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"
}
FieldDescription
eventAlways message.status.
msgId, batchIdIDs returned when the message was sent.
clientRefYour reference, or null.
to, countryRecipient and ISO country code.
status, errorCodeFinal status and error code (empty when delivered).
segments, amountBillable segments and cost in USD.
submittedAt, doneAtUTC 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 "", 204

Retries

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.

ProtocolSMPP 3.4
Hostsmpp.lemsg.com
Port2775
Bind typesTransmitter (TX), Receiver (RX) and Transceiver (TRX)
Credentialssystem_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 in esm_class and submit each part); parts are reassembled and billed per segment.
  • The message_id returned 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), where n is the part number starting at 1.
  • Set registered_delivery to request delivery receipts. Receipts are sent as deliver_sm (with the receipt flag in esm_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_codingEncoding
0GSM 7-bit (unpacked) — or Latin-1 if your account is configured for it
3Latin-1 (ISO-8859-1)
8UCS2 (UTF-16BE) — Chinese, Arabic, emoji, …

Command status codes

StatusNameMeaning
0x0000ESME_ROKAccepted
0x000EESME_RINVPASWDBind rejected: wrong system_id / password, account disabled, or IP not whitelisted
0x0005ESME_RALYBNDBind rejected: the account’s connection limit is reached
0x0003ESME_RINVCMDIDUnsupported command (only submit_sm is accepted; no submit_multi, query_sm, data_sm, …)
0x000BESME_RINVDSTADRInvalid or duplicate destination number
0x000AESME_RINVSRCADRInvalid sender ID, or the country requires a registered sender ID
0x0058ESME_RTHROTTLEDThrottled: submit rate above your TPS, daily limit or per-number limit reached — retry later
0x0045ESME_RSUBMITFAILOther rejection (insufficient balance, country not enabled, blocked content, …)
0x0008ESME_RSYSERRTemporary system error — retry later