SEED analytics API
Read SEED Global Education’s website analytics from another system: headline figures, pages and sections, traffic sources and campaigns, forms, every recorded event, visitors and their journeys, and, for server keys that are allowed it, the personal details people gave us.
- Base address
https://analytics.seedglobaleducation.com- Format
- JSON, always
{ data, meta } - Authentication
- An API key in a header
- Machine-readable
- OpenAPI 3.1
Quick start
-
Get a key. Open the dashboard, go to API access at the bottom and fill in Create a key. For a system that runs on a server, choose Everything, for our servers. The key is shown once. Copy it straight into your system’s secret settings, such as an environment variable called
SEED_ANALYTICS_KEY, and never into the code. -
Check the key. Call
GET /v1. It answers with the key’s name, what it can read and the endpoints it may call. - Ask for data. For example, the figures for the first three weeks of September. Leave the dates out to get the last 30 days.
# 1. Who am I, and what can I call?
curl -H "Authorization: Bearer $SEED_ANALYTICS_KEY" \
https://analytics.seedglobaleducation.com/v1
# 2. My first figures
curl -H "Authorization: Bearer $SEED_ANALYTICS_KEY" \
"https://analytics.seedglobaleducation.com/v1/stats/overview?from=2026-09-01&to=2026-09-21"// Node 18+ or any modern browser. The key comes from the environment,
// never from the code.
const BASE = 'https://analytics.seedglobaleducation.com';
const KEY = process.env.SEED_ANALYTICS_KEY;
async function seed(path, params = {}) {
const url = new URL(path, BASE);
for (const [name, value] of Object.entries(params)) url.searchParams.set(name, value);
const res = await fetch(url, { headers: { Authorization: `Bearer ${KEY}` } });
const body = await res.json();
if (!res.ok) {
throw new Error(`${body.error.code}: ${body.error.message} (request ${body.meta.request_id})`);
}
return body;
}
// 1. Who am I, and what can I call?
const me = await seed('/v1');
console.log(me.data.key.name, me.data.key.scopes.map((s) => s.scope));
// 2. My first figures
const figures = await seed('/v1/stats/overview', { from: '2026-09-01', to: '2026-09-21' });
console.log(figures.data);// config/services.php: 'seed_analytics' => ['key' => env('SEED_ANALYTICS_KEY')],
use Illuminate\Support\Facades\Http;
$seed = Http::withToken(config('services.seed_analytics.key'))
->baseUrl('https://analytics.seedglobaleducation.com')
->acceptJson()
->timeout(30);
// 1. Who am I, and what can I call?
$me = $seed->get('/v1')->throw()->json('data');
// 2. My first figures
$response = $seed->get('/v1/stats/overview', [
'from' => '2026-09-01',
'to' => '2026-09-21',
]);
if ($response->failed()) {
$error = $response->json('error');
throw new RuntimeException("{$error['code']}: {$error['message']}");
}
$figures = $response->json('data');Authentication
Every request carries the key in a header. Use either of these, whichever your HTTP client makes easier:
Authorization: Bearer sak_k7m2q9x4_…
X-API-Key: sak_k7m2q9x4_…
Never put a key in the URL (?key=…, ?api_key=…). Web addresses are written to server logs, proxies and browser history, and anyone who can read those can read the key. The API refuses such a request with key_in_url. The key has already travelled by then, so cancel it in the dashboard and create a new one.
A key looks like sak_<key id>_<secret>. The key id is the part the dashboard lists. It is not secret, so it is fine in a ticket or a chat. The whole key is shown once, when it is created, and cannot be shown again. A lost key is replaced, not recovered.
- No key, or a key we did not issue:
401 unauthorized. - A key that was cancelled:
401 key_revoked. - Keep keys out of source control. Use an environment variable, Laravel’s
.envor a secret manager. - Use one key per system, so you can cancel one without breaking the others, and so the dashboard’s request counts show which system is calling.
Server keys and browser keys
There are two kinds of key, and one question decides which you need: will a web browser use it?
| Server key | Browser key | |
|---|---|---|
| Used from | Your own servers: Laravel, a scheduled job, the CRM | JavaScript in web pages at the addresses you list |
| Website addresses | None | One or more, e.g. https://www.seedglobaleducation.com |
| Personal data | Yes, if it holds the people permission | Never, whatever it holds |
| The key itself | Must stay secret | Anyone who opens the page can read it, so treat it as public |
A browser key only works from the addresses it was created with. The API answers the browser’s cross-origin (CORS) checks for those addresses only. A browser on any other website gets 403 origin_not_allowed.
Personal data
Personal data means names, email addresses, phone numbers, IP addresses and user agents, plus any event property that holds one of those. Only a server key holding people ever sees it. For every other key:
- those fields are left out of the answer entirely. The field is not there at all, rather than being
null; - endpoints that exist only to return personal data answer
403 forbidden_scope.
In the reference below, these fields carry a Personal data badge. In your code, check whether a field is present ('email' in person, array_key_exists('email', $person)), not whether it is null. The dashboard will not create a browser key with people, and the API would ignore it on one anyway.
Permissions
A key holds one or more permissions (scopes). Each endpoint needs one, which the reference shows as Needs stats. Calling an endpoint without its permission answers 403 forbidden_scope, with the missing permission in error.details.required_scope. GET /v1 needs none, and lists only the endpoints the key can call.
Loading…
people works differently from the others. It adds personal details to what the other permissions already return, and it opens the People endpoints. It only counts on a server key.
The response format
Every successful answer is a JSON object with two keys:
{
"data": { … },
"meta": {
"request_id": "req_01J8ZK3T5V9QW4N7",
"generated_at": "2026-09-21T10:20:03.123Z",
"query": { "from": "2026-09-01", "to": "2026-09-21", "site": null, "bots": "human" },
"pagination": { "limit": 100, "next_cursor": "eyJ0IjoiMjAyNi0wOS0yMSJ9", "has_more": true }
}
}
datais the answer itself: an object, or a list.meta.queryis what was actually applied, defaults included. It is useful when you left something out.meta.paginationappears on lists that come in pages. See Pagination.meta.request_idis also sent as theX-Request-Idheader. Quote it if you report a problem.
How values are written
- Field names are
snake_case. - Ids are strings, even when they look like numbers.
- Counts are JSON numbers.
- Times are ISO 8601 in UTC, ending in
Z:2026-09-21T10:20:03.123Z. - Days are
YYYY-MM-DD. - Durations are seconds, in fields ending
_seconds. - Percentages run from 0 to 100, in fields ending
_pct, so12.5means 12.5%. - A missing value is
null, never an empty string. - Personal-data fields are absent unless the key may see them.
Errors
A failure has the same outer shape, with error in place of data:
{
"error": {
"code": "invalid_parameter",
"message": "from must be a date like 2026-09-01.",
"param": "from"
},
"meta": { "request_id": "req_01J8ZK3T5V9QW4N7", "generated_at": "2026-09-21T10:20:03.123Z" }
}
Branch on error.code, because it will not change. message is written for a person and may be reworded. param names the parameter at fault, and details carries extra facts for some codes. Messages never contain database text. The full story is in our logs under the request_id.
Loading…
Pagination
Lists of raw rows, such as events, visitors, sessions and people, come in pages. limit sets the page size: 100 by default, and at most 1000 unless the endpoint says otherwise. Each answer’s meta.pagination says whether there is more:
has_more: truemeans there is another page. Call again withcursorset tonext_cursor, and every other parameter unchanged.has_more: false, withnext_cursor: null, means you have everything.
A cursor is opaque. Do not build one or change it. Pages follow a fixed order, so a row never appears on two pages. To get totals, use the Stats endpoints rather than paging through a year of events.
// Uses seed() from the quick start.
async function everyRow(path, params) {
const rows = [];
let cursor = null;
do {
const page = await seed(path, cursor ? { ...params, limit: 1000, cursor } : { ...params, limit: 1000 });
rows.push(...page.data);
cursor = page.meta.pagination.next_cursor;
} while (cursor);
return rows;
}
const events = await everyRow('/v1/events', { from: '2026-09-20', to: '2026-09-21' });// Uses $seed from the quick start.
$rows = [];
$cursor = null;
do {
$page = $seed->get('/v1/events', array_filter([
'from' => '2026-09-20',
'to' => '2026-09-21',
'limit' => 1000,
'cursor' => $cursor,
]))->throw()->json();
array_push($rows, ...$page['data']);
$cursor = $page['meta']['pagination']['next_cursor'];
} while ($cursor !== null);Dates and time zones
fromandtoare whole days, writtenYYYY-MM-DD, and both are included.from=2026-09-01&to=2026-09-01is one day.- They are UTC days. A UTC day runs from 05:30 to 05:30 the next morning in India (IST, UTC+5:30). So an event at 02:00 IST on 2 September belongs to UTC day 1 September.
- Leave both out to get the 30 days ending today. They may be at most 400 days apart.
- Today is included by default and is still being written to, so its figures grow through the day. For a report that must not change later, end it yesterday.
- Every time in an answer is ISO 8601 in UTC, ending in
Z, e.g.2026-09-21T10:20:03.123Z. Convert it to local time only when you show it:new Date(value)in JavaScript, orCarbon::parse($value)->setTimezone('Asia/Kolkata')in Laravel.
Rate limits and busy answers
Each key may make a set number of requests a minute: 120, unless it was created with another figure. Every answer says where you stand:
X-RateLimit-Limit: requests allowed per minute;X-RateLimit-Remaining: requests left this minute;X-RateLimit-Reset: seconds until the allowance fills up again.
Two answers mean “not now, try again shortly”:
429 rate_limitedmeans this key is over its limit.503 server_busymeans the analytics database is busy. Integrations get a few requests at a time, so they can never slow the dashboard down.
Both carry a Retry-After header, in seconds. Wait that long, then send the same request again. If it is missing, wait 1, 2, 4… seconds, up to a minute, with a little randomness, and give up after five or six tries. Do not retry other 4xx answers. They will fail again until the request changes.
async function seedPolitely(path, params = {}, tries = 5) {
for (let attempt = 1; ; attempt++) {
const url = new URL(path, BASE);
for (const [name, value] of Object.entries(params)) url.searchParams.set(name, value);
const res = await fetch(url, { headers: { Authorization: `Bearer ${KEY}` } });
if ((res.status === 429 || res.status === 503) && attempt < tries) {
const wait = Number(res.headers.get('Retry-After')) || Math.min(60, 2 ** attempt);
await new Promise((resolve) => setTimeout(resolve, wait * 1000 + Math.random() * 500));
continue;
}
const body = await res.json();
if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message}`);
return body;
}
}use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
$response = Http::withToken(config('services.seed_analytics.key'))
->baseUrl('https://analytics.seedglobaleducation.com')
->retry(
5,
// How long to wait: Retry-After when it is given, otherwise 2, 4, 8… seconds.
fn (int $attempt, Throwable $e) => $e instanceof RequestException && $e->response->header('Retry-After')
? (int) $e->response->header('Retry-After') * 1000
: min(60000, 1000 * 2 ** $attempt),
// Only 429 and 503 are worth trying again.
fn (Throwable $e) => $e instanceof RequestException
&& in_array($e->response->status(), [429, 503], true),
)
->get('/v1/stats/overview', ['from' => '2026-09-01', 'to' => '2026-09-21']);Stability
v1 is a contract. Within it:
- a field in an answer keeps its name, its type and its meaning, and is not removed;
- endpoints, parameters and error codes are not removed or renamed;
- new endpoints, optional parameters, fields and error codes may be added. Ignore fields you do not recognise, and handle an unknown error code by its HTTP status;
- error messages may be reworded, so branch on
error.code.
A change that broke these rules would come as /v2, alongside v1, not instead of it. The machine-readable form of this reference is /v1/openapi.json (OpenAPI 3.1). You can generate a client from it, or import it into Postman or Insomnia.
Endpoint reference
Every endpoint, grouped by what it reads. Each one shows the permission it needs, its parameters, every field in its answer and an example.
Loading the reference…