Pular para o conteúdo

Técnico

Integração com a API de painel SMM: um passo a passo que funciona

Atualizado em pela Equipe editorial do PanelCompare · 7 min de leitura

Do que você precisa antes de escrever qualquer código?

Uma conta no painel, saldo depositado e uma chave de API tirada do painel de controle. Não existe sandbox, chave de teste nem ambiente de homologação em nenhum lugar deste mercado, então o primeiro pedido bem-sucedido no desenvolvimento é um pedido real, que custa dinheiro de verdade e entrega de verdade no link que você informar. Use um link seu com o qual você não se importe.

Leve em conta também o tamanho do catálogo. Catálogos reais vão de cerca de 3.000 a 8.000 itens, e a ação services devolve todos numa única resposta sem paginação, então é uma chamada cara, que deve rodar em intervalos fixos e não no caminho de cada requisição. Os dois painéis sincronizados no índice do PanelCompare devolvem 5.558 e 2.196 itens mapeados, respectivamente (índice de preços do PanelCompare, 10 de setembro de 2026).

Como confirmar que o endpoint existe antes de autenticar?

Envie a ação services sem chave nenhuma. Um endpoint real de API v2 responde com um erro estruturado, o que prova que ele existe. Um 404 ou um corpo em HTML desmente a alegação de ter API que quase todo painel faz na página inicial. É assim que um painel pode ser verificado sem nunca ter credenciais dele.

Verificando um endpoint com curl, sem precisar de chave
# 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

Às vezes uma chamada sem chave devolve uma lista, e não um erro. Não é bug, é um catálogo aberto: o painel publica a lista inteira de serviços para qualquer um. É raro o bastante para valer o registro quando você encontra.

Quais quatro comportamentos derrubam uma primeira integração?

As falhas que vêm da especificação, e não de um painel específico
ComportamentoO que um código ingênuo fazO que um código correto faz
Erros voltam com HTTP 200Confere res.ok, vê true e trata um objeto de erro como resultadoLê o corpo primeiro e verifica se há uma chave error antes de qualquer outra coisa
Alguns painéis devolvem 4xx com um erro em JSONPula para o próximo caminho só pelo status e registra uma chave errada como API inexistenteLê também o corpo do 4xx; um corpo com a chave error é um endpoint que funciona
Números chegam como textoCompara rate, min, max ou remains como números e dá errado em silêncioConverte explicitamente cada campo numérico no ponto de leitura
O status de vários pedidos é um parâmetro no plural, não uma açãoChama status uma vez por pedido e esbarra no limite de requisiçõesEnvia até 100 ids separados por vírgula no parâmetro orders da mesma ação status

Fonte: Comportamento verificado em justanotherpanel.com/api e implementado no cliente de sincronização do PanelCompare (src/lib/sync/panel-api.ts), 11 de setembro de 2026.

O caso do 401 é o que custa um ticket de suporte

O JustAnotherPanel responde a uma chave errada com HTTP 401 e um corpo JSON no formato {"error":"Invalid API key"} — uma resposta estruturada da API que por acaso traz um status de erro. Um cliente que trata qualquer 4xx como “não há API aqui” vai dizer ao dono de um painel que o painel dele não tem endpoint, quando o problema real é a chave. Leia o corpo antes de tirar conclusões pelo status.

Como é um cliente correto em Node?

Um cliente mínimo da API v2 com o tratamento de erros necessário (Node 18+, sem dependências)
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;
}

Três detalhes desse código são o que importa. O corpo é lido antes de o status ser consultado. O ramo do 4xx roda depois da verificação da chave error, e não antes. E todo campo numérico passa por uma conversão explícita, porque rate, min, max, charge, start_count e remains chegam todos como strings entre aspas.

Como fica o mesmo cliente em Python e em PHP?

Python, com 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, com 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"];

Como saber quais parâmetros um pedido exige?

Pelo campo type do item do catálogo, que é o único lugar onde a exigência aparece. Toda chamada add precisa de service e link; o que mais ela precisa não dá para adivinhar, e enviar o conjunto errado gera um erro, e não um valor padrão razoável. Leia o tipo antes de montar o formulário.

Escolhendo os parâmetros pelo tipo do item
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);
  }
}

O ramo default importa mais do que parece. Os painéis criam novos tipos de pedido, e um cliente que cai em silêncio num formato só com quantidade vai fazer pedidos malformados, que falham depois de o saldo ter sido descontado. Lançar um erro diante de um tipo desconhecido é a versão barata desse bug.

Como consultar o status dos pedidos?

  1. 1.Agrupe as consultas pelo parâmetro orders no plural, 100 ids por vez. Não existe ação multi_status na especificação canônica, e uma requisição por pedido é como as integrações esbarram no limite de requisições.
  2. 2.Trate Partial como um resultado normal, e não como erro. Ele chega com um valor em remains e um crédito automático no saldo, e é o resultado não definitivo mais comum deste mercado.
  3. 3.Consulte num ritmo proporcional à faixa de início anunciada, e não num intervalo curto fixo. A maioria dos itens publica uma faixa; quase nenhum publica velocidade.
  4. 4.Guarde a identificação do painel junto com cada id de pedido. Ids de pedido são únicos dentro de um painel e em nenhum outro lugar.
  5. 5.Sincronize o catálogo em intervalos fixos. Os ids de serviço são locais de cada painel e podem mudar, então um id guardado pode, sem aviso, passar a apontar para outro estoque.
  6. 6.Nunca grave o corpo da requisição em log. A chave está nele, e não há validade que salve você.

O vocabulário de status é pequeno e estável: Pending (pendente), In progress ou Processing (em andamento, processando), Completed (concluído), Partial (parcial) e Canceled (cancelado). Qualquer valor fora desse conjunto deve ser exibido, e não encaixado no valor conhecido mais próximo, porque um painel que devolve um status inesperado em geral está dizendo algo que o mapeamento esconderia.

Quais restrições de segurança a especificação impõe?

A chave viaja no corpo da requisição, sem assinatura, sem nonce e sem carimbo de tempo, e a especificação não define validade. Isso faz dela um segredo de portador de longa duração: quem a tiver pode gastar o saldo e ler todos os links para os quais a conta já fez pedidos. Não há proteção contra reutilização para limitar o estrago nem revogação possível, a não ser trocar a chave no painel de controle.

  • Nunca aceite uma chave vinda de um navegador e nunca a repasse por código do lado do cliente.
  • Nunca grave o corpo da requisição em log, e confira se a sua biblioteca HTTP não está gravando por você.
  • Mantenha as chaves fora de variáveis de ambiente que cheguem a um log de build, e fora do banco de dados.
  • Troque as chaves depois de cada mudança numa integração de terceiros e depois de qualquer mudança na equipe.
  • Considere a chave comprometida no momento em que ela aparecer num print, num ticket ou num documento compartilhado.

Respostas rápidas

Por que a API de painel SMM devolve 200 quando dá erro?

Porque a especificação não usa os códigos de status HTTP de forma significativa. As falhas costumam voltar como HTTP 200 com um objeto de erro no corpo, e alguns painéis respondem a uma chave inválida com 401 e o mesmo JSON, então todo cliente precisa ler o corpo para detectar a falha. Conferir só res.ok não diz nada.

Existe sandbox ou chave de teste para APIs de painel SMM?

Não. Não existe sandbox em nenhum lugar deste mercado, então a primeira chamada add bem-sucedida é um pedido real sobre um saldo real. Desenvolva com a menor quantidade que o item permite, num link seu.

Como consultar o status de muitos pedidos de uma vez?

Use a mesma ação status com o parâmetro orders no plural, com até 100 ids separados por vírgula. Não existe uma ação multi_status separada na especificação canônica.

Por que meu pedido com drip-feed está cobrando dez vezes o que eu esperava?

Porque a quantidade num pedido com drip-feed vale por execução. O total entregue e cobrado é a quantidade multiplicada pelas execuções, então 1.000 com runs=10 pede e paga 10.000 unidades.

Posso reutilizar um mesmo cliente em painéis diferentes?

Sim, trocando a URL base e a chave, que é a consequência prática de o mercado inteiro implementar uma única especificação. O que não se aproveita é o mapeamento dos ids de serviço, porque os ids são locais de cada painel e podem ser redirecionados.

Cada número aqui tem fonte e data

Os preços deste mercado mudam toda semana; então, um número sem data de coleta é decoração. Quando este guia cita um número, ele informa a fonte e quando foi conferido. Se algum estiver errado, o processo de correção descrito na página Quem somos tem meta de resposta de dois dias úteis, e as correções são publicadas com uma nota datada, em vez de remendadas em silêncio.