planely API
Read stats, purchases, customers and refunds from your planely instance with your own program: a Google Sheet, a dashboard, a script, a management system. The API is read-only and for now covers analytics only: it doesn't send messages, doesn't touch chats, doesn't change settings.
v1 read-only JSON · CSV
Getting started
- Open the panel, Admin menu, API section. The bot owner and super admins can see it.
- Press "Generate a key", give it a name (e.g. "Google Sheet") and copy it right away: it will never be shown again.
- Try the key with the call below:
curl -H "Authorization: Bearer pl_..." "https://<dominio>/admin/api/v1/me"
{"ok": true, "version": "v1", "key": {"label": "Foglio Google", "prefix": "3f9a2c1b"}, "bot_username": "mia_creator_bot", "accounts": 2}
The base URL is that of your panel followed by /admin/api/v1; the API section already shows it complete. All paths on this page are relative to that base.
Authentication
Every call carries the key in the Authorization header:
Authorization: Bearer pl_3f9a2c1b...
Alternatively, for tools that don't know how to set headers (some spreadsheet connectors), the key is passed in the query string: ?api_key=pl_... Not recommended when you can avoid it: it ends up in logs and in browser history.
A key is valid for the whole instance: all creators connected to the bot, exactly as the owner sees them. You can have multiple keys at the same time (one per integration, so you can revoke one without touching the others).
If a key gets out, revoke it from the panel and generate a new one: from the moment of revocation, every call with that key gets a 401.
Conventions
Responses. Always JSON with "ok": true. Lists have items (or fans, languages, accounts), count (rows on this page) and, where it makes sense, has_more, next and total.
Dates. Fields ending in _ts are Unix seconds in UTC (a number, possibly with decimals). Dates in the parameters (from, to) are YYYY-MM-DD and are in the server's timezone; both days are inclusive.
Amounts. They are in Telegram Stars (integers), planely's internal unit. For creators in Stripe mode, the panel converts to currency at the same rate as the dashboard; here the numbers stay in Stars. External sales have both units (equivalent Stars, what goes into the stats) and amount and currency (the original amount entered manually).
Identifiers. chat_id is the client's Telegram id; account is the creator's id (see /accounts); set_id is the id of the sold content (a script/set from the catalog); sent_by_user_id is the Telegram id of the chatter who sent the content.
Versions. The /v1 prefix is stable: fields and parameters on this page won't disappear. New fields may be added to responses: a program must not break if it finds one it doesn't know.
Common parameters
All parameters go in the query string. An unknown parameter is ignored; an invalid value responds 400 invalid_param with the parameter name.
Period
| Parameter | Meaning | Default |
|---|---|---|
days | The last N days, from now backwards. Max 365. | 30 |
from, to | A date range YYYY-MM-DD, both inclusive. If present, they take precedence over days. Maximum one year. |
Applies to all endpoints except /me and /accounts.
Creator
| Parameter | Meaning |
|---|---|
account_id | One or more creators, separated by comma (business,acc2). Absent = all. A non-existent id is an error. Ids are read from /accounts. |
Pagination
Long lists (/purchases, /paid_media_sent, /refunds) are read in pages, always from the most recent row. Two ways:
| Parameter | Meaning | Default |
|---|---|---|
limit | Rows per page. Max 200 (5000 for /fans and /external_sales). | 50 |
offset | How many rows to skip from the start. Max 5000. | 0 |
before_ts, before_id | The cursor: the response already provides them in the next field; send them back as they are for the next page. |
The cursor is the recommended way to scroll through the whole history: it doesn't skip or repeat rows even if new purchases come in meanwhile, and it doesn't cost more as you go further back. offset is handy for "rows 100 to 150" in a table. You can also combine them. Every paginated response contains:
{"items": [...], "count": 50, "has_more": true, "next": {"before_ts": 1727512345.2, "before_id": 4821}}
When has_more is false, next is null and you're done.
Filters
| Parameter | Where | Meaning |
|---|---|---|
chat_id | purchases, paid_media_sent, fans, external_sales, refunds | Only this customer. |
sent_by_user_id | purchases, paid_media_sent, refunds | Only content sent by this chatter. |
by_user_id | external_sales | Only sales attributed to this chatter. |
set_id | purchases, paid_media_sent | One or more sets, separated by commas. |
min_stars, max_stars | purchases, paid_media_sent, fans, refunds | Minimum/maximum amount in Stars (inclusive). |
min_units, max_units | external_sales | Minimum/maximum value in Stars. |
unlocked | paid_media_sent | true = only purchased ones, false = only unpurchased ones. |
include_free | paid_media_sent | true = also media sent for free (normally excluded). |
search | fans | Customer name containing this text (case-insensitive). |
min_purchases | fans | At least N purchases in the period. |
lang | languages | One or more language codes (it,en). |
min_people | languages | Only languages with at least N people. |
currency | external_sales | Only sales in this currency (EUR). |
connected | accounts | true/false: only creators with (or without) the bot connected. |
status | accounts | Account status (e.g. active). |
sections | stats | Only these dashboard sections, separated by commas. |
For /purchases and /paid_media_sent, the set and amount filters are applied on top of the cursor list: limit remains "how many rows I receive", and the scanned field tells how many rows were read to fill the page. With a very selective filter on a huge history, a page can come back short before the end (has_more true with fewer than limit rows): just continue with next.
Columns
| Parameter | Meaning |
|---|---|
fields | Only these fields in each row, separated by comma (fields=ts,stars,title). A field that doesn't exist is an error, and the message lists the available ones. |
Sorting
| Parameter | Where | Meaning |
|---|---|---|
sort | fans, languages, external_sales | field or field:asc / field:desc (default descending). |
Allowed fields:
/fans:stars,purchases,external,last_ts/languages:people,active,buyers,purchases,stars,external,new_customers/external_sales:ts,units,amount
Cursor lists (/purchases, /paid_media_sent, /refunds) are always by most recent.
Format
| Parameter | Meaning |
|---|---|
format | json (default) or csv: the list rows as a CSV file (UTF-8, header from the keys, nested fields as JSON in the cell). Applies to every list; it combines with fields to choose the columns. |
curl -H "Authorization: Bearer pl_..." \
"https://<dominio>/admin/api/v1/purchases?from=2026-09-01&to=2026-09-30&limit=200&fields=ts,customer_name,title,stars&format=csv" \
-o acquisti.csv
Errors
In case of error the response is:
{"ok": false, "error": "invalid_param", "param": "limit", "detail": "must be an integer"}
| HTTP | error | When |
|---|---|---|
| 401 | missing_api_key | No key in the request. |
| 401 | invalid_api_key | Unknown or revoked key. |
| 402 | subscription_inactive | The instance's planely subscription is not active. |
| 400 | invalid_param | An invalid parameter: non-numeric number, wrong date, nonexistent account_id, unknown fields or sort field, format other than json/csv. The response carries param (which) and detail (why). |
| 404 | Nonexistent path (non-JSON body). | |
| 429 | Over the request limit. |
The codes are stable: a program can compare them. detail is for humans, and can change.
Limits
- 5 requests per second per endpoint, with a reserve of 60 for bursts: beyond that, 429. A dashboard that refreshes every minute or an export in pages of 200 rows fits comfortably.
- /stats runs about fifteen queries on the database: call it when you need it, not in a tight loop. With sections you request only the parts you need.
- limit max 200 (5000 for /fans and /external_sales), offset max 5000: beyond that, you use the cursor.
- A filter applied after reading (set_id, min_stars...) reads at most 4000 rows per page; if that's not enough, the page comes back short with has_more true.
Endpoints
GET /me
Who you are. Useful for verifying the key and reading the server time. No parameters.
{
"ok": true,
"version": "v1",
"key": {"id": "a1b2c3d4e5f6", "label": "Foglio Google", "prefix": "3f9a2c1b", "created_ts": 1727500000.0},
"bot_username": "mia_creator_bot",
"accounts": 2,
"server_time": 1727512345.6
}
GET /accounts
The instance's creators. id is the value to use in account_id.
Parameters: connected, status, fields, format
{
"ok": true,
"count": 2,
"accounts": [
{"id": "business", "label": "Marika", "name": "Marika", "username": "marika", "status": "active", "connected": true},
{"id": "215f4b03", "label": "Nuovo account 2", "name": "Nuovo account 2", "username": null, "status": "active", "connected": false}
]
}
| Field | Meaning |
|---|---|
id | The creator's internal id (business is always the first one). |
label | The name given by the owner at creation. |
name | The Telegram name of the connected account, or label if there isn't one yet. |
username | The Telegram username, if connected. |
status | Account status. |
connected | true if the business bot is connected to the Telegram account. |
GET /stats
The panel's Statistics dashboard, identical number by number.
Parameters: period, account_id, tz_offset (minutes to add to UTC for time slots, e.g. 120 for Italy in summer; without it, the server time), sections
curl -H "Authorization: Bearer pl_..." \
"https://<dominio>/admin/api/v1/stats?days=7&tz_offset=120§ions=growth,funnel,customer_metrics"
Response sections (with sections you only get the ones requested):
| Section | What it contains |
|---|---|
growth | current and previous: revenue for the period and for the previous period of equal length; pct: percentage change. |
revenue_timeseries | Revenue day by day. |
new_customers_daily | New customers (first purchase ever) day by day. |
customer_metrics | Active customers, paying customers, repeat customers, average spend. |
funnel | Messages sent, paid media sent, unlocked, conversion rate. |
hourly_activity | Activity by time slot (in the tz_offset timezone). |
top_customers | The ten customers who spent the most (the full list is in /fans). |
top_content | The best-selling sets. |
refunds | count and stars of refunds accepted in the period. |
channel_messages | Stars paid to write in channel chats. |
external_sales | Summary of manually recorded sales. |
sales_by_source | Revenue by source: Stars, Stripe, external. |
by_account | The same numbers, creator by creator. |
accounts | The creators considered. |
GET /purchases
Purchases: one unlocked paid media = one row. From the most recent, in pages.
Parameters: period, account_id, limit, offset, before_ts, before_id, chat_id, sent_by_user_id, set_id, min_stars, max_stars, fields, format
{
"ok": true,
"count": 1,
"items": [
{
"id": 4821,
"ts": 1727512345.2,
"account": "business",
"chat_id": 123456789,
"customer_name": "Marco",
"set_id": "9cc0a143",
"title": "Set spiaggia",
"stars": 250,
"media_summary": {"photo_count": 3, "video_count": 1, "video_durations": [42], "known": true},
"sent_by_user_id": 987654321,
"sent_by_name": "Chatter Anna"
}
],
"has_more": true,
"next": {"before_ts": 1727512345.2, "before_id": 4821},
"scanned": 1
}
| Field | Meaning |
|---|---|
id | Row ID (used by the cursor). |
ts | When the customer paid. |
account | The creator. |
chat_id, customer_name | The customer. With no known name: "Chat |
set_id, title | The sold content. |
stars | How much they paid, in Stars. |
media_summary | What was inside: photo_count, video_count, video_durations (seconds); known false if unknown. |
sent_by_user_id, sent_by_name | The chatter who sent it; null if it started from an automation (welcome, follow-up) or from the creator's Telegram. |
GET /paid_media_sent
Paid media sent (bought or not), from the most recent, in pages. Same parameters and same fields as /purchases, plus:
| Parameter | Meaning |
|---|---|
unlocked | true only for those later bought, false only for those not bought. |
include_free | true to also count media sent for free. |
| Field | Meaning |
|---|---|
unlocked | true if the customer bought it. |
unlocked_ts | When, if bought. |
Welcome media and mass message media don't appear here (they go out in bulk and would skew conversion): their purchases are still in /purchases.
GET /fans
The ranking of customers by spend in the period: everyone, not just the top ten.
Parameters: period, account_id, limit (≤ 5000), offset, chat_id, search, min_stars, max_stars, min_purchases, sort, fields, format
{
"ok": true,
"days": 30,
"count": 2,
"total": 2,
"has_more": false,
"fans": [
{"chat_id": 123456789, "account": "business", "name": "Marco", "stars": 1200, "purchases": 6, "external": 0, "last_ts": 1727512345.2},
{"chat_id": 987654321, "account": "business", "name": "Chat 987654321", "stars": 300, "purchases": 1, "external": 50, "last_ts": 1727400000.0}
],
"totals": {"fans": 2, "stars": 1500, "external": 50}
}
| Field | Meaning |
|---|---|
stars | Total spend in the period (Stars + equivalent value of external sales). |
purchases | Number of purchases in the period. |
external | The portion of Stars coming from external sales. |
last_ts | The last purchase. |
total | How many customers pass the filters (before limit and offset). |
totals | The totals for all customers in the period, excluding filters. |
GET /languages
Statistics by customer language.
Parameters: period, account_id, lang, min_people, sort, fields, format
{
"ok": true,
"days": 30,
"count": 2,
"languages": [
{"lang": "it", "people": 340, "active": 120, "buyers": 41, "purchases": 88, "stars": 15400, "external": 0, "new_customers": 12},
{"lang": "?", "people": 20, "active": 3, "buyers": 0, "purchases": 0, "stars": 0, "external": 0, "new_customers": 0}
],
"totals": {"people": 360, "active": 123, "buyers": 41, "stars": 15400, "external": 0, "new_customers": 12}
}
| Field | Meaning |
|---|---|
lang | Base language code (pt-br becomes pt); ? = unknown. |
people | Customers with that language (all-time). |
active | Of these, how many wrote in the period. |
buyers, purchases | How many bought in the period and how many purchases. |
stars, external | Revenue for the period and the external portion. |
new_customers | First-ever purchase within the period. |
GET /external_sales
External sales: those recorded by hand from the panel (outside Telegram: bank transfer, PayPal, other).
Parameters: period, account_id, limit (≤ 5000), offset, chat_id, by_user_id, min_units, max_units, currency, sort, fields, format
{
"ok": true,
"days": 30,
"count": 1,
"total": 1,
"has_more": false,
"total_units": 3846,
"items": [
{
"id": 5120, "ts": 1727500000.0, "account": "business", "chat_id": 123456789, "name": "Marco",
"units": 3846, "amount": 50.0, "currency": "EUR", "note": "PayPal",
"by_user_id": 987654321, "assigned_to_name": "Chatter Anna",
"reassigned": null, "edited": {"ts": 1727501000.0, "by_name": "Marika"}
}
]
}
| Field | Meaning |
|---|---|
units | Equivalent value in Stars: that's what goes into the stats. |
amount, currency | The original amount, as written by hand. |
note | The note written with the sale. |
by_user_id, assigned_to_name | Who it's attributed to. |
reassigned | If it was moved to another chatter: when and by whom. |
edited | If it was corrected afterwards: when and by whom. |
GET /refunds
Refunds accepted in the period (the only ones that moved money), from the most recent, in pages with before_id.
Parameters: period, account_id, limit, offset, before_id, chat_id, sent_by_user_id, min_stars, max_stars, fields, format
{
"ok": true,
"days": 30,
"count": 1,
"items": [
{"id": 17, "chat_id": 123456789, "account": "business", "name": "Marco", "title": "Set spiaggia", "stars": 250, "ts": 1727512345.0, "reason_label": "Contenuto non ricevuto"}
],
"has_more": false,
"next": null
}
| Field | Meaning |
|---|---|
stars | How much was refunded. |
ts | When the refund was accepted (or requested, if the resolution date is missing). |
reason_label | The reason chosen by the customer. |
Recipes
Python: all purchases of the month in a CSV
import csv
import requests
BASE = "https://<dominio>/admin/api/v1"
HEAD = {"Authorization": "Bearer pl_..."}
righe, cursore = [], {}
while True:
r = requests.get(f"{BASE}/purchases", headers=HEAD, timeout=30,
params={"from": "2026-09-01", "to": "2026-09-30", "limit": 200, **cursore}).json()
if not r["ok"]:
raise SystemExit(f"errore {r['error']}: {r.get('detail', '')}")
righe += r["items"]
if not r["has_more"]:
break
cursore = r["next"]
with open("acquisti.csv", "w", newline="", encoding="utf-8") as fh:
w = csv.DictWriter(fh, fieldnames=["ts", "account", "customer_name", "title", "stars", "sent_by_name"])
w.writeheader()
for it in righe:
w.writerow({k: it[k] for k in w.fieldnames})
The same, in one line, letting the server do the CSV (up to 200 rows per call):
curl -H "Authorization: Bearer pl_..." \
"https://<dominio>/admin/api/v1/purchases?from=2026-09-01&to=2026-09-30&limit=200&format=csv&fields=ts,account,customer_name,title,stars,sent_by_name" \
-o acquisti.csv
Google Sheets: today's revenue in a cell (Apps Script)
Extensions, Apps Script, paste the code, then in a cell write =PLANELY_INCASSO(1).
const BASE = "https://<dominio>/admin/api/v1";
const KEY = "pl_..."; // meglio in PropertiesService, non nel foglio
function planelyGet(path, params) {
const qs = Object.entries(params || {}).map(([k, v]) => k + "=" + encodeURIComponent(v)).join("&");
const res = UrlFetchApp.fetch(BASE + path + (qs ? "?" + qs : ""), {
headers: { Authorization: "Bearer " + KEY }, muteHttpExceptions: true,
});
const data = JSON.parse(res.getContentText());
if (!data.ok) throw new Error(data.error + (data.detail ? ": " + data.detail : ""));
return data;
}
function PLANELY_INCASSO(giorni) {
return planelyGet("/stats", { days: giorni || 30, sections: "growth" }).growth.current;
}
// Riempie il foglio "Fan" con la classifica dei clienti del mese.
function aggiornaFan() {
const d = planelyGet("/fans", { days: 30, limit: 500, fields: "name,stars,purchases,last_ts" });
const righe = d.fans.map(f => [f.name, f.stars, f.purchases, new Date(f.last_ts * 1000)]);
const sh = SpreadsheetApp.getActive().getSheetByName("Fan");
sh.clearContents();
sh.getRange(1, 1, 1, 4).setValues([["Cliente", "Stelle", "Acquisti", "Ultimo acquisto"]]);
if (righe.length) sh.getRange(2, 1, righe.length, 4).setValues(righe);
}
JavaScript (Node)
const BASE = "https://<dominio>/admin/api/v1";
const headers = { Authorization: "Bearer " + process.env.PLANELY_KEY };
async function acquistiDi(chatId) {
const out = [];
let next = {};
do {
const qs = new URLSearchParams({ days: 365, limit: 200, chat_id: chatId, ...next });
const r = await fetch(`${BASE}/purchases?${qs}`, { headers }).then(r => r.json());
if (!r.ok) throw new Error(r.error);
out.push(...r.items);
next = r.next || {};
} while (next.before_id);
return out;
}
Don't call the API from a public web page: the key would end up in anyone's browser.
curl: typical cases
B="https://<dominio>/admin/api/v1"; H="Authorization: Bearer pl_..."
# revenue and funnel for the last 7 days
curl -H "$H" "$B/stats?days=7§ions=growth,funnel"
# a creator's purchases, above 500 Stars, in September
curl -H "$H" "$B/purchases?account_id=business&from=2026-09-01&to=2026-09-30&min_stars=500"
# how much a chatter sold this month (paid media + external sales)
curl -H "$H" "$B/purchases?days=30&sent_by_user_id=987654321&fields=ts,stars,title"
curl -H "$H" "$B/external_sales?days=30&by_user_id=987654321"
# customers who bought at least 3 times, sorted by purchases
curl -H "$H" "$B/fans?days=90&min_purchases=3&sort=purchases:desc"
# paid media sent and not purchased yesterday (to follow up)
curl -H "$H" "$B/paid_media_sent?from=2026-09-27&to=2026-09-27&unlocked=false&fields=chat_id,customer_name,title,stars"
# languages with at least 50 customers
curl -H "$H" "$B/languages?days=30&min_people=50&sort=stars"
Frequently asked questions
Numbers don't match the dashboard. Check the period: the panel uses the browser timezone for the calendar, the API uses the server timezone for from and to. days=7 in both gives the same result. For time slots pass tz_offset.
I want data for a single creator. account_id with the id taken from /accounts. A single key is enough for all.
How far back can I go? Up to one year per call (days=365 or a one-year interval). For more years, multiple calls with different intervals.
Can I write (send messages, change prices)? No: for now the API is read-only. If you need it, ask: the next versions will start from what integrators ask for.
Has the key expired? Keys do not expire: they are valid until you revoke them from the panel. If you get 401 with a key that was working, it has been revoked. If you get 402, the instance's planely subscription is not active.
How do I handle the key? Like a password: never in a repository, never in a shared sheet, never on a web page. One key per integration, so you can revoke one without stopping the others.
Version history
- 2026-09-28 — v1: first version. Nine analytics-only endpoints; pagination with cursor and offset; filters by client, chatter, set, amount, language, currency; fields, sort, format=csv, sections.