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

  1. Open the panel, Admin menu, API section. The bot owner and super admins can see it.
  2. Press "Generate a key", give it a name (e.g. "Google Sheet") and copy it right away: it will never be shown again.
  3. 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

ParameterMeaningDefault
daysThe last N days, from now backwards. Max 365.30
from, toA 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

ParameterMeaning
account_idOne 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:

ParameterMeaningDefault
limitRows per page. Max 200 (5000 for /fans and /external_sales).50
offsetHow many rows to skip from the start. Max 5000.0
before_ts, before_idThe 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

ParameterWhereMeaning
chat_idpurchases, paid_media_sent, fans, external_sales, refundsOnly this customer.
sent_by_user_idpurchases, paid_media_sent, refundsOnly content sent by this chatter.
by_user_idexternal_salesOnly sales attributed to this chatter.
set_idpurchases, paid_media_sentOne or more sets, separated by commas.
min_stars, max_starspurchases, paid_media_sent, fans, refundsMinimum/maximum amount in Stars (inclusive).
min_units, max_unitsexternal_salesMinimum/maximum value in Stars.
unlockedpaid_media_senttrue = only purchased ones, false = only unpurchased ones.
include_freepaid_media_senttrue = also media sent for free (normally excluded).
searchfansCustomer name containing this text (case-insensitive).
min_purchasesfansAt least N purchases in the period.
langlanguagesOne or more language codes (it,en).
min_peoplelanguagesOnly languages with at least N people.
currencyexternal_salesOnly sales in this currency (EUR).
connectedaccountstrue/false: only creators with (or without) the bot connected.
statusaccountsAccount status (e.g. active).
sectionsstatsOnly 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

ParameterMeaning
fieldsOnly 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

ParameterWhereMeaning
sortfans, languages, external_salesfield 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

ParameterMeaning
formatjson (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"}
HTTPerrorWhen
401missing_api_keyNo key in the request.
401invalid_api_keyUnknown or revoked key.
402subscription_inactiveThe instance's planely subscription is not active.
400invalid_paramAn 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).
404Nonexistent path (non-JSON body).
429Over 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}
  ]
}
FieldMeaning
idThe creator's internal id (business is always the first one).
labelThe name given by the owner at creation.
nameThe Telegram name of the connected account, or label if there isn't one yet.
usernameThe Telegram username, if connected.
statusAccount status.
connectedtrue 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&sections=growth,funnel,customer_metrics"

Response sections (with sections you only get the ones requested):

SectionWhat it contains
growthcurrent and previous: revenue for the period and for the previous period of equal length; pct: percentage change.
revenue_timeseriesRevenue day by day.
new_customers_dailyNew customers (first purchase ever) day by day.
customer_metricsActive customers, paying customers, repeat customers, average spend.
funnelMessages sent, paid media sent, unlocked, conversion rate.
hourly_activityActivity by time slot (in the tz_offset timezone).
top_customersThe ten customers who spent the most (the full list is in /fans).
top_contentThe best-selling sets.
refundscount and stars of refunds accepted in the period.
channel_messagesStars paid to write in channel chats.
external_salesSummary of manually recorded sales.
sales_by_sourceRevenue by source: Stars, Stripe, external.
by_accountThe same numbers, creator by creator.
accountsThe 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
}
FieldMeaning
idRow ID (used by the cursor).
tsWhen the customer paid.
accountThe creator.
chat_id, customer_nameThe customer. With no known name: "Chat ".
set_id, titleThe sold content.
starsHow much they paid, in Stars.
media_summaryWhat was inside: photo_count, video_count, video_durations (seconds); known false if unknown.
sent_by_user_id, sent_by_nameThe chatter who sent it; null if it started from an automation (welcome, follow-up) or from the creator's Telegram.

Paid media sent (bought or not), from the most recent, in pages. Same parameters and same fields as /purchases, plus:

ParameterMeaning
unlockedtrue only for those later bought, false only for those not bought.
include_freetrue to also count media sent for free.
FieldMeaning
unlockedtrue if the customer bought it.
unlocked_tsWhen, 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}
}
FieldMeaning
starsTotal spend in the period (Stars + equivalent value of external sales).
purchasesNumber of purchases in the period.
externalThe portion of Stars coming from external sales.
last_tsThe last purchase.
totalHow many customers pass the filters (before limit and offset).
totalsThe 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}
}
FieldMeaning
langBase language code (pt-br becomes pt); ? = unknown.
peopleCustomers with that language (all-time).
activeOf these, how many wrote in the period.
buyers, purchasesHow many bought in the period and how many purchases.
stars, externalRevenue for the period and the external portion.
new_customersFirst-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"}
    }
  ]
}
FieldMeaning
unitsEquivalent value in Stars: that's what goes into the stats.
amount, currencyThe original amount, as written by hand.
noteThe note written with the sale.
by_user_id, assigned_to_nameWho it's attributed to.
reassignedIf it was moved to another chatter: when and by whom.
editedIf 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
}
FieldMeaning
starsHow much was refunded.
tsWhen the refund was accepted (or requested, if the resolution date is missing).
reason_labelThe 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&sections=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.