Send one SMS from a CRM
Copy page
Use this endpoint when a CRM or automation platform needs one simple webhook that sends one SMS and can safely retry after a timeout.
POST /api/v1/integrations/sms/send · Permission: sms.send
Request
Section titled “Request”Send a strict JSON object with three fields and one stable idempotency key:
curl -X POST https://restlink23telecom.com/api/v1/integrations/sms/send \ -H "X-API-Key: $API_KEY" \ -H "Idempotency-Key: crm-event-20260718-001" \ -H "Content-Type: application/json" \ -d '{ "to": "+447700900123", "message": "Your booking is confirmed", "sender_id": "YourBrand"}'const res = await fetch('https://restlink23telecom.com/api/v1/integrations/sms/send', { method: 'POST', headers: { 'X-API-Key': process.env.API_KEY, 'Idempotency-Key': 'crm-event-20260718-001', 'Content-Type': 'application/json' }, body: JSON.stringify({ "to": "+447700900123", "message": "Your booking is confirmed", "sender_id": "YourBrand" }),});
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/integrations/sms/send", headers={"X-API-Key": os.environ["API_KEY"], "Idempotency-Key": "crm-event-20260718-001"}, json={ "to": "+447700900123", "message": "Your booking is confirmed", "sender_id": "YourBrand" },)res.raise_for_status()print(res.json())<?php$ch = curl_init('https://restlink23telecom.com/api/v1/integrations/sms/send');curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('API_KEY'), 'Idempotency-Key: crm-event-20260718-001', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => json_encode([ 'to' => '+447700900123', 'message' => 'Your booking is confirmed', 'sender_id' => 'YourBrand' ]),]);
$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/integrations/sms/send")req = Net::HTTP::Post.new(uri)req["X-API-Key"] = ENV.fetch("API_KEY")req["Idempotency-Key"] = "crm-event-20260718-001"req["Content-Type"] = "application/json"req.body = { to: "+447700900123", message: "Your booking is confirmed", sender_id: "YourBrand"}.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/integrations/sms/send")) .header("X-API-Key", System.getenv("API_KEY")) .header("Idempotency-Key", "crm-event-20260718-001") .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(""" { "to": "+447700900123", "message": "Your booking is confirmed", "sender_id": "YourBrand" }""")) .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(`{ "to": "+447700900123", "message": "Your booking is confirmed", "sender_id": "YourBrand"}`)
req, err := http.NewRequest("POST", "https://restlink23telecom.com/api/v1/integrations/sms/send", payload) if err != nil { panic(err) } req.Header.Set("X-API-Key", os.Getenv("API_KEY")) req.Header.Set("Idempotency-Key", "crm-event-20260718-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", "crm-event-20260718-001");
var json = """{ "to": "+447700900123", "message": "Your booking is confirmed", "sender_id": "YourBrand"}""";var content = new StringContent(json, Encoding.UTF8, "application/json");var response = await client.PostAsync("https://restlink23telecom.com/api/v1/integrations/sms/send", content);
response.EnsureSuccessStatusCode();Console.WriteLine(await response.Content.ReadAsStringAsync());| Value | Rule |
|---|---|
X-API-Key |
Required restricted key with sms.send. JWT is not accepted |
| Idempotency | Send exactly one source: Idempotency-Key (generic clients, 1–128 characters using letters, numbers, _, -, ., :) or Customer.io’s automatic X-CIO-Idempotency-Key (1–1024 visible ASCII bytes). Surrounding whitespace is trimmed from Idempotency-Key |
to |
One phone number of 6–15 digits with an optional leading +, for example +447700900123; a number without + is treated as the same number with + |
message |
Non-empty, maximum 4096 UTF-8 bytes and 10 SMS segments |
sender_id |
3–11 ASCII letters/numbers; cannot start with a number |
The full request body must be at most 32 KiB. Unknown fields, arrays and a
second JSON value are rejected. Do not send X-Workspace-ID: the API key is
already bound to the correct workspace.
Response
Section titled “Response”{ "status": "accepted", "message_id": "api_7_1784394012896569000_7c6d09d704f37e4b", "mode": "live"}Sandbox keys return the same shape with mode: "sandbox" and a test_...
message ID. Sandbox acceptance is a simulation and never creates a live SMS.
accepted means 23 Telecom durably recorded the live request for delivery. It
does not mean the handset received it. Use the returned message_id with
delivery webhooks or message status.
Safe retries
Section titled “Safe retries”Create one Idempotency-Key for one logical SMS. If the request times out or
returns 5xx, retry the exact same body with the same key.
Customer.io supplies X-CIO-Idempotency-Key automatically instead. Do not add
either idempotency header manually in Customer.io, and never send both sources.
They use separate internal namespaces, so one source cannot accidentally replay
the other.
- A completed retry returns the same
202body and message ID withIdempotent-Replayed: true. - The replay record is retained for 24 hours.
- A key is scoped to the account, the workspace, this endpoint and the key type
(live or
sk_test_). The two headers are separate namespaces: the same value sent asIdempotency-Keyand asX-CIO-Idempotency-Keyis two different keys. - Requests are matched by a fingerprint of
to,messageandsender_idafter leading and trailing whitespace is trimmed; atowithout a leading+matches the same number with one. JSON formatting and field order do not change the fingerprint. - A different fingerprint with a key that is running or completed returns
409 IDEMPOTENCY_KEY_REUSED. With a live key, some failures after reservation normally keep it bound to the first fingerprint for 24 hours when the retryable state is saved (for example403 NO_SMS_ACCESS,422 DESTINATION_BLOCKED,503 QUEUE_UNAVAILABLE,500 DB_ERROR, and a403 WORKSPACE_NOT_AVAILABLEreturned while the SMS is being stored); retry those only with the same body. If saving that state fails, the original in-progress claim expires after 15 minutes. Authentication and request-validation errors occur before a key is reserved. Any429,403 INTEGRATION_*,503 INTEGRATION_GATE_UNAVAILABLEand403 WORKSPACE_NOT_AVAILABLEreturned when access is checked normally leave the key unused. If releasing a reserved claim fails, it may remain in progress for up to 15 minutes. - A retry with the same fingerprint while the first request is still running
returns
409 IDEMPOTENCY_REQUEST_IN_PROGRESSwithRetry-After: 1; wait and retry. - The legacy
X-Idempotency-Keyis not accepted on this endpoint.
Errors
Section titled “Errors”Every error body is {"status": false, "error_code": "...", "description": "..."}. For 5xx the description is generic and carries a reference; branch on error_code. No error response is a 202.
This endpoint
Section titled “This endpoint”| Status | Code | Meaning |
|---|---|---|
| 400 | INVALID_BODY |
The body could not be read, is not one JSON object with to, message and sender_id, has an unknown field, or is followed by a second JSON value |
| 400 | INVALID_TO |
to is missing, has characters other than digits and one leading +, or does not have 6–15 digits |
| 400 | INVALID_MESSAGE |
message is empty, over 4096 bytes or over 10 SMS segments |
| 400 | INVALID_SENDER_ID |
sender_id is not 3–11 ASCII letters or numbers, or starts with a number |
| 400 | IDEMPOTENCY_KEY_REQUIRED |
Neither idempotency header was sent, or Idempotency-Key is empty |
| 400 | MULTIPLE_IDEMPOTENCY_KEYS |
Both idempotency headers were sent, or one was repeated |
| 400 | INVALID_IDEMPOTENCY_KEY |
Idempotency-Key is over 128 characters or has other characters, or X-CIO-Idempotency-Key is not 1–1024 visible ASCII bytes |
| 400 | INVALID_IDEMPOTENCY_HEADER |
The legacy X-Idempotency-Key header was sent |
| 401 | API_KEY_REQUIRED |
No X-API-Key header; a portal session or JWT is not accepted |
| 401 | INVALID_API_KEY |
The API key is unknown, its stored permissions cannot be read, or its account could not be resolved |
| 403 | INTEGRATION_KEY_NOT_ELIGIBLE |
The key lacks the sms.send permission, or a live key is not a production key. A sandbox sk_test_ key with sms.send is not refused; it runs in sandbox mode |
| 403 | INTEGRATION_NOT_ENABLED |
Customer.io sending is not enabled for the account. Message: “Customer.io sending is not enabled yet for this account. Please ask 23 Telecom support to enable it.” |
| 403 | INTEGRATION_DISABLED_BY_SUPPORT |
Support has blocked Customer.io sending for this workspace. Message: “Customer.io sending is disabled for this workspace by 23 Telecom support.” |
| 403 | WORKSPACE_NOT_AVAILABLE |
The key’s workspace is not available for SMS sending; no SMS was accepted. If it is returned when access is checked, the key stays unused; if it is returned while the SMS is being stored, the key normally stays bound to this body for 24 hours if its retryable state is saved |
| 403 | NO_SMS_ACCESS |
The workspace’s SMS credentials could not be read when the SMS was prepared; no SMS was accepted. Retry with the same key and body; contact support if it persists |
| 403 | CONFIG_ERROR |
The workspace’s SMS credentials became incomplete after the access check; no SMS was accepted. Contact support |
| 409 | IDEMPOTENCY_KEY_REUSED |
The key was already used for a different send (see Safe retries). Use a new key for a new SMS |
| 409 | IDEMPOTENCY_REQUEST_IN_PROGRESS |
The first request with this key is still running. Wait Retry-After (1 second) and retry |
| 413 | REQUEST_TOO_LARGE |
The body is over 32 KiB |
| 415 | UNSUPPORTED_MEDIA_TYPE |
Content-Type is not application/json (only a charset=utf-8 parameter is allowed) |
| 422 | DESTINATION_BLOCKED |
The destination is blocked for this account; no SMS was accepted |
| 429 | RATE_LIMIT_EXCEEDED |
Live keys: the workspace integration limit (at most 100 per second, burst 1,000) was exceeded. Wait Retry-After seconds and retry with the same key and body |
| 500 | IDEMPOTENCY_ERROR |
The idempotency record could not be read or reserved; no SMS was accepted. Retry with the same key and body |
| 500 | DB_ERROR |
Storing the SMS failed or its commit could not be confirmed, so the SMS may or may not have been accepted. Retry with the same key and body: if it was accepted, the retry returns the original 202 with Idempotent-Replayed: true |
| 500 | INTERNAL_ERROR |
An unexpected error, including an accepted SMS whose 202 could not be returned, so the SMS may or may not have been accepted. Retry with the same key and body: if it was accepted, the retry returns the original 202 |
| 503 | INTEGRATION_GATE_UNAVAILABLE |
Access, workspace credentials or the integration rate limit could not be verified; no SMS was accepted and the key stays unused. Retry later |
| 503 | IDEMPOTENCY_ERROR |
A refused attempt’s key could not be released; no SMS was accepted. For up to 15 minutes a retry with this key may return 409 IDEMPOTENCY_REQUEST_IN_PROGRESS |
| 503 | QUEUE_UNAVAILABLE |
The SMS queue is unavailable; no SMS was accepted. Retry later with the same key and body |
| 503 | SANDBOX_UNAVAILABLE |
Sandbox keys only: the sandbox could not process the request. Retry later |
Authentication, access and shared limits
Section titled “Authentication, access and shared limits”These come from layers shared with other API-key routes; see also Errors and Rate limits.
| Status | Code | Meaning |
|---|---|---|
| 400 | AMBIGUOUS_AUTHENTICATION |
The request also carried a portal session cookie; send only X-API-Key |
| 401 | API_KEY_INACTIVE |
The key has been disabled |
| 401 | API_KEY_EXPIRED |
The key has expired |
| 401 | API_KEY_OWNER_INACTIVE |
The account that owns the key is inactive |
| 401 | CROSS_TENANT_WORKSPACE |
The key’s workspace has been deleted |
| 401 | SIGNATURE_REQUIRED |
The key requires HMAC signing and X-Signature or X-Timestamp is missing |
| 401 | INVALID_SIGNATURE |
Only one of X-Signature and X-Timestamp was sent, or the signature did not verify |
| 403 | API_KEY_CREATOR_INACTIVE |
The team member who created the key is no longer active in the account |
| 403 | SECTION_ACCESS_DENIED |
The team member who created the key may no longer send SMS; the body also has section |
| 403 | CORS_ORIGIN_NOT_ALLOWED |
A browser preflight (OPTIONS) came from an origin that is not allowed; server-side clients do not send preflights |
| 405 | METHOD_NOT_ALLOWED |
Any method other than POST or an OPTIONS preflight; the Allow header is POST |
| 429 | TOO_MANY_ATTEMPTS |
Too many unknown-key attempts from this IP address; wait Retry-After seconds |
| 429 | RATE_LIMIT_EXCEEDED |
The per-IP limit or the account-wide limit was exceeded; wait Retry-After seconds (X-RateLimit-Limit and X-RateLimit-Burst are set). A suspended account gets Retry-After: 60 and limit 0; contact support |
| 500 | INTERNAL_SERVER_ERROR |
An unexpected server fault; the SMS may or may not have been accepted. Retry later with the same key and body |
| 503 | API_KEY_VALIDATION_UNAVAILABLE |
The key could not be checked; retry after Retry-After: 1 |
| 503 | AUTH_PROTECTION_UNAVAILABLE |
Protection against key guessing is unavailable, so an unrecognised key was refused; retry after Retry-After: 1 |
| 503 | REPLAY_PROTECTION_UNAVAILABLE |
A signed request could not be checked for replay; retry after Retry-After: 1 |
| 503 | SECTION_ACCESS_UNAVAILABLE |
The access of the team member who created the key could not be checked; retry later |
| 503 | RATE_LIMIT_UNAVAILABLE |
The account limiter is unavailable; retry later |
Production sending
Section titled “Production sending”- Complete the sandbox test with a dedicated
sk_test_...key. - Replace it with an active workspace-bound
sk_prod_...key that has SMS send permission. An existing SMS-capable production key is eligible too. - Keep the same URL and request body, then publish after the first
202.
If 23 Telecom has not yet enabled Customer.io sending for your account you receive
INTEGRATION_NOT_ENABLED; if support has blocked the workspace you receive
INTEGRATION_DISABLED_BY_SUPPORT; a key without SMS send permission, or a live
key that is not a production key, receives INTEGRATION_KEY_NOT_ELIGIBLE. A
sandbox sk_test_ key with SMS send permission is not refused: it runs in
sandbox mode. None of these sends an SMS or charges your balance.
