SmileCloudDocs

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
curl https://api.smile-app.co.il/v1/metrics \
  -H "Authorization: Bearer sk_live_..."
sc CLI
sc metrics
sc metrics treatments      # one dataset
json
{
  "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:

FieldTypeRequiredDescription
datasetstringyesDataset name from /v1/metrics.
measuresstring[]yesMeasure names to aggregate.
dimensions(string | {field, grain})[]noGroup-by axes. Use {field, grain} for time dimensions.
filters{field, op, value}[]noop is one of eq, in, not_in, between, gte, lte.
order{by, dir}[]noby references a selected measure or dimension; dir is asc/desc.
limitintegernoMax 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
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 CLI
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
json
{
  "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 *_label for named entities) and the measure values.
  • totals are the grand totals over the whole filtered set.
  • truncated is true when 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:

cURL
# 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 CLI
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 patient returns the patient id (plus a patient_label where available), not contact details — fetch those for the final, small gap list only.
  • Use a bounded between. An open-ended gte alone 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 treatment filter takes treatment ids — resolve codes via /v1/catalog/treatments, or group by treatment_code and filter client-side.

#Datasets

The available datasets grow over time; always check /v1/metrics for the live list. Common ones:

DatasetAggregateGroup by
treatmentsrevenue (price − discount), quantity, countsprovider, treatment, effective date
payment_splitscollected amount, by methodprovider, document type, value date
appointmentsappointment counts, unique patientsstatus, type, provider, branch, start date
patient_balancesbalance (paid − charged)patient, primary provider, status
incomesnet accounting incomedocument 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.