API
Everything the panel does is available through two separate APIs. Both use the same account, the same balance and the same catalog; they differ in format and capability.
Everything the panel does is available through two separate APIs. Both use the same account, the same balance and the same catalog; they differ in format and capability.
Why two APIs?
The classic reseller API (v2) that the whole industry uses posts a form to a single endpoint and always answers HTTP 200. That is exactly the shape off-the-shelf panel software expects, so it stays as it is. Developers writing their own systems kept hitting its limits: errors could not be told apart, the catalog arrived in one piece, and order status had to be polled forever. v3 was written for them.
Comparison
| Feature | Legacy (v2) | New (v3) |
|---|---|---|
| Shape | One endpoint, form post, action parameter | Resource-oriented REST, JSON body |
| HTTP status | Always 200, even on failure | Real codes (400, 401, 402, 404, 409, 429, 502) |
| Errors | Free-form text | type + stable code + localized message + param + doc_url |
| Order status | Localized text only | Stable machine value plus a separate display label |
| Service description | None | Description in 10 languages, average time, platform, category |
| Order fields | Guessed from the type name | Each service publishes its own field schema |
| Pricing unit | Unstated (a 1000x error source on packages) | Explicitly per_1000 or per_order |
| Catalog | Every service in one response | Filters plus cursor pagination |
| Duplicate protection | None | Idempotency-Key |
| Status updates | Constant polling | Signed webhooks or an event stream |
| Schema | None | OpenAPI 3.1 |
| Languages | English and Turkish (separate URLs) | 10 languages (header or parameter) |
Which one should I use?
Pick the legacy API if you run off-the-shelf panel software, a bot or a reseller panel. Most of them only ask you to change the API URL and the key, and start working in minutes.
Pick v3 if you are writing your own application, storefront or automation. Error handling, duplicate protection and notifications come built in, and you can generate your order form straight from the service schema.
Getting started
- 1Create an API key on the Keys tab.
- 2Fetch the service list and read the id and field schema of the service you need.
- 3Validate the order with preview first, then create it.
- 4Register a webhook, or read the event stream, to follow status changes.
A REST API designed for developers building their own systems: resource-oriented paths, real HTTP status codes, machine-readable errors and signed notifications.
Base URL
Every path is appended to this URL. The version lives in the path: if a breaking change is ever needed, a new path (v4) is published and this one keeps working untouched. The release date of the contract is returned in the X-Api-Version header on every response.
https://panelfollows.com/api/v3Authentication
Send your API key as a Bearer token in the Authorization header. The X-Api-Key header is accepted as an alternative.
GET https://panelfollows.com/api/v3/account
Authorization: Bearer pf_live_...Your existing legacy key also works on v3, so you can try it right away. Use a v3 key in production: it can be labelled, revoked individually, and is never stored in plain text.
Quick start
curl https://panelfollows.com/api/v3/services?limit=5 \
-H "Authorization: Bearer YOUR_API_KEY"Language
Pick the response language with the Accept-Language header or the ?lang= parameter; the parameter wins. Service names, service descriptions, category names, order status labels, order field labels and error messages all come back in that language.
Machine values never change with language: error.code, order.status, service.type and currency are always the same. Branch on those, and show the text to your users.
Accept-Language: tr
# veya
GET https://panelfollows.com/api/v3/services?lang=trRequest and response format
Request bodies are JSON (application/json); form-urlencoded is also accepted for quick tests. Responses are JSON: a single resource is a plain object, and lists come in an envelope with data, has_more and next_cursor. Every object carries an object field naming its type.
Amounts are decimal STRINGS ("1.2340"), not floats. Parse them into a decimal type on your side so no fractions are lost. The currency is USD.
Timestamps are RFC 3339 (2026-08-21T00:24:45.255Z).
Errors
Failures return a real HTTP status code and a body carrying a single error object. Your code should branch on error.code: that value is stable and never changes with language.
HTTP/1.1 400 Bad Request
Content-Type: application/json
X-Request-Id: req_0c858d8af7f65eca001b2f5a
{
"object": "error",
"error": {
"type": "invalid_request_error",
"code": "quantity_out_of_range",
"message": "Miktar, bu servisin izin verdiği aralığın dışında.",
"param": "quantity",
"doc_url": "https://panelfollows.com/api-docs#error-quantity_out_of_range",
"request_id": "req_0c858d8af7f65eca001b2f5a"
}
}| type | Broad class: is it retryable, is it your mistake. |
| code | Stable machine value. Branch on this. |
| message | Human-readable text in your chosen language. |
| param | Name of the offending field, when there is one. |
| doc_url | Link to the exact section of these docs. |
| request_id | The single reference to quote when contacting support. |
Rate limits
600 requests per minute per key, plus 900 per minute per IP. Every response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset so you can slow yourself down before hitting the wall. Exceeding the limit returns 429 with a Retry-After header.
Pagination
Lists are cursor-paginated. Send limit for the page size (500 max) and starting_after with the id of the last item on the previous page. Keep going until has_more is false; next_cursor gives you the cursor for the next call. An out-of-range limit is not silently clamped, it errors: silent clamping makes clients believe they fetched everything.
Duplicate protection (Idempotency-Key)
Add a random Idempotency-Key header when creating an order. If the connection drops and you retry with the same key, no second order is created: the first response is returned again, with an Idempotent-Replay: true header. Records are kept for 24 hours.
Sending the same key with a DIFFERENT body returns 409 idempotency_key_reuse. That almost always means key generation is broken on the client side. A failed request does not burn the key: fix the problem and retry with the same one.
Creating an order
curl -X POST https://panelfollows.com/api/v3/orders \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 8f14e45f-ea3a-4b1c-9c1e-2b0d5c6a7e91" \
-d '{
"service": 1234,
"link": "https://instagram.com/username",
"quantity": 1000
}'Service field schema
Every service publishes the fields it needs in a fields array: name, type, whether it is required, its limits, and a label plus description in your language. When a field has determines_quantity set to true, the quantity is derived from the number of lines in it.
// Servisin kendi alan şemasından formu OTOMATİK üretmek:
// hiçbir servis tipini koda gömmeniz gerekmez.
const res = await fetch("https://panelfollows.com/api/v3/services/1234", {
headers: { Authorization: "Bearer YOUR_API_KEY", "Accept-Language": "tr" },
});
const service = await res.json();
for (const field of service.fields) {
renderInput({
name: field.name,
label: field.label, // kullanıcının dilinde
hint: field.description, // kullanıcının dilinde
required: field.required,
type: field.type, // url | integer | string | text_lines
min: field.min,
max: field.max,
// true ise miktarı bu alanın satır sayısı belirler
countsLines: field.determines_quantity === true,
});
}The service object
{
"object": "service",
"id": 1234,
"name": "Instagram Takipçi | Türk | 30 gün telafi",
"description": "Gerçek hesaplardan Türk takipçi. Başlangıç 0-1 saat.",
"type": "default",
"platform": "instagram",
"category": { "slug": "instagram-takipci", "name": "Instagram Takipçi" },
"pricing": {
"rate": "1.2340",
"currency": "USD",
"unit": "per_1000",
"unit_note": "Fiyat 1000 adet içindir."
},
"limits": { "min": 100, "max": 100000 },
"features": { "refill": true, "cancel": false, "dripfeed": true },
"average_time_seconds": 4320,
"fields": [
{
"name": "link",
"type": "url",
"required": true,
"label": "Bağlantı",
"description": "Gönderimin yapılacağı profilin herkese açık adresi."
},
{
"name": "quantity",
"type": "integer",
"required": true,
"label": "Miktar",
"description": "Kaç adet gönderileceği.",
"min": 100,
"max": 100000
}
],
"is_active": true,
"updated_at": "2026-08-20T09:15:00.000Z"
}Order preview (dry run)
POST /orders/preview validates an order and computes its charge WITHOUT creating it. Show the price to your customer and check your balance covers it beforehand. Nothing is debited and no provider is contacted.
Batch orders
POST /orders/batch accepts up to 50 orders in one call. Items are processed in order and each reports its own result: if one fails the rest are still created, and you see exactly which one failed and why.
Embedding related objects
Pass include=service on the order endpoints and the service object is embedded in the response, saving you a second request.
Endpoint reference
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v3 | Discovery document: version, endpoints, limits and event types. |
| GET | /api/v3/openapi.json | OpenAPI 3.1 schema for this API. |
| GET | /api/v3/account | Account balance, currency and current rate-limit window. |
| PATCH | /api/v3/account | Set the low balance alert threshold that triggers account.low_balance. |
| GET | /api/v3/services | List services with filters and cursor pagination. |
| GET | /api/v3/services/{id} | Retrieve one service, including its order field schema. |
| GET | /api/v3/categories | List categories with their active service counts. |
| GET | /api/v3/platforms | List platform keys usable as the ?platform= filter, with counts. |
| POST | /api/v3/orders | Create an order. Supports the Idempotency-Key header. |
| POST | /api/v3/orders/preview | Validate an order and compute its charge without creating it. |
| POST | /api/v3/orders/batch | Create up to 50 orders in one call; each item reports its own result. |
| GET | /api/v3/orders | List your orders, newest first. |
| GET | /api/v3/orders/{id} | Retrieve one order. |
| POST | /api/v3/orders/{id}/cancel | Request cancellation. Only for services whose features.cancel is true. |
| POST | /api/v3/orders/{id}/refill | Request a refill for a completed order. |
| GET | /api/v3/refills | List your refill requests, newest first. |
| GET | /api/v3/refills/{id} | Retrieve one refill; refreshes its status from the provider. |
| GET | /api/v3/events | Read your event stream oldest-first; the polling alternative to webhooks. |
| GET | /api/v3/webhooks | List your webhook endpoints. |
| POST | /api/v3/webhooks | Register a webhook endpoint. The signing secret is returned once. |
| GET | /api/v3/webhooks/{id} | Retrieve one webhook endpoint. |
| PATCH | /api/v3/webhooks/{id} | Update a webhook endpoint's url, events, description or active state. |
| DELETE | /api/v3/webhooks/{id} | Delete a webhook endpoint and its delivery log. |
| POST | /api/v3/webhooks/{id}/test | Send a test event to this endpoint, ignoring its event filter. |
| POST | /api/v3/webhooks/{id}/rotate_secret | Generate a new signing secret. The old one stops working immediately. |
| GET | /api/v3/webhooks/{id}/deliveries | Delivery log for one endpoint: attempts, response codes and errors. |
OpenAPI schema
A machine-readable definition of every endpoint. Feed this file to a client generator (openapi-generator, Kiota) or to Postman to get a ready client in your own language.
https://panelfollows.com/api/v3/openapi.jsonFrequently asked
Does my legacy key work on v3?
Yes. You do not need a new key to try it. Still move to a v3 key in production: it can be revoked and is not stored in plain text.
Why are prices strings?
Floating point numbers lose fractions on decimal amounts. Returning strings and parsing them into a decimal type on your side removes that whole class of rounding differences.
Why does the unit field matter?
Most services are priced per 1000 units (per_1000), but package services are sold as a single item (per_order) where the rate covers the whole package. Integrations that ignore the distinction computed package prices 1000 times off.
My order was created but stayed pending. What happened?
Handoff to the provider may have been delayed. Your balance is held and the order is not lost; our team resends it automatically. The processing_delayed field marks this state.
Do all services support cancel and refill?
No. Check features.cancel and features.refill on the service object. Calling the endpoint on a service that does not support it returns 400.
Can I use both APIs at once?
Yes. Same account, same balance, same orders. An order placed through v2 can be read through v3.
The classic reseller API that is standard across the industry. This is the shape off-the-shelf panel software expects.
Endpoint
POST https://panelfollows.com/api/v2
POST https://panelfollows.com/api/v2/trAuthentication
Every request carries a key parameter. Keep your key secret and regenerate it immediately if it leaks.
Request and response format
Requests are POSTed as a form (application/x-www-form-urlencoded) and responses are JSON. Failures also return HTTP 200, with { "error": "..." } in the body.
Also supported, though never documented before: you can call it with GET, and you can send the body as application/json.
Rate limits
240 requests per minute per key, plus 300 per minute per IP. Excess requests are rejected with 429.
Actions and parameters
| action | Parameter | Description |
|---|---|---|
| services | key, action | Lists every active service (id, name, category, rate, min/max, refill, cancel, drip-feed). |
| add | key, action, service, link, quantity[, runs, interval, comments, username, posts, min, max] | Creates an order. service is the catalog service id. Add runs and interval for drip-feed, and the matching fields for special types. |
| status | key, action, order | orders | Order status. Use order for one, or a comma-separated orders list for many. |
| balance | key, action | Account balance and currency. |
| refill | key, action, order | orders | Creates a refill request, sent straight to the provider. |
| refill_status | key, action, refill | refills | Queries refill status. |
| cancel | key, action, orders | Cancels orders. Only works for services whose provider supports cancellation. |
Example
curl -X POST https://panelfollows.com/api/v2 \
-d "key=YOUR_API_KEY" \
-d "action=add" \
-d "service=1234" \
-d "link=https://instagram.com/username" \
-d "quantity=1000"
# Yanıt: { "order": 23501 }Turkish responses
Append /tr to the URL to receive service names, categories, order statuses and error messages in Turkish. Parameters, actions and the response shape are identical, and your key works on both URLs. Technical fields (type, refill_status, currency) stay in English for standard compatibility.
Moving to v3
Migration is optional. If you do move, most of your business logic survives because the parameter names are unchanged; what differs is the transport and how you read errors.
- 1Move the key from the body's key field to the Authorization: Bearer header.
- 2Call a resource path instead of action=... (POST /orders instead of add).
- 3Check for failure with the HTTP status and error.code instead of "is there an error field".
- 4Compare order status against the machine value, not the display text.
- 5Add an Idempotency-Key when creating orders.
- 6Replace status polling with webhooks.
A key grants full access to your account. Do not share it, do not embed it in client-side code, and never commit it to a public repository.
v3 keys
Create as many keys as you need, label each one and revoke them individually. Only a cryptographic digest of the key is stored on our side.
Create a free accountLegacy key
The single key used by the classic reseller API (v2). It also works on v3. Regenerating it invalidates the old value immediately.
Security
- Keep the key in an environment variable, never in source code.
- Do not put a key in code that runs in a browser; proxy the calls through your own server.
- Create a separate key per system so revoking one does not affect the others.
- If you suspect a leak, roll out the new key first, then revoke the old one.
When an order changes status we send a signed notification to your server, so you never have to poll for status.
Why webhooks?
Polling is both slow and wasteful: asking about thousands of orders every minute eats your rate limit and you still learn about changes minutes late. With webhooks the change reaches you as it happens.
Setup
- 1Prepare a public https URL (local and private network addresses are rejected).
- 2Add the URL below and store the signing secret shown once.
- 3Verify the signature on your side and answer 2xx.
- 4Use the test button to confirm the whole path end to end.
What we send
POST /hooks/pf HTTP/1.1
Content-Type: application/json
Webhook-Id: evt_7f910fba7cd042ef9d9069ba5c074fa0
Webhook-Timestamp: 1787261223
Webhook-Signature: t=1787261223,v1=9c1e2b0d5c6a7e91...
{
"object": "event",
"id": "evt_7f910fba7cd042ef9d9069ba5c074fa0",
"type": "order.completed",
"created_at": "2026-08-21T00:27:03.531Z",
"data": {
"previous_status": "in_progress",
"order": {
"object": "order",
"id": 23501,
"status": "completed",
"status_label": "Tamamlandı",
"service": 1234,
"quantity": 1000,
"start_count": 4210,
"remains": 0,
"charge": "1.2340",
"currency": "USD"
}
}
}Verifying the signature
Every request carries a Webhook-Signature header: t is the timestamp and v1 is the signature. The signature is the HMAC-SHA256 of the string "<timestamp>.<raw body>" using your secret.
- 1Parse t and v1 out of the header.
- 2Check that t is no older than 5 minutes, to block replays.
- 3Compute the HMAC-SHA256 of "<t>.<raw body>" with your secret.
- 4Compare it to v1 in constant time and reject the request if it does not match.
Verification example
import crypto from "node:crypto";
import express from "express";
const app = express();
// ÖNEMLİ: imza HAM gövde üzerinden hesaplanır. JSON'a çevirip yeniden
// dizeye dönüştürürseniz boşluklar değişir ve imza tutmaz.
app.post("/hooks/pf", express.raw({ type: "application/json" }), (req, res) => {
const raw = req.body.toString("utf8");
const header = req.get("Webhook-Signature") ?? "";
const m = /t=(\d+),v1=([0-9a-f]+)/.exec(header);
if (!m) return res.sendStatus(400);
const [, timestamp, signature] = m;
// Tekrar saldırısına karşı: 5 dakikadan eski damgayı reddet.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return res.sendStatus(400);
const expected = crypto
.createHmac("sha256", process.env.WEBHOOK_SECRET)
.update(`${timestamp}.${raw}`, "utf8")
.digest("hex");
const ok =
expected.length === signature.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
if (!ok) return res.sendStatus(401);
const event = JSON.parse(raw);
// 2xx dönmezseniz gönderim artan aralıklarla tekrar denenir.
res.sendStatus(200);
if (event.type === "order.completed") {
// ... siparişi kendi sisteminizde tamamlandı olarak işaretleyin
}
});Retries
The first attempt happens as the event occurs. If the response is not 2xx, or the connection fails, delivery is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours. After 6 attempts the delivery is marked failed and shows up in the delivery log.
Event types
Subscribe to the types you care about, or receive all of them. A status change produces exactly one event, using the type that best matches the new status.
| order.created | An order was created. |
| order.processing | The provider started working on the order. |
| order.completed | The order is complete. |
| order.partial | The order was partially delivered and the remainder refunded. |
| order.canceled | The order was canceled or refunded. |
| order.updated | The status changed in some other way. |
| refill.created | A refill was requested. |
| refill.updated | A refill changed status. |
| account.low_balance | Your balance dropped below the threshold you set with PATCH /account. Fires on the crossing, not on every order, and re-arms once the balance is back above it. |
If you cannot host a webhook
The same events can be read with a cursor from GET /api/v3/events. Use it while developing locally, when you have no static IP, or from behind a firewall.
// Webhook kuramıyorsanız (yerelde geliştirme, sabit IP yok) aynı bilgiyi
// imleçle çekebilirsiniz. İmleci kendi tarafınızda saklayın.
let cursor = loadCursor(); // en son işlediğiniz olayın "cursor" değeri
const url = new URL("https://panelfollows.com/api/v3/events");
url.searchParams.set("limit", "100");
if (cursor) url.searchParams.set("starting_after", String(cursor));
const res = await fetch(url, { headers: { Authorization: "Bearer YOUR_API_KEY" } });
const { data, has_more } = await res.json();
for (const event of data) {
handle(event); // sizin işleyiciniz
cursor = event.cursor; // imleci ilerlet
}
saveCursor(cursor);Errors (48)
| Code | Status | Description |
|---|---|---|
| missing_api_key | 401 | No API key provided. Send it as 'Authorization: Bearer <key>'. |
| invalid_api_key | 401 | The API key you provided is not valid. |
| revoked_api_key | 401 | This API key has been revoked and can no longer be used. |
| account_banned | 403 | This account is banned. |
| account_suspended | 403 | This account is suspended. |
| insufficient_scope | 403 | This API key does not have permission for this endpoint. |
| invalid_json | 400 | The request body is not valid JSON. |
| unsupported_content_type | 415 | Unsupported Content-Type. Use application/json or application/x-www-form-urlencoded. |
| method_not_allowed | 405 | This HTTP method is not allowed on this endpoint. |
| payload_too_large | 413 | The request body is too large. |
| missing_parameter | 400 | A required parameter is missing. |
| invalid_parameter | 400 | A parameter has an invalid value. |
| invalid_link | 400 | The link is missing or not a valid http(s) URL. |
| invalid_quantity | 400 | The quantity is not a valid positive whole number. |
| quantity_out_of_range | 400 | The quantity is outside the range allowed by this service. |
| invalid_comments | 400 | The comments field is empty or has too many lines. |
| invalid_username | 400 | The username is not valid for this service. |
| invalid_subscription | 400 | The subscription parameters are not valid. |
| invalid_runs | 400 | The 'runs' value is not valid for drip-feed. |
| invalid_interval | 400 | The 'interval' value is not valid for drip-feed. |
| dripfeed_not_supported | 400 | This service does not support drip-feed. |
| missing_required_field | 400 | A field required by this service type is missing or invalid. |
| service_inactive | 400 | This service is not currently available for ordering. |
| invalid_cursor | 400 | The pagination cursor is not valid. |
| invalid_limit | 400 | The 'limit' parameter is outside the allowed range. |
| invalid_webhook_url | 400 | The webhook URL must be a public https:// address. |
| invalid_events | 400 | One or more of the requested event types is unknown. |
| batch_too_large | 400 | Too many items in a single batch request. |
| cancel_not_supported | 400 | This service does not support cancellation. |
| refill_not_supported | 400 | This service does not offer refill. |
| unknown_endpoint | 404 | Unknown endpoint. See the API reference for the available routes. |
| service_not_found | 404 | No service exists with this id. |
| order_not_found | 404 | No order exists with this id on your account. |
| refill_not_found | 404 | No refill exists with this id on your account. |
| webhook_not_found | 404 | No webhook endpoint exists with this id on your account. |
| order_not_cancelable | 409 | This order can no longer be canceled because of its current status. |
| cancel_rejected | 409 | The provider rejected the cancellation request. |
| order_not_completed | 409 | Refill can only be requested for a completed order. |
| duplicate_link | 409 | There is already an active order for this link. Wait until it is completed. |
| idempotency_key_reuse | 409 | This Idempotency-Key was already used with a different request body. |
| idempotency_in_progress | 409 | A request with this Idempotency-Key is still being processed. Retry shortly. |
| webhook_limit_reached | 409 | You have reached the maximum number of webhook endpoints. |
| insufficient_balance | 402 | Not enough funds in your balance for this order. |
| rate_limit_exceeded | 429 | Rate limit exceeded. See the Retry-After response header. |
| provider_error | 502 | The upstream provider returned an error. Try again. |
| refill_failed | 502 | The refill request was rejected by the provider. |
| service_temporarily_unavailable | 503 | This service is temporarily unavailable. Try again later. |
| internal_error | 500 | An unexpected error occurred on our side. |