Send individual (personalized bulk)
Copy page
Send a personalized bulk of SMS in a single request: every recipient gets its
own message text and its own sender ID. This is the difference from
/sms/send, which fans one message out to many recipients.
POST /api/v1/sms/send-individual · Permission: sms.send
Each accepted recipient is queued, billed and tracked exactly like a normal send,
and gets its own message_id in the response — so you can correlate delivery
by message_id or by phone number via GET /sms/messages.
A blocked_country recipient is returned in results[] without a message_id
and is not queued. Missing local pricing does not reject a valid live recipient:
it is accepted and queued with a provisional local cost.
Request
Section titled “Request”Send Content-Type: application/json (a charset=utf-8 parameter is allowed). A request with any other media type is refused with 415 UNSUPPORTED_MEDIA_TYPE when strict media-type checking is enabled; otherwise its body is still parsed as JSON.
curl -X POST https://restlink23telecom.com/api/v1/sms/send-individual \ -H "X-API-Key: $API_KEY" \ -H "Idempotency-Key: send-20260627-batch-001" \ -H "Content-Type: application/json" \ -d '{ "messages": [ { "to": "+14155551234", "message": "Hi John, your code is 847291", "sender_id": "MyApp" }, { "to": "+447911123456", "message": "Hi Anna, your code is 113355", "sender_id": "MyApp" } ]}'const res = await fetch('https://restlink23telecom.com/api/v1/sms/send-individual', { method: 'POST', headers: { 'X-API-Key': process.env.API_KEY, 'Idempotency-Key': 'send-20260627-batch-001', 'Content-Type': 'application/json' }, body: JSON.stringify({ "messages": [ { "to": "+14155551234", "message": "Hi John, your code is 847291", "sender_id": "MyApp" }, { "to": "+447911123456", "message": "Hi Anna, your code is 113355", "sender_id": "MyApp" } ] }),});
if (!res.ok) { const err = await res.json(); throw new Error(`${err.error_code}: ${err.description}`);}const data = await res.json();console.log(data);import osimport requests
res = requests.post( "https://restlink23telecom.com/api/v1/sms/send-individual", headers={"X-API-Key": os.environ["API_KEY"], "Idempotency-Key": "send-20260627-batch-001"}, json={ "messages": [{ "to": "+14155551234", "message": "Hi John, your code is 847291", "sender_id": "MyApp" }, { "to": "+447911123456", "message": "Hi Anna, your code is 113355", "sender_id": "MyApp" }] },)res.raise_for_status()print(res.json())<?php$ch = curl_init('https://restlink23telecom.com/api/v1/sms/send-individual');curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('API_KEY'), 'Idempotency-Key: send-20260627-batch-001', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => json_encode([ 'messages' => [[ 'to' => '+14155551234', 'message' => 'Hi John, your code is 847291', 'sender_id' => 'MyApp' ], [ 'to' => '+447911123456', 'message' => 'Hi Anna, your code is 113355', 'sender_id' => 'MyApp' ]] ]),]);
$response = curl_exec($ch);$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);curl_close($ch);
if ($status !== 200) { throw new Exception("HTTP $status: $response");}$data = json_decode($response, true);print_r($data);require "net/http"require "json"
uri = URI("https://restlink23telecom.com/api/v1/sms/send-individual")req = Net::HTTP::Post.new(uri)req["X-API-Key"] = ENV.fetch("API_KEY")req["Idempotency-Key"] = "send-20260627-batch-001"req["Content-Type"] = "application/json"req.body = { messages: [{ to: "+14155551234", message: "Hi John, your code is 847291", sender_id: "MyApp" }, { to: "+447911123456", message: "Hi Anna, your code is 113355", sender_id: "MyApp" }]}.to_json
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }raise "HTTP #{res.code}: #{res.body}" unless res.is_a?(Net::HTTPSuccess)
puts JSON.parse(res.body)import java.net.URI;import java.net.http.HttpClient;import java.net.http.HttpRequest;import java.net.http.HttpResponse;
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://restlink23telecom.com/api/v1/sms/send-individual")) .header("X-API-Key", System.getenv("API_KEY")) .header("Idempotency-Key", "send-20260627-batch-001") .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(""" { "messages": [ { "to": "+14155551234", "message": "Hi John, your code is 847291", "sender_id": "MyApp" }, { "to": "+447911123456", "message": "Hi Anna, your code is 113355", "sender_id": "MyApp" } ] }""")) .build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());System.out.println(response.body());package main
import ( "fmt" "io" "net/http" "os" "strings")
func main() { payload := strings.NewReader(`{ "messages": [ { "to": "+14155551234", "message": "Hi John, your code is 847291", "sender_id": "MyApp" }, { "to": "+447911123456", "message": "Hi Anna, your code is 113355", "sender_id": "MyApp" } ]}`)
req, err := http.NewRequest("POST", "https://restlink23telecom.com/api/v1/sms/send-individual", payload) if err != nil { panic(err) } req.Header.Set("X-API-Key", os.Getenv("API_KEY")) req.Header.Set("Idempotency-Key", "send-20260627-batch-001") req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close()
data, _ := io.ReadAll(res.Body) fmt.Println(string(data))}using System.Text;
using var client = new HttpClient();client.DefaultRequestHeaders.Add("X-API-Key", Environment.GetEnvironmentVariable("API_KEY"));client.DefaultRequestHeaders.Add("Idempotency-Key", "send-20260627-batch-001");
var json = """{ "messages": [ { "to": "+14155551234", "message": "Hi John, your code is 847291", "sender_id": "MyApp" }, { "to": "+447911123456", "message": "Hi Anna, your code is 113355", "sender_id": "MyApp" } ]}""";var content = new StringContent(json, Encoding.UTF8, "application/json");var response = await client.PostAsync("https://restlink23telecom.com/api/v1/sms/send-individual", content);
response.EnsureSuccessStatusCode();Console.WriteLine(await response.Content.ReadAsStringAsync());| Field | Type | Required | Description |
|---|---|---|---|
Idempotency-Key header |
string | Yes (or legacy X-Idempotency-Key) | Stable key for this logical batch. 1-128 ASCII letters, numbers, _, -, . or :. A completed 2xx response is kept for 24 hours: a retry with the same key and body returns the same HTTP status and JSON content (formatting can differ) with Idempotent-Replayed: true and is not a new send. Error responses are not kept. A 400 validation error is returned before the key is recorded, so fix the request and resend with the same key (an invalid key must itself be corrected). If the request failed after it was accepted for processing (for example a 5xx), retrying with the same key and the same body runs it again, or returns the stored response if the batch was saved after all (see 500 DB_ERROR); a key that an earlier failed attempt could not release returns 409 IDEMPOTENCY_REQUEST_IN_PROGRESS for at most 15 minutes. A key is scoped to the account, the workspace and this endpoint, and live and sk_test_ keys are separate. Requests are matched by a fingerprint of the normalized send: each recipient’s number, message text and sender ID after leading and trailing whitespace is trimmed, in request order. JSON formatting, field order and fields the endpoint does not read do not change the fingerprint; a different recipient order or a different trimmed value does. A different fingerprint with a key that is running or completed returns 409 IDEMPOTENCY_KEY_REUSED; with a live key this also applies for 24 hours after a failed attempt. A retry with the same fingerprint while the first request is still running returns 409 IDEMPOTENCY_REQUEST_IN_PROGRESS. X-Idempotency-Key is accepted as a legacy alias when Idempotency-Key is missing or blank (including whitespace-only); a nonblank Idempotency-Key wins if both are sent. X-CIO-Idempotency-Key is not accepted on this endpoint. |
messages |
object[] | Yes | 1 to 1000 messages |
messages[].to |
string | Yes | Recipient phone number in E.164 format (+ optional, 6-15 digits) |
messages[].message |
string | Yes | Message text for this recipient — encoding is detected automatically; at most 4096 bytes and 10 SMS segments |
messages[].sender_id |
string | Yes | Sender ID for this recipient: 3-11 ASCII alphanumeric characters, first character cannot be a number |
The API trims leading and trailing whitespace from each to, message and
sender_id before validation. Values that are blank after trimming, malformed
phone numbers, messages over 4096 bytes or 10 SMS segments, and invalid senders return 400.
Response
Section titled “Response”The response shape is identical to /sms/send: each accepted
recipient appears in both messages and results with its own message_id.
When a completed request is retried with the same Idempotency-Key, the API
returns the stored response, does not consume per-recipient tokens again, and includes
Idempotent-Replayed: true.
{ "status": true, "messages": [ {"dnis": "+14155551234", "message_id": "api_42_1743667200123456789_a3f8b2c1d9e45f67", "segment_num": 1}, {"dnis": "+447911123456", "message_id": "api_42_1743667200123456789_b7c4e8f1a2d3690b", "segment_num": 1} ], "results": [ {"dnis": "+14155551234", "message_id": "api_42_1743667200123456789_a3f8b2c1d9e45f67", "segments": 1, "status": "accepted"}, {"dnis": "+447911123456", "message_id": "api_42_1743667200123456789_b7c4e8f1a2d3690b", "segments": 1, "status": "accepted"} ], "summary": { "total_recipients": 2, "total_segments": 2, "total_cost": 0.02, "encoding": "GSM-7", "accepted_count": 2, "blocked_count": 0, "unpriced_count": 0, "queue_error_count": 0, "config_error_count": 0, "db_error_count": 0 }}| Field | Description |
|---|---|
messages |
Accepted messages only (one entry per recipient) |
results |
All recipients with their individual status |
summary |
Totals: accepted, blocked, errors, cost and batch encoding. encoding is GSM-7 or UCS-2 when at least one recipient is accepted, and can be empty when no recipient is accepted |
Per-recipient statuses
Section titled “Per-recipient statuses”results[].status |
Meaning |
|---|---|
accepted |
Queued for delivery |
blocked_country |
Recipient’s country is in your blocked list |
message_too_long |
Live keys only; link shortening pushed the message past 10 SMS segments; no message_id; not queued; not counted in any summary field |
queue_error |
sk_test_ simulator only |
unpriced |
Not returned by current sends (legacy value) |
config_error |
Not returned by current sends (legacy value) |
db_error |
Not returned by current sends (legacy value) |
Errors
Section titled “Errors”| HTTP | Code | Description |
|---|---|---|
| 400 | INVALID_BODY |
Cannot parse request body |
| 400 | INVALID_MESSAGES |
Missing or empty messages array |
| 400 | TOO_MANY_MESSAGES |
Over 1000 messages |
| 400 | INVALID_TO |
A messages[i].to is missing or is not 6–15 digits with an optional leading + |
| 400 | INVALID_MESSAGE |
A messages[i].message is missing, over 4096 bytes or over 10 SMS segments |
| 400 | INVALID_SENDER |
A messages[i].sender_id is missing, is not 3–11 ASCII alphanumeric characters, or starts with a number |
| 400 | IDEMPOTENCY_KEY_REQUIRED |
Neither Idempotency-Key nor the legacy X-Idempotency-Key header is set; blank values count as missing |
| 400 | INVALID_IDEMPOTENCY_KEY |
The key is over 128 characters or has unsupported characters |
| 400 | AMBIGUOUS_AUTHENTICATION |
An X-API-Key was sent together with a portal session cookie (auth_token or admin_token). Send the API key without cookies. Returned before the request body is read; nothing is sent |
| 400 | INVALID_WORKSPACE_HEADER |
Portal sessions only (cookie or bearer token): X-Workspace-ID is not a positive integer. API-key requests ignore this header. Returned before the request body is read; nothing is sent |
| 401 | UNAUTHORIZED |
Valid API authentication is required |
| 403 | NO_SMS_ACCESS |
SMS is not set up for the workspace in use, or its SMS settings could not be loaded. That is the API key’s workspace; for a portal session, the X-Workspace-ID workspace or, without that header, the default described on the Workspaces page. No message is created. Check the workspace and retry; if it persists, contact support. sk_test_ keys never get this error |
| 403 | CONFIG_ERROR |
The workspace’s SMS settings are incomplete. No message is created; contact support. sk_test_ keys never get this error |
| 403 | WORKSPACE_NOT_AVAILABLE |
The workspace was deleted while the request was being processed. No message is created; send from a live workspace. sk_test_ keys never get this error |
| 403 | FORBIDDEN |
An API key lacks sms.send; the body carries required_permission ("sms.send"). Enable it in Portal → API Keys. An Integration SMS key also gets FORBIDDEN here, without required_permission |
| 403 | SECTION_ACCESS_DENIED |
A team member (portal session, or a key they created) without sms.send or whose role hides SMS; the body carries section. Nothing is sent |
| 403 | PERMISSION_DENIED |
Team-member portal sessions only: the send route’s own sms.send re-check refused the request (access changed during the request). API keys never get this code |
| 405 | METHOD_NOT_ALLOWED |
GET is not allowed; Allow: POST names the supported method |
| 409 | IDEMPOTENCY_KEY_REUSED |
The key was already used for a different batch: a trimmed to, message or sender_id, or the order of messages, differs (see the Idempotency-Key row). Use a new key for a new batch |
| 409 | IDEMPOTENCY_REQUEST_IN_PROGRESS |
The same key is still processing, or an earlier attempt failed and could not release it. Wait and retry with the same key and body; the key is released at the latest 15 minutes after the first attempt started |
| 415 | UNSUPPORTED_MEDIA_TYPE |
Content-Type is not application/json (a charset=utf-8 parameter is allowed) while strict media-type checking is enabled. Nothing is sent; resend as JSON |
| 422 | URL_TRACKING_INVALID |
URL tracking was requested but a URL cannot be safely shortened; fix the URL and retry |
| 429 | RATE_LIMIT_EXCEEDED |
The batch exceeds your per-second message budget — see the note below |
| 500 | DB_ERROR |
The batch could not be saved. Usually it was rolled back and no message was created, but if the error came at the final commit the batch may have been saved and will still be sent. Retry with the same Idempotency-Key and body: you get the stored response if the batch was saved, otherwise it runs again; if the retry returns 409 IDEMPOTENCY_REQUEST_IN_PROGRESS, wait, because the key is released 15 minutes after the first attempt |
| 500 | IDEMPOTENCY_ERROR |
Could not reserve the idempotency key; nothing was sent. Retry with the same key and body |
| 500 | INTERNAL_ERROR |
Portal could not prepare the request (its fingerprint, a message ID or the response). With a live key no message is created; retry, with the same Idempotency-Key and body if you sent one. With an sk_test_ key, test messages for earlier recipients may already exist and a retry creates new ones |
| 500 | PERMISSION_CHECK_FAILED |
Team-member portal sessions only: your permission to send could not be checked. Nothing is sent; retry |
Temporary errors (503)
Section titled “Temporary errors (503)”A 503 means no SMS was sent. Many dependency failures are temporary: retry a few times after a short pause (when Retry-After is present it is 1). A 503 is never stored for idempotency, so if you sent an Idempotency-Key, retry with the same key and body. Authentication, access, sandbox and request-level rate-limit errors happen before the key is reserved. QUEUE_UNAVAILABLE, URL_TRACKING_UNAVAILABLE and a RATE_LIMIT_UNAVAILABLE while a multi-recipient batch is counted can happen after an Idempotency-Key was reserved; Portal attempts to mark the key retryable. If that update fails, the key may remain in progress: follow the existing IDEMPOTENCY_REQUEST_IN_PROGRESS guidance. URL_TRACKING_UNAVAILABLE can also mean that no link-shortening domain is set up; that does not clear on retry, so if it persists set up a domain or contact support. SANDBOX_UNAVAILABLE can mean the simulator is disabled or absent; if it persists, request sandbox enablement or contact support. Every 5xx body carries the generic description An internal error occurred. Reference: req-…; quote the X-Request-ID header to support.
error_code |
When | Retry-After |
|---|---|---|
API_KEY_VALIDATION_UNAVAILABLE |
The API key could not be checked | 1 |
AUTH_PROTECTION_UNAVAILABLE |
Failed-attempt protection is unavailable (unrecognised API keys) | 1 |
REPLAY_PROTECTION_UNAVAILABLE |
Replay protection is unavailable (signed API-key requests) | 1 |
RATE_LIMIT_UNAVAILABLE |
The rate limiter is unavailable — when the request arrives, or while a multi-recipient batch is counted | — |
SECTION_ACCESS_UNAVAILABLE |
Section access could not be checked (team-member sessions and member-created API keys) | — |
WORKSPACE_ACCESS_UNAVAILABLE |
Workspace access could not be checked (portal sessions only) | 1 |
SANDBOX_UNAVAILABLE |
The sk_test_ simulator is not available on this server |
— |
QUEUE_UNAVAILABLE |
The SMS queue is unavailable; no message was created | — |
URL_TRACKING_UNAVAILABLE |
Link tracking could not be prepared — temporarily, or because no link-shortening domain is set up | — |
Delivery tracking
Section titled “Delivery tracking”The send response confirms acceptance, not delivery. Because every recipient
gets a distinct message_id, you can track each one precisely:
- Webhooks (recommended): receive a delivery report per recipient the moment the carrier reports it.
- Per message: call GET /sms/status/:message_id for any
message_idreturned above. - By phone: call GET /sms/messages with
phone=,from=andto=to pull every message to a number over a date range and match results by recipient.
