Skip to content

Technical

SMM panel API integration: a working walkthrough

Last updated by The PanelCompare editorial team, 6 min read

What do you need before writing any code?

An account on the panel, a funded balance, and an API key from the panel dashboard. There is no sandbox, no test key and no staging environment anywhere in this market, so the first successful order in development is a real order that really costs money and really delivers to whatever link you put in it. Use a link you own and do not care about.

Also budget for catalogue size. Real catalogues run from roughly 3,000 to 8,000 rows and the services action returns all of them in one unpaginated response, so it is an expensive call that belongs on a schedule rather than on a request path. The two panels synced into the PanelCompare index return 5,558 and 2,196 mapped rows respectively (PanelCompare price index, 2026-09-10).

How do you confirm the endpoint exists before authenticating?

Post the services action with no key at all. A real API v2 endpoint answers with a structured error, which proves it exists. A 404 or an HTML body disproves the API claim that nearly every panel makes on its homepage. This is how a panel can be verified without ever holding credentials for it.

Probing for an endpoint with curl, no key required
# 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

Occasionally a keyless call returns an array rather than an error. That is not a bug, it is an open catalogue: the panel publishes its whole service list to anyone. It is rare enough to be worth recording when you find it.

Which four behaviours break a first integration?

The failures that are specification-level, not panel-level
BehaviourWhat naive code doesWhat correct code does
Errors return HTTP 200Checks res.ok, sees true, and treats an error object as a resultParses the body first and checks for an error key before anything else
Some panels return 4xx with a JSON errorSkips to the next path on the status alone, and reports a wrong key as a missing APIParses the 4xx body too; a body with an error key is a working endpoint
Numbers arrive as stringsCompares rate, min, max or remains numerically and silently misbehavesCoerces every numeric field explicitly at the parse boundary
Multi-status is a plural parameter, not an actionCalls status once per order and gets rate limitedSends up to 100 comma-separated ids in the orders parameter of the same status action

Source: Behaviour verified against justanotherpanel.com/api and implemented in the PanelCompare sync client (src/lib/sync/panel-api.ts), 2026-09-11.

The 401 case is the one that costs you a support ticket

JustAnotherPanel answers a bad key with HTTP 401 and a JSON body of the form {"error":"Invalid API key"} — a structured API response that happens to carry an error status. A client that treats any 4xx as "no API here" will tell a panel owner their panel has no endpoint when the real problem is the key. Parse the body before drawing a conclusion from the status.

What does a correct client look like in Node?

A minimal API v2 client with the required error handling (Node 18+, no dependencies)
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;
}

Three details in that code are the whole point. The body is parsed before the status is consulted. The 4xx branch runs after the error-key check rather than before it. And every numeric field goes through an explicit coercion, because rate, min, max, charge, start_count and remains all arrive as quoted strings.

What does the same client look like in Python and PHP?

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

How do you know which parameters an order needs?

From the type field on the catalogue row, which is the only place the requirement is expressed. Every add call needs service and link; what else it needs is not guessable, and sending the wrong set produces an error rather than a sensible default. Read the type before building the form.

Dispatching on the row 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);
  }
}

The default branch matters more than it looks. Panels add order types, and a client that silently falls through to a quantity-only shape will place malformed orders that fail after the wallet has been debited. Throwing on an unknown type is the cheap version of that bug.

How should order status be polled?

  1. 1.Batch through the plural orders parameter, 100 ids at a time. There is no multi_status action in the canonical specification, and one request per order is how integrations get rate limited.
  2. 2.Treat Partial as a first-class outcome rather than an error. It arrives with a remains figure and an automatic wallet credit, and it is the most common non-terminal result in this market.
  3. 3.Poll on a schedule proportional to the advertised start-time band, not on a fixed short interval. Most rows publish a band; almost none publish a speed.
  4. 4.Store the panel identity alongside every order id. Order ids are unique within a panel and nowhere else.
  5. 5.Re-sync the catalogue on a schedule. Service ids are panel-local and mutable, so a stored id can quietly start pointing at different inventory.
  6. 6.Never log the request body. The key is in it, and it has no expiry to save you.

The status vocabulary is small and stable: Pending, In progress or Processing, Completed, Partial, Canceled. Anything outside that set should be surfaced rather than mapped onto the nearest known value, because a panel returning an unexpected status is usually telling you something the mapping would hide.

What are the security constraints the specification imposes?

The key travels in the request body with no signing, no nonce and no timestamp, and the specification defines no expiry. That makes it a long-lived bearer secret: anyone holding it can spend the wallet balance and read every link ordered against the account. There is no replay protection to limit the damage and no revocation short of rotating the key in the dashboard.

  • Never accept a key from a browser and never proxy one through client-side code.
  • Never log the request body, and check that your HTTP client library is not logging it for you.
  • Keep keys out of environment variables that reach a build log, and out of the database.
  • Rotate after every third-party integration change, and after any staff change.
  • Assume compromise the moment a key appears in a screenshot, a ticket or a shared document.

Quick answers

Why does the SMM panel API return 200 on errors?

Because the specification does not use HTTP status codes meaningfully. Failures come back as HTTP 200 with an error object in the body, so every client has to parse the body to detect failure. Checking res.ok tells you nothing.

Is there a sandbox or test key for SMM panel APIs?

No. There is no sandbox anywhere in this market, so the first successful add call is a real order against a real balance. Develop against the smallest quantity the row allows, on a link you own.

How do I check the status of many orders at once?

Use the same status action with a plural orders parameter holding up to 100 comma-separated ids. There is no separate multi_status action in the canonical specification.

Why is my drip-feed order charging ten times what I expected?

Because quantity on a drip-feed order is per run. Total delivered and charged is quantity multiplied by runs, so 1,000 with runs=10 orders and pays for 10,000 units.

Can I reuse one client across different panels?

Yes, with a base URL and key change, which is the practical consequence of the whole market implementing one specification. What does not carry over is the service id mapping, because ids are local to each panel and can be repointed.

Every figure here is attributed and dated

Prices in this market move weekly, so a number without a capture date is decorative. Where this guide quotes a figure it names the source and when it was checked. If one of them is wrong, the correction process on the about page has a two-working-day reply target, and corrections are published with a dated note rather than quietly patched.