How to Send SMS From Your App: Node.js, Python and PHP Examples

Table of Contents

To send SMS from your app, make an authenticated HTTPS POST to an SMS API with the recipient in E.164 format, the message text and a sender ID, then receive delivery reports on a webhook. This guide gives copy-ready Node.js, Python and PHP code for the SMS.to SMS API covering a single send, a send to multiple numbers, a delivery-report handler, and error handling with retries.

Every example calls the documented endpoint POST https://api.sms.to/sms/send with a JSON body and the header Authorization: Bearer YOUR_API_KEY. Keep the key in an environment variable called SMSTO_API_KEY, never in source control or in mobile or browser code.

What does a send request need?

The request body is small. These are the fields the examples use:

FieldRequiredWhat to put in it
messageYesThe text. Over 160 GSM-7 characters, or any Unicode character, and it becomes a multi-part message.
toYesOne number as a string, or an array of up to 2,000 numbers for the same text.
sender_idNoThe sender ID shown to the recipient. Check each country’s rules.
callback_urlNoYour webhook for delivery reports. You can also set a default in account settings.
bypass_optoutNoTrue sends even to opted-out numbers. Leave it unset for anything promotional.

A single send returns message_id; a send to an array returns campaign_id. Both come back with "success": true once the message is queued. Queued is not delivered: delivery is confirmed later by the callback.

How do you format numbers in E.164?

E.164 is the international format defined in ITU-T Recommendation E.164: a plus sign, the country code, then the national number without its leading trunk zero, up to 15 digits in total. Normalise numbers when you collect them, not at send time.

As typed by the userE.164
07700 900123 (country code 44)+447700900123
0044 7700 900123+447700900123
+44 (0)7700 900123+447700900123

A quick sanity check is the pattern ^\+[1-9]\d{1,14}$, but it cannot tell a valid number from a well-formed one. For real validation use a maintained port of Google’s libphonenumber: libphonenumber-js for Node.js, phonenumbers for Python or giggsey/libphonenumber-for-php for PHP. They parse national formats with a default country and output E.164. If some of your numbers arrive without a country code, the Default Prefix in your SMS.to account settings is applied to them, but explicit E.164 is safer.

How do you send SMS with Node.js?

This version uses the built-in fetch in Node.js 18 and later, so there is nothing to install. The same function handles one recipient or many, and retries only when retrying is safe.

// send.js - Node.js 18+ (built-in fetch). Run: node send.js
const API_KEY = process.env.SMSTO_API_KEY || "YOUR_API_KEY";
const RETRYABLE = new Set([429, 502, 503, 504]);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function sendSms(to, message, attempts = 3) {
  for (let i = 1; i <= attempts; i++) {
    const res = await fetch("https://api.sms.to/sms/send", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        message,
        to, // one E.164 string, or an array of up to 2,000
        sender_id: "SMSto",
        callback_url: "https://example.com/sms/dlr",
      }),
    });
    const body = await res.json().catch(() => ({}));
    if (res.ok && body.success) return body; // message_id or campaign_id

    if (!RETRYABLE.has(res.status) || i === attempts) {
      throw new Error(`HTTP ${res.status}: ${body.message || "request failed"}`);
    }
    const wait = Number(res.headers.get("retry-after")) * 1000 || 2 ** (i - 1) * 1000;
    await sleep(wait); // 1s, 2s, 4s unless the server says otherwise
  }
}

(async () => {
  try {
    const single = await sendSms("+447700900123", "Your order has shipped.");
    console.log("message_id:", single.message_id);

    const many = await sendSms(["+447700900123", "+447700900456"], "Maintenance tonight 22:00-23:00.");
    console.log("campaign_id:", many.campaign_id);
  } catch (err) {
    console.error(err.message);
  }
})();

How do you send SMS with Python?

The Python version uses requests. Passing json=payload serialises the body and sets the Content-Type: application/json header for you, and the 15-second timeout stops a stalled connection from hanging a worker.

# send.py - pip install requests
import os
import time
import requests

API_KEY = os.environ.get("SMSTO_API_KEY", "YOUR_API_KEY")
RETRYABLE = {429, 502, 503, 504}


def send_sms(to, message, attempts=3):
    """to: one E.164 string, or a list of up to 2,000 of them."""
    payload = {
        "message": message,
        "to": to,
        "sender_id": "SMSto",
        "callback_url": "https://example.com/sms/dlr",
    }
    headers = {"Authorization": f"Bearer {API_KEY}"}
    for attempt in range(1, attempts + 1):
        res = requests.post("https://api.sms.to/sms/send",
                            json=payload, headers=headers, timeout=15)
        try:
            body = res.json()
        except ValueError:
            body = {}
        if res.ok and body.get("success"):
            return body  # message_id or campaign_id
        if res.status_code not in RETRYABLE or attempt == attempts:
            raise RuntimeError(f"HTTP {res.status_code}: {body.get('message', 'request failed')}")
        retry_after = res.headers.get("Retry-After", "")
        time.sleep(int(retry_after) if retry_after.isdigit() else 2 ** (attempt - 1))


if __name__ == "__main__":
    try:
        print(send_sms("+447700900123", "Your order has shipped.")["message_id"])
        print(send_sms(["+447700900123", "+447700900456"],
                       "Maintenance tonight 22:00-23:00.")["campaign_id"])
    except (RuntimeError, requests.RequestException) as err:
        print(err)

How do you send SMS with PHP?

The PHP version uses the curl extension and PHP 8 union types, so $to can be a string or an array.

<?php
// send.php - PHP 8+ with the curl extension. Run: php send.php
const RETRYABLE = [429, 502, 503, 504];

function sendSms(string|array $to, string $message, int $attempts = 3): array
{
    $apiKey = getenv('SMSTO_API_KEY') ?: 'YOUR_API_KEY';
    $payload = json_encode([
        'message'      => $message,
        'to'           => $to, // one E.164 string, or an array of up to 2,000
        'sender_id'    => 'SMSto',
        'callback_url' => 'https://example.com/sms/dlr',
    ]);

    for ($i = 1; $i <= $attempts; $i++) {
        $ch = curl_init('https://api.sms.to/sms/send');
        curl_setopt_array($ch, [
            CURLOPT_POST           => true,
            CURLOPT_POSTFIELDS     => $payload,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT        => 15,
            CURLOPT_HTTPHEADER     => [
                'Authorization: Bearer ' . $apiKey,
                'Content-Type: application/json',
            ],
        ]);
        $raw = curl_exec($ch);
        if ($raw === false) {
            throw new RuntimeException('Network error: ' . curl_error($ch));
        }
        $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        $body = json_decode($raw, true) ?? [];

        if ($status >= 200 && $status < 300 && !empty($body['success'])) {
            return $body; // message_id or campaign_id
        }
        if (!in_array($status, RETRYABLE, true) || $i === $attempts) {
            throw new RuntimeException("HTTP $status: " . ($body['message'] ?? 'request failed'));
        }
        sleep(2 ** ($i - 1));
    }
    throw new RuntimeException('No attempts made');
}

try {
    echo sendSms('+447700900123', 'Your order has shipped.')['message_id'], PHP_EOL;
    echo sendSms(['+447700900123', '+447700900456'], 'Maintenance tonight 22:00-23:00.')['campaign_id'], PHP_EOL;
} catch (RuntimeException $e) {
    echo $e->getMessage(), PHP_EOL;
}

How do you receive delivery reports?

SMS.to posts a status update to your callback URL each time a message changes state. Per the callback documentation, the body is form-encoded by default and JSON if your callback URL ends in #json. The fields you need are messageId, status (such as SENT, DELIVERED, FAILED or EXPIRED), parts, price and, on failures, errorCode and errorMessage. Each handler below accepts both formats.

Node.js (Express)

// dlr.js - npm install express
const express = require("express");
const app = express();
app.use(express.urlencoded({ extended: false })); // default format
app.use(express.json()); // callback URLs ending in #json

app.post("/sms/dlr", (req, res) => {
  const { messageId, status, parts, price, errorCode } = req.body;
  // Queue the update for processing; keep this handler fast
  console.log(messageId, status, parts, price, errorCode || "");
  res.sendStatus(200);
});

app.listen(3000);

Python (Flask)

# dlr.py - pip install flask
from flask import Flask, request

app = Flask(__name__)


@app.post("/sms/dlr")
def dlr():
    data = request.get_json(silent=True) or request.form
    print(data.get("messageId"), data.get("status"),
          data.get("parts"), data.get("errorCode", ""))
    return "", 200


if __name__ == "__main__":
    app.run(port=3000)

PHP

<?php
// dlr.php
$isJson = str_contains($_SERVER['CONTENT_TYPE'] ?? '', 'application/json');
$data = $isJson ? (json_decode(file_get_contents('php://input'), true) ?? []) : $_POST;

error_log(sprintf('%s %s %s', $data['messageId'] ?? '', $data['status'] ?? '', $data['errorCode'] ?? ''));
http_response_code(200);

Three rules keep a handler reliable. Return HTTP 200 within 3 seconds, or the callback is marked failed and retried after 1, 2, 4 and 24 hours before being discarded. Make updates idempotent, because a retried callback can arrive twice. And never let a later SENT overwrite a final DELIVERED or FAILED. Note that the handlers log the message ID, not the phone number, so your logs stay free of personal data. To keep strangers from posting fake reports, use HTTPS and a long random token in the callback path, and reject requests without it.

Which errors should you retry?

Error responses have "success": false and a message explaining the problem. Most are permanent and retrying only wastes time:

HTTP statusMeaningWhat to do
400, 422Invalid or missing fields; 422 lists them in dataFix the payload. Do not retry.
401Invalid API key or tokenCheck the key. Do not retry.
403, 412No permission, or the request IP is not on the key’s allowlistFix permissions or the allowlist.
429Too many requestsBack off and retry.
502, 503, 504Gateway or availability errorRetry with backoff.
500 or a timeoutOutcome unknownCheck before resending.

The examples retry 429 and 502 to 504 up to three times with exponential backoff (1, 2 and 4 seconds), and honour a Retry-After header if the server sends one, as RFC 9110 describes. They deliberately do not retry a 500 or a network timeout, because the message may already be queued and a blind retry can deliver it twice. The API has no documented idempotency key, so reconcile instead: query GET https://api.sms.to/v2/messages with the to and created_at_from filters, and resend only if nothing matches. For an OTP, a duplicate is usually harmless; for a payment alert, it is not.

Delivery failures reported in the callback are a separate decision. Absent-subscriber codes mean the phone was off, so one later retry can make sense for important alerts. Unknown-subscriber codes mean the number does not exist, so stop sending to it. SMS.to lists every code in its failure reasons guide, and our post on SMS error codes explains the common ones.

How SMS.to handles this

SMS.to is run by Intergo Telecom, a licensed telecom and CPaaS operator. The SMS API queues and retries transient network issues on its side, routes each message to the destination network, and posts delivery reports and replies to your webhooks. Pay as you go with no setup fees, and if you plan to load test, ask for higher rate limits first so you measure your code rather than the default throughput cap. The full reference, including scheduling, personalised sends and cost estimates, is in the SMS.to developer docs. For the concepts behind these examples, read what a text message API is and how it works or the five-step SMS API integration guide.

FAQs

Do I need an SDK to send SMS from my app?

No. The API is plain HTTPS with a JSON body, so the standard HTTP client in any language works, as the examples show.

How many numbers can I send to in one request?

Up to 2,000 numbers in the to array. For larger lists, split them into batches or use a saved contact list with list_id.

Why did the API return success but the message never arrived?

Success means the message was queued. Check the delivery report or the message log for the final status and error code.

Should I retry when a message fails?

Retry 429 and gateway errors from the API with backoff. For failed deliveries, check the error code first: temporary absence may justify one retry, an unknown number never does.

Run the Node.js, Python or PHP example against your own number today. Create a free account, no credit card needed, and your first delivery report will arrive at the webhook you just built.

author avatar
Marios Italos

More Articles

Copy-ready Node.js, Python and PHP code for sending SMS through the SMS.to API, receiving delivery
"Absent subscriber" means the network knows the number but cannot reach the handset right now.