Overview
- All requests use HTTPS and JSON; send Content-Type: application/json on requests with a body.
- Dates are written in UTC with a Z suffix (2026-10-06T09:00:00Z). Amounts are returned as strings with 8 decimals ("0.25000000").
- Successful responses contain the data and meta.request_id fields; errors contain the error and meta.request_id fields.
- Every response includes an X-Request-Id header; share this ID in support tickets. You can also send your own X-Request-Id value.
- The language of error messages is selected via Accept-Language (tr, en); error codes are stable, so check the code, not the text.
- 202 Accepted means the message has been durably accepted; the delivery result arrives via a status query or webhook.
| Endpoint | Permission | Success | Description |
|---|---|---|---|
POST/messages |
messages:write | 202 | Sends a message to a single recipient. |
POST/message-batches |
messages:write | 202 | Accepts or rejects up to 100 messages in a single request, all or nothing. |
GET/messages/{id} |
messages:read | 200 | Returns the status, cost or credits of a message. |
GET/messages |
messages:read | 200 | Filters messages and lists them with pagination. |
GET/message-batches/{id} |
messages:read | 200 | Returns the status breakdown and messages of a bulk send. |
POST/messages/{id}/cancel |
messages:write | 200 | Cancels a message waiting in the queue (e.g. scheduled). |
GET/wallet |
wallet:read | 200 | Returns the balance, payment model and remaining SMS credits. |
Authentication
Create the API key in the panel under Account → API keys. The key (starts with smsgo_live_) is shown only once; keep it secret on the server side and never embed it in client apps. Send it in the Authorization header with every request:
curl https://freesendsms.com/api/v1/wallet \
-H "Authorization: Bearer smsgo_live_XXXXXXXX"
Permissions (scopes)
messages:write— Messages and bulk sends, cancelmessages:read— Look up and list messages and bulk sendswallet:read— Balance and credit lookup
Security
- Restrict the key to your own servers by defining up to 20 IP addresses or CIDR ranges (other addresses get 403 ip_not_allowed).
- You can set an expiry date on keys and revoke a leaked key with one click.
- Use a separate key for each environment/application, with only the permissions it needs.
Recipient numbers
Messages are sent to Turkish GSM subscriber numbers only. In the API, write the to field in E.164 format: +90 followed by 10 digits (+905321112233). Unassigned codes, landlines, 850/444/800 numbers and international numbers are rejected before sending; no credits or balance are spent on an invalid number.
- Accepted
- +90 501, 505, 506, 507, 510, 516, 530–539, 540–549, 551–555, 559, 561
- Also in the panel and lists
- 05321112233 · 5321112233 · 905321112233 · 00905321112233 · +90 (532) 111 22 33
A rejected number returns 422 validation_failed; the reason is in error.fields.to (for bulk sends, error.fields["messages.N.to"]):
| Reason | Example |
|---|---|
| Invalid number format | abc, 0532-ABC |
| Incorrect number of digits | 053211122, 4441234 |
| Non-Turkish number | +4915112345678 |
| Landline number | 02121112233 |
| Special/corporate number (850, 444, 800…) | 08501112233 |
| Unassigned GSM code | +905701112233, +905821112233 |
For bulk sends from the panel, the list is cleaned first: how many numbers there are, how many were filtered out and why, and how many duplicates were removed are shown before sending and kept in the send history.
POST/messages
Sends a message to a single recipient. The message is validated, its cost is reserved (or SMS credits are deducted) and it is queued. The Location header gives the message's lookup URL.
| Field | Type | Required | Description |
|---|---|---|---|
| channel | string | Yes | sms (whatsapp if enabled on your WhatsApp account). |
| to | string | Yes | E.164 Turkish mobile number, e.g. +905321112233. |
| sender_id | string | No | Approved sender (header) ID from the panel. For SMS, leave it out to send without a sender ID; required for WhatsApp. |
| content.type | string | Yes | text for SMS. |
| content.text | string | Yes | Message text, at most 5 SMS parts: 765 characters in the standard alphabet, 335 with Turkish or other special characters. |
| client_reference | string | No | Your own reference (1–100 characters); filterable in the list and returned via webhook. |
| schedule_at | string | No | Scheduled send, in UTC with Z: 2026-10-07T09:00:00Z (up to 30 days ahead). |
| expires_at | string | No | Will not be sent after this time. Defaults to 24 hours after sending, up to a maximum of 7 days. |
Unknown fields are rejected (422). Text containing Turkish characters is Unicode-encoded and uses more segments; one segment is 160 characters in GSM text and 70 in Unicode text, and for multi-segment messages these limits are 153 and 67.
curl -X POST https://freesendsms.com/api/v1/messages \
-H "Authorization: Bearer smsgo_live_XXXXXXXX" \
-H "Idempotency-Key: order-1042-sms-0001" \
-H "Content-Type: application/json" \
-H "Accept-Language: en" \
-d '{
"channel": "sms",
"to": "+905321112233",
"sender_id": "01SENDEREXAMPLE",
"content": {"type": "text", "text": "Your order has shipped."},
"client_reference": "order-1042"
}'
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('SMSGO_API_KEY'))
->withHeaders(['Idempotency-Key' => 'order-1042-sms-0001'])
->acceptJson()
->post('https://freesendsms.com/api/v1/messages', [
'channel' => 'sms',
'to' => '+905321112233',
'sender_id' => '01SENDEREXAMPLE',
'content' => ['type' => 'text', 'text' => 'Your order has shipped.'],
'client_reference' => 'order-1042',
]);
if ($response->status() === 202) {
$messageId = $response->json('data.id');
}
const response = await fetch('https://freesendsms.com/api/v1/messages', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.SMSGO_API_KEY}`,
'Idempotency-Key': 'order-1042-sms-0001',
'Content-Type': 'application/json',
},
body: JSON.stringify({
channel: 'sms',
to: '+905321112233',
sender_id: '01SENDEREXAMPLE',
content: { type: 'text', text: 'Your order has shipped.' },
client_reference: 'order-1042',
}),
});
const { data, error } = await response.json();
import os, requests
response = requests.post(
"https://freesendsms.com/api/v1/messages",
headers={
"Authorization": f"Bearer {os.environ['SMSGO_API_KEY']}",
"Idempotency-Key": "order-1042-sms-0001",
},
json={
"channel": "sms",
"to": "+905321112233",
"sender_id": "01SENDEREXAMPLE",
"content": {"type": "text", "text": "Your order has shipped."},
"client_reference": "order-1042",
},
timeout=10,
)
message_id = response.json()["data"]["id"] if response.status_code == 202 else None
{
"data": {
"id": "01J9Z8Q4M6T2V3X5Y7A9B1C3D5",
"channel": "sms",
"status": "queued",
"billing_status": "reserved",
"reserved_amount": "0.25000000",
"currency": "TRY",
"credits": null,
"created_at": "2026-10-06T09:00:00Z"
},
"meta": {"request_id": "01J9Z8Q4M6REQUEST0000000000"}
}
POST/message-batches
Sends 1–100 messages in a single request. A batch is atomic: if one item is invalid, no messages are created and the error is returned with the item's path (messages.N.field). Balance/credits are also reserved once for the whole batch. Split large lists into chunks of 100 and use a separate Idempotency-Key for each chunk; filtering out invalid numbers before sending prevents chunks from being rejected.
curl -X POST https://freesendsms.com/api/v1/message-batches \
-H "Authorization: Bearer smsgo_live_XXXXXXXX" \
-H "Idempotency-Key: campaign-77-part-001" \
-H "Content-Type: application/json" \
-d '{
"messages": [
{"channel": "sms", "to": "+905321112233", "sender_id": "01SENDEREXAMPLE",
"content": {"type": "text", "text": "The campaign has started!"}, "client_reference": "c77-1"},
{"channel": "sms", "to": "+905421112233", "sender_id": "01SENDEREXAMPLE",
"content": {"type": "text", "text": "The campaign has started!"}, "client_reference": "c77-2"}
]
}'
If you send the same text to many recipients, use this endpoint: messages with the same text and sender are passed to the operator in bulk, so they go out much faster than one /messages call each.
Response (202): id, status=accepted, message_ids (in send order), reserved_amount, currency, created_at. If an item is invalid:
{
"error": {
"code": "validation_failed",
"message": "Check the request fields.",
"fields": {
"messages.1.to": ["This mobile prefix is not allocated in Turkey."]
}
},
"meta": {"request_id": "01J9Z8Q4M6REQUEST0000000001"}
}
Message lookup
GET/messages/{id}
The current status of the message. For security, the message text and recipient number are not included in API responses; match your own record using client_reference.
{
"data": {
"id": "01J9Z8Q4M6T2V3X5Y7A9B1C3D5",
"channel": "sms",
"status": "delivered",
"billing_status": "captured",
"client_reference": "order-1042",
"reserved_amount": "0.25000000",
"charged_amount": "0.25000000",
"currency": "TRY",
"credits": null,
"schedule_at": "2026-10-06T09:00:00Z",
"expires_at": "2026-10-07T09:00:00Z",
"failure_code": null,
"created_at": "2026-10-06T09:00:00Z",
"updated_at": "2026-10-06T09:00:04Z"
},
"meta": {"request_id": "01J9Z8Q4M6REQUEST0000000002"}
}
GET/messages
Filters: status, channel, created_from, created_to (UTC with Z), client_reference, limit (1–100, default 25). Results are sorted newest to oldest. For the next page, send the meta.next_cursor value in the cursor parameter with the same filters; on the last page next_cursor is null. If a cursor is used with different filters, 422 invalid_cursor is returned.
# First page: yesterday's undeliverable messages
curl "https://freesendsms.com/api/v1/messages?status=undeliverable&created_from=2026-10-05T00:00:00Z&created_to=2026-10-06T00:00:00Z&limit=100" \
-H "Authorization: Bearer smsgo_live_XXXXXXXX"
# Next page: same filters + meta.next_cursor
curl "https://freesendsms.com/api/v1/messages?status=undeliverable&created_from=2026-10-05T00:00:00Z&created_to=2026-10-06T00:00:00Z&limit=100&cursor=eyJjIjoi..." \
-H "Authorization: Bearer smsgo_live_XXXXXXXX"
GET/message-batches/{id}
Returns id, total, counts_by_status (e.g. {"delivered": 97, "undeliverable": 3}) and a page of the batch's messages (messages); meta.next_cursor and the limit/cursor parameters are the same as for listing.
POST/messages/{id}/cancel
Can be cancelled only while status=queued (mostly scheduled messages); the reserved amount or credits are refunded. Send no body (or {}). For an already cancelled message, the same resource is returned with 200; for a message already in sending, 409 message_not_cancelable is returned. Idempotency-Key is required.
GET/wallet
Your account runs on one of two payment models; the billing_mode field tells you which:
- wallet — Sends are paid from your money balance according to your tariff. reserved_amount and charged_amount show the message cost; insufficient balance returns 409 insufficient_balance.
- credits — Sends are paid from SMS credits: 1 credit = 1 SMS; long messages count as several SMS. The message's credits field shows the credits taken; not enough credits returns 409 insufficient_credits. Only a message you cancel before it is sent gets its credits back; failed, undeliverable or expired messages are not refunded.
{
"data": {
"currency": "TRY",
"posted_balance": "0.00000000",
"reserved_amount": "0.00000000",
"available_balance": "0.00000000",
"billing_mode": "credits",
"credits": 9842,
"as_of": "2026-10-06T09:00:00Z"
},
"meta": {"request_id": "01J9Z8Q4M6REQUEST0000000003"}
}
Statuses and error codes
| status | Meaning |
|---|---|
| queued | Accepted and waiting for its place in the send queue or its scheduled time. It can only be canceled in this status. |
| submitting | Being handed to the carrier. |
| accepted | Received by the carrier; awaiting delivery report. |
| unknown | The delivery result could not be verified yet; it will be updated after reconciliation. |
| delivered | Delivered to the recipient. |
| failed | Could not be handed to the carrier. |
| undeliverable | The carrier could not reach the recipient (switched-off line, invalid subscriber, etc.). |
| expired | Expired before delivery. |
| canceled | Cancelled by you. |
| billing_status | Meaning |
|---|---|
| reserved | Amount or credits reserved. |
| captured | Charge finalized. |
| released | The amount/credits held for a canceled message were returned. |
| adjusted | Corrected after reconciliation. |
| failure_code | Meaning |
|---|---|
| send_failed | Could not be passed to the operator. The charge is not refunded. |
| undeliverable | The carrier could not reach the recipient. |
| expired | Expired. |
Final statuses are delivered, failed, undeliverable, expired and canceled. A late operator report can revise a final status (e.g. undeliverable → delivered); every change increases sequence and the highest sequence wins. Without webhooks, poll the message every few minutes.
Idempotency
The Idempotency-Key header is required on all POST requests: 16–128 characters, letters, digits and . _ : - only. A request repeated with the same key and the same body does not create a new message; the first response is returned unchanged with the Idempotency-Replayed: true header. This lets you safely retry after a network error.
- Derive the key from your own job record (e.g. order-1042-sms-0001); use a new key for each new message or batch.
- If the same key arrives with a different body, 409 idempotency_conflict is returned; if the first request is still being processed, 409 request_in_progress (Retry-After: 1).
- If a request ends in an error, the key is released; you can reuse the same key with the corrected request. Successful responses are stored for 30 days.
Limits
- 10 POST and 30 GET requests per second per account; creating more keys does not raise the limit. Your account may also have a per-minute, daily or monthly sending limit.
- When the limit is exceeded, 429 rate_limited and Retry-After (seconds) are returned; wait that long and retry with the same Idempotency-Key.
- Request body up to 1 MB; up to 100 messages per batch; message text up to 5,000 characters.
- For high volume, send batches of 100 sequentially or with low concurrency; back off when you receive a 429.
Error responses
The error body is always the same: error.code (stable), error.message (in the Accept-Language language), and error.fields for field errors. Decide whether to retry based on the HTTP status and error.code: for 4xx errors, fix the request; for 429 and 503, wait for Retry-After; for 500, retry a limited number of times with the same Idempotency-Key.
| HTTP | error.code | Meaning |
|---|---|---|
| 400 | malformed_json | Body is not valid JSON. |
| 400 | invalid_idempotency_key | Idempotency-Key is missing or malformed. |
| 401 | unauthenticated | The key is missing, invalid, revoked or expired. |
| 403 | insufficient_scope | The key is not authorized for this operation. |
| 403 | ip_not_allowed | The request came from an address that is not on the key's allowed IP list. |
| 403 | account_suspended | The account is suspended. |
| 404 | resource_not_found | The resource does not exist or belongs to another account. |
| 405 | method_not_allowed | This path does not support this HTTP method. |
| 409 | insufficient_balance | The balance does not cover this send. |
| 409 | insufficient_credits | SMS credits don't cover this send. |
| 409 | idempotency_conflict | The same Idempotency-Key was used with a different body. |
| 409 | request_in_progress | A request with the same key is still being processed; wait for the Retry-After duration. |
| 409 | message_not_cancelable | The message is no longer in the queue. |
| 413 | payload_too_large | Body exceeds the 1 MB limit. |
| 415 | unsupported_media_type | Content-Type must be application/json. |
| 422 | validation_failed | Field error; details are in error.fields. |
| 422 | invalid_cursor | The pagination cursor does not belong to this account/filter set. |
| 422 | channel_unavailable | The channel is not available for this destination. |
| 422 | pricing_unavailable | There is no valid tariff for this send. |
| 429 | rate_limited | Request or send limit exceeded; wait for the Retry-After duration. |
| 503 | service_paused | Sending has been temporarily stopped by the platform; retry with the same key after Retry-After. |
| 503 | maintenance | Maintenance work. |
| 500 | internal_error | Unexpected error; contact support with the request_id. |
Webhook
In the panel, define your HTTPS (443) URL under Account → Webhook; the signing key (whsec_…) is shown only once. When a message status or charge status changes, a JSON POST is sent to your URL. To test the connection, you can send a webhook.test event from the panel.
message.status_changed— status changed.message.billing_updated— billing_status changed (the charge was settled, or a canceled message's amount was returned).webhook.test— Test sent from the panel; data is only {"test": true}.
POST /your/webhook HTTP/1.1
Content-Type: application/json
User-Agent: SMSGO-Webhooks/1
X-SMSGO-Event-Id: 01J9Z9A0EVENT00000000000000
X-SMSGO-Timestamp: 1791277204
X-SMSGO-Signature: v1=5d41402abc4b2a76b9719d911017c592...
{
"id": "01J9Z9A0EVENT00000000000000",
"type": "message.status_changed",
"schema_version": "1",
"occurred_at": "2026-10-06T09:00:04Z",
"data": {
"message_id": "01J9Z8Q4M6T2V3X5Y7A9B1C3D5",
"status": "delivered",
"billing_status": "captured",
"sequence": 4,
"client_reference": "order-1042"
}
}
Signature verification
X-SMSGO-Signature = "v1=" + HMAC-SHA256(signing key, X-SMSGO-Timestamp + "." + raw body) (lowercase hex). Verify against the raw bytes without re-serializing the body as JSON, use a constant-time comparison, and reject timestamps older than 300 seconds.
// Laravel controller: verify before trusting the body.
$secret = config('services.smsgo.webhook_secret'); // whsec_...
$timestamp = $request->header('X-SMSGO-Timestamp');
$signature = $request->header('X-SMSGO-Signature'); // v1=<hex>
$raw = $request->getContent(); // raw bytes, not re-encoded JSON
$expected = 'v1='.hash_hmac('sha256', $timestamp.'.'.$raw, $secret);
abort_unless(
$timestamp && abs(time() - (int) $timestamp) <= 300 && hash_equals($expected, (string) $signature),
400
);
$event = json_decode($raw, true);
// Deduplicate on $event['id'], then answer 2xx quickly.
import crypto from 'node:crypto';
import express from 'express';
app.post('/smsgo/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.get('X-SMSGO-Timestamp');
const signature = req.get('X-SMSGO-Signature') ?? '';
const expected = 'v1=' + crypto
.createHmac('sha256', process.env.SMSGO_WEBHOOK_SECRET)
.update(`${timestamp}.${req.body}`)
.digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;
const valid = signature.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!fresh || !valid) return res.sendStatus(400);
const event = JSON.parse(req.body);
// Deduplicate on event.id, then answer 2xx quickly.
res.sendStatus(204);
});
import hashlib, hmac, os, time
def verify(headers, raw_body: bytes) -> bool:
timestamp = headers.get("X-SMSGO-Timestamp", "")
signature = headers.get("X-SMSGO-Signature", "")
expected = "v1=" + hmac.new(
os.environ["SMSGO_WEBHOOK_SECRET"].encode(),
timestamp.encode() + b"." + raw_body,
hashlib.sha256,
).hexdigest()
fresh = timestamp.isdigit() and abs(time.time() - int(timestamp)) <= 300
return fresh and hmac.compare_digest(expected, signature)
Delivery and retries
- Respond with a 2xx within 10 seconds; queue heavy work. Redirects (3xx) are not followed.
- On 408, 429, 5xx and connection errors, delivery is retried after 1 min, 5 min, 15 min, 1 h, 6 h and 12 h (Retry-After is honored on 429); it is abandoned 24 hours after the first attempt and the account owner is notified by email. Other 4xx responses are treated as permanent failures.
- The same event may arrive more than once and ordering is not guaranteed: deduplicate by id and determine recency with data.sequence. You can resend failed deliveries from the panel.
- When you rotate the signing key, signing switches to the new key immediately; accept both the old and new keys for 24 hours during the transition.
OpenAPI
Download the machine-readable OpenAPI 3.1 definition and import it into Postman, Insomnia or code generators. Within v1, only backward-compatible changes (such as a new optional field) are made; ignore unknown fields.
openapi.json