Analytics
The analytics surface answers "total / count / average per X" questions with a single server-side aggregation, instead of paging every record and summing it client-side. You describe what to aggregate as a small JSON query against a registry of datasets; the API runs one GROUP BY in the database and returns grouped rows plus grand totals. It requires the analytics:read scope (financial measures additionally require their own scope, e.g. payments:read).
New questions need no new endpoint — anything the registry exposes is queryable by composing a request.
#Discover what's queryable
GET
Returns the datasets and, for each, its measures (what you can aggregate), dimensions (what you can group by), and filterable fields with their allowed operators. Call this first to compose a valid query.
curl https://api.smile-app.co.il/v1/metrics \
-H "Authorization: Bearer sk_live_..."
sc metrics
sc metrics treatments # one dataset
{
"data": {
"datasets": [
{
"name": "treatments",
"description": "Performed treatments: revenue (price − discount), quantity and counts…",
"measures": [
{ "name": "net_revenue", "kind": "sum", "unit": "currency", "requires_scope": "payments:read" },
{ "name": "treatment_count", "kind": "count", "unit": "count" }
],
"dimensions": [
{ "name": "provider", "kind": "column" },
{ "name": "effective_date", "kind": "time", "grains": ["day", "week", "month", "quarter", "year"] }
],
"filters": [
{ "name": "effective_date", "type": "date", "operators": ["between", "gte", "lte"] },
{ "name": "provider", "type": "id_list", "operators": ["in", "not_in"] }
],
"requires_date_range": true,
"date_filter": "effective_date"
}
]
}
}
A measure or dimension that needs a scope the key lacks is simply omitted from the response.
#Run an aggregation
POST
The request body is the query contract:
| Field | Type | Required | Description |
|---|---|---|---|
dataset | string | yes | Dataset name from /v1/metrics. |
measures | string[] | yes | Measure names to aggregate. |
dimensions | (string | {field, grain})[] | no | Group-by axes. Use {field, grain} for time dimensions. |
filters | {field, op, value}[] | no | op is one of eq, in, not_in, between, gte, lte. |
order | {by, dir}[] | no | by references a selected measure or dimension; dir is asc/desc. |
limit | integer | no | Max groups returned (capped per dataset). |
Datasets that aggregate over time require a bounded date range (a between, or gte + lte) on their date field.
curl https://api.smile-app.co.il/v1/query \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"dataset": "treatments",
"measures": ["net_revenue", "treatment_count"],
"dimensions": ["provider"],
"filters": [
{ "field": "effective_date", "op": "between", "value": ["2026-01-01", "2026-06-30"] }
],
"order": [{ "by": "net_revenue", "dir": "desc" }],
"limit": 50
}'
sc query treatments \
-m net_revenue -m treatment_count \
-b provider \
-f effective_date:between:2026-01-01..2026-06-30 \
-o net_revenue:desc --limit 50
{
"data": {
"dataset": "treatments",
"rows": [
{ "net_revenue": 153204.5, "treatment_count": 412, "provider": "12", "provider_label": "Dr. Cohen" }
],
"totals": { "net_revenue": 1512333.0, "treatment_count": 4120 },
"measures": ["net_revenue", "treatment_count"],
"dimensions": ["provider"],
"truncated": false
}
}
- Each row carries the dimension values (with a
*_labelfor named entities) and the measure values. totalsare the grand totals over the whole filtered set.truncatedistruewhen the group count hit the cap — narrow the filters.
Why this exists
Asking an agent for "revenue per provider" the slow way means listing every patient, paging their treatments, and summing in the model — hundreds of calls, and easy to get wrong (no discount netting, wrong dates). One /v1/query returns the exact figure straight from the database.
#Cohorts and gaps
A query runs one GROUP BY over a single dataset — there are no cross-dataset joins or NOT EXISTS. You don't need them: answer "in A but not in B" questions by running two aggregations grouped by patient and taking the set difference of the returned patient ids in your own code.
Example — patients who had a treatment in a window but have no upcoming appointment:
# A — patients who had the treatment (group treatments by patient)
curl https://api.smile-app.co.il/v1/query \
-H "Authorization: Bearer sk_live_..." -H "Content-Type: application/json" \
-d '{ "dataset": "treatments", "measures": ["treatment_count"], "dimensions": ["patient"],
"filters": [
{ "field": "effective_date", "op": "between", "value": ["2026-06-16", "2026-06-30"] },
{ "field": "treatment", "op": "in", "value": ["79", "80", "81"] } ] }'
# B — patients who have a future appointment (group appointments by patient)
curl https://api.smile-app.co.il/v1/query \
-H "Authorization: Bearer sk_live_..." -H "Content-Type: application/json" \
-d '{ "dataset": "appointments", "measures": ["appointment_count"], "dimensions": ["patient"],
"filters": [
{ "field": "start_date", "op": "between", "value": ["2026-06-30", "2027-06-29"] } ] }'
# gap = A − B → subtract the patient ids client-side, then fetch the small gap list:
# GET /v1/patients/{id} for names, phones, balances.
sc query treatments -m treatment_count -b patient \
-f effective_date:between:2026-06-16..2026-06-30 -f treatment:in:79,80,81 --json
sc query appointments -m appointment_count -b patient \
-f start_date:between:2026-06-30..2027-06-29 --json
# subtract the patient ids; then `sc patients get <id>` for the gap list.
This turns an N+1 sweep (one appointment lookup per cohort patient) into two aggregations + set math + one lookup per answer. The same shape answers "treated but never returned", "has a balance but no upcoming visit", and "no recall booked".
Gotchas
- Group by
patientreturns the patient id (plus apatient_labelwhere available), not contact details — fetch those for the final, small gap list only. - Use a bounded
between. An open-endedgtealone can return nothing, and ranges over 400 days are rejected. For "any future appointment", a[today, today+365]window is the practical horizon. - Filter values are ids, not human codes. The
treatmentfilter takes treatment ids — resolve codes via/v1/catalog/treatments, or group bytreatment_codeand filter client-side.
#Datasets
The available datasets grow over time; always check /v1/metrics for the live list. Common ones:
| Dataset | Aggregate | Group by |
|---|---|---|
treatments | revenue (price − discount), quantity, counts | provider, treatment, effective date |
payment_splits | collected amount, by method | provider, document type, value date |
appointments | appointment counts, unique patients | status, type, provider, branch, start date |
patient_balances | balance (paid − charged) | patient, primary provider, status |
incomes | net accounting income | document type, business (pinkas), document date |
Read-only and scoped
Analytics is read-only and tenant-scoped like the rest of v1. Financial measures (net_revenue, total_amount, balance, net_income, …) require payments:read in addition to analytics:read; without it they don't appear in /v1/metrics and can't be queried.