SMM panel API integration: complete guide with code examples
How to integrate an SMM panel API into your application. v2 spec walkthrough, authentication, idempotent order placement, status polling vs webhooks, and the bugs that cost real money.
Most SMM panels expose a REST API that follows the same convention. One endpoint, form-encoded body, a key field for auth, an action field that selects the operation. The convention is called "v2" across the industry. Once you've integrated against one v2 panel, every other one is a base-URL swap away.
This guide is the practical reality of integrating. The parts of the spec that matter, the parts that vary between panels, and how to build a robust client that doesn't lose you money. Code samples target JavaScript and Python; the patterns translate.
Why integrate against an API at all?
For occasional manual orders, the dashboard is fine. The API matters the moment you're either:
- Running a downstream business that needs to forward orders to your upstream provider in real time. (You're a reseller.)
- Automating order placement based on triggers. New client signup, campaign launch, scheduled drops.
- Building a dashboard, analytics layer, or report that aggregates across multiple sources.
- Bulk-placing orders during high-volume promotional periods where manual entry is impractical.
The v2 contract in two paragraphs
Every request is an HTTP POST to a single endpoint (typically https://<panel>/api/v2) with an application/x-www-form-urlencoded body. The body always contains key (your API key) and action (the operation name). Other fields depend on the action.
Responses are JSON. Successes contain action-specific fields. Errors are { "error": "..." } with a 4xx (client) or 5xx (server) status. Idempotency for order placement is achieved via a caller-supplied request_id — repeated calls with the same value return the same response, never a duplicate order.
The minimum viable client
Here's a JavaScript client with just enough abstraction to be useful. Auth, body encoding, JSON parsing, typed call() method.
const PANEL_BASE_URL = "https://notpanel.com/api/v2";
class SmmClient {
constructor(apiKey, baseUrl = PANEL_BASE_URL) {
this.apiKey = apiKey;
this.baseUrl = baseUrl;
}
async call(action, params = {}) {
const body = new URLSearchParams({
key: this.apiKey,
action,
...params,
});
const res = await fetch(this.baseUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
const data = await res.json();
if (!res.ok || data.error) {
throw new SmmApiError(data.error ?? "Unknown error", res.status);
}
return data;
}
services() { return this.call("services"); }
balance() { return this.call("balance"); }
status(orderId) { return this.call("status", { order: orderId }); }
cancel(orderId) { return this.call("cancel", { order: orderId }); }
refill(orderId) { return this.call("refill", { order: orderId }); }
add(params) {
if (!params.request_id) {
throw new Error("request_id is required for idempotency");
}
return this.call("add", params);
}
}
class SmmApiError extends Error {
constructor(message, status) {
super(message);
this.name = "SmmApiError";
this.status = status;
}
}Listing services
The services action returns the full catalog. Each entry has a stable numeric ID, name, per-1,000 rate, min/max bounds, and capability flags. Cache the result — most panels return the same data for many minutes, and the catalog rarely changes within a session.
const client = new SmmClient(process.env.SMM_API_KEY);
const services = await client.services();
console.log(`Loaded ${services.length} services`);
const cheapestIgFollowers = services
.filter(s => s.name.toLowerCase().includes("instagram followers"))
.sort((a, b) => parseFloat(a.rate) - parseFloat(b.rate))[0];
console.log(`Cheapest IG followers: $${cheapestIgFollowers.rate} per 1K`);Placing orders with idempotency
Order placement is the only mutating action where idempotency is non-negotiable. Generate a UUID at order-creation time. Pass it as request_id on every retry of the same logical order. Don't, and network glitches plus timeout retries will create duplicate orders. You'll be charged for both.
import { randomUUID } from "node:crypto";
async function placeOrder(client, serviceId, link, quantity) {
const requestId = randomUUID(); // ← outside the retry loop
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const result = await client.add({
service: serviceId,
link,
quantity: String(quantity),
request_id: requestId,
});
return result.order;
} catch (err) {
if (err.status >= 500 && attempt < 3) {
await new Promise(r => setTimeout(r, 1000 * attempt));
continue;
}
throw err;
}
}
}Status — polling vs webhooks
After placing an order, you need to know when it completes. Two approaches.
Polling. Call action=status periodically with the order ID. Simple. Expensive in rate-limit terms. Introduces lag. The batch form (orders= with comma-separated IDs) is much cheaper than per-order polling.
async function pollUntilDone(client, orderId, maxWaitSec = 3600) {
const start = Date.now();
while (Date.now() - start < maxWaitSec * 1000) {
const result = await client.status(orderId);
if (["completed", "partial", "cancelled", "refunded"].includes(result.status)) {
return result;
}
await new Promise(r => setTimeout(r, 30_000));
}
throw new Error("Order did not complete within max wait time");
}Webhooks. Register an endpoint with the panel. Receive a POST whenever an order changes state. Order of magnitude less work, real-time updates, no rate-limit pressure. Strongly preferred for any non-trivial integration.
await client.call("webhook.add", {
url: "https://your-server.example.com/notpanel-webhook",
events: "order.completed,order.partial,order.failed,balance.low",
});How do reseller apps integrated against NotPanel actually consume order updates?
Verifying webhook signatures
Public webhook URLs are by definition reachable by attackers. Without signature verification, anyone who guesses your URL can fabricate completion notifications, fake refunds, trigger your downstream logic. Always verify.
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyWebhook(rawBody, signatureHeader, timestampHeader, secret) {
// 1. Reject deliveries older than 5 minutes (replay protection).
const ageSec = Math.abs(Date.now() / 1000 - parseInt(timestampHeader, 10));
if (ageSec > 300) return false;
// 2. Compute expected HMAC of the raw body.
const expected = createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
// 3. Constant-time compare against the provided signature.
const a = Buffer.from(signatureHeader, "hex");
const b = Buffer.from(expected, "hex");
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}Error handling that doesn't lose money
The two failure modes that cost real money:
- Duplicate orders from missing idempotency. You place an order, response times out, you retry, the panel accepts both. You're charged twice. Only one is the order you wanted. Always pass
request_id. - Lost orders from over-aggressive error handling. Panel returns 5xx, your code logs and moves on, the order actually went through but you don't know. Now you have a customer charged for a service you have no record of.
Treat 5xx as "unknown outcome", not "failure". Retry with the same key. Then check status. Never assume failure from a single error response.
Rate-limit etiquette
Most panels run layered limits — per-IP, per-key, per-tier. Polling status on hundreds of in-flight orders is the most common cause of unnecessary 429s. Practical mitigations:
- Switch to webhooks for status wherever possible.
- Use the multi-status form (
orders=1,2,3,...) to batch up to 100 IDs in a single request. - Honour
X-RateLimit-Reseton a 429. Sleep until that timestamp. Don't retry sooner. - Check
X-RateLimit-DeniedByto know which limit fired — distributing across more keys helps for per-key denials but nothing for per-IP.
What I'd build differently the second time
After integrating against several panels, the patterns that pay off:
- A single
SmmClientabstraction per panel, with a consistent error class and idempotency baked in. Don't sprinklefetch()across your codebase. - A persistent record of every order placed, with its
request_id, in your own database. When orders go missing or get rejected, you have the source of truth to reconcile against the panel's view. - A retry layer at the
SmmClientboundary with exponential backoff and idempotency awareness. Not at every call site. - Webhooks first, polling as fallback. Polling is acceptable while you haven't integrated webhooks yet — but it should be the temporary state, not the steady state.
For NotPanel's specific implementation of the v2 contract — exact parameter names, response shapes, error codes — see the developer documentation. Patterns above apply across panels; implementation details vary in small ways, and the docs are the authoritative source for our specific case.