FREE SEND SMS SMS • VoIP • WhatsApp • Viber
Developers

API Reference v1

A complete guide to sending SMS, bulk send, status lookup, balance/credits and webhook notifications. All examples can be copied and used as-is.

https://freesendsms.com/api/v1 Download OpenAPI 3.1

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.
EndpointPermissionSuccessDescription
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:

auth.sh
curl https://freesendsms.com/api/v1/wallet \
  -H "Authorization: Bearer smsgo_live_XXXXXXXX"

Permissions (scopes)

  • messages:write — Messages and bulk sends, cancel
  • messages:read — Look up and list messages and bulk sends
  • wallet: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"]):

ReasonExample
Invalid number formatabc, 0532-ABC
Incorrect number of digits053211122, 4441234
Non-Turkish number+4915112345678
Landline number02121112233
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.

FieldTypeRequiredDescription
channelstringYessms (whatsapp if enabled on your WhatsApp account).
tostringYesE.164 Turkish mobile number, e.g. +905321112233.
sender_idstringNoApproved sender (header) ID from the panel. For SMS, leave it out to send without a sender ID; required for WhatsApp.
content.typestringYestext for SMS.
content.textstringYesMessage text, at most 5 SMS parts: 765 characters in the standard alphabet, 335 with Turkish or other special characters.
client_referencestringNoYour own reference (1–100 characters); filterable in the list and returned via webhook.
schedule_atstringNoScheduled send, in UTC with Z: 2026-10-07T09:00:00Z (up to 30 days ahead).
expires_atstringNoWill 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.

send.sh
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"
  }'
send.php
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');
}
send.mjs
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();
send.py
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.

batch.sh
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.

list.sh
# 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

statusMeaning
queuedAccepted and waiting for its place in the send queue or its scheduled time. It can only be canceled in this status.
submittingBeing handed to the carrier.
acceptedReceived by the carrier; awaiting delivery report.
unknownThe delivery result could not be verified yet; it will be updated after reconciliation.
deliveredDelivered to the recipient.
failedCould not be handed to the carrier.
undeliverableThe carrier could not reach the recipient (switched-off line, invalid subscriber, etc.).
expiredExpired before delivery.
canceledCancelled by you.
billing_statusMeaning
reservedAmount or credits reserved.
capturedCharge finalized.
releasedThe amount/credits held for a canceled message were returned.
adjustedCorrected after reconciliation.
failure_codeMeaning
send_failedCould not be passed to the operator. The charge is not refunded.
undeliverableThe carrier could not reach the recipient.
expiredExpired.

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.

HTTPerror.codeMeaning
400malformed_jsonBody is not valid JSON.
400invalid_idempotency_keyIdempotency-Key is missing or malformed.
401unauthenticatedThe key is missing, invalid, revoked or expired.
403insufficient_scopeThe key is not authorized for this operation.
403ip_not_allowedThe request came from an address that is not on the key's allowed IP list.
403account_suspendedThe account is suspended.
404resource_not_foundThe resource does not exist or belongs to another account.
405method_not_allowedThis path does not support this HTTP method.
409insufficient_balanceThe balance does not cover this send.
409insufficient_creditsSMS credits don't cover this send.
409idempotency_conflictThe same Idempotency-Key was used with a different body.
409request_in_progressA request with the same key is still being processed; wait for the Retry-After duration.
409message_not_cancelableThe message is no longer in the queue.
413payload_too_largeBody exceeds the 1 MB limit.
415unsupported_media_typeContent-Type must be application/json.
422validation_failedField error; details are in error.fields.
422invalid_cursorThe pagination cursor does not belong to this account/filter set.
422channel_unavailableThe channel is not available for this destination.
422pricing_unavailableThere is no valid tariff for this send.
429rate_limitedRequest or send limit exceeded; wait for the Retry-After duration.
503service_pausedSending has been temporarily stopped by the platform; retry with the same key after Retry-After.
503maintenanceMaintenance work.
500internal_errorUnexpected 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}.
webhook.http
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.

verify.php
// 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.
verify.mjs
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);
});
verify.py
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