This is the full developer documentation for 23 Telecom SMS API # Build with the 23 Telecom CPaaS API > Send SMS to 230+ countries, track delivery in real time, and automate your messaging — with a clean REST API, webhooks, and examples in 8 languages. 23telecom — demo ``` ___ _____ __ __ |__ \|__ / / /____ / /__ _________ ____ ___ __/ / /_ < / __/ _ \/ / _ \/ ___/ __ \/ __ `__ \ / __/___/ / / /_/ __/ / __/ /__/ /_/ / / / / / / /____/____/ \__/\___/_/\___/\___/\____/_/ /_/ /_/ ``` Welcome to the 23 Telecom SMS API. This demo replays real request/response pairs from the docs. Try: send an SMS ▷ check delivery ▷ get balance ▷ ``` ``` Want the real thing? [Open the API playground](/api) or grab the [5-minute quickstart](/quickstart). ## Start integrating [Section titled “Start integrating”](#start-integrating) Quickstart Get an API key, send your first SMS and check its delivery status — all in about five minutes. [Start now →](/quickstart) Send SMS One endpoint, up to 100 recipients per request, automatic GSM-7/UCS-2 encoding and per-recipient results. [POST /sms/send →](/sms/send) Webhooks & events Real-time delivery reports, clicks and conversions pushed to your server, with HMAC-signed payloads. [Set up webhooks →](/webhooks/overview) Statistics & reporting Aggregate, daily and per-country statistics plus full message history with CSV export. [Explore statistics →](/sms/statistics) ## Tools for every workflow [Section titled “Tools for every workflow”](#tools-for-every-workflow) [API playground ](/api)Try every endpoint in the browser — interactive reference generated from our OpenAPI spec. [Postman collection ](/tools/postman)Download the ready-made collection, set two variables and start sending. [OpenAPI specification ](/tools/openapi)Machine-readable OpenAPI 3.1 spec — generate clients, mocks and tests. [Use docs with AI ](/tools/llms)Copy the docs into ChatGPT or Claude in one click — or point your coding agent at llms.txt. ## Why developers choose 23 Telecom [Section titled “Why developers choose 23 Telecom”](#why-developers-choose-23-telecom) * **Simple authentication** — one `X-API-Key` header, with optional HMAC request signing for high-security environments. * **Honest delivery data** — real DLR statuses from carriers (`DELIVRD`, `UNDELIV`, `REJECTD`, `EXPIRED`), not optimistic guesses. * **Atomic batch sends** — a multi-recipient request either fully queues or fully fails. No partial-success surprises. * **Built to grow** — SMS today; additional messaging channels (Viber and more) are coming to the same API and docs. # Get balance > Check your 23 Telecom account balance via GET /user/balance — currency, credit-block status and degraded-cache signaling with X-Sms-Gateway-Status headers. Current account balance. GET  `/api/v1/user/balance`  · Permission: `balance.read` ## Request [Section titled “Request”](#request) * cURL ```bash curl https://restlink23telecom.com/api/v1/user/balance \ -H "X-API-Key: $API_KEY" ``` * Node.js ```js const res = await fetch('https://restlink23telecom.com/api/v1/user/balance', { method: 'GET', headers: { 'X-API-Key': process.env.API_KEY }, }); if (!res.ok) { const err = await res.json(); throw new Error(`${err.error_code}: ${err.description}`); } const data = await res.json(); console.log(data); ``` * Python ```py import os import requests res = requests.get( "https://restlink23telecom.com/api/v1/user/balance", headers={"X-API-Key": os.environ["API_KEY"]}, ) res.raise_for_status() print(res.json()) ``` * PHP ```php true, CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('API_KEY')], ]); $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); ``` * Ruby ```rb require "net/http" require "json" uri = URI("https://restlink23telecom.com/api/v1/user/balance") req = Net::HTTP::Get.new(uri) req["X-API-Key"] = ENV.fetch("API_KEY") 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) ``` * Java ```java 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/user/balance")) .header("X-API-Key", System.getenv("API_KEY")) .GET() .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println(response.body()); ``` * Go ```go package main import ( "fmt" "io" "net/http" "os" ) func main() { req, err := http.NewRequest("GET", "https://restlink23telecom.com/api/v1/user/balance", nil) if err != nil { panic(err) } req.Header.Set("X-API-Key", os.Getenv("API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() data, _ := io.ReadAll(res.Body) fmt.Println(string(data)) } ``` * .NET ```csharp using System.Text; using var client = new HttpClient(); client.DefaultRequestHeaders.Add("X-API-Key", Environment.GetEnvironmentVariable("API_KEY")); var response = await client.GetAsync("https://restlink23telecom.com/api/v1/user/balance"); response.EnsureSuccessStatusCode(); Console.WriteLine(await response.Content.ReadAsStringAsync()); ``` ## Response [Section titled “Response”](#response) 200 OK ```json { "balance": 1250.50, "currency": "EUR", "updated": "2026.02.13 10:15:00" } ``` When the balance is zero or negative, the response additionally contains `"credit_blocked": true` — SMS sending is blocked until you top up. Account still in setup? During onboarding this endpoint returns HTTP 200 with `{"status": false, "error": "Account is in setup process", …}` — treat it as “not ready yet”, not as an error. ## Balance checks on send [Section titled “Balance checks on send”](#balance-checks-on-send) Every [send request](/sms/send) is pre-checked against your balance. If it would exceed the available funds you get `402 INSUFFICIENT_BALANCE` with the estimated cost breakdown: 402 Payment Required ```json { "status": false, "error_code": "INSUFFICIENT_BALANCE", "description": "Insufficient balance", "balance": 0.42, "estimated_cost": 1.07, "currency": "EUR", "total_recipients": 100, "billable_recipients": 98, "segments": 2 } ``` ## Degraded state [Section titled “Degraded state”](#degraded-state) Balance, pricing and payments are served from a background-refreshed cache. If the cache is unavailable, the API still returns HTTP 200 but flags degradation via response headers: | Header | Values | Meaning | | ---------------------- | --------------------------------------------- | ----------------------- | | `X-Sms-Gateway-Status` | `partial`, `unavailable` | Data quality indicator | | `X-Sms-Gateway-Reason` | `cache_disabled`, `cache_miss_refresh_failed` | Machine-readable reason | These headers are **absent** on healthy responses. Degraded balance responses look like `{"balance": 0, "currency": "EUR", "unavailable": true, "reason": "…"}` — check the `unavailable` flag before alerting on a zero balance. ## Monitoring recommendation [Section titled “Monitoring recommendation”](#monitoring-recommendation) Poll balance once every few minutes at most (the cache refreshes on its own cadence), alert on `credit_blocked: true`, and ignore zero-balance readings that carry the `unavailable: true` flag. # Get payments > Retrieve payment and adjustment history via GET /user/payments — deposits, usage and manual adjustments merged from the SMS gateway and portal sources. Transaction history, merged from two sources: gateway payments and portal adjustments. GET  `/api/v1/user/payments`  · Permission: `payments.read` ## Request [Section titled “Request”](#request) * cURL ```bash curl https://restlink23telecom.com/api/v1/user/payments \ -H "X-API-Key: $API_KEY" ``` * Node.js ```js const res = await fetch('https://restlink23telecom.com/api/v1/user/payments', { method: 'GET', headers: { 'X-API-Key': process.env.API_KEY }, }); if (!res.ok) { const err = await res.json(); throw new Error(`${err.error_code}: ${err.description}`); } const data = await res.json(); console.log(data); ``` * Python ```py import os import requests res = requests.get( "https://restlink23telecom.com/api/v1/user/payments", headers={"X-API-Key": os.environ["API_KEY"]}, ) res.raise_for_status() print(res.json()) ``` * PHP ```php true, CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('API_KEY')], ]); $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); ``` * Ruby ```rb require "net/http" require "json" uri = URI("https://restlink23telecom.com/api/v1/user/payments") req = Net::HTTP::Get.new(uri) req["X-API-Key"] = ENV.fetch("API_KEY") 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) ``` * Java ```java 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/user/payments")) .header("X-API-Key", System.getenv("API_KEY")) .GET() .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println(response.body()); ``` * Go ```go package main import ( "fmt" "io" "net/http" "os" ) func main() { req, err := http.NewRequest("GET", "https://restlink23telecom.com/api/v1/user/payments", nil) if err != nil { panic(err) } req.Header.Set("X-API-Key", os.Getenv("API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() data, _ := io.ReadAll(res.Body) fmt.Println(string(data)) } ``` * .NET ```csharp using System.Text; using var client = new HttpClient(); client.DefaultRequestHeaders.Add("X-API-Key", Environment.GetEnvironmentVariable("API_KEY")); var response = await client.GetAsync("https://restlink23telecom.com/api/v1/user/payments"); response.EnsureSuccessStatusCode(); Console.WriteLine(await response.Content.ReadAsStringAsync()); ``` ## Response [Section titled “Response”](#response) 200 OK ```json [ { "id": "sms_gateway_S12345", "amount": 500.00, "currency": "EUR", "date": "2026-02-10T14:30:00Z", "type": "deposit", "description": "Wire transfer REF-2026-001", "source": "sms_gateway" }, { "id": "portal_P678", "amount": 50.00, "currency": "EUR", "date": "2026-02-08T09:00:00Z", "type": "adjustment", "description": "Manual credit by admin", "source": "portal" } ] ``` | Field | Description | | -------- | ------------------------------------------------------------------------------- | | `id` | `sms_gateway_S{id}` for gateway payments, `portal_P{id}` for portal adjustments | | `source` | `sms_gateway` (upstream payment) or `portal` (manual admin adjustment) | | `type` | Meaning | | ------------ | ------------------------------------------ | | `deposit` | Incoming payment | | `usage` | SMS cost (negative amount) | | `adjustment` | Manual credit or debit by an administrator | ## Notes [Section titled “Notes”](#notes) * During account setup the endpoint returns HTTP 200 with `{"status": false, "error": "Account is in setup process", …}`. * Degraded-cache behavior (see [balance](/account/balance#degraded-state)): `partial` returns portal adjustments only; `unavailable` returns `[]` — check the `X-Sms-Gateway-Status` header. # Get pricing > Retrieve per-country and per-network SMS rates via GET /user/pricing — MCC/MNC identifiers, current and previous rates, and rate-change tracking. SMS rates per destination country and network. GET  `/api/v1/user/pricing`  · Permission: `pricing.read` ## Request [Section titled “Request”](#request) * cURL ```bash curl https://restlink23telecom.com/api/v1/user/pricing \ -H "X-API-Key: $API_KEY" ``` * Node.js ```js const res = await fetch('https://restlink23telecom.com/api/v1/user/pricing', { method: 'GET', headers: { 'X-API-Key': process.env.API_KEY }, }); if (!res.ok) { const err = await res.json(); throw new Error(`${err.error_code}: ${err.description}`); } const data = await res.json(); console.log(data); ``` * Python ```py import os import requests res = requests.get( "https://restlink23telecom.com/api/v1/user/pricing", headers={"X-API-Key": os.environ["API_KEY"]}, ) res.raise_for_status() print(res.json()) ``` * PHP ```php true, CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('API_KEY')], ]); $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); ``` * Ruby ```rb require "net/http" require "json" uri = URI("https://restlink23telecom.com/api/v1/user/pricing") req = Net::HTTP::Get.new(uri) req["X-API-Key"] = ENV.fetch("API_KEY") 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) ``` * Java ```java 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/user/pricing")) .header("X-API-Key", System.getenv("API_KEY")) .GET() .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println(response.body()); ``` * Go ```go package main import ( "fmt" "io" "net/http" "os" ) func main() { req, err := http.NewRequest("GET", "https://restlink23telecom.com/api/v1/user/pricing", nil) if err != nil { panic(err) } req.Header.Set("X-API-Key", os.Getenv("API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() data, _ := io.ReadAll(res.Body) fmt.Println(string(data)) } ``` * .NET ```csharp using System.Text; using var client = new HttpClient(); client.DefaultRequestHeaders.Add("X-API-Key", Environment.GetEnvironmentVariable("API_KEY")); var response = await client.GetAsync("https://restlink23telecom.com/api/v1/user/pricing"); response.EnsureSuccessStatusCode(); Console.WriteLine(await response.Content.ReadAsStringAsync()); ``` ## Response [Section titled “Response”](#response) An array of pricing entries — one per network: 200 OK (one entry shown) ```json [ { "mcc": "310", "mnc": "004", "mccmnc": "310004", "dialcode": "1", "country": "United States", "network": "Verizon Wireless", "rate": 0.0085, "prev_rate": 0.0080, "rate_start_date": "2026.01.08 22:15:29", "rate_end_date": "2100.01.01 00:00:00", "change_type": "Increase" } ] ``` | Field | Description | | ------------------------ | ------------------------------------------------------------- | | `mcc` / `mnc` / `mccmnc` | Mobile Country Code, Network Code and the combined identifier | | `dialcode` | International dialing prefix | | `country` / `network` | Human-readable names | | `rate` | Current rate in your account currency | | `prev_rate` | Previous rate, for change tracking | | `rate_start_date` | When the current rate took effect (`YYYY.MM.DD HH:MM:SS`) | | `rate_end_date` | Far-future sentinel (`2100.01.01`) while the rate is ongoing | | `change_type` | `Same`, `Increase` or `Decrease` | Watching for price changes Cache the pricing array and diff `change_type != "Same"` entries on each refresh — that gives you a rate-change feed per network for cost alerting. ## Notes [Section titled “Notes”](#notes) * `POST /user/pricing` returns identical data (kept for frontend compatibility; the request body is ignored). * `GET /user/pricing/:id` is currently a stub returning `{}` — fetch the full list and filter client-side by `mccmnc`. * During account setup the endpoint returns HTTP 200 with `{"status": false, "error": "Account is in setup process", …}`. * Degraded-cache behavior matches [balance](/account/balance#degraded-state): an empty array `[]` plus `X-Sms-Gateway-Status: unavailable`. # Workspaces > Use 23 Telecom multi-workspace accounts — route SMS through multiple gateway products from one portal account, scope requests with X-Workspace-ID or per-workspace API keys. By default every request runs against your **main workspace** — if you have a single workspace, nothing here concerns you. Read on only if your account routes traffic through more than one SMS gateway product. ## The model [Section titled “The model”](#the-model) One portal account can own several workspaces. Each workspace binds to one SMS gateway product; exactly one workspace is **main** (the default fallback). Workspaces are created by your administrator. ## API keys: no header needed [Section titled “API keys: no header needed”](#api-keys-no-header-needed) Each API key is **server-side bound to one workspace** — the key itself decides where its traffic routes. The `X-Workspace-ID` header is ignored on API-key requests. Tip To send from a different workspace with API-key auth, create an API key **inside that workspace** (portal → switch workspace → Settings → API Keys). One key per workspace is the cleanest integration pattern. ## JWT: the X-Workspace-ID header [Section titled “JWT: the X-Workspace-ID header”](#jwt-the-x-workspace-id-header) When authenticating with JWT, scope any request to a workspace you own: ```plaintext X-Workspace-ID: 18 ``` | Header value | Behavior | | ------------------------------------- | ----------------------------------------- | | *omitted* | Request runs against your main workspace | | Valid ID of a live workspace you own | Request scoped to that workspace | | Not a positive integer | `400 INVALID_WORKSPACE_HEADER` | | Workspace you don’t own, or deleted | `403 CROSS_TENANT_WORKSPACE` | | No live main workspace on the account | `403 NO_MAIN_WORKSPACE` — contact support | ## List your workspaces (JWT only) [Section titled “List your workspaces (JWT only)”](#list-your-workspaces-jwt-only) `GET /user/workspaces` returns your live workspaces. It is **not** available to API-key requests; `GET /user/profile` already embeds the same list. ```bash curl -H "Authorization: Bearer $TOKEN" \ https://restlink23telecom.com/api/v1/user/workspaces ``` 200 OK ```json [ { "id": 16, "name": "Main", "is_main": true, "account_id": 279, "product_id": 1430 }, { "id": 18, "name": "test-space", "is_main": false, "color": "#5298D9", "account_id": 279, "product_id": 1406 } ] ``` | Field | Description | | --------------------------- | ------------------------------------------- | | `id` | Workspace ID — use this in `X-Workspace-ID` | | `name` | Label chosen by your admin | | `is_main` | `true` for exactly one workspace | | `color` | Optional UI tint | | `account_id` / `product_id` | Bound SMS gateway product | ## Example: send from a specific workspace (JWT) [Section titled “Example: send from a specific workspace (JWT)”](#example-send-from-a-specific-workspace-jwt) ```bash curl -X POST https://restlink23telecom.com/api/v1/sms/send \ -H "Authorization: Bearer $TOKEN" \ -H "X-Workspace-ID: 18" \ -H "Content-Type: application/json" \ -d '{"to":["+14155551234"],"message":"hi","sender_id":"MyApp"}' ``` ## Webhooks are per-workspace [Section titled “Webhooks are per-workspace”](#webhooks-are-per-workspace) Webhook URLs, field selections and signing secrets are configured **per workspace** — each workspace routes its events independently. See [webhooks](/webhooks/overview). # API keys & permissions > Create and manage 23 Telecom API keys in the customer portal. Scope keys with granular permissions like sms.send, sms.read and balance.read, and follow key-security best practices. API keys are the primary way to authenticate. Each key is scoped by **permissions** and bound to one **workspace**, so you can issue separate keys per application, per environment, or per team — and revoke them independently. ## Create a key [Section titled “Create a key”](#create-a-key) 1. Log in to the [customer portal](https://restlink23telecom.com). 2. Go to **Settings → API Keys** and click **Create API Key**. 3. Pick the permissions the key needs (see the table below) and, optionally, enable **HMAC signature verification** for high-security environments. 4. Copy the key (`sk_prod_…`). It is shown **only once** — store it in your secrets manager right away. Test mode Enable **Test mode** when creating a key to get a [sandbox key](/sandbox) (`sk_test_…`) — sends are simulated and free, delivery webhooks are synthesized, and nothing touches your balance. Multiple workspaces? An API key always routes traffic to the workspace it was created in. To send from a different workspace, create a key in that workspace — see [workspaces](/account/workspaces). ## Permissions [Section titled “Permissions”](#permissions) Keys follow a deny-by-default model: a key can only call endpoints its permissions allow. | Permission | Grants | | ---------------- | --------------------------------------------------------------------------------- | | `*` | Full access | | `sms.send` | Send SMS (`POST /sms/send`) | | `sms.read` | Status, stats, messages, unified view (`GET /sms/*`) | | `balance.read` | Balance (`GET /user/balance`) | | `pricing.read` | Pricing (`GET/POST /user/pricing`) | | `payments.read` | Payments (`GET /user/payments`) | | `webhooks.read` | Webhook settings and logs (`GET /user/webhooks`, `GET /user/webhooks/logs`) | | `webhooks.write` | Update webhooks and send tests (`PUT /user/webhooks`, `POST /user/webhooks/test`) | ### Recommended configurations [Section titled “Recommended configurations”](#recommended-configurations) | Use case | Permissions | | ------------------------- | ---------------------------------------------------------------------------------------------------------- | | Send + track delivery | `sms.send`, `sms.read` | | Send only (fire & forget) | `sms.send` | | Dashboard / monitoring | `sms.read`, `balance.read` | | Webhook management | `webhooks.read`, `webhooks.write` | | Full API access | `sms.send`, `sms.read`, `balance.read`, `pricing.read`, `payments.read`, `webhooks.read`, `webhooks.write` | Calling an endpoint outside the key’s permissions returns `403 FORBIDDEN`. ## Key security best practices [Section titled “Key security best practices”](#key-security-best-practices) * **One key per application and environment.** Separate keys for staging and production let you rotate or revoke one without touching the other. * **Grant the minimum permissions.** A background sender rarely needs `payments.read`. * **Store keys in environment variables** or a secrets manager. Never commit them to version control or embed them in client-side code. * **Rotate on staff changes** and on any suspicion of exposure — create a new key, switch traffic, then delete the old one. * **Enable HMAC signing** for environments where transport interception is a concern — see [authentication](/authentication#api-key-with-hmac-signature). ## Errors you may see [Section titled “Errors you may see”](#errors-you-may-see) | HTTP | Code | Meaning | | ---- | ------------------ | ------------------------------------------ | | 401 | `INVALID_API_KEY` | Key not found or malformed | | 401 | `API_KEY_INACTIVE` | Key exists but is disabled | | 401 | `API_KEY_EXPIRED` | Key past its expiry date | | 403 | `FORBIDDEN` | Key lacks the permission for this endpoint | Full list: [error reference](/reference/errors). # Authentication > Authenticate 23 Telecom API requests with an X-API-Key header, optional HMAC-SHA256 request signing, or a JWT token. Examples for every auth mode in 8 programming languages. The API supports three authentication methods. For production integrations, use an **API key** — optionally with HMAC request signing. | Method | Best for | Header(s) | | ---------------------------------------------- | ------------------------------ | ----------------------------------------- | | [API key](#api-key-recommended) | Production integrations | `X-API-Key` | | [API key + HMAC](#api-key-with-hmac-signature) | High-security environments | `X-API-Key`, `X-Timestamp`, `X-Signature` | | [JWT token](#jwt-token) | Testing, portal-only endpoints | `Authorization: Bearer …` | ## API key (recommended) [Section titled “API key (recommended)”](#api-key-recommended) Create keys in the [portal](https://restlink23telecom.com) under **Settings → API Keys** (see [API keys & permissions](/api-keys)). Pass the key in the `X-API-Key` header with every request: ```plaintext X-API-Key: sk_prod_a1b2c3d4e5f6g7h8i9j0... ``` * cURL ```bash curl "https://restlink23telecom.com/api/v1/sms/stats?period=7d" \ -H "X-API-Key: $API_KEY" ``` * Node.js ```js const res = await fetch('https://restlink23telecom.com/api/v1/sms/stats?period=7d', { method: 'GET', headers: { 'X-API-Key': process.env.API_KEY }, }); if (!res.ok) { const err = await res.json(); throw new Error(`${err.error_code}: ${err.description}`); } const data = await res.json(); console.log(data); ``` * Python ```py import os import requests res = requests.get( "https://restlink23telecom.com/api/v1/sms/stats", headers={"X-API-Key": os.environ["API_KEY"]}, params={ "period": "7d" }, ) res.raise_for_status() print(res.json()) ``` * PHP ```php true, CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('API_KEY')], ]); $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); ``` * Ruby ```rb require "net/http" require "json" uri = URI("https://restlink23telecom.com/api/v1/sms/stats?period=7d") req = Net::HTTP::Get.new(uri) req["X-API-Key"] = ENV.fetch("API_KEY") 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) ``` * Java ```java 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/stats?period=7d")) .header("X-API-Key", System.getenv("API_KEY")) .GET() .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println(response.body()); ``` * Go ```go package main import ( "fmt" "io" "net/http" "os" ) func main() { req, err := http.NewRequest("GET", "https://restlink23telecom.com/api/v1/sms/stats?period=7d", nil) if err != nil { panic(err) } req.Header.Set("X-API-Key", os.Getenv("API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() data, _ := io.ReadAll(res.Body) fmt.Println(string(data)) } ``` * .NET ```csharp using System.Text; using var client = new HttpClient(); client.DefaultRequestHeaders.Add("X-API-Key", Environment.GetEnvironmentVariable("API_KEY")); var response = await client.GetAsync("https://restlink23telecom.com/api/v1/sms/stats?period=7d"); response.EnsureSuccessStatusCode(); Console.WriteLine(await response.Content.ReadAsStringAsync()); ``` Keep keys out of code Store keys in environment variables or a secrets manager — never commit them to version control. All examples in these docs read the key from the `API_KEY` environment variable. ## API key with HMAC signature [Section titled “API key with HMAC signature”](#api-key-with-hmac-signature) For enhanced security, enable signature verification when creating the key. Every request must then carry three headers: | Header | Description | | ------------- | --------------------------------- | | `X-API-Key` | Your API key | | `X-Timestamp` | Current Unix timestamp in seconds | | `X-Signature` | HMAC-SHA256 signature (hex) | **Signature computation:** ```plaintext message = "{METHOD}|{path}|{timestamp}|{body}" signature = HMAC-SHA256(api_secret, message) ``` Three rules to get it right: * `path` is the URL path **without** the query string — `/api/v1/sms/stats`, not `/api/v1/sms/stats?period=7d`. * `body` is the raw request body string; use an empty string `""` for GET. * Timestamps must be within **5 minutes** of server time, and each timestamp + signature pair is accepted **once** (replay protection). - Node.js ```js import crypto from 'node:crypto'; const apiKey = process.env.API_KEY; const apiSecret = process.env.API_SECRET; const timestamp = Math.floor(Date.now() / 1000).toString(); const method = 'POST'; const path = '/api/v1/sms/send'; const body = JSON.stringify({ to: ['+14155551234'], message: 'Hello!', sender_id: 'MyApp', }); const signature = crypto .createHmac('sha256', apiSecret) .update(`${method}|${path}|${timestamp}|${body}`) .digest('hex'); const res = await fetch('https://restlink23telecom.com' + path, { method, headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey, 'X-Timestamp': timestamp, 'X-Signature': signature, }, body, }); console.log(await res.json()); ``` - Python ```py import hmac, hashlib, time, os, requests api_key = os.environ["API_KEY"] api_secret = os.environ["API_SECRET"] timestamp = str(int(time.time())) method = "POST" path = "/api/v1/sms/send" body = '{"to":["+14155551234"],"message":"Hello!","sender_id":"MyApp"}' message = f"{method}|{path}|{timestamp}|{body}" signature = hmac.new(api_secret.encode(), message.encode(), hashlib.sha256).hexdigest() res = requests.post( "https://restlink23telecom.com" + path, headers={ "Content-Type": "application/json", "X-API-Key": api_key, "X-Timestamp": timestamp, "X-Signature": signature, }, data=body, ) print(res.json()) ``` - PHP ```php ['+14155551234'], 'message' => 'Hello!', 'sender_id' => 'MyApp']); $signature = hash_hmac('sha256', "$method|$path|$timestamp|$body", $apiSecret); $ch = curl_init("https://restlink23telecom.com$path"); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', "X-API-Key: $apiKey", "X-Timestamp: $timestamp", "X-Signature: $signature", ], ]); echo curl_exec($ch); ``` - Go ```go package main import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "fmt" "net/http" "os" "strconv" "strings" "time" ) func main() { apiKey, apiSecret := os.Getenv("API_KEY"), os.Getenv("API_SECRET") timestamp := strconv.FormatInt(time.Now().Unix(), 10) method, path := "POST", "/api/v1/sms/send" body := `{"to":["+14155551234"],"message":"Hello!","sender_id":"MyApp"}` mac := hmac.New(sha256.New, []byte(apiSecret)) mac.Write([]byte(method + "|" + path + "|" + timestamp + "|" + body)) signature := hex.EncodeToString(mac.Sum(nil)) req, _ := http.NewRequest(method, "https://restlink23telecom.com"+path, strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-API-Key", apiKey) req.Header.Set("X-Timestamp", timestamp) req.Header.Set("X-Signature", signature) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() fmt.Println(res.Status) } ``` Signature failures return `401 SIGNATURE_REQUIRED` (headers missing) or `401 INVALID_SIGNATURE` (verification failed) — see [errors](/reference/errors). ## JWT token [Section titled “JWT token”](#jwt-token) You can also authenticate by logging in with portal credentials. This is mainly useful for testing and for the few endpoints that are JWT-only (for example [listing workspaces](/account/workspaces)). For production integrations use API keys. Log in ```bash curl -X POST https://restlink23telecom.com/api/v1/user/auth \ -H "Content-Type: application/json" \ -d '{"login": "your_username", "password": "your_password"}' ``` 200 OK ```json { "status": true, "token": "eyJhbGciOiJIUzI1NiIs..." } ``` Pass the token in subsequent requests: ```plaintext Authorization: Bearer eyJhbGciOiJIUzI1NiIs... ``` Note Tokens are invalidated when you change or reset your password, or when a token is explicitly revoked. Your integration should handle `401 TOKEN_EXPIRED` / `401 INVALID_TOKEN` by re-authenticating. # Quickstart — send your first SMS > Send your first SMS with the 23 Telecom API in about five minutes. Get an API key, call POST /sms/send and check delivery status — with examples in cURL, Node.js, Python, PHP, Ruby, Java, Go and .NET. This guide takes you from zero to a delivered SMS in about five minutes. **Prerequisites:** an active 23 Telecom account with SMS sending enabled. Your account manager provides your login credentials. 1. **Get your API key.** Log in to the [customer portal](https://restlink23telecom.com), go to **Settings → API Keys**, and create a key with the `sms.send` and `sms.read` permissions. Shown only once Copy the key immediately — for security it is displayed a single time. If you lose it, create a new key. 2. **Send your first SMS.** Replace the recipient with your own phone number in E.164 format (for example `+447911123456`): * cURL ```bash curl -X POST https://restlink23telecom.com/api/v1/sms/send \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": [ "+14155551234" ], "message": "Hello from 23 Telecom!", "sender_id": "MyCompany" }' ``` * Node.js ```js const res = await fetch('https://restlink23telecom.com/api/v1/sms/send', { method: 'POST', headers: { 'X-API-Key': process.env.API_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ "to": [ "+14155551234" ], "message": "Hello from 23 Telecom!", "sender_id": "MyCompany" }), }); if (!res.ok) { const err = await res.json(); throw new Error(`${err.error_code}: ${err.description}`); } const data = await res.json(); console.log(data); ``` * Python ```py import os import requests res = requests.post( "https://restlink23telecom.com/api/v1/sms/send", headers={"X-API-Key": os.environ["API_KEY"]}, json={ "to": ["+14155551234"], "message": "Hello from 23 Telecom!", "sender_id": "MyCompany" }, ) res.raise_for_status() print(res.json()) ``` * PHP ```php 'POST', CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('API_KEY'), 'Content-Type: application/json'], CURLOPT_POSTFIELDS => json_encode([ 'to' => ['+14155551234'], 'message' => 'Hello from 23 Telecom!', 'sender_id' => 'MyCompany' ]), ]); $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); ``` * Ruby ```rb require "net/http" require "json" uri = URI("https://restlink23telecom.com/api/v1/sms/send") req = Net::HTTP::Post.new(uri) req["X-API-Key"] = ENV.fetch("API_KEY") req["Content-Type"] = "application/json" req.body = { to: ["+14155551234"], message: "Hello from 23 Telecom!", sender_id: "MyCompany" }.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) ``` * Java ```java 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")) .header("X-API-Key", System.getenv("API_KEY")) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(""" { "to": [ "+14155551234" ], "message": "Hello from 23 Telecom!", "sender_id": "MyCompany" }""")) .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println(response.body()); ``` * Go ```go package main import ( "fmt" "io" "net/http" "os" "strings" ) func main() { payload := strings.NewReader(`{ "to": [ "+14155551234" ], "message": "Hello from 23 Telecom!", "sender_id": "MyCompany" }`) req, err := http.NewRequest("POST", "https://restlink23telecom.com/api/v1/sms/send", payload) if err != nil { panic(err) } req.Header.Set("X-API-Key", os.Getenv("API_KEY")) 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)) } ``` * .NET ```csharp using System.Text; using var client = new HttpClient(); client.DefaultRequestHeaders.Add("X-API-Key", Environment.GetEnvironmentVariable("API_KEY")); var json = """ { "to": [ "+14155551234" ], "message": "Hello from 23 Telecom!", "sender_id": "MyCompany" } """; var content = new StringContent(json, Encoding.UTF8, "application/json"); var response = await client.PostAsync("https://restlink23telecom.com/api/v1/sms/send", content); response.EnsureSuccessStatusCode(); Console.WriteLine(await response.Content.ReadAsStringAsync()); ``` A successful response confirms acceptance and tells you the cost, encoding and a `message_id` for each recipient: 200 OK ```json { "status": true, "messages": [ { "dnis": "+14155551234", "message_id": "api_42_1743667200123456789_a3f8b2c1d9e45f67", "segment_num": 1 } ], "results": [ { "dnis": "+14155551234", "message_id": "api_42_1743667200123456789_a3f8b2c1d9e45f67", "segments": 1, "status": "accepted" } ], "summary": { "total_recipients": 1, "total_segments": 1, "total_cost": 0.01, "encoding": "GSM-7", "accepted_count": 1, "blocked_count": 0 } } ``` 3. **Check the delivery status.** Use the `message_id` from the send response: * cURL ```bash curl https://restlink23telecom.com/api/v1/sms/status/api_42_1743667200123456789_a3f8b2c1d9e45f67 \ -H "X-API-Key: $API_KEY" ``` * Node.js ```js const res = await fetch('https://restlink23telecom.com/api/v1/sms/status/api_42_1743667200123456789_a3f8b2c1d9e45f67', { method: 'GET', headers: { 'X-API-Key': process.env.API_KEY }, }); if (!res.ok) { const err = await res.json(); throw new Error(`${err.error_code}: ${err.description}`); } const data = await res.json(); console.log(data); ``` * Python ```py import os import requests res = requests.get( "https://restlink23telecom.com/api/v1/sms/status/api_42_1743667200123456789_a3f8b2c1d9e45f67", headers={"X-API-Key": os.environ["API_KEY"]}, ) res.raise_for_status() print(res.json()) ``` * PHP ```php true, CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('API_KEY')], ]); $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); ``` * Ruby ```rb require "net/http" require "json" uri = URI("https://restlink23telecom.com/api/v1/sms/status/api_42_1743667200123456789_a3f8b2c1d9e45f67") req = Net::HTTP::Get.new(uri) req["X-API-Key"] = ENV.fetch("API_KEY") 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) ``` * Java ```java 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/status/api_42_1743667200123456789_a3f8b2c1d9e45f67")) .header("X-API-Key", System.getenv("API_KEY")) .GET() .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println(response.body()); ``` * Go ```go package main import ( "fmt" "io" "net/http" "os" ) func main() { req, err := http.NewRequest("GET", "https://restlink23telecom.com/api/v1/sms/status/api_42_1743667200123456789_a3f8b2c1d9e45f67", nil) if err != nil { panic(err) } req.Header.Set("X-API-Key", os.Getenv("API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() data, _ := io.ReadAll(res.Body) fmt.Println(string(data)) } ``` * .NET ```csharp using System.Text; using var client = new HttpClient(); client.DefaultRequestHeaders.Add("X-API-Key", Environment.GetEnvironmentVariable("API_KEY")); var response = await client.GetAsync("https://restlink23telecom.com/api/v1/sms/status/api_42_1743667200123456789_a3f8b2c1d9e45f67"); response.EnsureSuccessStatusCode(); Console.WriteLine(await response.Content.ReadAsStringAsync()); ``` 200 OK ```json { "status": true, "message": { "message_id": "api_42_1743667200123456789_a3f8b2c1d9e45f67", "recipient": "+14155551234", "sender_id": "MyCompany", "message": "Hello from 23 Telecom!", "segments": 1, "status": "DELIVRD", "cost": 0.0085, "created_at": "2026-02-13T10:30:00Z", "delivered_at": "2026-02-13T10:30:04Z" } } ``` `DELIVRD` means the message reached the recipient’s handset. See all possible values in [delivery statuses](/reference/delivery-statuses). ## Next steps [Section titled “Next steps”](#next-steps) * **Stop polling, start listening** — set up a [delivery webhook](/webhooks/delivery) to receive real-time delivery reports on your server. * **Going to production?** Read [authentication](/authentication) for HMAC request signing and [rate limits](/reference/rate-limits). * **Sending campaigns?** Check [encoding & segments](/sms/encoding) so long or Unicode messages don’t surprise you on cost. * **Prefer clicking to coding?** Import the [Postman collection](/tools/postman) or open the [API playground](/api). # Delivery statuses > SMS delivery status reference — pending, sent, DELIVRD, UNDELIV, REJECTD, EXPIRED and UNKNOWN. Status lifecycle, final vs non-final states and reconciliation behavior. Every message moves through a lifecycle of statuses, reported by carriers via delivery reports (DLR). ## Status table [Section titled “Status table”](#status-table) | Status | Description | Final? | | --------- | ------------------------------------------- | ------ | | `pending` | Queued, waiting to send | No | | `sent` | Submitted to carrier network | No | | `DELIVRD` | Delivered to recipient’s handset | Yes | | `UNDELIV` | Delivery failed (invalid number, phone off) | Yes | | `REJECTD` | Rejected by carrier (filtered, blacklisted) | Yes | | `EXPIRED` | Delivery timed out | Yes | | `UNKNOWN` | Final status unknown | Yes | | `failed` | Internal error (queue/config failure) | Yes | ## Typical flows [Section titled “Typical flows”](#typical-flows) ```plaintext pending → sent → DELIVRD success pending → sent → UNDELIV bad number / phone off pending → sent → REJECTD carrier blocked pending → sent → EXPIRED recipient unreachable pending → failed internal error ``` ## Where statuses appear [Section titled “Where statuses appear”](#where-statuses-appear) | Place | Field | | -------------------------------------- | ------------------------------------------------------------------- | | [Message status](/sms/status) | `message.status` | | [Message lists](/sms/messages) | `messages[].status` (+ filter aliases like `delivered`) | | [Delivery webhook](/webhooks/delivery) | `status` | | [Statistics](/sms/statistics) | Aggregated into `delivered` / `undelivered` / `pending` / `expired` | ## Reconciliation [Section titled “Reconciliation”](#reconciliation) Messages stuck in `sent`/`ENROUTE` without a final DLR for **3+ days** are automatically moved to `UNDELIV` by an hourly reconciliation job — so long-running integrations never see permanently “pending” messages. ## Interpreting statuses [Section titled “Interpreting statuses”](#interpreting-statuses) * **`DELIVRD` is the only confirmed handset delivery.** `sent` means the carrier accepted the message, not that the phone received it. * **`UNDELIV` vs `REJECTD`:** `UNDELIV` is usually a recipient problem (dead number, phone off for days); `REJECTD` is a carrier policy decision (content filtering, blacklist) — review your content/sender if it spikes. * **`EXPIRED`** often means the handset was off or out of coverage for the entire validity period. * **Clean your lists:** repeatedly `UNDELIV` numbers should be suppressed — they cost money and hurt your sending reputation. # Error reference > Complete 23 Telecom API error code reference — authentication, validation, rate limiting and server errors with HTTP statuses and stable machine-readable error_code values. All errors share one shape — `status` is always `false`, `error_code` is stable and machine-readable: ```json { "status": false, "error_code": "UNAUTHORIZED", "description": "Human-readable message" } ``` Match on `error_code`, never on `description` (wording may change). ## Authentication & authorization [Section titled “Authentication & authorization”](#authentication--authorization) | HTTP | Code | When | | ---- | ------------------------- | ------------------------------------------------------------- | | 401 | `UNAUTHORIZED` | Missing or unrecognized credentials | | 401 | `INVALID_TOKEN` | JWT invalid, malformed or revoked | | 401 | `TOKEN_EXPIRED` | JWT past expiry | | 401 | `INVALID_API_KEY` | API key not found or hash mismatch | | 401 | `API_KEY_INACTIVE` | API key disabled | | 401 | `API_KEY_EXPIRED` | API key expired | | 401 | `INVALID_CREDENTIALS` | Wrong username/password | | 401 | `USER_NOT_ACTIVE` | Account suspended or inactive | | 401 | `IP_NOT_ALLOWED` | Client IP not in the allowed list | | 401 | `SIGNATURE_REQUIRED` | Key requires HMAC but signature headers missing | | 401 | `INVALID_SIGNATURE` | HMAC signature failed verification | | 403 | `FORBIDDEN` | Authenticated, but route or permission denied | | 403 | `NO_SMS_ACCESS` | SMS not configured on the account | | 403 | `CONFIG_ERROR` | Credentials incomplete — contact support | | 403 | `CROSS_TENANT_WORKSPACE` | `X-Workspace-ID` names a workspace you don’t own (or deleted) | | 403 | `NO_MAIN_WORKSPACE` | No live main workspace — contact support | | 403 | `WORKSPACE_NOT_AVAILABLE` | Target workspace deleted — use a live workspace | ## Validation [Section titled “Validation”](#validation) | HTTP | Code | When | | ---- | -------------------------- | ---------------------------------------------- | | 400 | `INVALID_BODY` | Malformed JSON body | | 400 | `INVALID_PARAMS` | Invalid query or path parameters | | 400 | `INVALID_TO` | Missing/empty recipients | | 400 | `INVALID_MESSAGE` | Missing/empty message | | 400 | `INVALID_SENDER` | Missing/empty sender\_id | | 400 | `TOO_MANY_RECIPIENTS` | Over 100 recipients | | 400 | `INVALID_WORKSPACE_HEADER` | `X-Workspace-ID` is not a positive integer | | 402 | `INSUFFICIENT_BALANCE` | Balance too low — body includes cost breakdown | ## Rate limiting [Section titled “Rate limiting”](#rate-limiting) | HTTP | Code | When | | ---- | --------------------- | ---------------------------------------------------- | | 429 | `RATE_LIMIT_EXCEEDED` | Per-account rate limit hit | | 429 | `TOO_MANY_ATTEMPTS` | Login/2FA brute-force limit (includes `Retry-After`) | Details and recommended client behavior: [rate limits](/reference/rate-limits). ## Other [Section titled “Other”](#other) | HTTP | Code | When | | ---- | ---------------- | ------------------------------------------------------------------------------------- | | 404 | `NOT_FOUND` | Message or resource not found | | 500 | `INTERNAL_ERROR` | Server-side error | | 500 | `DB_ERROR` | Send request could not be queued — atomic rollback, [safe to retry](/sms/send#errors) | ## Handling errors well [Section titled “Handling errors well”](#handling-errors-well) * **Retry** `429` (after a delay) and `500 DB_ERROR` (idempotent by design). Do not blind-retry `4xx` — fix the request instead. * **Alert** on `402 INSUFFICIENT_BALANCE` and `credit_blocked` from [balance](/account/balance) before campaigns stall. * **Re-authenticate** on `401 TOKEN_EXPIRED` / `INVALID_TOKEN` if using JWT. * **Log** `error_code` + `description` + the request ID from your HTTP client for support escalations. # Rate limits > 23 Telecom API rate limiting explained — per-IP, per-account and brute-force layers, HTTP 429 handling, Retry-After semantics and backoff recommendations for high-volume senders. Rate limiting is applied at multiple independent layers. Limits are configurable per account and may change — treat the numbers below as approximate. | Layer | Scope | Approximate limit | On exceed | | ----------------- | ----------------------------- | --------------------- | --------------------------------------- | | Per-IP general | All requests from one IP | Configurable (high) | HTTP 429, **no JSON body** | | Per-IP auth | Login endpoint per IP | \~5 req/min | 429 `TOO_MANY_ATTEMPTS` | | Login brute-force | Per login name, progressive | Progressive delay | 429 `TOO_MANY_ATTEMPTS` + `Retry-After` | | 2FA brute-force | Per user + IP, progressive | Progressive delay | 429 `TOO_MANY_ATTEMPTS` + `Retry-After` | | Per-account | All requests from one account | Default \~1,000 req/s | 429 `RATE_LIMIT_EXCEEDED` | Notes worth coding against: * Only the **login brute-force** limiter sends `Retry-After`. Other 429s do not. * The per-IP general limiter returns a bare 429 with **no JSON body** — don’t assume every 429 parses as JSON. * The per-account limiter is shared across **all team members and keys** on the account, for both JWT and API-key traffic. * An edge-proxy limit also exists in front of the application and returns 429 with an **HTML body**. ## Recommended client behavior [Section titled “Recommended client behavior”](#recommended-client-behavior) ```plaintext attempt = 0 while attempt < max_retries: response = send_request() if response.status != 429: break delay = retry_after_header or (base_delay * 2 ** attempt) + jitter sleep(delay) attempt += 1 ``` * **Exponential backoff with jitter**, starting around 1s. * **Honor `Retry-After`** when present. * **Spread batch traffic** — for sustained high volume, a steady stream beats bursts that bounce off the limiter. Need a higher limit? Per-account limits are configurable. If your legitimate traffic approaches the default, contact your account manager with your expected request rate. # Sandbox & test mode > Test the 23 Telecom SMS API for free with sk_test_ sandbox keys — simulated sends, synthesized delivery webhooks and full response parity. Build your whole integration before spending a cent. Sandbox keys (`sk_test_…`) let you build and test your entire integration — sending, status polling, **and webhook handling** — without sending real SMS or spending money. Switching to production is swapping one environment variable. ## How it works [Section titled “How it works”](#how-it-works) | | Live key (`sk_prod_…`) | Sandbox key (`sk_test_…`) | | ------------------- | ---------------------- | ------------------------------------------------------------ | | Validation & errors | Real | **Identical** — same codes, same order | | Segments & encoding | Real | **Identical** (GSM-7/UCS-2 detection) | | Cost preview | Real rates | Real rates when cached, else 0 | | SMS delivery | Real carrier | **Simulated** — nothing is sent | | Balance | Deducted | **Never touched** | | Delivery webhook | Real DLR | **Synthesized `DELIVRD` \~5s after send**, same HMAC signing | | Message storage | Permanent history | 24 hours, last 500 messages | | Response | — | Carries `"sandbox": true` | 1. **Create a sandbox key.** In the [portal](https://restlink23telecom.com) under **Settings → API Keys**, enable **Test mode** when creating the key. You get an `sk_test_…` key. 2. **Send a simulated SMS** — same request as production: * cURL ```bash curl -X POST https://restlink23telecom.com/api/v1/sms/send \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": [ "+447911123456" ], "message": "Sandbox hello!", "sender_id": "MyApp" }' ``` * Node.js ```js const res = await fetch('https://restlink23telecom.com/api/v1/sms/send', { method: 'POST', headers: { 'X-API-Key': process.env.API_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ "to": [ "+447911123456" ], "message": "Sandbox hello!", "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); ``` * Python ```py import os import requests res = requests.post( "https://restlink23telecom.com/api/v1/sms/send", headers={"X-API-Key": os.environ["API_KEY"]}, json={ "to": ["+447911123456"], "message": "Sandbox hello!", "sender_id": "MyApp" }, ) res.raise_for_status() print(res.json()) ``` * PHP ```php 'POST', CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('API_KEY'), 'Content-Type: application/json'], CURLOPT_POSTFIELDS => json_encode([ 'to' => ['+447911123456'], 'message' => 'Sandbox hello!', '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); ``` * Ruby ```rb require "net/http" require "json" uri = URI("https://restlink23telecom.com/api/v1/sms/send") req = Net::HTTP::Post.new(uri) req["X-API-Key"] = ENV.fetch("API_KEY") req["Content-Type"] = "application/json" req.body = { to: ["+447911123456"], message: "Sandbox hello!", 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) ``` * Java ```java 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")) .header("X-API-Key", System.getenv("API_KEY")) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(""" { "to": [ "+447911123456" ], "message": "Sandbox hello!", "sender_id": "MyApp" }""")) .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println(response.body()); ``` * Go ```go package main import ( "fmt" "io" "net/http" "os" "strings" ) func main() { payload := strings.NewReader(`{ "to": [ "+447911123456" ], "message": "Sandbox hello!", "sender_id": "MyApp" }`) req, err := http.NewRequest("POST", "https://restlink23telecom.com/api/v1/sms/send", payload) if err != nil { panic(err) } req.Header.Set("X-API-Key", os.Getenv("API_KEY")) 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)) } ``` * .NET ```csharp using System.Text; using var client = new HttpClient(); client.DefaultRequestHeaders.Add("X-API-Key", Environment.GetEnvironmentVariable("API_KEY")); var json = """ { "to": [ "+447911123456" ], "message": "Sandbox hello!", "sender_id": "MyApp" } """; var content = new StringContent(json, Encoding.UTF8, "application/json"); var response = await client.PostAsync("https://restlink23telecom.com/api/v1/sms/send", content); response.EnsureSuccessStatusCode(); Console.WriteLine(await response.Content.ReadAsStringAsync()); ``` The response has the exact production shape plus `"sandbox": true`. 3. **Watch the delivery report arrive.** About 5 seconds later your configured `delivery_url` receives a synthesized `DELIVRD` webhook with a valid HMAC signature — verify it exactly like a real one ([securing webhooks](/webhooks/security)). 4. **Poll status if you prefer:** `GET /sms/status/{message_id}` returns `sent`, then `DELIVRD` after the simulated carrier latency. ## What is intentionally different [Section titled “What is intentionally different”](#what-is-intentionally-different) * Sandbox message history is kept for **24 hours** (last 500 per workspace) — it never appears in production statistics or exports. * URL auto-shortening and blocked-country filtering are skipped. * `GET /sms/messages` supports pagination but not SQL-grade filters. * If the simulator is unavailable you get `503 SANDBOX_UNAVAILABLE` — a test key never falls through to the live pipeline. ## Going live [Section titled “Going live”](#going-live) Replace the key. That’s the whole migration: ```bash # before export API_KEY=sk_test_abc... # after export API_KEY=sk_prod_xyz... ``` Agents and CI Sandbox keys are perfect for AI agents ([MCP server](/tools/install-tools)) and CI pipelines — deterministic, free, and clearly marked in every response. # Libraries & SDKs > Use the 23 Telecom SMS API from Node.js, Python, PHP, Ruby, Java, Go and .NET — idiomatic examples on every endpoint today, official SDK packages on the roadmap. The API is a clean REST + JSON interface, so it works from any language with an HTTP client — no SDK required. Every endpoint page in these docs includes ready-to-paste examples in **eight languages**: | Language | HTTP client used in examples | | -------- | ------------------------------------- | | cURL | — | | Node.js | Built-in `fetch` (Node 18+) | | Python | `requests` | | PHP | `curl` extension | | Ruby | `net/http` (stdlib) | | Java | `java.net.http.HttpClient` (Java 11+) | | Go | `net/http` (stdlib) | | .NET | `HttpClient` (C#) | Pick your language once in any code block — the choice follows you across the entire site. ## The full loop in your language [Section titled “The full loop in your language”](#the-full-loop-in-your-language) Send a message: * cURL ```bash curl -X POST https://restlink23telecom.com/api/v1/sms/send \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": [ "+14155551234" ], "message": "Your verification code is 847291", "sender_id": "MyApp" }' ``` * Node.js ```js const res = await fetch('https://restlink23telecom.com/api/v1/sms/send', { method: 'POST', headers: { 'X-API-Key': process.env.API_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ "to": [ "+14155551234" ], "message": "Your verification code is 847291", "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); ``` * Python ```py import os import requests res = requests.post( "https://restlink23telecom.com/api/v1/sms/send", headers={"X-API-Key": os.environ["API_KEY"]}, json={ "to": ["+14155551234"], "message": "Your verification code is 847291", "sender_id": "MyApp" }, ) res.raise_for_status() print(res.json()) ``` * PHP ```php 'POST', CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('API_KEY'), 'Content-Type: application/json'], CURLOPT_POSTFIELDS => json_encode([ 'to' => ['+14155551234'], 'message' => 'Your verification code is 847291', '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); ``` * Ruby ```rb require "net/http" require "json" uri = URI("https://restlink23telecom.com/api/v1/sms/send") req = Net::HTTP::Post.new(uri) req["X-API-Key"] = ENV.fetch("API_KEY") req["Content-Type"] = "application/json" req.body = { to: ["+14155551234"], message: "Your verification code is 847291", 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) ``` * Java ```java 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")) .header("X-API-Key", System.getenv("API_KEY")) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(""" { "to": [ "+14155551234" ], "message": "Your verification code is 847291", "sender_id": "MyApp" }""")) .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println(response.body()); ``` * Go ```go package main import ( "fmt" "io" "net/http" "os" "strings" ) func main() { payload := strings.NewReader(`{ "to": [ "+14155551234" ], "message": "Your verification code is 847291", "sender_id": "MyApp" }`) req, err := http.NewRequest("POST", "https://restlink23telecom.com/api/v1/sms/send", payload) if err != nil { panic(err) } req.Header.Set("X-API-Key", os.Getenv("API_KEY")) 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)) } ``` * .NET ```csharp using System.Text; using var client = new HttpClient(); client.DefaultRequestHeaders.Add("X-API-Key", Environment.GetEnvironmentVariable("API_KEY")); var json = """ { "to": [ "+14155551234" ], "message": "Your verification code is 847291", "sender_id": "MyApp" } """; var content = new StringContent(json, Encoding.UTF8, "application/json"); var response = await client.PostAsync("https://restlink23telecom.com/api/v1/sms/send", content); response.EnsureSuccessStatusCode(); Console.WriteLine(await response.Content.ReadAsStringAsync()); ``` Check its status: * cURL ```bash curl https://restlink23telecom.com/api/v1/sms/status/api_42_1743667200123456789_a3f8b2c1d9e45f67 \ -H "X-API-Key: $API_KEY" ``` * Node.js ```js const res = await fetch('https://restlink23telecom.com/api/v1/sms/status/api_42_1743667200123456789_a3f8b2c1d9e45f67', { method: 'GET', headers: { 'X-API-Key': process.env.API_KEY }, }); if (!res.ok) { const err = await res.json(); throw new Error(`${err.error_code}: ${err.description}`); } const data = await res.json(); console.log(data); ``` * Python ```py import os import requests res = requests.get( "https://restlink23telecom.com/api/v1/sms/status/api_42_1743667200123456789_a3f8b2c1d9e45f67", headers={"X-API-Key": os.environ["API_KEY"]}, ) res.raise_for_status() print(res.json()) ``` * PHP ```php true, CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('API_KEY')], ]); $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); ``` * Ruby ```rb require "net/http" require "json" uri = URI("https://restlink23telecom.com/api/v1/sms/status/api_42_1743667200123456789_a3f8b2c1d9e45f67") req = Net::HTTP::Get.new(uri) req["X-API-Key"] = ENV.fetch("API_KEY") 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) ``` * Java ```java 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/status/api_42_1743667200123456789_a3f8b2c1d9e45f67")) .header("X-API-Key", System.getenv("API_KEY")) .GET() .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println(response.body()); ``` * Go ```go package main import ( "fmt" "io" "net/http" "os" ) func main() { req, err := http.NewRequest("GET", "https://restlink23telecom.com/api/v1/sms/status/api_42_1743667200123456789_a3f8b2c1d9e45f67", nil) if err != nil { panic(err) } req.Header.Set("X-API-Key", os.Getenv("API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() data, _ := io.ReadAll(res.Body) fmt.Println(string(data)) } ``` * .NET ```csharp using System.Text; using var client = new HttpClient(); client.DefaultRequestHeaders.Add("X-API-Key", Environment.GetEnvironmentVariable("API_KEY")); var response = await client.GetAsync("https://restlink23telecom.com/api/v1/sms/status/api_42_1743667200123456789_a3f8b2c1d9e45f67"); response.EnsureSuccessStatusCode(); Console.WriteLine(await response.Content.ReadAsStringAsync()); ``` For webhook receivers in Python, Node.js and PHP, see the [delivery webhook examples](/webhooks/delivery#receiver-examples). ## Official SDK packages [Section titled “Official SDK packages”](#official-sdk-packages) Official clients for the three most-requested languages, with automatic 429/5xx retries, stable `error_code` errors and webhook signature verification built in: | Language | Package | Install | | --------------------- | ---------------- | -------------------------------- | | TypeScript / Node 18+ | `@23telecom/sms` | `npm install @23telecom/sms` | | Python 3.9+ | `telecom23` | `pip install telecom23` | | PHP 8.0+ | `23telecom/sms` | `composer require 23telecom/sms` | TypeScript ```ts import { TelecomClient } from '@23telecom/sms'; const client = new TelecomClient({ apiKey: process.env.API_KEY! }); await client.sms.send({ to: ['+447911123456'], message: 'Hi!', sender_id: 'MyApp' }); ``` Python ```python from telecom23 import Client client = Client(api_key=os.environ["API_KEY"]) client.sms.send(to=["+447911123456"], message="Hi!", sender_id="MyApp") ``` PHP ```php $client = new \Telecom23\Client(getenv('API_KEY')); $client->sendSms(['+447911123456'], 'Hi!', 'MyApp'); ``` All three work with [sandbox keys](/sandbox) (`sk_test_…`) for free, simulated testing. Ruby, Java, Go and .NET packages are next — until then use the examples above or [generate a client](/tools/openapi) from the spec. AI agents & CLI There’s also an [MCP server and a CLI](/tools/install-tools) built on the same API. ## Integration tips that save support tickets [Section titled “Integration tips that save support tickets”](#integration-tips-that-save-support-tickets) * **Store the key in `API_KEY`** env var like the examples do — rotating keys then never touches code. * **Treat `messages[].message_id` as your join key** between send responses, status polls and webhook events. * **Handle `429` with backoff** ([rate limits](/reference/rate-limits)) and `402` with an alert ([balance](/account/balance)). * **Make webhook handlers idempotent** — retries deliver duplicates by design. # Encoding & segments > How SMS encoding works — GSM-7 vs UCS-2, 160 vs 70 character limits, multi-part segments and how one emoji can double your SMS cost. Segment calculation rules and examples. SMS cost is per **segment**, not per message. Encoding determines how many characters fit in a segment — and a single character can change the encoding of the whole message. ## The two encodings [Section titled “The two encodings”](#the-two-encodings) | Encoding | Single SMS | Multi-part (per segment) | Used when | | --------- | ---------- | ------------------------ | ------------------------------------------------------------------- | | **GSM-7** | 160 chars | 153 chars | All characters fit the GSM alphabet (Latin, digits, common symbols) | | **UCS-2** | 70 chars | 67 chars | Any character outside GSM-7: emoji, Cyrillic, Chinese, Arabic, … | One emoji changes everything Encoding applies to the **entire message**. A 150-character Latin text is 1 segment (GSM-7); add a single 😀 and it becomes UCS-2 — now 3 segments at 67 chars each. Three times the cost for one emoji. ## Extended GSM characters [Section titled “Extended GSM characters”](#extended-gsm-characters) These characters are valid GSM-7 but **count as 2 characters** each: ```plaintext € ^ { } [ ] \ ~ | ``` A 159-character message containing one `€` is therefore 160 GSM-7 characters — still 1 segment. At 160 it would split into 2. ## Examples [Section titled “Examples”](#examples) | Message | Encoding | Segments | | ---------------------------- | -------- | ----------- | | 160 Latin characters | GSM-7 | 1 | | 161 Latin characters | GSM-7 | 2 | | 306 Latin characters | GSM-7 | 2 (153 × 2) | | 50 characters with one emoji | UCS-2 | 1 | | 71 Cyrillic characters | UCS-2 | 2 | ## Where to see it in the API [Section titled “Where to see it in the API”](#where-to-see-it-in-the-api) The [send response](/sms/send#response) reports what was detected and billed: ```json "summary": { "encoding": "GSM-7", "total_segments": 2, "total_cost": 0.017 } ``` Per-recipient segment counts are in `results[].segments`. ## Practical tips [Section titled “Practical tips”](#practical-tips) * **Verification codes & alerts:** stick to plain Latin text — they will always be 1 cheap segment. * **Marketing texts in Cyrillic or with emoji:** budget for UCS-2 — keep texts under 70 characters (or under 67 × N for multi-part) to control cost. * **Watch invisible characters:** smart quotes (`"…"`), long dashes (`—`) and non-breaking spaces pasted from editors are not in the GSM alphabet and silently switch the message to UCS-2. * **Test before a campaign:** send the exact text to your own number and check `summary.encoding` and `total_segments` in the response. # List messages > Query SMS message history with GET /sms/messages — pagination, status and country filters, unified API + campaign view, and streaming CSV export up to 1M rows. Paginated list of sent messages with filtering. GET  `/api/v1/sms/messages`  · Permission: `sms.read` ## Request [Section titled “Request”](#request) * cURL ```bash curl "https://restlink23telecom.com/api/v1/sms/messages?status=delivered&limit=10" \ -H "X-API-Key: $API_KEY" ``` * Node.js ```js const res = await fetch('https://restlink23telecom.com/api/v1/sms/messages?status=delivered&limit=10', { method: 'GET', headers: { 'X-API-Key': process.env.API_KEY }, }); if (!res.ok) { const err = await res.json(); throw new Error(`${err.error_code}: ${err.description}`); } const data = await res.json(); console.log(data); ``` * Python ```py import os import requests res = requests.get( "https://restlink23telecom.com/api/v1/sms/messages", headers={"X-API-Key": os.environ["API_KEY"]}, params={ "status": "delivered", "limit": 10 }, ) res.raise_for_status() print(res.json()) ``` * PHP ```php true, CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('API_KEY')], ]); $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); ``` * Ruby ```rb require "net/http" require "json" uri = URI("https://restlink23telecom.com/api/v1/sms/messages?status=delivered&limit=10") req = Net::HTTP::Get.new(uri) req["X-API-Key"] = ENV.fetch("API_KEY") 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) ``` * Java ```java 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/messages?status=delivered&limit=10")) .header("X-API-Key", System.getenv("API_KEY")) .GET() .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println(response.body()); ``` * Go ```go package main import ( "fmt" "io" "net/http" "os" ) func main() { req, err := http.NewRequest("GET", "https://restlink23telecom.com/api/v1/sms/messages?status=delivered&limit=10", nil) if err != nil { panic(err) } req.Header.Set("X-API-Key", os.Getenv("API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() data, _ := io.ReadAll(res.Body) fmt.Println(string(data)) } ``` * .NET ```csharp using System.Text; using var client = new HttpClient(); client.DefaultRequestHeaders.Add("X-API-Key", Environment.GetEnvironmentVariable("API_KEY")); var response = await client.GetAsync("https://restlink23telecom.com/api/v1/sms/messages?status=delivered&limit=10"); response.EnsureSuccessStatusCode(); Console.WriteLine(await response.Content.ReadAsStringAsync()); ``` | Parameter | Default | Description | | ------------- | ------- | ----------------------------------------------------------------------------- | | `page` | 1 | Page number | | `limit` | 25 | Results per page (max 100) | | `status` | — | `delivered`, `undelivered`, `expired`, `pending`, `sent`, `failed`, `unknown` | | `phone` | — | Search by phone number (substring) | | `country` | — | ISO code: `US`, `GB`, … | | `sender` | — | Filter by sender ID | | `period` | `7d` | `today`, `yesterday`, `7d`, `30d`, `this_month`, `last_month` | | `from` / `to` | — | Custom range `YYYY-MM-DD` (omit `period`; the `to` day is included) | ## Response [Section titled “Response”](#response) 200 OK ```json { "messages": [ { "id": 1847, "client_message_id": "api_42_1743667200123456789_a3f8b2c1d9e45f67", "recipient": "+14155551234", "sender_id": "MyApp", "message": "Your verification code is 847291", "status": "DELIVRD", "status_code": "000", "segments": 1, "cost": 0.0085, "created_at": "2026-02-13T10:30:00Z", "delivered_at": "2026-02-13T10:30:04Z" } ], "total": 1250, "page": 1, "limit": 10, "total_pages": 125 } ``` ### Status filter mapping [Section titled “Status filter mapping”](#status-filter-mapping) | Filter | Matches statuses | | ------------- | --------------------------------------- | | `delivered` | `DELIVRD` | | `undelivered` | `UNDELIV`, `REJECTD`, `DELETED` | | `expired` | `EXPIRED` | | `pending` | `pending`, `sent`, `ENROUTE`, `ACCEPTD` | | `sent` | Same as `pending` (alias) | | `failed` | Same as `undelivered` (alias) | | `unknown` | `UNKNOWN` | ## Unified messages [Section titled “Unified messages”](#unified-messages) GET  `/api/v1/sms/messages/unified`  · Permission: `sms.read` Combines **API traffic and broadcast campaigns** in one view. Accepts the same parameters as `/sms/messages` plus: | Parameter | Default | Description | | --------- | ------- | -------------------------- | | `source` | `all` | `api`, `broadcasts`, `all` | * cURL ```bash curl "https://restlink23telecom.com/api/v1/sms/messages/unified?source=all&limit=20" \ -H "X-API-Key: $API_KEY" ``` * Node.js ```js const res = await fetch('https://restlink23telecom.com/api/v1/sms/messages/unified?source=all&limit=20', { method: 'GET', headers: { 'X-API-Key': process.env.API_KEY }, }); if (!res.ok) { const err = await res.json(); throw new Error(`${err.error_code}: ${err.description}`); } const data = await res.json(); console.log(data); ``` * Python ```py import os import requests res = requests.get( "https://restlink23telecom.com/api/v1/sms/messages/unified", headers={"X-API-Key": os.environ["API_KEY"]}, params={ "source": "all", "limit": 20 }, ) res.raise_for_status() print(res.json()) ``` * PHP ```php true, CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('API_KEY')], ]); $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); ``` * Ruby ```rb require "net/http" require "json" uri = URI("https://restlink23telecom.com/api/v1/sms/messages/unified?source=all&limit=20") req = Net::HTTP::Get.new(uri) req["X-API-Key"] = ENV.fetch("API_KEY") 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) ``` * Java ```java 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/messages/unified?source=all&limit=20")) .header("X-API-Key", System.getenv("API_KEY")) .GET() .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println(response.body()); ``` * Go ```go package main import ( "fmt" "io" "net/http" "os" ) func main() { req, err := http.NewRequest("GET", "https://restlink23telecom.com/api/v1/sms/messages/unified?source=all&limit=20", nil) if err != nil { panic(err) } req.Header.Set("X-API-Key", os.Getenv("API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() data, _ := io.ReadAll(res.Body) fmt.Println(string(data)) } ``` * .NET ```csharp using System.Text; using var client = new HttpClient(); client.DefaultRequestHeaders.Add("X-API-Key", Environment.GetEnvironmentVariable("API_KEY")); var response = await client.GetAsync("https://restlink23telecom.com/api/v1/sms/messages/unified?source=all&limit=20"); response.EnsureSuccessStatusCode(); Console.WriteLine(await response.Content.ReadAsStringAsync()); ``` Different schema The unified response is **not** the same shape as `/sms/messages`: `sent_at` replaces `created_at`, `source` is `"api"` or `"campaign"`, and click/conversion fields (`clicked`, `clicked_at`, `action_type`, `action_at`, `broadcast_id`, `broadcast_name`) are included. 200 OK (unified, one item) ```json { "messages": [ { "id": "3201", "source": "campaign", "recipient": "+447911123456", "sender_id": "Promo", "message": "50% off today!", "status": "DELIVRD", "status_code": "000", "cost": 0.042, "sent_at": "2026-02-13T09:00:00Z", "delivered_at": "2026-02-13T09:00:03Z", "clicked": true, "clicked_at": "2026-02-13T09:05:12Z", "action_type": "registration", "action_at": "2026-02-13T10:00:00Z", "message_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "client_message_id": null, "segments": 1, "broadcast_id": 42, "broadcast_name": "February Promo" } ], "total": 5420, "page": 1, "limit": 20, "total_pages": 271 } ``` ## CSV export [Section titled “CSV export”](#csv-export) GET  `/api/v1/sms/messages/unified/export`  · Permission: `sms.read` Streaming CSV export with the same filters as the unified list: * cURL ```bash curl "https://restlink23telecom.com/api/v1/sms/messages/unified/export?period=30d" \ -H "X-API-Key: $API_KEY" ``` * Node.js ```js const res = await fetch('https://restlink23telecom.com/api/v1/sms/messages/unified/export?period=30d', { method: 'GET', headers: { 'X-API-Key': process.env.API_KEY }, }); if (!res.ok) { const err = await res.json(); throw new Error(`${err.error_code}: ${err.description}`); } const data = await res.json(); console.log(data); ``` * Python ```py import os import requests res = requests.get( "https://restlink23telecom.com/api/v1/sms/messages/unified/export", headers={"X-API-Key": os.environ["API_KEY"]}, params={ "period": "30d" }, ) res.raise_for_status() print(res.json()) ``` * PHP ```php true, CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('API_KEY')], ]); $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); ``` * Ruby ```rb require "net/http" require "json" uri = URI("https://restlink23telecom.com/api/v1/sms/messages/unified/export?period=30d") req = Net::HTTP::Get.new(uri) req["X-API-Key"] = ENV.fetch("API_KEY") 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) ``` * Java ```java 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/messages/unified/export?period=30d")) .header("X-API-Key", System.getenv("API_KEY")) .GET() .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println(response.body()); ``` * Go ```go package main import ( "fmt" "io" "net/http" "os" ) func main() { req, err := http.NewRequest("GET", "https://restlink23telecom.com/api/v1/sms/messages/unified/export?period=30d", nil) if err != nil { panic(err) } req.Header.Set("X-API-Key", os.Getenv("API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() data, _ := io.ReadAll(res.Body) fmt.Println(string(data)) } ``` * .NET ```csharp using System.Text; using var client = new HttpClient(); client.DefaultRequestHeaders.Add("X-API-Key", Environment.GetEnvironmentVariable("API_KEY")); var response = await client.GetAsync("https://restlink23telecom.com/api/v1/sms/messages/unified/export?period=30d"); response.EnsureSuccessStatusCode(); Console.WriteLine(await response.Content.ReadAsStringAsync()); ``` What to expect: * **Excel-compatible** — the body starts with a UTF-8 BOM. * **Row cap** — default 1,000,000 rows; the active cap is advertised in the `X-Export-Limit` response header. Larger result sets are truncated silently, so narrow the date window to drill down. * `Content-Disposition: attachment; filename="messages_export_.csv"`. # SMS API overview > Overview of the 23 Telecom SMS API — send messages to 230+ countries, check delivery status, list message history and pull statistics. Endpoint map with required permissions. The SMS API covers the full lifecycle of a message: **send → track → analyze**. It is the first channel of the 23 Telecom messaging platform — additional channels (Viber and more) will appear here alongside SMS, sharing the same [authentication](/authentication), [webhooks](/webhooks/overview) and [account](/account/balance) layer. | Endpoint | Method | Purpose | Permission | | ----------------------------------------------------------- | ------ | ---------------------------------- | ---------- | | [/sms/send](/sms/send) | POST | Send to 1–100 recipients | `sms.send` | | [/sms/status/:message\_id](/sms/status) | GET | Delivery status of one message | `sms.read` | | [/sms/messages](/sms/messages) | GET | Paginated message history | `sms.read` | | [/sms/messages/unified](/sms/messages#unified-messages) | GET | API + campaign traffic in one view | `sms.read` | | [/sms/messages/unified/export](/sms/messages#csv-export) | GET | Streaming CSV export | `sms.read` | | [/sms/stats](/sms/statistics) | GET | Aggregate statistics | `sms.read` | | [/sms/stats/daily](/sms/statistics#daily-statistics) | GET | Day-by-day breakdown | `sms.read` | | [/sms/stats/by-country](/sms/statistics#country-statistics) | GET | Per-country breakdown | `sms.read` | ## How delivery works [Section titled “How delivery works”](#how-delivery-works) ```plaintext your server ──POST /sms/send──▶ 23 Telecom ──▶ carrier ──▶ handset ▲ │ └──── webhook (DLR) ◀──────────┘ ``` 1. You send a message; the API responds immediately with a `message_id` per recipient and a cost/encoding summary. 2. The carrier returns a **delivery report (DLR)** — typically within seconds. 3. You receive the final status by [webhook](/webhooks/delivery) (recommended) or by polling [message status](/sms/status). ## Things worth knowing up front [Section titled “Things worth knowing up front”](#things-worth-knowing-up-front) * **E.164 numbers.** Always send phone numbers as `+`, e.g. `+447911123456`. * **Encoding affects cost.** One emoji switches the whole message from GSM-7 (160 chars/segment) to UCS-2 (70 chars/segment) — see [encoding & segments](/sms/encoding). * **Sender IDs are regulated.** 3–11 alphanumeric characters, must not start with a digit; some countries restrict them — see [sender IDs](/sms/sender-id). * **Batch sends are atomic.** A multi-recipient request either fully queues or fails as a whole (`500 DB_ERROR`) — safe to retry, no partial sends. * **Balance is checked before sending.** Requests that would exceed your balance return `402 INSUFFICIENT_BALANCE` with cost details — see [balance](/account/balance). # Send SMS > Send SMS to up to 100 recipients with one POST /sms/send request. Automatic GSM-7/UCS-2 encoding, per-recipient results, atomic batch behavior and cost summary. Examples in 8 languages. Send an SMS to one or more recipients. POST  `/api/v1/sms/send`  · Permission: `sms.send` ## Request [Section titled “Request”](#request) * cURL ```bash curl -X POST https://restlink23telecom.com/api/v1/sms/send \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": [ "+14155551234", "+447911123456" ], "message": "Your verification code is 847291", "sender_id": "MyApp" }' ``` * Node.js ```js const res = await fetch('https://restlink23telecom.com/api/v1/sms/send', { method: 'POST', headers: { 'X-API-Key': process.env.API_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ "to": [ "+14155551234", "+447911123456" ], "message": "Your verification code is 847291", "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); ``` * Python ```py import os import requests res = requests.post( "https://restlink23telecom.com/api/v1/sms/send", headers={"X-API-Key": os.environ["API_KEY"]}, json={ "to": ["+14155551234", "+447911123456"], "message": "Your verification code is 847291", "sender_id": "MyApp" }, ) res.raise_for_status() print(res.json()) ``` * PHP ```php 'POST', CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('API_KEY'), 'Content-Type: application/json'], CURLOPT_POSTFIELDS => json_encode([ 'to' => ['+14155551234', '+447911123456'], 'message' => 'Your verification code is 847291', '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); ``` * Ruby ```rb require "net/http" require "json" uri = URI("https://restlink23telecom.com/api/v1/sms/send") req = Net::HTTP::Post.new(uri) req["X-API-Key"] = ENV.fetch("API_KEY") req["Content-Type"] = "application/json" req.body = { to: ["+14155551234", "+447911123456"], message: "Your verification code is 847291", 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) ``` * Java ```java 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")) .header("X-API-Key", System.getenv("API_KEY")) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(""" { "to": [ "+14155551234", "+447911123456" ], "message": "Your verification code is 847291", "sender_id": "MyApp" }""")) .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println(response.body()); ``` * Go ```go package main import ( "fmt" "io" "net/http" "os" "strings" ) func main() { payload := strings.NewReader(`{ "to": [ "+14155551234", "+447911123456" ], "message": "Your verification code is 847291", "sender_id": "MyApp" }`) req, err := http.NewRequest("POST", "https://restlink23telecom.com/api/v1/sms/send", payload) if err != nil { panic(err) } req.Header.Set("X-API-Key", os.Getenv("API_KEY")) 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)) } ``` * .NET ```csharp using System.Text; using var client = new HttpClient(); client.DefaultRequestHeaders.Add("X-API-Key", Environment.GetEnvironmentVariable("API_KEY")); var json = """ { "to": [ "+14155551234", "+447911123456" ], "message": "Your verification code is 847291", "sender_id": "MyApp" } """; var content = new StringContent(json, Encoding.UTF8, "application/json"); var response = await client.PostAsync("https://restlink23telecom.com/api/v1/sms/send", content); response.EnsureSuccessStatusCode(); Console.WriteLine(await response.Content.ReadAsStringAsync()); ``` | Field | Type | Required | Description | | ----------- | --------- | -------- | ----------------------------------------------------- | | `to` | string\[] | Yes | 1 to 100 phone numbers in E.164 format | | `message` | string | Yes | Message text — encoding is detected automatically | | `sender_id` | string | Yes | Alphanumeric sender ID (max 11 chars) or phone number | ## Response [Section titled “Response”](#response) 200 OK ```json { "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, "queue_error_count": 0, "config_error_count": 0, "db_error_count": 0 } } ``` | Field | Description | | ---------- | ---------------------------------------------------------- | | `messages` | Accepted messages only (backward-compatible array) | | `results` | **All** recipients with their individual status | | `summary` | Totals: accepted, blocked, errors, cost, detected encoding | ### Per-recipient statuses [Section titled “Per-recipient statuses”](#per-recipient-statuses) | `results[].status` | Meaning | | ------------------ | ------------------------------------------- | | `accepted` | Queued for delivery | | `blocked_country` | Recipient’s country is in your blocked list | | `queue_error` | Internal queue failure | | `config_error` | SMS sending not configured on the account | Atomic batches A multi-recipient request is **all-or-nothing** at the persistence layer: either every recipient is queued or none is. If the database is briefly unavailable you get `500 DB_ERROR` and nothing was sent — simply retry the entire request. You will never get a silent partial send. ## Errors [Section titled “Errors”](#errors) | HTTP | Code | Description | | ---- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | 400 | `INVALID_BODY` | Cannot parse request body | | 400 | `INVALID_TO` | Missing or empty `to` array | | 400 | `INVALID_MESSAGE` | Missing or empty `message` | | 400 | `INVALID_SENDER` | Missing or empty `sender_id` | | 400 | `TOO_MANY_RECIPIENTS` | Over 100 recipients | | 402 | `INSUFFICIENT_BALANCE` | Balance too low — response includes `balance`, `estimated_cost`, `currency`, `total_recipients`, `billable_recipients`, `segments` | | 403 | `NO_SMS_ACCESS` | SMS not enabled on your account | | 403 | `CONFIG_ERROR` | SMS credentials incomplete — contact support | | 403 | `WORKSPACE_NOT_AVAILABLE` | Target workspace was deleted — use a live workspace | | 500 | `DB_ERROR` | Could not queue (atomic rollback) — retry the whole request | ## Delivery tracking [Section titled “Delivery tracking”](#delivery-tracking) The send response confirms **acceptance**, not delivery. To learn whether the message reached the handset: * **Webhooks (recommended):** receive a [delivery report](/webhooks/delivery) on your server the moment the carrier reports it. * **Polling:** call [GET /sms/status/:message\_id](/sms/status) until the status is final. # Sender IDs > Sender ID rules for SMS — 3 to 11 alphanumeric characters, must not start with a digit. Alphanumeric vs numeric senders, country restrictions and best practices for deliverability. The sender ID is what recipients see as the message sender. You pass it as `sender_id` in every [send request](/sms/send). ## Rules [Section titled “Rules”](#rules) | Rule | Detail | | --------------- | -------------------------------------- | | Length | 3–11 characters | | Charset | Alphanumeric (`A–Z`, `a–z`, `0–9`) | | First character | Must not be a digit | | Examples | `MyCompany`, `PromoAlert`, `AcmeStore` | Invalid sender IDs are rejected with `400 INVALID_SENDER`. ## Alphanumeric vs numeric senders [Section titled “Alphanumeric vs numeric senders”](#alphanumeric-vs-numeric-senders) * **Alphanumeric** (`MyApp`) — best for branding; recipients **cannot reply**. * **Phone number** (`+14155551234`) — allows replies where supported; also accepted as `sender_id`. ## Country restrictions [Section titled “Country restrictions”](#country-restrictions) Sender ID handling differs by destination country: some carriers pass alphanumeric senders as-is, others **overwrite them** with a generic ID or a short code, and some require pre-registration of sender names. Tip If a specific sender name is critical for your campaign in a particular country, ask your account manager about registration requirements for that destination before going live. ## Best practices [Section titled “Best practices”](#best-practices) * **Stay consistent** — one recognizable sender per use case builds trust and improves engagement. * **Match your brand** — `Acme` beats `ACM3X9`. Avoid anything that looks like spam or a random string; carriers filter aggressively. * **Don’t rotate senders to dodge filters** — it hurts deliverability and can get traffic blocked entirely. * **Test per country** — send to a local number and verify what the handset actually displays before launching. # SMS statistics > Pull aggregate, daily and per-country SMS statistics from the 23 Telecom API — delivery rates, costs, clicks and conversions, with period presets and custom date ranges. Three endpoints cover reporting: totals, time series and country breakdown. All require the `sms.read` permission and accept the same period filters. ## Common parameters [Section titled “Common parameters”](#common-parameters) | Parameter | Default | Description | | ------------- | ------- | -------------------------------------------------------------------------------------- | | `period` | `7d` | `today`, `yesterday`, `7d`, `30d`, `this_month`, `last_month` | | `from` / `to` | — | Custom range `YYYY-MM-DD`. Use only when `period` is omitted; the `to` day is included | | `source` | `api` | `api`, `broadcasts`, `all` | | `country` | — | ISO code filter: `US`, `GB`, … | | `sender` | — | Filter by sender ID | | `phone` | — | Search by phone number (substring) | | `status` | — | `delivered`, `undelivered`, `pending`, `expired` | ## Aggregate statistics [Section titled “Aggregate statistics”](#aggregate-statistics) GET  `/api/v1/sms/stats`  · Permission: `sms.read` * cURL ```bash curl "https://restlink23telecom.com/api/v1/sms/stats?period=30d" \ -H "X-API-Key: $API_KEY" ``` * Node.js ```js const res = await fetch('https://restlink23telecom.com/api/v1/sms/stats?period=30d', { method: 'GET', headers: { 'X-API-Key': process.env.API_KEY }, }); if (!res.ok) { const err = await res.json(); throw new Error(`${err.error_code}: ${err.description}`); } const data = await res.json(); console.log(data); ``` * Python ```py import os import requests res = requests.get( "https://restlink23telecom.com/api/v1/sms/stats", headers={"X-API-Key": os.environ["API_KEY"]}, params={ "period": "30d" }, ) res.raise_for_status() print(res.json()) ``` * PHP ```php true, CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('API_KEY')], ]); $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); ``` * Ruby ```rb require "net/http" require "json" uri = URI("https://restlink23telecom.com/api/v1/sms/stats?period=30d") req = Net::HTTP::Get.new(uri) req["X-API-Key"] = ENV.fetch("API_KEY") 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) ``` * Java ```java 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/stats?period=30d")) .header("X-API-Key", System.getenv("API_KEY")) .GET() .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println(response.body()); ``` * Go ```go package main import ( "fmt" "io" "net/http" "os" ) func main() { req, err := http.NewRequest("GET", "https://restlink23telecom.com/api/v1/sms/stats?period=30d", nil) if err != nil { panic(err) } req.Header.Set("X-API-Key", os.Getenv("API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() data, _ := io.ReadAll(res.Body) fmt.Println(string(data)) } ``` * .NET ```csharp using System.Text; using var client = new HttpClient(); client.DefaultRequestHeaders.Add("X-API-Key", Environment.GetEnvironmentVariable("API_KEY")); var response = await client.GetAsync("https://restlink23telecom.com/api/v1/sms/stats?period=30d"); response.EnsureSuccessStatusCode(); Console.WriteLine(await response.Content.ReadAsStringAsync()); ``` 200 OK ```json { "status": true, "stats": { "total_sent": 1250, "sent": 1200, "delivered": 1150, "undelivered": 30, "pending": 15, "expired": 5, "not_billed": 50, "failed": 30, "click_count": 42, "unique_click_count": 38, "action_count": 12, "sent_rate": 96.00, "delivery_rate": 95.83, "total_cost": 10.63, "avg_cost_per_msg": 0.0085, "currency": "EUR" }, "period": {"from": "2026-01-14", "to": "2026-02-13"} } ``` How the numbers relate: ```plaintext Sent = Delivered + Undelivered + Pending + Expired TotalSent = Sent + NotBilled ``` | Field | Description | | ------------------------------------ | ------------------------------------------------------------------------------- | | `total_sent` | All messages attempted | | `sent` | Accepted by carrier (`total_sent − not_billed`) | | `delivered` | Delivered to handset (`DELIVRD`) | | `undelivered` | Failed: `UNDELIV`, `REJECTD`, `DELETED`, `UNKNOWN` | | `pending` | Still in transit | | `expired` | Delivery timed out — counted separately from `undelivered` | | `not_billed` | Always `0` for API traffic; for broadcasts: messages never submitted to carrier | | `failed` | Alias of `undelivered` | | `click_count` / `unique_click_count` | Link clicks (broadcast traffic) | | `action_count` | Recorded conversions | | `sent_rate` | `(sent / total_sent) × 100` | | `delivery_rate` | `(delivered / sent) × 100` | | `total_cost` / `avg_cost_per_msg` | Cost in account currency | ## Daily statistics [Section titled “Daily statistics”](#daily-statistics) GET  `/api/v1/sms/stats/daily`  · Permission: `sms.read` Day-by-day breakdown for charts, sorted by date **ascending**. * cURL ```bash curl "https://restlink23telecom.com/api/v1/sms/stats/daily?period=7d" \ -H "X-API-Key: $API_KEY" ``` * Node.js ```js const res = await fetch('https://restlink23telecom.com/api/v1/sms/stats/daily?period=7d', { method: 'GET', headers: { 'X-API-Key': process.env.API_KEY }, }); if (!res.ok) { const err = await res.json(); throw new Error(`${err.error_code}: ${err.description}`); } const data = await res.json(); console.log(data); ``` * Python ```py import os import requests res = requests.get( "https://restlink23telecom.com/api/v1/sms/stats/daily", headers={"X-API-Key": os.environ["API_KEY"]}, params={ "period": "7d" }, ) res.raise_for_status() print(res.json()) ``` * PHP ```php true, CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('API_KEY')], ]); $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); ``` * Ruby ```rb require "net/http" require "json" uri = URI("https://restlink23telecom.com/api/v1/sms/stats/daily?period=7d") req = Net::HTTP::Get.new(uri) req["X-API-Key"] = ENV.fetch("API_KEY") 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) ``` * Java ```java 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/stats/daily?period=7d")) .header("X-API-Key", System.getenv("API_KEY")) .GET() .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println(response.body()); ``` * Go ```go package main import ( "fmt" "io" "net/http" "os" ) func main() { req, err := http.NewRequest("GET", "https://restlink23telecom.com/api/v1/sms/stats/daily?period=7d", nil) if err != nil { panic(err) } req.Header.Set("X-API-Key", os.Getenv("API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() data, _ := io.ReadAll(res.Body) fmt.Println(string(data)) } ``` * .NET ```csharp using System.Text; using var client = new HttpClient(); client.DefaultRequestHeaders.Add("X-API-Key", Environment.GetEnvironmentVariable("API_KEY")); var response = await client.GetAsync("https://restlink23telecom.com/api/v1/sms/stats/daily?period=7d"); response.EnsureSuccessStatusCode(); Console.WriteLine(await response.Content.ReadAsStringAsync()); ``` Extra parameters: | Parameter | Description | | ------------- | ----------------------------------------------------------------------------------------- | | `granularity` | `auto` switches to hourly buckets on a single-day range (dates become `2026-02-13T09:00`) | | `countries` | Comma-separated ISO codes `US,GB,DE` — also flips sorting to descending | 200 OK (one day shown) ```json { "status": true, "daily_stats": [ { "date": "2026-02-13", "sent": 195, "delivered": 188, "undelivered": 4, "pending": 1, "expired": 2, "not_billed": 3, "failed": 4, "click_count": 12, "cost": 1.66, "currency": "EUR" } ], "period": {"from": "2026-02-07", "to": "2026-02-13"} } ``` ## Country statistics [Section titled “Country statistics”](#country-statistics) GET  `/api/v1/sms/stats/by-country`  · Permission: `sms.read` Grouped by destination country, sorted by `total_sent` descending. * cURL ```bash curl "https://restlink23telecom.com/api/v1/sms/stats/by-country?period=30d" \ -H "X-API-Key: $API_KEY" ``` * Node.js ```js const res = await fetch('https://restlink23telecom.com/api/v1/sms/stats/by-country?period=30d', { method: 'GET', headers: { 'X-API-Key': process.env.API_KEY }, }); if (!res.ok) { const err = await res.json(); throw new Error(`${err.error_code}: ${err.description}`); } const data = await res.json(); console.log(data); ``` * Python ```py import os import requests res = requests.get( "https://restlink23telecom.com/api/v1/sms/stats/by-country", headers={"X-API-Key": os.environ["API_KEY"]}, params={ "period": "30d" }, ) res.raise_for_status() print(res.json()) ``` * PHP ```php true, CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('API_KEY')], ]); $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); ``` * Ruby ```rb require "net/http" require "json" uri = URI("https://restlink23telecom.com/api/v1/sms/stats/by-country?period=30d") req = Net::HTTP::Get.new(uri) req["X-API-Key"] = ENV.fetch("API_KEY") 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) ``` * Java ```java 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/stats/by-country?period=30d")) .header("X-API-Key", System.getenv("API_KEY")) .GET() .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println(response.body()); ``` * Go ```go package main import ( "fmt" "io" "net/http" "os" ) func main() { req, err := http.NewRequest("GET", "https://restlink23telecom.com/api/v1/sms/stats/by-country?period=30d", nil) if err != nil { panic(err) } req.Header.Set("X-API-Key", os.Getenv("API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() data, _ := io.ReadAll(res.Body) fmt.Println(string(data)) } ``` * .NET ```csharp using System.Text; using var client = new HttpClient(); client.DefaultRequestHeaders.Add("X-API-Key", Environment.GetEnvironmentVariable("API_KEY")); var response = await client.GetAsync("https://restlink23telecom.com/api/v1/sms/stats/by-country?period=30d"); response.EnsureSuccessStatusCode(); Console.WriteLine(await response.Content.ReadAsStringAsync()); ``` 200 OK (one country shown) ```json { "status": true, "countries": [ { "country": "United States", "country_code": "US", "country_flag": "🇺🇸", "total_sent": 520, "sent": 500, "delivered": 485, "undelivered": 10, "pending": 3, "expired": 2, "not_billed": 20, "failed": 10, "click_count": 25, "delivery_rate": 97.00, "cost": 4.25, "currency": "EUR" } ] } ``` # Get message status > Check SMS delivery status by message ID with GET /sms/status. Returns carrier DLR status, cost, segments and delivery timestamp. Examples in 8 languages. Check the delivery status of a sent message. GET  `/api/v1/sms/status/:message_id`  · Permission: `sms.read` ## Request [Section titled “Request”](#request) * cURL ```bash curl https://restlink23telecom.com/api/v1/sms/status/api_42_1743667200123456789_a3f8b2c1d9e45f67 \ -H "X-API-Key: $API_KEY" ``` * Node.js ```js const res = await fetch('https://restlink23telecom.com/api/v1/sms/status/api_42_1743667200123456789_a3f8b2c1d9e45f67', { method: 'GET', headers: { 'X-API-Key': process.env.API_KEY }, }); if (!res.ok) { const err = await res.json(); throw new Error(`${err.error_code}: ${err.description}`); } const data = await res.json(); console.log(data); ``` * Python ```py import os import requests res = requests.get( "https://restlink23telecom.com/api/v1/sms/status/api_42_1743667200123456789_a3f8b2c1d9e45f67", headers={"X-API-Key": os.environ["API_KEY"]}, ) res.raise_for_status() print(res.json()) ``` * PHP ```php true, CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('API_KEY')], ]); $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); ``` * Ruby ```rb require "net/http" require "json" uri = URI("https://restlink23telecom.com/api/v1/sms/status/api_42_1743667200123456789_a3f8b2c1d9e45f67") req = Net::HTTP::Get.new(uri) req["X-API-Key"] = ENV.fetch("API_KEY") 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) ``` * Java ```java 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/status/api_42_1743667200123456789_a3f8b2c1d9e45f67")) .header("X-API-Key", System.getenv("API_KEY")) .GET() .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println(response.body()); ``` * Go ```go package main import ( "fmt" "io" "net/http" "os" ) func main() { req, err := http.NewRequest("GET", "https://restlink23telecom.com/api/v1/sms/status/api_42_1743667200123456789_a3f8b2c1d9e45f67", nil) if err != nil { panic(err) } req.Header.Set("X-API-Key", os.Getenv("API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() data, _ := io.ReadAll(res.Body) fmt.Println(string(data)) } ``` * .NET ```csharp using System.Text; using var client = new HttpClient(); client.DefaultRequestHeaders.Add("X-API-Key", Environment.GetEnvironmentVariable("API_KEY")); var response = await client.GetAsync("https://restlink23telecom.com/api/v1/sms/status/api_42_1743667200123456789_a3f8b2c1d9e45f67"); response.EnsureSuccessStatusCode(); Console.WriteLine(await response.Content.ReadAsStringAsync()); ``` Message ID format `api_{user_id}_{nanosecond_timestamp}_{16_char_hex}` — for example `api_42_1743667200123456789_a3f8b2c1d9e45f67`. You receive it in the [send response](/sms/send#response). ## Response [Section titled “Response”](#response) 200 OK ```json { "status": true, "message": { "message_id": "api_42_1743667200123456789_a3f8b2c1d9e45f67", "telecom_message_id": "6946d0f5-040b-106e-42c7-49026f2b5bc1", "recipient": "+14155551234", "segments": 1, "message": "Your verification code is 847291", "sender_id": "MyApp", "status": "DELIVRD", "status_code": "000", "cost": 0.0085, "created_at": "2026-02-13T10:30:00Z", "delivered_at": "2026-02-13T10:30:04Z" } } ``` Returns `404 NOT_FOUND` if the message does not exist or belongs to another account. ## Status values [Section titled “Status values”](#status-values) | Status | Description | Final? | | --------- | ------------------------------------------- | ------ | | `pending` | Queued, waiting to send | No | | `sent` | Submitted to carrier network | No | | `DELIVRD` | Delivered to recipient’s handset | Yes | | `UNDELIV` | Delivery failed (invalid number, phone off) | Yes | | `REJECTD` | Rejected by carrier (filtered, blacklisted) | Yes | | `EXPIRED` | Delivery timed out | Yes | | `UNKNOWN` | Final status unknown | Yes | | `failed` | Internal error (queue/config failure) | Yes | See [delivery statuses](/reference/delivery-statuses) for typical status flows. ## Polling guidance [Section titled “Polling guidance”](#polling-guidance) If you poll instead of using [webhooks](/webhooks/delivery): * Poll every **3–5 seconds**, stop on any final status. * Give up after **a few minutes** and treat the message as in-flight — some carriers report late; the hourly reconciliation will finalize stuck messages. * Prefer webhooks for anything beyond low-volume testing: they remove polling load and deliver statuses the moment carriers report them. # Install tools > Developer tools for the 23 Telecom SMS API — MCP server for AI agents (Claude, Cursor), the 23telecom CLI, Postman collection and OpenAPI spec. ## MCP server — let AI agents send SMS [Section titled “MCP server — let AI agents send SMS”](#mcp-server--let-ai-agents-send-sms) The `@23telecom/mcp` server gives Claude, Cursor or any MCP client five tools: `send_sms`, `get_message_status`, `get_balance`, `get_stats`, `list_messages`. * Claude Code ```bash claude mcp add 23telecom -e TELECOM23_API_KEY=sk_test_your_key -- npx -y @23telecom/mcp ``` * Claude Desktop / Cursor ```json { "mcpServers": { "23telecom": { "command": "npx", "args": ["-y", "@23telecom/mcp"], "env": { "TELECOM23_API_KEY": "sk_test_your_key" } } } } ``` Give agents a sandbox key Start agents on a [sandbox key](/sandbox) (`sk_test_…`) — simulated and free; the `send_sms` tool tells the agent which mode is active. For live keys, grant the minimum permissions (e.g. `sms.send` only) — see [API keys](/api-keys). ## 23telecom CLI [Section titled “23telecom CLI”](#23telecom-cli) Single static binary, Go stdlib only — send and inspect SMS from any shell: ```bash cd cli && go build -o 23telecom . export TELECOM23_API_KEY=sk_test_... 23telecom send --to +447911123456 --message "Hello!" --sender MyApp 23telecom status 23telecom balance 23telecom stats --period 30d 23telecom messages --status delivered --limit 10 ``` Every command takes `--json` for raw output (pipe to `jq`). Exit codes: 0 ok · 1 API error · 2 usage error. ## Postman & OpenAPI [Section titled “Postman & OpenAPI”](#postman--openapi) * [Postman collection](/tools/postman) — import, set two variables, send. * [OpenAPI 3.1 spec](/tools/openapi) — generate clients, mocks and contract tests. ## SDKs [Section titled “SDKs”](#sdks) Official libraries for TypeScript, Python and PHP — see [Libraries & SDKs](/sdks). # Use these docs with AI > Use 23 Telecom docs with AI assistants — copy the docs to your clipboard, point agents at llms.txt, or load the OpenAPI spec so ChatGPT, Claude and coding agents write correct integration code. You can feed these docs to ChatGPT, Claude, Cursor or any coding agent — they’re published in formats AI tools read natively. ## Copy the docs into your chat [Section titled “Copy the docs into your chat”](#copy-the-docs-into-your-chat) One click, then paste: **Copy compact docs** llms-small.txt — fits small context windows **Copy full docs** llms-full.txt — complete documentation **Copy OpenAPI spec** openapi.yaml — exact request/response schemas **Copy llms.txt index** page index for agents that browse ## Copy a single page [Section titled “Copy a single page”](#copy-a-single-page) Every page in these docs has a **Copy page** button next to its title. It copies that page as plain Markdown — or opens it directly in ChatGPT or Claude with a ready-made prompt. You can also append `.md` to any docs URL to get the plain-Markdown version: ```plaintext https://docs.23telecom.co.uk/quickstart → the web page https://docs.23telecom.co.uk/quickstart.md → the same page as Markdown ``` ## Point your AI at a URL [Section titled “Point your AI at a URL”](#point-your-ai-at-a-url) If your tool fetches URLs itself, use these: | URL | What it is | | ------------------------------------ | -------------------------------------------------------------- | | [`/llms.txt`](/llms.txt) | Index of all pages ([emerging standard](https://llmstxt.org/)) | | [`/llms-full.txt`](/llms-full.txt) | The entire documentation in one file | | [`/llms-small.txt`](/llms-small.txt) | Compact version for small context windows | | [`/openapi.yaml`](/openapi.yaml) | Exact request and response schemas | ## Example prompt [Section titled “Example prompt”](#example-prompt) ```plaintext Read https://docs.23telecom.co.uk/llms-full.txt and help me send SMS via the 23 Telecom API from my Node.js app. Auth is an X-API-Key header. ``` That’s it. The AI gets real endpoints and field names instead of guessing. Building an AI agent that sends SMS? Give it an API key with the `sms.send` permission only — see [API keys & permissions](/api-keys). # OpenAPI specification > Machine-readable OpenAPI 3.1 specification for the 23 Telecom SMS API — generate typed clients, mock servers, contract tests and import into any API tooling. The entire public API is described in one OpenAPI 3.1 document — the same source that powers the [API playground](/api) and the [Postman collection](/tools/postman). [](/openapi.yaml) [Download openapi.yaml ↓](/openapi.yaml) Stable URL for tooling: ```plaintext https://docs.23telecom.co.uk/openapi.yaml ``` ## What you can do with it [Section titled “What you can do with it”](#what-you-can-do-with-it) **Generate a typed client** in your language: ```bash # TypeScript (fetch-based) npx openapi-typescript https://docs.23telecom.co.uk/openapi.yaml -o ./api-types.ts # Any of 50+ languages via OpenAPI Generator openapi-generator-cli generate \ -i https://docs.23telecom.co.uk/openapi.yaml \ -g python -o ./23telecom-python ``` **Mock the API** for local development: ```bash npx @stoplight/prism-cli mock https://docs.23telecom.co.uk/openapi.yaml ``` **Contract-test your integration** — validate your requests and our responses against the spec in CI (Schemathesis, Dredd, Spectral). **Import into API clients** — Insomnia, Bruno, Hoppscotch and Postman all accept OpenAPI 3.1 directly. ## Coverage [Section titled “Coverage”](#coverage) The spec describes all public endpoints (SMS, messages, statistics, account, webhooks), the `X-API-Key` security scheme, every documented [error shape](/reference/errors), and the **delivery webhook** as an OpenAPI `webhooks` entry — so code generators can type your DLR receiver too. ## Versioning [Section titled “Versioning”](#versioning) The spec follows the live API. Breaking changes would ship as a new major version with a migration period — additive changes (new fields, new endpoints) land in place. Pin to fields you use, ignore unknown fields. # Postman collection > Download the 23 Telecom SMS API Postman collection — every endpoint pre-configured with auth headers and example bodies. Set two variables and send your first request in a minute. Every endpoint, pre-configured — generated from the same [OpenAPI spec](/tools/openapi) as this documentation, so it never drifts. [](/postman/23telecom-sms-api.postman_collection.json) [Download Postman collection ↓](/postman/23telecom-sms-api.postman_collection.json) ## Set up in one minute [Section titled “Set up in one minute”](#set-up-in-one-minute) 1. **Import.** In Postman: **File → Import** and drop the downloaded `23telecom-sms-api.postman_collection.json`. 2. **Set two variables.** Open the collection → **Variables** tab: | Variable | Value | | --------- | -------------------------------------------------- | | `baseUrl` | `https://restlink23telecom.com/api/v1` | | `apiKey` | Your key from the portal (**Settings → API Keys**) | 3. **Send.** Open **SMS → Send SMS**, replace the recipient with your own number in the example body, and hit **Send**. Keep keys out of shared workspaces Store `apiKey` as a **current value** (not initial value) so it never syncs to shared/team workspaces or exports. ## What’s inside [Section titled “What’s inside”](#whats-inside) The collection mirrors the API structure: **SMS** (send, status), **Messages** (list, unified, CSV export), **Statistics** (aggregate, daily, by country), **Account** (balance, pricing, payments) and **Webhooks** (settings, test, logs). Auth is handled at collection level — every request inherits the `X-API-Key: {{apiKey}}` header. ## Alternatives [Section titled “Alternatives”](#alternatives) * [API playground](/api) — try requests right in the browser, nothing to install. * [OpenAPI spec](/tools/openapi) — import the raw spec into Insomnia, Bruno, Hoppscotch or your own tooling. # Delivery webhook > Receive SMS delivery reports (DLR) in real time — webhook payload reference, 5-second response window, retry policy with backoff, and receiver examples in Python, Node.js and PHP. When a carrier returns a delivery report, we POST a JSON payload to your `delivery_url`: ```plaintext POST https://your-server.com/webhook/delivery Content-Type: application/json User-Agent: 23Telecom-Webhooks/1.0 ``` Example payload ```json { "message_id": "api_42_1743667200123456789_a3f8b2c1d9e45f67", "recipient": "+14155551234", "sender_id": "MyApp", "status": "DELIVRD", "status_code": "000", "num_parts": 1, "cost": 0.0085, "timestamp": "2026-02-13T10:30:04Z" } ``` ## Response & retries [Section titled “Response & retries”](#response--retries) Your server must respond with **HTTP 2xx within 5 seconds**. Anything else triggers automatic retries: up to **3 attempts total**, with backoff delays of **10s** and **30s**. Every attempt is logged — see [webhook logs](/webhooks/overview#test--inspect). Ack fast, process later Respond `200` immediately and process the event asynchronously (queue it). Slow handlers hit the 5-second timeout and cause duplicate deliveries on retry — make your handler idempotent by `message_id`. ## Payload fields [Section titled “Payload fields”](#payload-fields) Choose fields when [configuring the webhook](/webhooks/overview#configure). Defaults: `message_id`, `recipient`, `sender_id`, `status`, `status_code`, `num_parts`, `cost`, `timestamp`. | Field | Description | | -------------------- | -------------------------------------------------------- | | `message_id` | Your message ID (from the send response) | | `telecom_message_id` | Carrier-assigned ID (deprecated — use `message_id`) | | `recipient` | Destination phone number | | `sender_id` | Sender ID used | | `status` | `DELIVRD`, `UNDELIV`, `REJECTD`, `EXPIRED`, `UNKNOWN` | | `status_code` | Carrier-specific status code | | `num_parts` | SMS segment count | | `cost` | Actual cost from the carrier (may be `0` on free routes) | | `timestamp` | Delivery time, ISO 8601 UTC | | `workspace_id` | Opt-in — see [workspaces](/account/workspaces) | ## Receiver examples [Section titled “Receiver examples”](#receiver-examples) Signature verification shown below is optional but recommended — set a signing secret first (see [securing webhooks](/webhooks/security)). * Python Flask ```py import hmac, hashlib from flask import Flask, request app = Flask(__name__) SECRET = "your_webhook_signing_secret" @app.route("/webhook/delivery", methods=["POST"]) def delivery(): ts = request.headers.get("X-Webhook-Timestamp", "") sig = request.headers.get("X-Webhook-Signature", "") body = request.get_data(as_text=True) if SECRET and sig: expected = "sha256=" + hmac.new( SECRET.encode(), f"{ts}.{body}".encode(), hashlib.sha256 ).hexdigest() if not hmac.compare_digest(sig, expected): return "Bad signature", 401 data = request.get_json() print(f"DLR: {data['message_id']} -> {data['status']}") return "OK", 200 ``` * Node.js Express ```js const crypto = require('crypto'); const express = require('express'); const app = express(); const SECRET = 'your_webhook_signing_secret'; app.post('/webhook/delivery', express.raw({ type: '*/*' }), (req, res) => { const ts = req.headers['x-webhook-timestamp']; const sig = req.headers['x-webhook-signature']; const body = req.body.toString(); if (SECRET && sig) { const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(`${ts}.${body}`).digest('hex'); if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) { return res.status(401).send('Bad signature'); } } const data = JSON.parse(body); console.log(`DLR: ${data.message_id} -> ${data.status}`); res.send('OK'); }); app.listen(3000); ``` * PHP ```php {$data['status']}"); echo 'OK'; ``` ## Checklist before production [Section titled “Checklist before production”](#checklist-before-production) * Endpoint is HTTPS and responds 2xx in well under 5 seconds. * Handler is **idempotent** — retries can deliver the same event twice. * Signature verification enabled ([securing webhooks](/webhooks/security)). * Alerting on webhook failures — check delivery logs periodically. # Webhooks overview > Receive real-time SMS events from 23 Telecom — impression, click, conversion and delivery webhooks with configurable payload fields, per-workspace routing and HMAC signing. Webhooks push events to your server the moment they happen — no polling. Each event type goes to its own URL, configured per workspace. ## Event types [Section titled “Event types”](#event-types) | Type | Fires when | | ------------ | ----------------------------------------------------------- | | `impression` | SMS accepted by the gateway (API single-send path) | | `click` | Recipient clicks a short URL (optional per-recipient dedup) | | `conversion` | Postback received via `/track` or createAction | | `delivery` | Carrier returns a delivery report (DLR) | ## Configure [Section titled “Configure”](#configure) In the [portal](https://restlink23telecom.com) under **Webhooks**, or via API: PUT  `/api/v1/user/webhooks`  · Permission: `webhooks.write` * cURL ```bash curl -X PUT https://restlink23telecom.com/api/v1/user/webhooks \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "delivery_url": "https://your-server.com/webhook/delivery", "click_url": "https://your-server.com/webhook/click", "unique_clicks_only": true, "delivery_fields": [ "message_id", "recipient", "sender_id", "status", "status_code", "num_parts", "cost", "timestamp" ] }' ``` * Node.js ```js const res = await fetch('https://restlink23telecom.com/api/v1/user/webhooks', { method: 'PUT', headers: { 'X-API-Key': process.env.API_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ "delivery_url": "https://your-server.com/webhook/delivery", "click_url": "https://your-server.com/webhook/click", "unique_clicks_only": true, "delivery_fields": [ "message_id", "recipient", "sender_id", "status", "status_code", "num_parts", "cost", "timestamp" ] }), }); if (!res.ok) { const err = await res.json(); throw new Error(`${err.error_code}: ${err.description}`); } const data = await res.json(); console.log(data); ``` * Python ```py import os import requests res = requests.put( "https://restlink23telecom.com/api/v1/user/webhooks", headers={"X-API-Key": os.environ["API_KEY"]}, json={ "delivery_url": "https://your-server.com/webhook/delivery", "click_url": "https://your-server.com/webhook/click", "unique_clicks_only": True, "delivery_fields": ["message_id", "recipient", "sender_id", "status", "status_code", "num_parts", "cost", "timestamp"] }, ) res.raise_for_status() print(res.json()) ``` * PHP ```php 'PUT', CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('API_KEY'), 'Content-Type: application/json'], CURLOPT_POSTFIELDS => json_encode([ 'delivery_url' => 'https://your-server.com/webhook/delivery', 'click_url' => 'https://your-server.com/webhook/click', 'unique_clicks_only' => true, 'delivery_fields' => ['message_id', 'recipient', 'sender_id', 'status', 'status_code', 'num_parts', 'cost', 'timestamp'] ]), ]); $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); ``` * Ruby ```rb require "net/http" require "json" uri = URI("https://restlink23telecom.com/api/v1/user/webhooks") req = Net::HTTP::Put.new(uri) req["X-API-Key"] = ENV.fetch("API_KEY") req["Content-Type"] = "application/json" req.body = { delivery_url: "https://your-server.com/webhook/delivery", click_url: "https://your-server.com/webhook/click", unique_clicks_only: true, delivery_fields: ["message_id", "recipient", "sender_id", "status", "status_code", "num_parts", "cost", "timestamp"] }.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) ``` * Java ```java 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/user/webhooks")) .header("X-API-Key", System.getenv("API_KEY")) .header("Content-Type", "application/json") .PUT(HttpRequest.BodyPublishers.ofString(""" { "delivery_url": "https://your-server.com/webhook/delivery", "click_url": "https://your-server.com/webhook/click", "unique_clicks_only": true, "delivery_fields": [ "message_id", "recipient", "sender_id", "status", "status_code", "num_parts", "cost", "timestamp" ] }""")) .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println(response.body()); ``` * Go ```go package main import ( "fmt" "io" "net/http" "os" "strings" ) func main() { payload := strings.NewReader(`{ "delivery_url": "https://your-server.com/webhook/delivery", "click_url": "https://your-server.com/webhook/click", "unique_clicks_only": true, "delivery_fields": [ "message_id", "recipient", "sender_id", "status", "status_code", "num_parts", "cost", "timestamp" ] }`) req, err := http.NewRequest("PUT", "https://restlink23telecom.com/api/v1/user/webhooks", payload) if err != nil { panic(err) } req.Header.Set("X-API-Key", os.Getenv("API_KEY")) 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)) } ``` * .NET ```csharp using System.Text; using var client = new HttpClient(); client.DefaultRequestHeaders.Add("X-API-Key", Environment.GetEnvironmentVariable("API_KEY")); var json = """ { "delivery_url": "https://your-server.com/webhook/delivery", "click_url": "https://your-server.com/webhook/click", "unique_clicks_only": true, "delivery_fields": [ "message_id", "recipient", "sender_id", "status", "status_code", "num_parts", "cost", "timestamp" ] } """; var content = new StringContent(json, Encoding.UTF8, "application/json"); var response = await client.PutAsync("https://restlink23telecom.com/api/v1/user/webhooks", content); response.EnsureSuccessStatusCode(); Console.WriteLine(await response.Content.ReadAsStringAsync()); ``` Partial merge `PUT` only touches the fields you send. To disable a channel, send `null` (or `""`) as its URL. Reading settings back requires `webhooks.read`; the signing secret is never echoed — only `signing_secret_set: true|false`. ### Validation errors [Section titled “Validation errors”](#validation-errors) `PUT /user/webhooks` returns `400` with stable typed codes: | Code | When | | -------------------- | ----------------------------------------------------------------------------------------------- | | `INVALID_URL` | URL is not a string or fails SSRF validation (`""`/`null` are valid — they disable the channel) | | `INVALID_SECRET` | `signing_secret` empty, longer than 64 chars, or wrong type | | `INVALID_FIELDS` | `*_fields` is null, empty, or contains an unknown field name | | `INVALID_TOGGLE` | `unique_clicks_only` is not a boolean | | `CONFLICTING_FIELDS` | `signing_secret` and `clear_signing_secret: true` sent together | | `INVALID_BODY` | JSON parse error | ## Choose your payload fields [Section titled “Choose your payload fields”](#choose-your-payload-fields) You pick which fields each webhook carries (defaults shown in the example above). The full field list per event is in [delivery webhook](/webhooks/delivery#payload-fields). `workspace_id` is **opt-in** for every event type — add it to the relevant `*_fields` list if you route multiple workspaces to one receiver. ## Click dedup (`unique_clicks_only`) [Section titled “Click dedup (unique\_clicks\_only)”](#click-dedup-unique_clicks_only) When `true`, the click webhook fires only on the **first human click** per (broadcast, recipient) pair; repeat clicks are skipped. Bot clicks never fire webhooks. Applies to broadcast links only — API auto-shortened links always deliver every human click. ## Test & inspect [Section titled “Test & inspect”](#test--inspect) POST  `/api/v1/user/webhooks/test`  · Permission: `webhooks.write` * cURL ```bash curl -X POST https://restlink23telecom.com/api/v1/user/webhooks/test \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "type": "delivery" }' ``` * Node.js ```js const res = await fetch('https://restlink23telecom.com/api/v1/user/webhooks/test', { method: 'POST', headers: { 'X-API-Key': process.env.API_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ "type": "delivery" }), }); if (!res.ok) { const err = await res.json(); throw new Error(`${err.error_code}: ${err.description}`); } const data = await res.json(); console.log(data); ``` * Python ```py import os import requests res = requests.post( "https://restlink23telecom.com/api/v1/user/webhooks/test", headers={"X-API-Key": os.environ["API_KEY"]}, json={ "type": "delivery" }, ) res.raise_for_status() print(res.json()) ``` * PHP ```php 'POST', CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('API_KEY'), 'Content-Type: application/json'], CURLOPT_POSTFIELDS => json_encode([ 'type' => 'delivery' ]), ]); $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); ``` * Ruby ```rb require "net/http" require "json" uri = URI("https://restlink23telecom.com/api/v1/user/webhooks/test") req = Net::HTTP::Post.new(uri) req["X-API-Key"] = ENV.fetch("API_KEY") req["Content-Type"] = "application/json" req.body = { type: "delivery" }.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) ``` * Java ```java 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/user/webhooks/test")) .header("X-API-Key", System.getenv("API_KEY")) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(""" { "type": "delivery" }""")) .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println(response.body()); ``` * Go ```go package main import ( "fmt" "io" "net/http" "os" "strings" ) func main() { payload := strings.NewReader(`{ "type": "delivery" }`) req, err := http.NewRequest("POST", "https://restlink23telecom.com/api/v1/user/webhooks/test", payload) if err != nil { panic(err) } req.Header.Set("X-API-Key", os.Getenv("API_KEY")) 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)) } ``` * .NET ```csharp using System.Text; using var client = new HttpClient(); client.DefaultRequestHeaders.Add("X-API-Key", Environment.GetEnvironmentVariable("API_KEY")); var json = """ { "type": "delivery" } """; var content = new StringContent(json, Encoding.UTF8, "application/json"); var response = await client.PostAsync("https://restlink23telecom.com/api/v1/user/webhooks/test", content); response.EnsureSuccessStatusCode(); Console.WriteLine(await response.Content.ReadAsStringAsync()); ``` GET  `/api/v1/user/webhooks/logs`  · Permission: `webhooks.read` * cURL ```bash curl "https://restlink23telecom.com/api/v1/user/webhooks/logs?limit=10" \ -H "X-API-Key: $API_KEY" ``` * Node.js ```js const res = await fetch('https://restlink23telecom.com/api/v1/user/webhooks/logs?limit=10', { method: 'GET', headers: { 'X-API-Key': process.env.API_KEY }, }); if (!res.ok) { const err = await res.json(); throw new Error(`${err.error_code}: ${err.description}`); } const data = await res.json(); console.log(data); ``` * Python ```py import os import requests res = requests.get( "https://restlink23telecom.com/api/v1/user/webhooks/logs", headers={"X-API-Key": os.environ["API_KEY"]}, params={ "limit": 10 }, ) res.raise_for_status() print(res.json()) ``` * PHP ```php true, CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('API_KEY')], ]); $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); ``` * Ruby ```rb require "net/http" require "json" uri = URI("https://restlink23telecom.com/api/v1/user/webhooks/logs?limit=10") req = Net::HTTP::Get.new(uri) req["X-API-Key"] = ENV.fetch("API_KEY") 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) ``` * Java ```java 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/user/webhooks/logs?limit=10")) .header("X-API-Key", System.getenv("API_KEY")) .GET() .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println(response.body()); ``` * Go ```go package main import ( "fmt" "io" "net/http" "os" ) func main() { req, err := http.NewRequest("GET", "https://restlink23telecom.com/api/v1/user/webhooks/logs?limit=10", nil) if err != nil { panic(err) } req.Header.Set("X-API-Key", os.Getenv("API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() data, _ := io.ReadAll(res.Body) fmt.Println(string(data)) } ``` * .NET ```csharp using System.Text; using var client = new HttpClient(); client.DefaultRequestHeaders.Add("X-API-Key", Environment.GetEnvironmentVariable("API_KEY")); var response = await client.GetAsync("https://restlink23telecom.com/api/v1/user/webhooks/logs?limit=10"); response.EnsureSuccessStatusCode(); Console.WriteLine(await response.Content.ReadAsStringAsync()); ``` Logs include the payload sent, response code, response time and error details for every delivery attempt. ## Next [Section titled “Next”](#next) * [Delivery webhook](/webhooks/delivery) — payload, retries, receiver examples * [Securing webhooks](/webhooks/security) — HMAC signatures, secret rotation * [Tracker postbacks](/webhooks/postbacks) — conversion ingestion and URL placeholders # Tracker postbacks & conversions > Send conversion events into 23 Telecom with tracker postbacks — X-Api-Token authentication, /track endpoint, URL placeholders like {phone} and {status} for affiliate tracker integrations. Conversions close the loop between an SMS and a business result: a recipient clicked your link, registered, deposited — and your tracker reports it back. ## Ingestion endpoints [Section titled “Ingestion endpoints”](#ingestion-endpoints) Public action/conversion ingestion requires the `X-Api-Token` header on every request: | Endpoint | Notes | | ------------------------------ | ------------------------ | | `GET /api/v1/track` | Primary tracker endpoint | | `POST /api/v1/callback/action` | JSON callback variant | | `GET /api/v1/action/track` | Deprecated | | `POST /action` | Legacy | Missing or invalid tokens return **HTTP 401 before any side effect** — no postback, webhook or database write happens. Tokens are validated against your current tracker token (and the unexpired previous one, so rotation is safe). Note Your tracker token is available in the portal. It is separate from API keys — scoped only to conversion ingestion. ## URL placeholders for outgoing postbacks [Section titled “URL placeholders for outgoing postbacks”](#url-placeholders-for-outgoing-postbacks) When 23 Telecom calls **your** tracker (conversion/delivery postbacks), you can embed dynamic values in the configured URL: ```plaintext https://tracker.example.com/postback?phone={phone}&status={status}&cost={cost} ``` Becomes: ```plaintext https://tracker.example.com/postback?phone=%2B14155551234&status=DELIVRD&cost=0.0085 ``` Available placeholders: ```plaintext {message_id} {phone} {recipient} {sender_id} {cost} {status} {status_code} {num_parts} {timestamp} {telecom_message_id} ``` The JSON body is still POSTed regardless of URL placeholders — placeholders exist for trackers that only read query strings. ## Conversion webhooks [Section titled “Conversion webhooks”](#conversion-webhooks) Recorded conversions also fire the `conversion` [webhook](/webhooks/overview) to your `conversion_url`, with your selected `conversion_fields`. Use this to mirror conversion data into your own analytics in real time. # Securing webhooks > Verify 23 Telecom webhook authenticity with HMAC-SHA256 signatures — X-Webhook-Signature header, constant-time comparison, secret rotation and replay protection. Anyone who discovers your webhook URL can POST fake events to it. A signing secret lets you verify that events really come from 23 Telecom. ## How signing works [Section titled “How signing works”](#how-signing-works) Set a signing secret in the portal (**Settings → Webhooks**) or via `PUT /user/webhooks`. Every webhook request then includes: | Header | Value | | --------------------- | ------------------------ | | `X-Webhook-Timestamp` | Unix timestamp (seconds) | | `X-Webhook-Signature` | `sha256={hex}` | **Verification:** ```plaintext expected = HMAC-SHA256(your_secret, "{timestamp}.{raw_body}") compare: "sha256=" + expected == X-Webhook-Signature ``` Three rules: * Compute over the **raw request body** — before any JSON parsing or re-serialization. * Use **constant-time comparison** (`hmac.compare_digest`, `crypto.timingSafeEqual`, `hash_equals`) — never `==` on signatures. * Reject stale timestamps (e.g. older than 5 minutes) to limit replay windows. Verification code in Python, Node.js and PHP is included in the [delivery webhook examples](/webhooks/delivery#receiver-examples). ## Managing the secret [Section titled “Managing the secret”](#managing-the-secret) The secret is **write-only**: `GET /user/webhooks` returns only `signing_secret_set: true|false`, never the value. | Operation | PUT body | | ------------------------------ | ----------------------------------------------------- | | Preserve existing secret | Omit `signing_secret`, or send `signing_secret: null` | | Set / rotate secret | `{ "signing_secret": "your-new-secret" }` | | Clear secret | `{ "clear_signing_secret": true }` | | Invalid (`400 INVALID_SECRET`) | `signing_secret: ""` or longer than 64 chars | Rotation is atomic Rotating or clearing the secret atomically rewrites all pending, retrying and failed deliveries in the retry queue — the old secret never lingers in queued events. During rotation, accept signatures computed with either the old or new secret on your side until the queue drains. ## Defense in depth [Section titled “Defense in depth”](#defense-in-depth) * **HTTPS only** — webhook URLs must be TLS; plain HTTP exposes payloads and signatures in transit. * **Don’t reflect errors verbosely** — a `401` with no body is enough for a failed signature. * **Idempotency by `message_id`** — replayed or retried events must not double-process. * **Secret hygiene** — generate 32+ random characters, store alongside your other secrets, rotate on staff changes.