Identity, organizations, applications and keys
Everything an agent needs before it can send its first event. See README.md for authentication, scopes, errors and pagination.
Endpoints
| Endpoint | Scope | Permission | What it does |
|---|---|---|---|
GET /me |
read | — | Describe the token and who it acts as |
GET /orgs |
read | — | List the organizations this token may address |
GET /orgs/{org}/apps |
read | apps.read |
List the applications in an organization |
POST /orgs/{org}/apps |
write | apps.write |
Create an application with its three environments |
GET /apps/{app} |
read | apps.read |
Read one application |
PATCH /apps/{app} |
write | apps.write |
Update an application's settings |
GET /apps/{app}/keys |
admin | keys.read |
List ingest keys |
POST /apps/{app}/keys |
admin | keys.write |
Mint an ingest key |
DELETE /apps/{app}/keys/{id} |
admin | keys.write |
Revoke an ingest key |
GET /apps/{app}/install |
read | apps.read |
Install snippets for a framework |
GET /me
The first call to make when you do not know what a token can do.
{
"token": { "id": "9f2c…", "kind": "personal", "scopes": ["read", "write"] },
"user": { "id": "u_1", "email": "ada@example.com", "name": "Ada" },
"organizations": [{ "id": "o_1", "slug": "acme", "name": "Acme", "role": "owner" }],
"permissions": ["org.read", "apps.read", "analytics.read", "analytics.write", "…"]
}
permissions is already intersected with the token's scopes, so it is exactly
what this token may do — not what its owner may do in the console.
GET /orgs
{ "data": [{ "id": "o_1", "slug": "acme", "name": "Acme", "role": "owner" }], "nextCursor": null }
An organization token lists exactly one row.
Applications
GET /orgs/{org}/apps and GET /apps/{app} return the same shape:
{
"id": "a_1",
"slug": "site",
"name": "Marketing site",
"platform": "web",
"url": "https://example.com",
"timezone": "Europe/Paris",
"modules": ["analytics", "people", "errors", "metrics", "flags"],
"retentionDays": 365,
"allowedOrigins": ["https://example.com"],
"connector": {
"status": "healthy",
"capabilities": ["overview", "users", "controls"],
"version": "demo-0.1.0",
"lastHealthAt": "2026-09-03T09:12:44.117Z",
"url": "https://app.example.com/api/spm/connector"
},
"organization": { "id": "o_1", "slug": "acme", "name": "Acme" },
"environments": [
{ "id": "e_1", "slug": "production", "name": "Production" },
{ "id": "e_2", "slug": "staging", "name": "Staging" },
{ "id": "e_3", "slug": "development", "name": "Development" }
]
}
modules is the list to check before calling anything else: an endpoint whose
module is off answers 403, and the assistant simply does not offer the tool.
connector
null unless the application has a signed connector
configured. When present:
| Field | Meaning |
|---|---|
status |
unconfigured, configured, healthy, unhealthy or disabled |
capabilities |
What the tenant handler answered on its last /health, a subset of overview, users, controls, prompts, agent-context, communications |
version |
Version string the tenant reported, or null |
lastHealthAt |
When that probe answered, or null |
url |
Base URL of the tenant handler — only present for a token holding apps.write |
status is the verdict of the last probe, not a live check: a healthy
connector whose lastHealthAt is hours old has not been asked since. The url
is where signed 60-second tokens are sent, so it is omitted for a read-only
token rather than returned as null — the field is simply absent.
POST /orgs/{org}/apps
{
"name": "Marketing site",
"slug": "site",
"platform": "web",
"url": "https://example.com",
"modules": ["analytics", "errors"]
}
Only name is required; the slug is derived from it. The three environments are
created with the application. A slug already used in that organization answers
409 conflict.
Enabling ai requires at least one data module — the assistant with nothing to
read is a configuration error, not an empty dashboard.
PATCH /apps/{app}
Send only what changes:
{ "name": "Site", "timezone": "UTC", "modules": ["analytics"], "allowedOrigins": ["https://example.com"], "retentionDays": 90 }
Origins are normalised to their origin form (https://example.com/pricing
becomes https://example.com) and de-duplicated; * is kept as-is. Anything
that does not parse as a URL is a 400. An empty body is a 400 rather than a
silent no-op.
Ingest keys
These are the keys your application sends events with — not access tokens. Two kinds:
| Kind | Prefix | May |
|---|---|---|
| public | spm_pub_<env>_ |
send events, evaluate flags — safe in a browser bundle |
| secret | spm_sec_<env>_ |
the above, plus server-sent context.ip and context.userAgent |
curl -X POST -H "Authorization: Bearer $SPM_TOKEN" -H 'content-type: application/json' \
-d '{"kind":"public","environment":"production","label":"marketing site"}' \
https://saaspro.dev/api/v1/apps/acme/site/keys
{
"id": "k_1",
"kind": "public",
"environment": "production",
"label": "marketing site",
"prefix": "spm_pub_prod",
"last4": "9f2c",
"key": "spm_pub_prod_xxxxxxxxxxxxxxxxxxxxxx"
}
key is present in this response and nowhere else, ever. GET returns the
prefix and last four characters only. DELETE revokes; revoking twice answers
404.
GET /apps/{app}/install
Optional privacyMode=persistent|ephemeral defaults to persistent. Ephemeral instructions support html, react and nextjs only; other frameworks return 400. The response includes privacyMode. Ephemeral examples start consent pending and return no secret-key prefix or server instrumentation. All snippets show placeholders or stored public-key prefixes, which must be replaced with a full key.
Copy-paste instructions for one framework, using this application's real key prefix and host.
?framework=nextjs|react|node|html|express|hono (default nextjs)
?environment=production|staging|development (default production)
{
"framework": "nextjs",
"environment": "production",
"publicKeyPrefix": "spm_pub_prod",
"secretKeyPrefix": "spm_sec_prod",
"host": "https://saaspro.dev",
"docsUrl": "/docs/sdk/recipes/nextjs-app-router",
"snippets": [{ "title": "Install", "language": "bash", "code": "npm install @saaspro/react @saaspro/node" }]
}
The snippets mirror docs/sdk exactly — same packages, same
option names, same exported symbols — so they can be pasted verbatim. They
contain the key prefix, never a key: mint one with POST /keys and put the
value in an environment file.
Telemetry usage and controls
GET /apps/{app}/telemetry?env=production returns the current policy, 30 UTC days of usage and the latest 50 budget alerts. GET /apps/{app}/telemetry/cardinality returns bounded label/property diagnostics with signal-specific permissions. Both require apps.read. POST /apps/{app}/telemetry accepts {expectedRevision, policy} and requires apps.write; changing notification channel targets additionally requires metrics.write. See Telemetry budgets for accounting, sampling, cardinality and retry semantics.