Sandbox & test mode
Copy page
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. For the standard API endpoints, switching to production is
swapping one environment variable. Integrations follow their platform guide;
only guides marked managed require managed activation.
How it works
Section titled “How it works”Live key (sk_prod_…) |
Sandbox key (sk_test_…) |
|
|---|---|---|
| Validation & errors | Real | Same request validation; production account/cost lookup and blocked-country filtering are skipped |
| 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, payload carries "sandbox": true, one attempt, no retries |
| Message storage | Normal retained history | 24 hours, last 500 messages |
| Response | — | Carries "sandbox": true |
-
Create a sandbox key. In the portal under Settings → API Keys, enable Test mode when creating the key. You get an
sk_test_…key. -
Send a simulated SMS — same request as production:
Terminal window 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"}'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);import osimport requestsres = 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$ch = curl_init('https://restlink23telecom.com/api/v1/sms/send');curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => '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);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_jsonres = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }raise "HTTP #{res.code}: #{res.body}" unless res.is_a?(Net::HTTPSuccess)puts JSON.parse(res.body)import java.net.URI;import java.net.http.HttpClient;import java.net.http.HttpRequest;import java.net.http.HttpResponse;HttpClient client = HttpClient.newHttpClient();HttpRequest request = HttpRequest.newBuilder().uri(URI.create("https://restlink23telecom.com/api/v1/sms/send")).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<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());System.out.println(response.body());package mainimport ("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))}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 success response has the production fields plus
"sandbox": true. -
Watch the delivery report arrive. About 5 seconds later your configured
delivery_urlreceives a synthesizedDELIVRDwebhook with a valid HMAC signature — verify it exactly like a real one (securing webhooks). -
Poll status if you prefer:
GET /sms/status/{message_id}returnssent, thenDELIVRDafter the simulated carrier latency.
What is intentionally different
Section titled “What is intentionally different”Synthesized webhooks use an isolated bounded pool with a single attempt. They appear on your webhook-logs page, are never retried, and are never queued with live traffic.
- Sandbox message IDs are opaque values such as
test_<account>_<nanosecond_timestamp>_<hex>and history is kept for 24 hours (last 500 per workspace). Always use the value returned by the send response. It never appears in production statistics or exports. An unknown or expired ID returns404 NOT_FOUND: “sandbox message not found or expired; sandbox message IDs start with test_ and are retained for 24 hours.” Atest_ID queried with a livesk_prod_key returns 404 guidance to query it with thesk_test_key. - URL auto-shortening, blocked-country filtering and production account/cost lookup are skipped. Live pricing or balance cache misses are fail-open, not local send gates.
GET /sms/messagessupports 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.
From the docs playground (https://docs.23telecom.co.uk/api) the API additionally refuses live keys with 403 LIVE_KEY_FROM_PLAYGROUND and answers preflights for non-sandbox routes with 403 CORS_ROUTE_NOT_ALLOWED.
Limits
Section titled “Limits”The daily quota is 10,000 recipients per account per UTC day. Exceeding it returns 429 SANDBOX_QUOTA_EXCEEDED with Retry-After until UTC midnight. The global capacity limit returns 503 SANDBOX_BUSY with Retry-After: 5.
Going live
Section titled “Going live”For the standard /sms/send and /sms/send-individual endpoints, replace the
key. That’s the whole migration:
# beforeexport API_KEY=sk_test_abc...# afterexport API_KEY=sk_prod_xyz...For the self-service /integrations/sms/send path, replace the test key with an eligible live key
bound to your workspace with sms.send permission to send immediately. Live requests also require
the integration to be enabled, the workspace not to be blocked, and workspace
credentials to be available; normal validation and retryable infrastructure errors also apply; see production sending.
CRM platform guides marked managed follow their own Production activation
section. Do not promote a legacy CRM batch template by changing only its key.
