Saltar al contenido

Técnico

Integración con la API de un panel SMM: una guía práctica que funciona

Actualizado el por el equipo editorial de PanelCompare, 7 min de lectura

¿Qué necesitas antes de escribir una sola línea de código?

Una cuenta en el panel, saldo cargado y una clave de API del panel de control del panel. En este mercado no hay sandbox, ni clave de prueba, ni entorno de preproducción en ninguna parte, así que el primer pedido correcto durante el desarrollo es un pedido real, que cuesta dinero de verdad y entrega de verdad en el enlace que pongas. Usa un enlace tuyo que no te importe.

Ten en cuenta también el tamaño del catálogo. Los catálogos reales tienen entre unas 3000 y 8000 ofertas, y la acción services las devuelve todas en una única respuesta sin paginar, así que es una llamada costosa que debe ir en una tarea programada y no en el camino de una petición. Los dos paneles sincronizados en el índice de PanelCompare devuelven 5558 y 2196 ofertas asignadas, respectivamente (índice de precios de PanelCompare, 2026-09-10).

¿Cómo confirmar que el endpoint existe antes de autenticarte?

Envía la acción services sin ninguna clave. Un endpoint real de la API v2 responde con un error estructurado, lo que demuestra que existe. Un 404 o un cuerpo HTML desmienten la API que casi todos los paneles dicen tener en su portada. Así se puede verificar un panel sin tener nunca credenciales de él.

Sondear un endpoint con curl, sin necesidad de clave
# 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

De vez en cuando, una llamada sin clave devuelve un array en lugar de un error. No es un fallo, es un catálogo abierto: el panel publica toda su lista de servicios para cualquiera. Es lo bastante raro como para que valga la pena anotarlo cuando lo encuentres.

¿Qué cuatro comportamientos rompen una primera integración?

Los fallos que vienen de la especificación, no del panel
ComportamientoQué hace el código ingenuoQué hace el código correcto
Los errores devuelven HTTP 200Comprueba res.ok, ve true y trata un objeto de error como un resultadoAnaliza primero el cuerpo y busca una clave error antes que nada
Algunos paneles devuelven 4xx con un error en JSONSalta a la siguiente ruta solo por el estado y presenta una clave equivocada como una API ausenteAnaliza también el cuerpo del 4xx; un cuerpo con una clave error es un endpoint que funciona
Los números llegan como cadenasCompara rate, min, max o remains numéricamente y falla sin avisarConvierte explícitamente cada campo numérico en el punto de análisis
El estado de varios pedidos es un parámetro en plural, no una acciónLlama a status una vez por pedido y acaba limitado por exceso de peticionesEnvía hasta 100 id separados por comas en el parámetro orders de la misma acción status

Fuente: Comportamiento comprobado en justanotherpanel.com/api e implementado en el cliente de sincronización de PanelCompare (src/lib/sync/panel-api.ts), 2026-09-11.

El caso del 401 es el que te cuesta un ticket de soporte

JustAnotherPanel responde a una clave incorrecta con HTTP 401 y un cuerpo JSON de la forma {"error":"Invalid API key"}: una respuesta estructurada de la API que da la casualidad de que lleva un estado de error. Un cliente que trata cualquier 4xx como “aquí no hay API” le dirá al dueño de un panel que su panel no tiene endpoint cuando el problema real es la clave. Analiza el cuerpo antes de sacar conclusiones del estado.

¿Cómo es un cliente correcto en Node?

Un cliente mínimo de la API v2 con el manejo de errores necesario (Node 18+, sin dependencias)
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;
}

Tres detalles de ese código son lo esencial. El cuerpo se analiza antes de consultar el estado. La rama de los 4xx se ejecuta después de la comprobación de la clave error, no antes. Y cada campo numérico pasa por una conversión explícita, porque rate, min, max, charge, start_count y remains llegan todos como cadenas entre comillas.

¿Cómo es el mismo cliente en Python y PHP?

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

¿Cómo saber qué parámetros necesita un pedido?

Por el campo type de la oferta del catálogo, que es el único lugar donde se expresa el requisito. Toda llamada add necesita service y link; lo que necesite además no se puede adivinar, y enviar el conjunto equivocado produce un error, no un valor por defecto razonable. Lee el tipo antes de construir el formulario.

Despachar según el tipo de la oferta
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);
  }
}

La rama default importa más de lo que parece. Los paneles añaden tipos de pedido, y un cliente que cae en silencio en una forma con solo cantidad hará pedidos mal formados que fallan después de que se haya descontado el saldo. Lanzar un error ante un tipo desconocido es la versión barata de ese fallo.

¿Cómo conviene consultar el estado de los pedidos?

  1. 1.Agrupa con el parámetro en plural orders, de 100 en 100 id. En la especificación canónica no existe una acción multi_status, y una petición por pedido es la forma en que las integraciones acaban limitadas por exceso de peticiones.
  2. 2.Trata Partial como un resultado de pleno derecho, no como un error. Llega con una cifra remains y un abono automático al saldo, y es el resultado no definitivo más común de este mercado.
  3. 3.Consulta con una frecuencia proporcional a la franja de inicio anunciada, no con un intervalo corto fijo. La mayoría de las ofertas publican una franja; casi ninguna publica una velocidad.
  4. 4.Guarda la identidad del panel junto a cada id de pedido. Los id de pedido son únicos dentro de un panel y en ningún otro sitio.
  5. 5.Vuelve a sincronizar el catálogo con un calendario fijo. Los id de servicio son propios de cada panel y pueden cambiar, así que un id guardado puede empezar a apuntar a otro inventario sin que te enteres.
  6. 6.Nunca registres en los logs el cuerpo de la petición. La clave va dentro, y no tiene caducidad que te salve.

El vocabulario de estados es pequeño y estable: Pending, In progress o Processing, Completed, Partial, Canceled. Cualquier valor fuera de ese conjunto debe mostrarse tal cual en lugar de asignarse al valor conocido más parecido, porque un panel que devuelve un estado inesperado suele estar diciéndote algo que esa asignación ocultaría.

¿Qué restricciones de seguridad impone la especificación?

La clave viaja en el cuerpo de la petición sin firma, sin nonce y sin marca de tiempo, y la especificación no define ninguna caducidad. Eso la convierte en un secreto de portador de larga duración: cualquiera que la tenga puede gastar el saldo y leer todos los enlaces sobre los que se han hecho pedidos con la cuenta. No hay protección contra repetición que limite el daño ni más revocación que cambiar la clave en el panel de control.

  • Nunca aceptes una clave desde un navegador ni la hagas pasar por código del lado del cliente.
  • Nunca registres en los logs el cuerpo de la petición, y comprueba que tu biblioteca de cliente HTTP no lo esté registrando por ti.
  • Mantén las claves fuera de las variables de entorno que lleguen a un log de compilación, y fuera de la base de datos.
  • Cámbialas después de cada cambio en una integración de terceros y después de cualquier cambio de personal.
  • Da la clave por comprometida en cuanto aparezca en una captura de pantalla, un ticket o un documento compartido.

Respuestas rápidas

¿Por qué la API de un panel SMM devuelve 200 cuando hay errores?

Porque la especificación no usa los códigos de estado HTTP con sentido. Los fallos suelen volver como HTTP 200 con un objeto de error en el cuerpo, y algunos paneles responden a una clave incorrecta con un 401 y el mismo JSON, así que todo cliente tiene que analizar el cuerpo para detectar el fallo. Comprobar solo res.ok no te dice nada.

¿Hay sandbox o clave de prueba para las API de los paneles SMM?

No. En este mercado no hay sandbox en ninguna parte, así que la primera llamada add correcta es un pedido real contra un saldo real. Desarrolla con la cantidad más pequeña que permita la oferta, sobre un enlace tuyo.

¿Cómo consultar el estado de muchos pedidos a la vez?

Usa la misma acción status con el parámetro en plural orders, con hasta 100 id separados por comas. En la especificación canónica no existe una acción multi_status aparte.

¿Por qué mi pedido por goteo me cobra diez veces lo que esperaba?

Porque en un pedido por goteo la cantidad es por tanda. El total entregado y cobrado es la cantidad multiplicada por las tandas, así que 1000 con runs=10 pide y cobra 10.000 unidades.

¿Puedo reutilizar un mismo cliente con distintos paneles?

Sí, cambiando la URL base y la clave, que es la consecuencia práctica de que todo el mercado implemente una única especificación. Lo que no se traslada es la asignación de los id de servicio, porque los id son propios de cada panel y se pueden redirigir.

Cada cifra de esta guía tiene fuente y fecha

Los precios de este mercado cambian cada semana, así que un número sin fecha de captura es decorativo. Cuando esta guía cita una cifra, indica la fuente y cuándo se comprobó. Si alguna es incorrecta, el procedimiento de corrección de la página sobre nosotros tiene como objetivo responder en dos días hábiles, y las correcciones se publican con una nota fechada, no se arreglan en silencio.