SMS API

Send SMS and read their delivery results from your own systems. JSON over HTTPS. The panel is at /panel/api, where you create your keys.

Contents

Ready-made code · Authentication · Send · Message status · Delivery reports · Contacts · Groups · Blacklist · Balance · Limits · Errors

Ready-made code

The same steps in six languages: set up, send, check one message, read the delivery results, read the balance, send us your contacts, send to a group, and send safely with retries. Every example was run against the real API before it was published. Replace YOUR_KEY with your key (or set the environment variable SMS_API_KEY) and the sender with one approved on your account.

Needs only curl. The examples are shell commands.

Set up

# Keep the key in an environment variable, not in your scripts.
export SMS_API_KEY="YOUR_KEY"

Send an SMS

curl -X POST https://api.isms.gr/v1/messages \
  -H "Authorization: Bearer $SMS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"sender": "MyShop", "to": ["306900000000"], "text": "Your order is on its way", "client_ref": "order-1042"}'

# 201 with the message when it is accepted. Any other answer is {"error": "...", "message": "..."}

Check one message

# By the id you received when you sent it
curl https://api.isms.gr/v1/messages/KN-7K3M9-Q2XPD \
  -H "Authorization: Bearer $SMS_API_KEY"

# or by your own reference (this answers with the summary, without the list of recipients)
curl "https://api.isms.gr/v1/messages?client_ref=order-1042" \
  -H "Authorization: Bearer $SMS_API_KEY"

Read the delivery results

# First call. You get the results that nobody has confirmed yet, and an "ack".
curl "https://api.isms.gr/v1/reports?limit=500" \
  -H "Authorization: Bearer $SMS_API_KEY"

# Store what you got, then confirm it by sending the ack back. You get the next batch.
curl "https://api.isms.gr/v1/reports?limit=500&ack=THE_ACK_YOU_RECEIVED" \
  -H "Authorization: Bearer $SMS_API_KEY"

# An empty list means nothing is new: wait "next_poll_after" seconds before asking again.

Read the balance

curl https://api.isms.gr/v1/credits \
  -H "Authorization: Bearer $SMS_API_KEY"

# {"credits": "12.34000", "currency": "EUR"}

Send your contacts

# The fields a contact can have, with their type (the administrator can add more at any time)
curl https://api.isms.gr/v1/contact-fields \
  -H "Authorization: Bearer $SMS_API_KEY"

# Send contacts: up to 1,000 a request. A number we do not have is created, one we have is updated.
# "nextAppointment" is an example: use the names that /v1/contact-fields gives you.
curl -X PUT https://api.isms.gr/v1/contacts \
  -H "Authorization: Bearer $SMS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"contacts": [{"mobile": "306900000000", "name": "Maria", "surname": "P", "fields": {"nextAppointment": "2026-11-12T14:30:00Z"}, "groups": ["Clients"]}]}'

# {"created": 1, "updated": 0, "rejected": [], "warnings": [], ...}
# A contact we cannot accept is listed in "rejected" (index, error, message); the others are saved.
# Add "replace_groups": true to make each group named in the request hold exactly the contacts of that request.

Send to a group

# One message to every contact of a group, by its name. Retries are safe with client_ref, as for any send.
curl -X POST https://api.isms.gr/v1/messages \
  -H "Authorization: Bearer $SMS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"sender": "MyShop", "group": "Clients", "text": "Your appointment is tomorrow", "client_ref": "reminders-2026-11-11"}'

# 201 with the message; "recipients" is the number of contacts it goes to. Any other answer is {"error": "...", "message": "..."}

Send safely, with retries

# The same client_ref on every attempt: if an attempt reached us but you never saw the answer,
# the next one does not send or charge a second time (you get 200 and "duplicate": true).
for attempt in 1 2 3 4 5; do
  code=$(curl -s -o /tmp/sms-answer.json -w "%{http_code}" --max-time 15 \
    -X POST https://api.isms.gr/v1/messages \
    -H "Authorization: Bearer $SMS_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"sender": "MyShop", "to": ["306900000000"], "text": "Your order is on its way", "client_ref": "order-1042"}')
  case "$code" in
    200|201) cat /tmp/sms-answer.json; break ;;     # sent (or already sent before)
    429|503|000) sleep $((attempt * 2)) ;;          # slow down, no provider for a moment, or no answer: try again
    *) cat /tmp/sms-answer.json; break ;;           # the request itself is wrong: retrying will not help
  esac
done

Python 3.8 or newer and the requests package (pip install requests).

Set up

import os
import time

import requests

BASE = "https://api.isms.gr"
KEY = os.environ.get("SMS_API_KEY", "YOUR_KEY")   # keep the key in an environment variable, not in your code
HEADERS = {"Authorization": f"Bearer {KEY}"}

Send an SMS

def send():
    response = requests.post(
        f"{BASE}/v1/messages",
        headers=HEADERS,
        json={
            "sender": "MyShop",
            "to": ["306900000000"],
            "text": "Your order is on its way",
            "client_ref": "order-1042",      # your own reference: makes a retry safe (see the last example)
        },
        timeout=15,
    )
    data = response.json()
    if response.status_code in (200, 201):
        print(data["id"], data["status"], "cost", data["cost"], "again" if data["duplicate"] else "new")
        return data["id"]
    print("Refused:", data["error"], "-", data["message"])

Check one message

def status(message_id):
    response = requests.get(f"{BASE}/v1/messages/{message_id}", headers=HEADERS, timeout=15)
    data = response.json()
    print(data["status"], data["counts"])           # for example {"queued": 0, "sent": 0, "delivered": 1, ...}
    for row in data["items"]:
        print(row["mobile"], row["status"])


# or by your own reference (this answers with the summary, without the list of recipients):
# requests.get(f"{BASE}/v1/messages", params={"client_ref": "order-1042"}, headers=HEADERS, timeout=15)

Read the delivery results

def read_reports(save, rounds=1):
    """save(report) is YOUR code: store the result, keyed by (message_id, mobile). Repeats are possible and harmless."""
    ack = None
    for _ in range(rounds):                          # in a real program: while True
        params = {"limit": 500}
        if ack:
            params["ack"] = ack                      # confirms the previous batch and asks for the next
        response = requests.get(f"{BASE}/v1/reports", headers=HEADERS, params=params, timeout=15)
        if response.status_code == 429:
            time.sleep(int(response.headers.get("Retry-After", 10)))
            continue
        response.raise_for_status()
        batch = response.json()
        for report in batch["reports"]:
            save(report)                             # {"message_id", "client_ref", "mobile", "status", "reason", "at"}
        ack = batch["ack"]                           # None when nothing was new
        if not batch["reports"]:
            time.sleep(batch["next_poll_after"])

Read the balance

def balance():
    data = requests.get(f"{BASE}/v1/credits", headers=HEADERS, timeout=15).json()
    print(data["credits"], data["currency"])

Send your contacts

def list_fields():
    """The fields a contact can have and their type. The administrator can add more at any time, so ask instead of writing the list into your code."""
    data = requests.get(f"{BASE}/v1/contact-fields", headers=HEADERS, timeout=15).json()
    for item in data["fields"]:
        print(item["name"], item["type"])               # for example: nextAppointment date


def push_contacts():
    """Up to 1,000 contacts a request. A number we do not have is created, one we have is updated; what you do not send stays as it was."""
    response = requests.put(
        f"{BASE}/v1/contacts",
        headers=HEADERS,
        json={
            "contacts": [{
                "mobile": "306900000000",
                "name": "Maria",
                "surname": "P",
                "fields": {"nextAppointment": "2026-11-12T14:30:00Z"},   # an example: use the names from list_fields()
                "groups": ["Clients"],                                    # created when it does not exist yet
            }],
            # "replace_groups": True,   # each group named here then holds exactly the contacts of this request
        },
        timeout=15,
    )
    data = response.json()
    if response.status_code != 200:
        print("Refused:", data["error"], "-", data["message"])
        return
    print("created", data["created"], "updated", data["updated"], "rejected", len(data["rejected"]))
    for row in data["rejected"]:                         # a contact we cannot accept; the others were saved
        print("  #%d %s: %s" % (row["index"], row["error"], row["message"]))
    for row in data["warnings"]:                         # saved, but worth a look, for example birthday_not_read
        print("  warning", row["mobile"], row["warning"])

Send to a group

def send_to_group():
    """One message to every contact of a group, by its name. The members are those at the moment of the request."""
    response = requests.post(
        f"{BASE}/v1/messages",
        headers=HEADERS,
        json={"sender": "MyShop", "group": "Clients", "text": "Your appointment is tomorrow", "client_ref": "reminders-2026-11-11"},
        timeout=15,
    )
    data = response.json()
    if response.status_code in (200, 201):
        print(data["id"], data["status"], "to", data["recipients"], "contacts, cost", data["cost"], "again" if data["duplicate"] else "new")
        return data["id"]
    print("Refused:", data["error"], "-", data["message"])

Send safely, with retries

def send_safely(sender, numbers, text, client_ref):
    """Retries only what can succeed on a retry. The same client_ref on every attempt means no second SMS and no second charge."""
    for attempt in range(5):
        try:
            response = requests.post(
                f"{BASE}/v1/messages", headers=HEADERS, timeout=15,
                json={"sender": sender, "to": numbers, "text": text, "client_ref": client_ref},
            )
        except requests.RequestException:            # timeout or no connection: we do not know if it arrived
            time.sleep(2 ** attempt)
            continue
        data = response.json()
        if response.status_code in (200, 201):
            return data                              # data["duplicate"] is True when an earlier attempt had arrived
        if response.status_code == 429:
            time.sleep(int(response.headers.get("Retry-After", 5)))
        elif response.status_code == 503:            # no provider for a moment: nothing was charged
            time.sleep(2 ** attempt)
        else:                                        # 400, 401, 402, 403 ...: the request itself must change
            raise RuntimeError(f'{data["error"]}: {data["message"]}')
    raise RuntimeError("The service could not be reached. Try again later with the same client_ref.")

PHP 8 with the curl extension (standard on most servers). Run with php sms.php.

Set up

<?php
const BASE = 'https://api.isms.gr';
$key = getenv('SMS_API_KEY') ?: 'YOUR_KEY';          // keep the key in an environment variable, not in your code

/** One request. Returns [HTTP status, decoded JSON, Retry-After seconds or null]. Throws when there is no answer at all. */
function api(string $method, string $path, ?array $body = null): array
{
    global $key;
    $ch = curl_init(BASE . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Content-Type: application/json'],
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HEADER => true,
        CURLOPT_TIMEOUT => 15,
    ]);
    if ($body !== null) {
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
    }
    $raw = curl_exec($ch);
    if ($raw === false) {
        $message = curl_error($ch);
        curl_close($ch);
        throw new RuntimeException($message);
    }
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
    curl_close($ch);
    $retryAfter = null;
    foreach (explode("\r\n", substr($raw, 0, $headerSize)) as $line) {
        if (stripos($line, 'Retry-After:') === 0) {
            $retryAfter = (int) trim(substr($line, 12));
        }
    }
    return [$status, json_decode(substr($raw, $headerSize), true), $retryAfter];
}

Send an SMS

function sendSms(): ?string
{
    [$status, $data] = api('POST', '/v1/messages', [
        'sender' => 'MyShop',
        'to' => ['306900000000'],
        'text' => 'Your order is on its way',
        'client_ref' => 'order-1042',        // your own reference: makes a retry safe (see the last example)
    ]);
    if ($status === 200 || $status === 201) {
        echo $data['id'], ' ', $data['status'], ' cost ', $data['cost'], ' ', $data['duplicate'] ? 'again' : 'new', "\n";
        return $data['id'];
    }
    echo 'Refused: ', $data['error'], ' - ', $data['message'], "\n";
    return null;
}

Check one message

function messageStatus(string $id): void
{
    [, $data] = api('GET', '/v1/messages/' . rawurlencode($id));
    echo $data['status'], ' ', json_encode($data['counts']), "\n";   // for example {"queued":0,"sent":0,"delivered":1,...}
    foreach ($data['items'] as $row) {
        echo $row['mobile'], ' ', $row['status'], "\n";
    }
}

// or by your own reference (this answers with the summary, without the list of recipients):
// api('GET', '/v1/messages?client_ref=' . rawurlencode('order-1042'));

Read the delivery results

/** $save is YOUR function: store the result, keyed by message_id and mobile. Repeats are possible and harmless. */
function readReports(callable $save, int $rounds = 1): void
{
    $ack = null;
    for ($i = 0; $i < $rounds; $i++) {                // in a real program: while (true)
        $path = '/v1/reports?limit=500' . ($ack ? '&ack=' . rawurlencode($ack) : '');   // the ack confirms the previous batch
        [$status, $batch, $retryAfter] = api('GET', $path);
        if ($status === 429) {
            sleep($retryAfter ?? 10);
            continue;
        }
        foreach ($batch['reports'] as $report) {
            $save($report);                            // message_id, client_ref, mobile, status, reason, at
        }
        $ack = $batch['ack'];                          // null when nothing was new
        if (!$batch['reports']) {
            sleep($batch['next_poll_after']);
        }
    }
}

Read the balance

function balance(): void
{
    [, $data] = api('GET', '/v1/credits');
    echo $data['credits'], ' ', $data['currency'], "\n";
}

Send your contacts

function listFields(): void
{
    // The fields a contact can have and their type. The administrator can add more at any time, so ask instead of writing the list into your code.
    [, $data] = api('GET', '/v1/contact-fields');
    foreach ($data['fields'] as $item) {
        echo $item['name'], ' ', $item['type'], "\n";    // for example: nextAppointment date
    }
}

/** Up to 1,000 contacts a request. A number we do not have is created, one we have is updated; what you do not send stays as it was. */
function pushContacts(): void
{
    [$status, $data] = api('PUT', '/v1/contacts', [
        'contacts' => [[
            'mobile' => '306900000000',
            'name' => 'Maria',
            'surname' => 'P',
            'fields' => ['nextAppointment' => '2026-11-12T14:30:00Z'],   // an example: use the names from listFields()
            'groups' => ['Clients'],                                      // created when it does not exist yet
        ]],
        // 'replace_groups' => true,   // each group named here then holds exactly the contacts of this request
    ]);
    if ($status !== 200) {
        echo 'Refused: ', $data['error'], ' - ', $data['message'], "\n";
        return;
    }
    echo 'created ', $data['created'], ' updated ', $data['updated'], ' rejected ', count($data['rejected']), "\n";
    foreach ($data['rejected'] as $row) {              // a contact we cannot accept; the others were saved
        echo '  #', $row['index'], ' ', $row['error'], ': ', $row['message'], "\n";
    }
    foreach ($data['warnings'] as $row) {              // saved, but worth a look, for example birthday_not_read
        echo '  warning ', $row['mobile'], ' ', $row['warning'], "\n";
    }
}

Send to a group

/** One message to every contact of a group, by its name. The members are those at the moment of the request. */
function sendToGroup(): ?string
{
    [$status, $data] = api('POST', '/v1/messages', [
        'sender' => 'MyShop', 'group' => 'Clients', 'text' => 'Your appointment is tomorrow', 'client_ref' => 'reminders-2026-11-11',
    ]);
    if ($status === 200 || $status === 201) {
        echo $data['id'], ' ', $data['status'], ' to ', $data['recipients'], ' contacts, cost ', $data['cost'], ' ', $data['duplicate'] ? 'again' : 'new', "\n";
        return $data['id'];
    }
    echo 'Refused: ', $data['error'], ' - ', $data['message'], "\n";
    return null;
}

Send safely, with retries

/** Retries only what can succeed on a retry. The same client_ref on every attempt means no second SMS and no second charge. */
function sendSafely(string $sender, array $numbers, string $text, string $clientRef): array
{
    for ($attempt = 0; $attempt < 5; $attempt++) {
        try {
            [$status, $data, $retryAfter] = api('POST', '/v1/messages', [
                'sender' => $sender, 'to' => $numbers, 'text' => $text, 'client_ref' => $clientRef,
            ]);
        } catch (RuntimeException $e) {                // timeout or no connection: we do not know if it arrived
            sleep(2 ** $attempt);
            continue;
        }
        if ($status === 200 || $status === 201) {
            return $data;                              // $data['duplicate'] is true when an earlier attempt had arrived
        }
        if ($status === 429) {
            sleep($retryAfter ?? 5);
        } elseif ($status === 503) {                   // no provider for a moment: nothing was charged
            sleep(2 ** $attempt);
        } else {                                       // 400, 401, 402, 403 ...: the request itself must change
            throw new RuntimeException($data['error'] . ': ' . $data['message']);
        }
    }
    throw new RuntimeException('The service could not be reached. Try again later with the same client_ref.');
}

Node.js 18 or newer (it has fetch built in). Save as sms.mjs and run with node sms.mjs.

Set up

const BASE = "https://api.isms.gr";
const KEY = process.env.SMS_API_KEY || "YOUR_KEY";   // keep the key in an environment variable, not in your code
const HEADERS = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };
const sleep = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000));

Send an SMS

async function send() {
  const response = await fetch(`${BASE}/v1/messages`, {
    method: "POST",
    headers: HEADERS,
    body: JSON.stringify({
      sender: "MyShop",
      to: ["306900000000"],
      text: "Your order is on its way",
      client_ref: "order-1042",          // your own reference: makes a retry safe (see the last example)
    }),
    signal: AbortSignal.timeout(15000),
  });
  const data = await response.json();
  if (response.ok) {
    console.log(data.id, data.status, "cost", data.cost, data.duplicate ? "again" : "new");
    return data.id;
  }
  console.log("Refused:", data.error, "-", data.message);
}

Check one message

async function status(messageId) {
  const response = await fetch(`${BASE}/v1/messages/${encodeURIComponent(messageId)}`, { headers: HEADERS, signal: AbortSignal.timeout(15000) });
  const data = await response.json();
  console.log(data.status, data.counts);            // for example { queued: 0, sent: 0, delivered: 1, ... }
  for (const row of data.items) console.log(row.mobile, row.status);
}

// or by your own reference (this answers with the summary, without the list of recipients):
// fetch(`${BASE}/v1/messages?client_ref=${encodeURIComponent("order-1042")}`, { headers: HEADERS });

Read the delivery results

// save(report) is YOUR function: store the result, keyed by message_id and mobile. Repeats are possible and harmless.
async function readReports(save, rounds = 1) {
  let ack = null;
  for (let i = 0; i < rounds; i++) {                // in a real program: while (true)
    const url = `${BASE}/v1/reports?limit=500` + (ack ? `&ack=${encodeURIComponent(ack)}` : "");   // the ack confirms the previous batch
    const response = await fetch(url, { headers: HEADERS, signal: AbortSignal.timeout(15000) });
    if (response.status === 429) {
      await sleep(Number(response.headers.get("Retry-After") || 10));
      continue;
    }
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    const batch = await response.json();
    for (const report of batch.reports) await save(report);   // message_id, client_ref, mobile, status, reason, at
    ack = batch.ack;                                 // null when nothing was new
    if (batch.reports.length === 0) await sleep(batch.next_poll_after);
  }
}

Read the balance

async function balance() {
  const response = await fetch(`${BASE}/v1/credits`, { headers: HEADERS, signal: AbortSignal.timeout(15000) });
  const data = await response.json();
  console.log(data.credits, data.currency);
}

Send your contacts

// The fields a contact can have and their type. The administrator can add more at any time, so ask instead of writing the list into your code.
async function listFields() {
  const response = await fetch(`${BASE}/v1/contact-fields`, { headers: HEADERS, signal: AbortSignal.timeout(15000) });
  const data = await response.json();
  for (const item of data.fields) console.log(item.name, item.type);      // for example: nextAppointment date
}

// Up to 1,000 contacts a request. A number we do not have is created, one we have is updated; what you do not send stays as it was.
async function pushContacts() {
  const response = await fetch(`${BASE}/v1/contacts`, {
    method: "PUT",
    headers: HEADERS,
    body: JSON.stringify({
      contacts: [{
        mobile: "306900000000",
        name: "Maria",
        surname: "P",
        fields: { nextAppointment: "2026-11-12T14:30:00Z" },   // an example: use the names from listFields()
        groups: ["Clients"],                                    // created when it does not exist yet
      }],
      // replace_groups: true,   // each group named here then holds exactly the contacts of this request
    }),
    signal: AbortSignal.timeout(15000),
  });
  const data = await response.json();
  if (!response.ok) {
    console.log("Refused:", data.error, "-", data.message);
    return;
  }
  console.log("created", data.created, "updated", data.updated, "rejected", data.rejected.length);
  for (const row of data.rejected) console.log(`  #${row.index} ${row.error}: ${row.message}`);   // a contact we cannot accept; the others were saved
  for (const row of data.warnings) console.log("  warning", row.mobile, row.warning);              // saved, but worth a look, for example birthday_not_read
}

Send to a group

// One message to every contact of a group, by its name. The members are those at the moment of the request.
async function sendToGroup() {
  const response = await fetch(`${BASE}/v1/messages`, {
    method: "POST",
    headers: HEADERS,
    body: JSON.stringify({ sender: "MyShop", group: "Clients", text: "Your appointment is tomorrow", client_ref: "reminders-2026-11-11" }),
    signal: AbortSignal.timeout(15000),
  });
  const data = await response.json();
  if (response.ok) {
    console.log(data.id, data.status, "to", data.recipients, "contacts, cost", data.cost, data.duplicate ? "again" : "new");
    return data.id;
  }
  console.log("Refused:", data.error, "-", data.message);
}

Send safely, with retries

// Retries only what can succeed on a retry. The same client_ref on every attempt means no second SMS and no second charge.
async function sendSafely(sender, numbers, text, clientRef) {
  for (let attempt = 0; attempt < 5; attempt++) {
    let response;
    try {
      response = await fetch(`${BASE}/v1/messages`, {
        method: "POST",
        headers: HEADERS,
        body: JSON.stringify({ sender, to: numbers, text, client_ref: clientRef }),
        signal: AbortSignal.timeout(15000),
      });
    } catch (error) {                                // timeout or no connection: we do not know if it arrived
      await sleep(2 ** attempt);
      continue;
    }
    const data = await response.json();
    if (response.ok) return data;                    // data.duplicate is true when an earlier attempt had arrived
    if (response.status === 429) await sleep(Number(response.headers.get("Retry-After") || 5));
    else if (response.status === 503) await sleep(2 ** attempt);   // no provider for a moment: nothing was charged
    else throw new Error(`${data.error}: ${data.message}`);        // 400, 401, 402, 403 ...: the request itself must change
  }
  throw new Error("The service could not be reached. Try again later with the same client_ref.");
}

Java 11 or newer, no libraries. Save as Sms.java and run with java Sms.java. The small field() reader is only for these examples: use Jackson or Gson in your own code.

Set up

import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class Sms {
    static final String BASE = "https://api.isms.gr";
    static final String KEY = System.getenv().getOrDefault("SMS_API_KEY", "YOUR_KEY");   // keep the key in an environment variable
    static final HttpClient CLIENT = HttpClient.newHttpClient();

    /** One request. Returns the response (status code, headers, body as text). Throws when there is no answer at all. */
    static HttpResponse<String> call(String method, String path, String json) throws Exception {
        HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(BASE + path))
                .timeout(Duration.ofSeconds(15))
                .header("Authorization", "Bearer " + KEY)
                .header("Content-Type", "application/json");
        request = json == null
                ? request.method(method, HttpRequest.BodyPublishers.noBody())
                : request.method(method, HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8));
        return CLIENT.send(request.build(), HttpResponse.BodyHandlers.ofString());
    }

    /** Reads one simple field of a JSON answer (text, number, true/false or null). For these examples only: use Jackson or Gson in your own code. */
    static String field(String json, String name) {
        Matcher m = Pattern.compile("\"" + name + "\"\\s*:\\s*(\"((?:[^\"\\\\]|\\\\.)*)\"|[0-9.]+|true|false|null)").matcher(json);
        if (!m.find()) return null;
        if (m.group(2) != null) return m.group(2);
        return m.group(1).equals("null") ? null : m.group(1);
    }

    // The methods below go inside this class, before the last closing brace.

Send an SMS

    static String send() throws Exception {
        String body = "{\"sender\":\"MyShop\",\"to\":[\"306900000000\"],\"text\":\"Your order is on its way\",\"client_ref\":\"order-1042\"}";
        HttpResponse<String> response = call("POST", "/v1/messages", body);   // client_ref: your own reference, makes a retry safe
        if (response.statusCode() == 200 || response.statusCode() == 201) {
            System.out.println(field(response.body(), "id") + " " + field(response.body(), "status") + " cost " + field(response.body(), "cost")
                    + (field(response.body(), "duplicate").equals("true") ? " again" : " new"));
            return field(response.body(), "id");
        }
        System.out.println("Refused: " + field(response.body(), "error") + " - " + field(response.body(), "message"));
        return null;
    }

Check one message

    static void status(String messageId) throws Exception {
        HttpResponse<String> response = call("GET", "/v1/messages/" + URLEncoder.encode(messageId, StandardCharsets.UTF_8), null);
        System.out.println(response.body());     // status, counts per recipient state, and the recipients (mobile, status)
    }

    // or by your own reference (the summary, without the list of recipients): call("GET", "/v1/messages?client_ref=" + URLEncoder.encode("order-1042", StandardCharsets.UTF_8), null)

Read the delivery results

    interface Saver { void save(String reportJson); }

    /** save is YOUR code: store the result, keyed by message_id and mobile. Repeats are possible and harmless. */
    static void readReports(Saver saver, int rounds) throws Exception {
        String ack = null;
        for (int i = 0; i < rounds; i++) {                       // in a real program: while (true)
            String path = "/v1/reports?limit=500" + (ack != null ? "&ack=" + URLEncoder.encode(ack, StandardCharsets.UTF_8) : "");   // the ack confirms the previous batch
            HttpResponse<String> response = call("GET", path, null);
            if (response.statusCode() == 429) {
                Thread.sleep(Long.parseLong(response.headers().firstValue("Retry-After").orElse("10")) * 1000);
                continue;
            }
            if (response.statusCode() != 200) throw new RuntimeException("HTTP " + response.statusCode());
            String body = response.body();
            Matcher report = Pattern.compile("\\{\"message_id\":[^}]*\\}").matcher(body);   // one report: message_id, client_ref, mobile, status, reason, at
            boolean any = false;
            while (report.find()) { saver.save(report.group()); any = true; }
            ack = field(body, "ack");                            // null when nothing was new
            if (!any) Thread.sleep(Long.parseLong(field(body, "next_poll_after")) * 1000);
        }
    }

Read the balance

    static void balance() throws Exception {
        HttpResponse<String> response = call("GET", "/v1/credits", null);
        System.out.println(field(response.body(), "credits") + " " + field(response.body(), "currency"));
    }

Send your contacts

    static void listFields() throws Exception {
        // The fields a contact can have and their type. The administrator can add more at any time, so ask instead of writing the list into your code.
        System.out.println(call("GET", "/v1/contact-fields", null).body());     // {"fields":[{"name":"nextAppointment","type":"date"}, ...]}
    }

    /** Up to 1,000 contacts a request. A number we do not have is created, one we have is updated; what you do not send stays as it was. */
    static void pushContacts() throws Exception {
        String body = "{\"contacts\":[{\"mobile\":\"306900000000\",\"name\":\"Maria\",\"surname\":\"P\","
                + "\"fields\":{\"nextAppointment\":\"2026-11-12T14:30:00Z\"},"      // an example: use the names from listFields()
                + "\"groups\":[\"Clients\"]}]}";                                     // a group is created when it does not exist yet
        HttpResponse<String> response = call("PUT", "/v1/contacts", body);           // add "replace_groups":true to make each group named here hold exactly these contacts
        String answer = response.body();
        if (response.statusCode() != 200) {
            System.out.println("Refused: " + field(answer, "error") + " - " + field(answer, "message"));
            return;
        }
        System.out.println("created " + field(answer, "created") + " updated " + field(answer, "updated") + " rejected " + (answer.contains("\"rejected\":[]") ? "0" : "some, see below"));
        if (!answer.contains("\"rejected\":[]")) System.out.println(answer);       // a contact we cannot accept is listed with index, error and message; the others were saved
    }

Send to a group

    /** One message to every contact of a group, by its name. The members are those at the moment of the request. */
    static String sendToGroup() throws Exception {
        String body = "{\"sender\":\"MyShop\",\"group\":\"Clients\",\"text\":\"Your appointment is tomorrow\",\"client_ref\":\"reminders-2026-11-11\"}";
        HttpResponse<String> response = call("POST", "/v1/messages", body);
        String answer = response.body();
        if (response.statusCode() == 200 || response.statusCode() == 201) {
            System.out.println(field(answer, "id") + " " + field(answer, "status") + " to " + field(answer, "recipients") + " contacts, cost " + field(answer, "cost")
                    + (field(answer, "duplicate").equals("true") ? " again" : " new"));
            return field(answer, "id");
        }
        System.out.println("Refused: " + field(answer, "error") + " - " + field(answer, "message"));
        return null;
    }

Send safely, with retries

    /** Retries only what can succeed on a retry. The same client_ref on every attempt means no second SMS and no second charge. */
    static String sendSafely(String sender, String number, String text, String clientRef) throws Exception {
        String body = "{\"sender\":\"" + sender + "\",\"to\":[\"" + number + "\"],\"text\":\"" + text.replace("\\", "\\\\").replace("\"", "\\\"")
                + "\",\"client_ref\":\"" + clientRef + "\"}";
        for (int attempt = 0; attempt < 5; attempt++) {
            HttpResponse<String> response;
            try {
                response = call("POST", "/v1/messages", body);
            } catch (java.io.IOException e) {                    // timeout or no connection: we do not know if it arrived
                Thread.sleep((1L << attempt) * 1000);
                continue;
            }
            int status = response.statusCode();
            if (status == 200 || status == 201) return response.body();   // "duplicate": true when an earlier attempt had arrived
            if (status == 429) Thread.sleep(Long.parseLong(response.headers().firstValue("Retry-After").orElse("5")) * 1000);
            else if (status == 503) Thread.sleep((1L << attempt) * 1000);   // no provider for a moment: nothing was charged
            else throw new RuntimeException(field(response.body(), "error") + ": " + field(response.body(), "message"));   // 400, 401, 402, 403 ...: the request itself must change
        }
        throw new RuntimeException("The service could not be reached. Try again later with the same client_ref.");
    }

Go 1.20 or newer, standard library only. Save as main.go and run with go run main.go.

Set up

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"strconv"
	"time"
)

const base = "https://api.isms.gr"

var key = envOr("SMS_API_KEY", "YOUR_KEY") // keep the key in an environment variable, not in your code
var client = &http.Client{Timeout: 15 * time.Second}

func envOr(name, fallback string) string {
	if value := os.Getenv(name); value != "" {
		return value
	}
	return fallback
}

// call makes one request. It returns the HTTP status, the decoded JSON answer and the Retry-After header in seconds (0 when absent).
// An error means there was no answer at all.
func call(method, path string, body any) (int, map[string]any, int, error) {
	var reader io.Reader
	if body != nil {
		raw, err := json.Marshal(body)
		if err != nil {
			return 0, nil, 0, err
		}
		reader = bytes.NewReader(raw)
	}
	request, err := http.NewRequest(method, base+path, reader)
	if err != nil {
		return 0, nil, 0, err
	}
	request.Header.Set("Authorization", "Bearer "+key)
	request.Header.Set("Content-Type", "application/json")
	response, err := client.Do(request)
	if err != nil {
		return 0, nil, 0, err
	}
	defer response.Body.Close()
	var data map[string]any
	_ = json.NewDecoder(response.Body).Decode(&data)
	wait, _ := strconv.Atoi(response.Header.Get("Retry-After"))
	return response.StatusCode, data, wait, nil
}

Send an SMS

func send() (string, error) {
	status, data, _, err := call("POST", "/v1/messages", map[string]any{
		"sender":     "MyShop",
		"to":         []string{"306900000000"},
		"text":       "Your order is on its way",
		"client_ref": "order-1042", // your own reference: makes a retry safe (see the last example)
	})
	if err != nil {
		return "", err
	}
	if status == 200 || status == 201 {
		again := "new"
		if data["duplicate"] == true {
			again = "again"
		}
		fmt.Println(data["id"], data["status"], "cost", data["cost"], again)
		return data["id"].(string), nil
	}
	fmt.Println("Refused:", data["error"], "-", data["message"])
	return "", nil
}

Check one message

func messageStatus(id string) error {
	_, data, _, err := call("GET", "/v1/messages/"+url.PathEscape(id), nil)
	if err != nil {
		return err
	}
	fmt.Println(data["status"], data["counts"]) // for example map[queued:0 sent:0 delivered:1 ...]
	for _, row := range data["items"].([]any) {
		item := row.(map[string]any)
		fmt.Println(item["mobile"], item["status"])
	}
	return nil
}

// or by your own reference (the summary, without the list of recipients): call("GET", "/v1/messages?client_ref="+url.QueryEscape("order-1042"), nil)

Read the delivery results

// save is YOUR function: store the result, keyed by message_id and mobile. Repeats are possible and harmless.
func readReports(save func(report map[string]any), rounds int) error {
	ack := ""
	for i := 0; i < rounds; i++ { // in a real program: for {
		path := "/v1/reports?limit=500"
		if ack != "" {
			path += "&ack=" + url.QueryEscape(ack) // confirms the previous batch and asks for the next
		}
		status, batch, wait, err := call("GET", path, nil)
		if err != nil {
			return err
		}
		if status == 429 {
			if wait == 0 {
				wait = 10
			}
			time.Sleep(time.Duration(wait) * time.Second)
			continue
		}
		if status != 200 {
			return fmt.Errorf("HTTP %d", status)
		}
		reports, _ := batch["reports"].([]any)
		for _, item := range reports {
			save(item.(map[string]any)) // message_id, client_ref, mobile, status, reason, at
		}
		ack, _ = batch["ack"].(string) // empty when nothing was new
		if len(reports) == 0 {
			time.Sleep(time.Duration(batch["next_poll_after"].(float64)) * time.Second)
		}
	}
	return nil
}

Read the balance

func balance() error {
	_, data, _, err := call("GET", "/v1/credits", nil)
	if err != nil {
		return err
	}
	fmt.Println(data["credits"], data["currency"])
	return nil
}

Send your contacts

// listFields prints the fields a contact can have and their type. The administrator can add more at any time, so ask instead of writing the list into your code.
func listFields() error {
	_, data, _, err := call("GET", "/v1/contact-fields", nil)
	if err != nil {
		return err
	}
	for _, entry := range data["fields"].([]any) {
		item := entry.(map[string]any)
		fmt.Println(item["name"], item["type"]) // for example: nextAppointment date
	}
	return nil
}

// pushContacts sends up to 1,000 contacts a request. A number we do not have is created, one we have is updated; what you do not send stays as it was.
func pushContacts() error {
	status, data, _, err := call("PUT", "/v1/contacts", map[string]any{
		"contacts": []map[string]any{{
			"mobile":  "306900000000",
			"name":    "Maria",
			"surname": "P",
			"fields":  map[string]any{"nextAppointment": "2026-11-12T14:30:00Z"}, // an example: use the names from listFields()
			"groups":  []string{"Clients"},                                       // created when it does not exist yet
		}},
		// "replace_groups": true, // each group named here then holds exactly the contacts of this request
	})
	if err != nil {
		return err
	}
	if status != 200 {
		fmt.Println("Refused:", data["error"], "-", data["message"])
		return nil
	}
	rejected, _ := data["rejected"].([]any)
	fmt.Println("created", data["created"], "updated", data["updated"], "rejected", len(rejected))
	for _, entry := range rejected { // a contact we cannot accept; the others were saved
		row := entry.(map[string]any)
		fmt.Printf("  #%v %v: %v\n", row["index"], row["error"], row["message"])
	}
	warnings, _ := data["warnings"].([]any)
	for _, entry := range warnings { // saved, but worth a look, for example birthday_not_read
		row := entry.(map[string]any)
		fmt.Println("  warning", row["mobile"], row["warning"])
	}
	return nil
}

Send to a group

// sendToGroup sends one message to every contact of a group, by its name. The members are those at the moment of the request.
func sendToGroup() (string, error) {
	status, data, _, err := call("POST", "/v1/messages", map[string]any{
		"sender": "MyShop", "group": "Clients", "text": "Your appointment is tomorrow", "client_ref": "reminders-2026-11-11",
	})
	if err != nil {
		return "", err
	}
	if status == 200 || status == 201 {
		again := "new"
		if data["duplicate"] == true {
			again = "again"
		}
		fmt.Println(data["id"], data["status"], "to", data["recipients"], "contacts, cost", data["cost"], again)
		return data["id"].(string), nil
	}
	fmt.Println("Refused:", data["error"], "-", data["message"])
	return "", nil
}

Send safely, with retries

// sendSafely retries only what can succeed on a retry. The same client_ref on every attempt means no second SMS and no second charge.
func sendSafely(sender string, numbers []string, text, clientRef string) (map[string]any, error) {
	for attempt := 0; attempt < 5; attempt++ {
		status, data, wait, err := call("POST", "/v1/messages", map[string]any{
			"sender": sender, "to": numbers, "text": text, "client_ref": clientRef,
		})
		if err != nil { // timeout or no connection: we do not know if it arrived
			time.Sleep(time.Duration(1<<attempt) * time.Second)
			continue
		}
		switch {
		case status == 200 || status == 201:
			return data, nil // data["duplicate"] is true when an earlier attempt had arrived
		case status == 429:
			if wait == 0 {
				wait = 5
			}
			time.Sleep(time.Duration(wait) * time.Second)
		case status == 503: // no provider for a moment: nothing was charged
			time.Sleep(time.Duration(1<<attempt) * time.Second)
		default: // 400, 401, 402, 403 ...: the request itself must change
			return nil, fmt.Errorf("%v: %v", data["error"], data["message"])
		}
	}
	return nil, fmt.Errorf("the service could not be reached; try again later with the same client_ref")
}

Authentication

Create a key in the panel (API page). It is shown once and cannot be recovered, so store it safely. Send it in every request:

Authorization: Bearer kn_xxxxxxxxxxxxxxxxxxxxxxxx

A key has four rights: send (send SMS and read the balance), read (read message status and delivery reports), blacklist (see and change your blacklist; off by default) and contacts (send us your contacts and manage your groups; off by default). Give each system only what it needs. You can restrict a key to a list of IP addresses, and revoke it at any time. Every request must use HTTPS.

You must sign in to your account to create a key. If you use the API, it is your responsibility to check the Terms of use from time to time and to accept them when they change.

Send: POST /v1/messages

curl -X POST https://api.isms.gr/v1/messages \
  -H "Authorization: Bearer YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"sender": "MyShop", "to": ["306900000000"], "text": "Your order is on its way", "client_ref": "order-1042"}'
FieldRequiredMeaning
senderyesUp to 11 Latin letters, digits or dots (it starts and ends with a letter or digit, for example iSMS.gr), or up to 15 digits. It must be a sender approved on your account.
groupinstead of toThe name of one of your groups (capitals do not matter). The message goes to the contacts in the group at the moment of the request. See Groups. Give to or group, not both.
toyes, or groupList of mobile numbers, each with its country code (for example 306900000000; a leading + or 00 is accepted). A number without its country code, or one that is not a valid mobile number, is refused with invalid_number and the whole request is rejected: nothing is sent or charged. A single text is accepted for one number. Up to 1,000 numbers per request.
textyesThe message. Stored exactly as sent.
unicodenotrue to send as Unicode (70 characters per part, then 67). Default false: GSM (160, then 153).
schedule_atnoISO 8601 time, for example 2026-10-11T19:30:00Z.
client_refnoYour own reference, 1 to 64 characters, unique per account. See below.
{
  "id": "KN-7K3M9-Q2XPD",
  "client_ref": "order-1042",
  "status": "queued",
  "created_at": "2026-10-12T08:20:30Z",
  "scheduled_at": null,
  "cost": "0.03572",
  "recipients": 1,
  "counts": {"queued": 1, "sent": 0, "delivered": 0, "failed": 0, "unknown": 0, "blocked": 0},
  "duplicate": false
}

id is the reference of the message: 10 random characters, written KN-XXXXX-XXXXX. It is the same reference the customer sees in the panel. It does not show how many messages exist. Keep it as text; capital letters and hyphens may be left out when you send it back.

The first time you get 201. The price is charged when the message is accepted, to the account balance.

Retries are safe with client_ref. If a request times out and you do not know whether it arrived, send it again with the same client_ref. No second SMS is sent or charged: you get 200 with the first message and "duplicate": true. A client_ref is remembered permanently.

Message status

curl https://api.isms.gr/v1/messages/KN-7K3M9-Q2XPD \
  -H "Authorization: Bearer YOUR_KEY"

curl "https://api.isms.gr/v1/messages?client_ref=order-1042" \
  -H "Authorization: Bearer YOUR_KEY"

Returns the same summary as sending, with page, pages and items (mobile and status for each recipient, 1,000 per page, ?page=2 for the next). The same message can be asked about at most 10 times a minute. For many messages use the delivery reports below instead of asking one by one.

Recipient statuses:

StatusMeaning
queuedAccepted, not handed to the operator yet.
sentHanded to the operator, or possibly handed (we did not get a clear answer), and no final result yet. Do not send it again: it may already have been delivered.
deliveredFinal. The handset received it.
failedFinal. It was not delivered. See reason in the delivery report.
blockedFinal. The number was on your blacklist when the message was due. It was not sent and is still charged.
unknownFinal. No result arrived within 72 hours. The message may or may not have been delivered. It is still charged.

Delivery reports: GET /v1/reports

The recommended way to learn the result of your messages. You receive each final result once, in small batches, and you do not need to track anything yourself. It covers messages sent through the API. Needs the read right.

curl "https://api.isms.gr/v1/reports?limit=500" \
  -H "Authorization: Bearer YOUR_KEY"
{
  "reports": [
    {"message_id": "KN-7K3M9-Q2XPD", "client_ref": "order-1042", "mobile": "306900000000",
     "status": "delivered", "reason": null, "at": "2026-10-12T08:20:36Z"}
  ],
  "count": 1,
  "ack": "tVEgYU10LopOB6biUNbftQ",
  "next_poll_after": 5
}
  1. Process the batch, then call again with ?ack=<the ack you received>. This confirms the batch and returns the next one.
  2. If you do not send the ack (your system crashed before saving), you get the same batch again. Nothing is lost. Handle repeats by message_id and mobile.
  3. When nothing is new you get an empty list and ack: null. Wait next_poll_after seconds before asking again. It grows from 10 up to 60 seconds while nothing arrives, and returns to 5 when there are reports.

status is delivered, failed, unknown or blocked (reason is then blocked too). For failed, reason is one of:

ReasonMeaning
invalid_numberThe number does not exist or is malformed.
unreachableThe handset was off or out of coverage until the message expired.
rejectedThe operator refused the message.
blockedBlocked by the operator or the recipient (a number on your own blacklist gets the status blocked instead).
expiredThe message expired before delivery.
otherAny other failure.

limit is 500 by default and at most 1,000. Reports you have confirmed are kept for 30 days, then removed.

Contacts: /v1/contacts

Keep your contacts in step with your own application: it sends them to us, and we keep a copy in your account. One direction: your application is the source. A contact is known by its mobile number only. Needs the contacts right.

curl -X PUT https://api.isms.gr/v1/contacts \
  -H "Authorization: Bearer YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"contacts": [{"mobile": "306900000000", "name": "Maria", "surname": "P",
        "fields": {"nextAppointment": "2026-11-12T14:30:00Z"}, "groups": ["Clients"]}]}'

{"created": 1, "updated": 0, "rejected": [], "warnings": [], "groups_created": 1, "removed_from_groups": 0, "blacklisted": 0}

Fields: GET /v1/contact-fields

The fields a contact can have are set by the service and can change. Ask for the list instead of writing it into your code:

{"fields": [{"name": "birthday", "type": "text"}, {"name": "nextAppointment", "type": "date"}, {"name": "balance", "type": "number"}, {"name": "vip", "type": "yesno"}]}
TypeWhat we accept
textAny text up to 500 characters (a number sent as a JSON number is stored as its text).
dateISO 8601: 2026-11-12, or with a time 2026-11-12T14:30:00Z or 2026-11-12T14:30:00+02:00. Nothing else (not 12/11/2026).
numberA JSON number or text such as 12.5: a dot, no thousands separator, up to 15 digits.
yesnotrue or false (also "yes", "no"). Stored as yes or no.

Field names are not case sensitive. A name that is not in the list gives unknown_field for that contact. birthday is a text field that we read as dd/mm/yyyy, dd/mm (no year) or yyyy-mm-dd. Values are used in your messages as {fieldName}.

Groups: /v1/groups and sending to a group

A group is a name you give to a set of contacts, so that you can send one message to all of them instead of listing the numbers. A group name is unique in your account, whatever the capitals. Needs the contacts right (sending to a group needs only the send right).

Keeping a group equal to your own list. Add "replace_groups": true to a contacts request: each group named in the request then holds exactly the contacts listed with it, and the others leave the group (they stay as contacts, and a group you do not name is not touched). The whole list must be in that one request and valid; if one contact is rejected, the whole request is refused with invalid_contacts and nothing is changed. For a list longer than 1,000: empty the group with DELETE /v1/groups/members, then send the list in requests of up to 1,000.

curl -X POST https://api.isms.gr/v1/messages \
  -H "Authorization: Bearer YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"sender": "MyShop", "group": "Clients", "text": "Your appointment is tomorrow", "client_ref": "reminders-2026-11-11"}'

The answer is the same as for any send, and the same rules apply: the blacklist, the price, the balance, schedule_at, unicode and client_ref (a repeat gives the first message with "duplicate": true). The recipients are the contacts in the group at the moment of the request; a contact added later does not receive it. Compare recipients with the size of the group to see how many were left out. A group of more than 20,000 contacts is not sent through the API (group_too_large): send it from the panel or split it. An empty group is refused (empty_group), and an unknown name gives 404 group_not_found. In every refusal nothing is sent or charged.

Blacklist: /v1/blacklist

Numbers on your blacklist are never sent to and never charged. Use these calls to keep it in step with your own system, for example when a customer replies STOP. Needs the blacklist right. Numbers need their country code, as in a send.

curl -X POST https://api.isms.gr/v1/blacklist \
  -H "Authorization: Bearer YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"to": ["306900000000", "306900000001"]}'

{"added": 2, "already_listed": 0, "with_scheduled_messages": 0}

When you send, a number on the blacklist is left out and not charged, and the answer does not list it: compare recipients with the number of different numbers you sent. If every number is on the blacklist the request is refused with 400 invalid_request ("Recipients are on the blacklist") and nothing is charged. A message scheduled for later is checked again when it is due.

Balance: GET /v1/credits

curl https://api.isms.gr/v1/credits \
  -H "Authorization: Bearer YOUR_KEY"

{"credits": "12.34000", "currency": "EUR"}

Limits

Errors

Every error is JSON with a machine-readable error and a readable message:

{"error": "insufficient_balance", "message": "..."}
HTTPerrorMeaning
400invalid_json, invalid_request, invalid_sender, invalid_number, too_many_recipients, blocked, empty_group, group_too_large, too_many_contacts, invalid_contacts, invalid_group, contact_limit, group_limitThe request is wrong. Fix it before retrying. blocked means the text was refused by the spam filter.
401unauthorized, invalid_keyMissing, unknown or revoked key.
402insufficient_balanceNot enough balance. Nothing was sent or charged.
403forbidden, account_inactive, ip_not_allowedThe key lacks the right, the account is not active, or the request came from an address that is not allowed.
404not_found, group_not_foundNo such message, contact or group.
409conflictAnother request changed the same contacts or groups at the same moment. Send it again.
413too_largeBody over 1 MB.
429rate_limited, too_soon, too_many_requests_for_message, lockedSlow down. Wait for Retry-After seconds.
503no_providerTemporarily unable to send. Nothing was charged. Retry later with the same client_ref.