Перейти к содержимому
PanelCompare

Техническое

Интеграция с API SMM-панели: рабочий разбор

Обновлено . Автор: Редакция PanelCompare. 5 мин чтения

Что нужно до того, как писать код?

Аккаунт на панели, пополненный баланс и API-ключ из личного кабинета панели. Ни песочницы, ни тестового ключа, ни тестовой среды на этом рынке нет нигде, поэтому первый успешный заказ при разработке — настоящий заказ, который стоит настоящих денег и действительно доставляется на ту ссылку, которую вы в нём указали. Используйте свою ссылку, которой вам не жалко.

Учтите и размер каталога. Реальные каталоги насчитывают примерно 3000–8000 позиций, а действие services возвращает их все одним ответом без разбивки на страницы, поэтому это дорогой вызов, место которому в расписании, а не в обработке каждого запроса. Две панели, синхронизированные с индексом PanelCompare, возвращают 5558 и 2196 привязанных позиций соответственно (индекс цен PanelCompare, 10 сентября 2026 г.).

Как убедиться, что эндпоинт существует, до аутентификации?

Отправьте действие services вообще без ключа. Настоящий эндпоинт API v2 ответит структурированной ошибкой — это доказывает, что он существует. Ответ 404 или HTML-страница опровергают заявление об API, которое почти каждая панель делает на главной. Так панель можно проверить, ни разу не получив от неё учётных данных.

Проверка эндпоинта через curl, ключ не нужен
# A real endpoint answers with JSON, even without a key.
curl -s -X POST https://example-panel.com/api/v2 \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "action=services"

# Endpoint present:
# {"error":"Invalid API key"}

# Endpoint absent (HTTP 404, or an HTML login page):
# <!DOCTYPE html> ...

# Panels vary on the path. Try these three, in order:
#   /api/v2      the convention
#   /api/v2.php  older builds
#   /api         a minority

Иногда вызов без ключа возвращает не ошибку, а массив. Это не баг, а открытый каталог: панель публикует весь список услуг для всех. Такое встречается достаточно редко, чтобы записывать каждый найденный случай.

Какие четыре особенности ломают первую интеграцию?

Сбои на уровне спецификации, а не отдельной панели
ОсобенностьЧто делает наивный кодЧто делает правильный код
Ошибки возвращаются с кодом HTTP 200Проверяет res.ok, видит true и принимает объект ошибки за результатСначала разбирает тело и первым делом проверяет ключ error
Некоторые панели возвращают 4xx с ошибкой в JSONПо одному только статусу переходит к следующему адресу и сообщает о неверном ключе как об отсутствующем APIРазбирает и тело ответа 4xx; тело с ключом error означает рабочий эндпоинт
Числа приходят строкамиСравнивает rate, min, max или remains как числа и незаметно ошибаетсяЯвно преобразует каждое числовое поле на границе разбора
Статус нескольких заказов — параметр во множественном числе, а не действиеВызывает status по одному разу на заказ и упирается в лимит запросовОтправляет до 100 номеров через запятую в параметре orders того же действия status

Источник: Поведение сверено с justanotherpanel.com/api и реализовано в клиенте синхронизации PanelCompare (src/lib/sync/panel-api.ts), 11 сентября 2026 г.

Случай с 401 обходится вам в тикет поддержки

JustAnotherPanel отвечает на неверный ключ кодом HTTP 401 и JSON-телом вида {"error":"Invalid API key"} — это структурированный ответ API, у которого просто статус ошибки. Клиент, который считает любой 4xx признаком «здесь нет API», скажет владельцу панели, что у его панели нет эндпоинта, хотя настоящая проблема — в ключе. Разбирайте тело, прежде чем делать вывод по статусу.

Как выглядит правильный клиент на Node?

Минимальный клиент API v2 с нужной обработкой ошибок (Node 18+, без зависимостей)
const PATHS = ["/api/v2", "/api/v2.php", "/api"];

async function call(domain, key, params, path = PATHS[0]) {
  const res = await fetch("https://" + domain + path, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({ key, ...params }).toString(),
  });

  const text = await res.text();
  let body;
  try {
    body = JSON.parse(text);
  } catch {
    // HTML here usually means the login page: the path is wrong, not the key.
    throw new Error("Not JSON at " + path + " (HTTP " + res.status + ")");
  }

  // Errors arrive as HTTP 200 with an error field, and sometimes as 4xx with
  // the same body. Either way the body is what decides.
  if (body && typeof body === "object" && "error" in body) {
    throw new Error("API error: " + body.error);
  }
  if (res.status >= 400) throw new Error("HTTP " + res.status);

  return body;
}

const num = (v) => (v === null || v === undefined ? null : Number(v));

async function services(domain, key) {
  const rows = await call(domain, key, { action: "services" });
  // Every numeric field is a string on the wire. Coerce at the boundary.
  return rows.map((r) => ({
    id: String(r.service),
    name: r.name,
    type: r.type,
    rate: num(r.rate),
    min: num(r.min),
    max: num(r.max),
    refill: Boolean(r.refill),
    cancel: Boolean(r.cancel),
  }));
}

async function addOrder(domain, key, { service, link, quantity, runs, interval }) {
  const params = { action: "add", service: String(service), link };
  if (quantity != null) params.quantity = String(quantity);
  // Drip-feed: quantity is PER RUN. Total delivered and charged is quantity x runs.
  if (runs != null) params.runs = String(runs);
  if (interval != null) params.interval = String(interval);
  const body = await call(domain, key, params);
  return String(body.order);
}

async function statusMany(domain, key, orderIds) {
  const out = {};
  // The plural parameter is capped at 100 ids. There is no multi_status action.
  for (let i = 0; i < orderIds.length; i += 100) {
    const chunk = orderIds.slice(i, i + 100);
    const body = await call(domain, key, { action: "status", orders: chunk.join(",") });
    for (const [id, row] of Object.entries(body)) {
      out[id] = row && row.error
        ? { error: row.error }
        : {
            status: row.status,
            charge: num(row.charge),
            startCount: num(row.start_count),
            remains: num(row.remains),
            currency: row.currency,
          };
    }
  }
  return out;
}

Весь смысл — в трёх деталях этого кода. Тело разбирается раньше, чем проверяется статус. Ветка для 4xx идёт после проверки ключа error, а не до неё. И каждое числовое поле проходит явное преобразование, потому что rate, min, max, charge, start_count и remains приходят строками в кавычках.

Как тот же клиент выглядит на Python и PHP?

Python, с библиотекой requests
import requests

PATHS = ["/api/v2", "/api/v2.php", "/api"]


class PanelError(Exception):
    pass


def call(domain, key, params, path=PATHS[0], timeout=30):
    res = requests.post(
        "https://" + domain + path,
        data={"key": key, **params},
        headers={"Content-Type": "application/x-www-form-urlencoded"},
        timeout=timeout,
    )

    try:
        body = res.json()
    except ValueError:
        raise PanelError("Not JSON at %s (HTTP %s)" % (path, res.status_code))

    # 200 with an error field is the normal failure shape. Some panels use 401
    # with the same body, so the body is checked before the status.
    if isinstance(body, dict) and "error" in body:
        raise PanelError(body["error"])
    if res.status_code >= 400:
        raise PanelError("HTTP %s" % res.status_code)

    return body


def services(domain, key):
    rows = call(domain, key, {"action": "services"})
    return [
        {
            "id": str(r["service"]),
            "name": r["name"],
            "rate": float(r["rate"]),
            "min": int(r["min"]),
            "max": int(r["max"]),
            "refill": bool(r.get("refill")),
        }
        for r in rows
    ]


def status_many(domain, key, order_ids):
    out = {}
    for i in range(0, len(order_ids), 100):  # the plural parameter caps at 100
        chunk = ",".join(str(o) for o in order_ids[i : i + 100])
        out.update(call(domain, key, {"action": "status", "orders": chunk}))
    return out
PHP, через curl
<?php
function panel_call(string $domain, string $key, array $params, string $path = "/api/v2") {
    $ch = curl_init("https://" . $domain . $path);
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => http_build_query(array_merge(["key" => $key], $params)),
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 30,
    ]);

    $text = curl_exec($ch);
    $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    $body = json_decode($text, true);
    if ($body === null) {
        throw new RuntimeException("Not JSON at {$path} (HTTP {$code})");
    }
    // Body before status: errors come back as 200, and sometimes as 401.
    if (is_array($body) && isset($body["error"])) {
        throw new RuntimeException("API error: " . $body["error"]);
    }
    if ($code >= 400) {
        throw new RuntimeException("HTTP {$code}");
    }
    return $body;
}

// Placing an order. Numeric fields come back as strings; cast what you compare.
$order = panel_call($domain, $key, [
    "action"   => "add",
    "service"  => "1",
    "link"     => "https://example.com/p/abc",
    "quantity" => "1000",
]);
$orderId = (string) $order["order"];

Как узнать, какие параметры нужны заказу?

Из поля type у позиции каталога — это единственное место, где выражено требование. Любому вызову add нужны service и link; что ещё нужно, угадать нельзя, а неверный набор параметров даёт ошибку, а не разумное значение по умолчанию. Прочитайте type, прежде чем строить форму.

Выбор параметров по типу позиции
function paramsForType(row, input) {
  switch (row.type) {
    case "Default":
    case "Drip-feed":
      // runs and interval are optional; quantity is PER RUN when runs is set.
      return { quantity: input.quantity, runs: input.runs, interval: input.interval };

    case "Custom Comments":
    case "Custom Comments Package":
      return { comments: input.comments.join("\n") };

    case "Mentions User Followers":
      return { quantity: input.quantity, username: input.username };

    case "Mentions Hashtag":
      return { quantity: input.quantity, hashtag: input.hashtag };

    case "Mentions Media Likers":
      return { quantity: input.quantity, media: input.mediaUrl };

    case "Poll":
      return { quantity: input.quantity, answer_number: input.answerNumber };

    case "Subscriptions":
      return {
        username: input.username,
        min: input.min,
        max: input.max,
        posts: input.posts,
        delay: input.delay,
        expiry: input.expiry,
      };

    case "Package":
      return {}; // link only; the quantity is fixed by the package

    default:
      throw new Error("Unhandled order type: " + row.type);
  }
}

Ветка default важнее, чем кажется. Панели добавляют новые типы заказов, и клиент, который молча откатывается к форме «только количество», будет отправлять некорректные заказы, которые сорвутся уже после списания с баланса. Выбросить ошибку на неизвестном типе — дешёвая защита от этого бага.

Как правильно опрашивать статус заказов?

  1. 1.Опрашивайте пачками через параметр orders, по 100 номеров за раз. В канонической спецификации нет действия multi_status, а один запрос на заказ — верный способ упереться в лимит запросов.
  2. 2.Обрабатывайте «Частично выполнен» (Partial) как полноценный исход, а не ошибку. Вместе с ним приходят остаток и автоматический возврат на баланс, и это самый частый неокончательный результат на этом рынке.
  3. 3.Опрашивайте с периодичностью, соразмерной заявленному интервалу времени старта, а не с фиксированным коротким интервалом. Интервал старта публикует большинство позиций, скорость — почти ни одна.
  4. 4.Храните идентификатор панели рядом с каждым номером заказа. Номера заказов уникальны внутри панели и больше нигде.
  5. 5.Синхронизируйте каталог по расписанию. Номера услуг локальны для панели и могут меняться, поэтому сохранённый номер может незаметно начать указывать на другой запас.
  6. 6.Никогда не записывайте тело запроса в логи. В нём ключ, а срока действия, который бы вас спас, у него нет.

Словарь статусов небольшой и стабильный: Pending («В ожидании»), In progress («Выполняется») или Processing («В обработке»), Completed («Выполнен»), Partial («Частично выполнен»), Canceled («Отменён»). Всё, что выходит за этот набор, нужно показывать, а не приводить к ближайшему известному значению: панель, вернувшая неожиданный статус, обычно сообщает что-то, что такое приведение скрыло бы.

Какие ограничения безопасности накладывает спецификация?

Ключ передаётся в теле запроса без подписи, без nonce и без метки времени, а срок действия спецификация не определяет. Поэтому это долгоживущий секрет: любой, у кого он есть, может потратить баланс и прочитать все ссылки, на которые с этого аккаунта делали заказы. Нет ни защиты от повторного использования, которая ограничила бы ущерб, ни отзыва — кроме замены ключа в личном кабинете.

  • Никогда не принимайте ключ из браузера и никогда не пропускайте его через клиентский код.
  • Никогда не записывайте тело запроса в логи и проверьте, не делает ли это за вас ваша HTTP-библиотека.
  • Не храните ключи в переменных окружения, которые попадают в логи сборки, и в базе данных.
  • Меняйте ключ после каждого изменения в сторонних интеграциях и после любой смены сотрудников.
  • Считайте ключ скомпрометированным с того момента, как он попал на скриншот, в тикет или в общий документ.

Быстрые ответы

Почему API SMM-панели возвращает 200 при ошибках?

Потому что спецификация не использует коды статуса HTTP по назначению. Ошибки обычно приходят с кодом HTTP 200 и объектом ошибки в теле, а некоторые панели отвечают на неверный ключ кодом 401 с тем же JSON, поэтому любой клиент должен разбирать тело, чтобы обнаружить сбой. Одна проверка res.ok ничего не говорит.

Есть ли песочница или тестовый ключ для API SMM-панелей?

Нет. Песочницы на этом рынке нет нигде, поэтому первый успешный вызов add — настоящий заказ с настоящего баланса. Разрабатывайте на минимальном количестве, которое допускает позиция, и на своей ссылке.

Как проверить статус многих заказов сразу?

Используйте то же действие status с параметром orders во множественном числе, в котором до 100 номеров через запятую. Отдельного действия multi_status в канонической спецификации нет.

Почему заказ с drip-feed списал в десять раз больше, чем я ожидал?

Потому что в заказе с постепенной подачей количество указывается на один повтор. Всего доставляется и оплачивается количество, умноженное на runs, поэтому 1000 при runs=10 — это заказ и оплата 10 000 единиц.

Можно ли использовать один клиент для разных панелей?

Да, сменив базовый адрес и ключ: это практическое следствие того, что весь рынок реализует одну спецификацию. Не переносится привязка номеров услуг: номера локальны для каждой панели и могут быть перенаправлены.

У каждой цифры здесь есть источник и дата

Цены на этом рынке меняются каждую неделю, поэтому число без даты — просто украшение. Где это руководство приводит цифру, оно называет источник и дату проверки. Если какая-то из них неверна, по процедуре исправлений со страницы «О нас» мы стараемся ответить в течение двух рабочих дней, а исправления публикуем с датированной пометкой, а не вносим молча.