---
title: "Client API"
description: "Read your organization's programs, studies and collected responses with a client key."
url: https://developer.tendralhealth.com/docs/client-api
---

# Client API

Read your organization's programs, studies and collected responses from your own systems. Every endpoint is a `GET`, reads only, and answers JSON. The API has exactly the reads your dashboard shows, through the same code, so a number here and on screen cannot differ.

## Keys

- Any login of your organization makes a key on the **Developers** page of the Tendral dashboard. You see the key once; Tendral keeps only its hash.
- Send it as a bearer token: `Authorization: Bearer tk_live_…`.
- A key reads your own organization's data only. No parameter names an organization; the key decides it.
- A key belongs to your organization, not to the person who made it: it keeps working when they leave, until someone revokes it on the Developers page.
- Every client key carries `client:programs:read` and `client:responses:read`. Each endpoint names the one it needs.

## What the API never returns

- A respondent's name or NPI. A response carries the clinician's specialty and practice state, never who they are.
- A response a clinician has not completed, one from a clinician whose identity is not verified, or one not yet delivered to you.
- A program, study or response that is hidden from you or belongs to another organization: its id answers `404` [`NOT_FOUND`](/docs/errors#NOT_FOUND), the same as an id that does not exist.

## Calling it

- Call it from your server. No endpoint sends CORS headers, so a browser page cannot call it, and a key must never be in one.
- Every answer carries `Cache-Control: private, no-store`. Do not cache answers on a shared cache.
- Each key may make 100 requests per minute ([Rate limits](/docs/rate-limits)).
- A parameter an endpoint does not take, a repeated one, or a malformed one is `400` [`INVALID_PARAMETER`](/docs/errors#INVALID_PARAMETER), and `error.details.parameter` names it.
- While the client API is switched off, every endpoint answers `503` [`CLIENT_API_DISABLED`](/docs/errors#CLIENT_API_DISABLED). Retry later.

## Endpoints

| Endpoint | Path | Scope |
| --- | --- | --- |
| [List programs](#list-programs) | `GET /v1/client/programs` | `client:programs:read` |
| [Get a program](#get-a-program) | `GET /v1/client/programs/{programId}` | `client:programs:read` |
| [Responses per day](#responses-per-day) | `GET /v1/client/programs/{programId}/series` | `client:programs:read` |
| [Get program audience](#get-program-audience) | `GET /v1/client/programs/{programId}/audience` | `client:programs:read` |
| [List study questions](#list-study-questions) | `GET /v1/client/studies/{surveyId}/questions` | `client:programs:read` |
| [Get study results](#get-study-results) | `GET /v1/client/studies/{surveyId}/results` | `client:responses:read` |
| [List responses](#list-responses) | `GET /v1/client/programs/{programId}/responses` | `client:responses:read` |
| [Get a response](#get-a-response) | `GET /v1/client/responses/{responseId}` | `client:responses:read` |
| [List what changed](#list-what-changed) | `GET /v1/client/changes` | `client:programs:read` |

## List programs

`GET /v1/client/programs` · scope `client:programs:read`

The programs this client can see, newest first. For each: its id, its organization, its name, program_status (the status an admin recorded; it does not say whether the program is collecting), study_count (its studies the client can see: live or closed, or sent for the client's review), collected (its responses the client can see: complete, from an identity-verified clinician, and delivered) and four facts from its studies: is_complete (every study closed), is_collecting (a study is live), has_previews_waiting (a study waits for the client's review) and has_scheduled_start (a study not yet live has a future start).

### Parameters

No parameters.

### Response

A list (`object: "list"`); every row is in `data`, on one page. Each row:

| Field | Type |
| --- | --- |
| `program_id` | uuid |
| `organization_id` | uuid |
| `name` | string |
| `program_status` | string |
| `study_count` | integer |
| `collected` | integer |
| `is_complete` | boolean |
| `is_collecting` | boolean |
| `has_previews_waiting` | boolean |
| `has_scheduled_start` | boolean |

```bash curl
curl -H "Authorization: Bearer $TENDRAL_API_KEY" "https://api.tendralhealth.com/v1/client/programs"
```

## Get a program

`GET /v1/client/programs/{programId}` · scope `client:programs:read`

One program this client can see, as exactly one row. Its program_id, name and description. Four facts from its studies: is_complete (every study closed, or the program marked complete), is_collecting (a study is live), has_previews_waiting (a study waits for the client's review) and has_scheduled_start (a study not yet live has a future start). Counts are of responses the client can see (complete, from an identity-verified clinician, and delivered) in the studies it can see (live or closed, or sent for its review): collected (all), collected_last_14_days (the 14 UTC calendar days that end today, today included), new_last_24h and new_last_7d (by when each response first became visible to the client), clinician_count (distinct clinicians who answered) and study_count. first_collected_at and last_collected_at are ISO 8601 times, null when there are no responses. goals: each live goal, target_count responses by due_on (YYYY-MM-DD), with an optional label. next_step_body and next_step_at: the program's latest "Now" line and when it was written, or null. studies: each study it can see, with survey_id, title, status, collected, review_state (the latest review event: sent_for_review, approved or changes_requested; null when none), start_at / end_at, and target_count (how many responses the study is set to collect; null when it has no target). A program that does not exist, is hidden, or is not this client's is refused. It never returns a response rate, an invitation count, a pool size or a price.

### Parameters

| Parameter | In | Required | Value |
| --- | --- | --- | --- |
| `programId` | path | yes | string uuid. |

### Response

One resource, with `object` naming it. Fields:

| Field | Type |
| --- | --- |
| `program_id` | uuid |
| `name` | string |
| `description` | string or null |
| `is_complete` | boolean |
| `is_collecting` | boolean |
| `has_previews_waiting` | boolean |
| `has_scheduled_start` | boolean |
| `study_count` | integer |
| `clinician_count` | integer |
| `collected` | integer |
| `collected_last_14_days` | integer |
| `new_last_24h` | integer |
| `new_last_7d` | integer |
| `first_collected_at` | date-time or null |
| `last_collected_at` | date-time or null |
| `goals` | array of object (`goal_id`, `target_count`, `due_on`, `label`) |
| `next_step_body` | string or null |
| `next_step_at` | date-time or null |
| `studies` | array of object (`survey_id`, `title`, `status`, `collected`, `review_state`, `start_at`, `end_at`, `target_count`) |

```bash curl
curl -H "Authorization: Bearer $TENDRAL_API_KEY" "https://api.tendralhealth.com/v1/client/programs/<programId>"
```

## Responses per day

`GET /v1/client/programs/{programId}/series` · scope `client:programs:read`

One program's responses per day, from the day of its first visible response to today, in timeZone (an IANA name such as America/New_York; UTC when left out). One row per day: day (YYYY-MM-DD), collected_on_day (responses that first became visible to the client that day; a response not yet stamped counts today) and cumulative (the running total). Days with none are listed with 0; no rows means the program has no visible responses. It counts only responses the client can see (complete, from an identity-verified clinician, and delivered) in the studies it can see. A time zone that does not exist is refused as invalid input; a program that does not exist, is hidden, or is not this client's is refused. It never returns a rate or a forecast.

### Parameters

| Parameter | In | Required | Value |
| --- | --- | --- | --- |
| `programId` | path | yes | string uuid. |
| `timeZone` | query | no | string matching `^[A-Za-z0-9_+/-]+$`. |

### Response

A list (`object: "list"`); every row is in `data`, on one page. Each row:

| Field | Type |
| --- | --- |
| `day` | date (YYYY-MM-DD) |
| `collected_on_day` | integer |
| `cumulative` | integer |

```bash curl
curl -H "Authorization: Bearer $TENDRAL_API_KEY" "https://api.tendralhealth.com/v1/client/programs/<programId>/series"
```

## Get program audience

`GET /v1/client/programs/{programId}/audience` · scope `client:programs:read`

Who one program targets and who answered, as exactly one row. audiences: each audience the survey admits, with specialties (NUCC taxonomy code and label), states (an empty list means every state) and activity_list (true when the audience is also selected by treatment activity; the list itself is never named). respondents_by_state and respondents_by_specialty: distinct clinicians whose responses the client can see, by practice state and by primary specialty from the NPI registry, largest group first; clinicians with no registry entry are counted under null. A program that does not exist, is hidden, or is not this client's is refused. It never returns a pool size, an invitation count, a vendor or list name, or a rate.

### Parameters

| Parameter | In | Required | Value |
| --- | --- | --- | --- |
| `programId` | path | yes | string uuid. |

### Response

One resource, with `object` naming it. Fields:

| Field | Type |
| --- | --- |
| `audiences` | array of object (`specialties`, `states`, `activity_list`) |
| `respondents_by_state` | array of object (`state`, `clinicians`) |
| `respondents_by_specialty` | array of object (`code`, `label`, `clinicians`) |

```bash curl
curl -H "Authorization: Bearer $TENDRAL_API_KEY" "https://api.tendralhealth.com/v1/client/programs/<programId>/audience"
```

## List study questions

`GET /v1/client/studies/{surveyId}/questions` · scope `client:programs:read`

The questions of one study this client can see, in order: one row per question, plus one row per sub-question of a compound question (parent_id is its question; sub_position its place). Each row: question_id (the key of its answer in a response's answers), position, text, type, scale_visualization, case_id, options (each choice's value and text, or null), scale_points (5 when the study set none), scale_options (a labelled scale's words, the study's own or the default ones; null for other scales), and what the question offered: allow_other ("Other"), allow_none ("None of the above") with none_text, items (best/worst items and grid statements, each value and text), scale_headers (a grid's columns, in order), scale_labels (a scale's end words), yes_text / no_text, and point_labels (the word shown on each point of a compound part's scale, in point order, or null). Each question is read from the newest version of the study that a visible response answered, else from the study's current setup. A study that does not exist, is not live, closed or sent for the client's review, is in a hidden program, or is not this client's is refused. It never returns answers, counts or rates.

### Parameters

| Parameter | In | Required | Value |
| --- | --- | --- | --- |
| `surveyId` | path | yes | string uuid. |

### Response

A list (`object: "list"`); every row is in `data`, on one page. Each row:

| Field | Type |
| --- | --- |
| `question_id` | string |
| `parent_id` | string or null |
| `position` | integer |
| `sub_position` | integer or null |
| `text` | string or null |
| `type` | string or null |
| `scale_visualization` | string or null |
| `case_id` | string or null |
| `options` | array of object (`value`, `text`) or null |
| `scale_points` | integer or null |
| `scale_options` | array of any JSON value or null |
| `allow_other` | boolean |
| `allow_none` | boolean |
| `none_text` | string or null |
| `items` | array of object (`value`, `text`) or null |
| `scale_headers` | array of string or null |
| `scale_labels` | object (`start`, `end`) or null |
| `yes_text` | string or null |
| `no_text` | string or null |
| `point_labels` | array of string or null |

```bash curl
curl -H "Authorization: Bearer $TENDRAL_API_KEY" "https://api.tendralhealth.com/v1/client/studies/<surveyId>/questions"
```

## Get study results

`GET /v1/client/studies/{surveyId}/results` · scope `client:responses:read`

How the responses this client can see answered each question of one study: one row per question, plus one row per sub-question of a compound question (parent_id is its question), in the order of study_questions. Each row: question_id, parent_id, responses and clinicians (the totals counted, the same on every row), answered (how many of those responses answered the question), counts (a list of { value, count }: every option of the study's newest questions in their order, zeros included, then __other__ when the question offers "Other" and __none__ when it offers "None of the above", each at zero when nobody picked it; yes and no; each scale word, or each point from 1 to scale_points; each number, ascending), unlisted (answers, picks or cells that are not on that list — including a pick of "Other" or "None of the above" the question did not offer) and item_counts (best/worst: per item, { item, shown, best, worst } — how many sets showed it and how often it was picked most and least; a grid: per statement, { item, counts } with every column's count; empty for other questions). Free text, the old multi-dimension grid and a compound question's own row give answered only, with no counts. Filter: specialty (up to 50 NUCC taxonomy codes, matched on the clinician's primary specialty; an empty list is no filter). A study with no questions has no rows. A study that does not exist, is not live, closed or sent for the client's review, is in a hidden program, or is not this client's is refused. It returns counts only: never what a clinician wrote, and no percents or averages.

### Parameters

| Parameter | In | Required | Value |
| --- | --- | --- | --- |
| `surveyId` | path | yes | string uuid. |
| `specialty` | query | no | string matching `^[0-9A-Z]{9}X$`, repeatable. Repeat `specialty` for each value. |

### Response

A list (`object: "list"`); every row is in `data`, on one page. Each row:

| Field | Type |
| --- | --- |
| `question_id` | string |
| `parent_id` | string or null |
| `responses` | integer |
| `clinicians` | integer |
| `answered` | integer |
| `counts` | array of object (`value`, `count`) |
| `unlisted` | integer |
| `item_counts` | array of object |

```bash curl
curl -H "Authorization: Bearer $TENDRAL_API_KEY" "https://api.tendralhealth.com/v1/client/studies/<surveyId>/results"
```

## List responses

`GET /v1/client/programs/{programId}/responses` · scope `client:responses:read`

One page of a program's responses this client can see (complete, from an identity-verified clinician, and delivered), in the studies it can see, newest completed first: ordered by sort_at (the completed time, or the created time when none was recorded) then response_id, both descending. Filters: surveyId (one study), specialty (up to 50 NUCC taxonomy codes, matched on the clinician's primary specialty; an empty list is no filter) and since (only responses that first became visible after this ISO 8601 time). limit: 1 to 500 rows, 100 when left out. A walk returns each row once; a later call with since can return a response again (at least once), so de-duplicate by response_id. Each row: response_id, survey_id, study_title, completed_at, first_visible_at (null until stamped), sort_at, specialty_code and specialty_label, practice_state, answers (keyed by question_id from study_questions; only answers to known questions) and unmapped_answer_count (the answers left out). A program or study that does not exist, is hidden, or is not this client's is refused. It never returns an email, a fraud or quality score, a payout or a rate.

### Parameters

| Parameter | In | Required | Value |
| --- | --- | --- | --- |
| `programId` | path | yes | string uuid. |
| `surveyId` | query | no | string uuid. |
| `specialty` | query | no | string matching `^[0-9A-Z]{9}X$`, repeatable. Repeat `specialty` for each value. |
| `since` | query | no | string date-time. |
| `cursor` | query | no | string. `next_cursor` from the previous page, unchanged. Omit it for the first page. |
| `limit` | query | no | integer from 1 to 500. Default 100. |

### Response

A list (`object: "list"`), one page at a time: pass `next_cursor` back as `?cursor=` while `has_more` is true. Each row:

| Field | Type |
| --- | --- |
| `response_id` | uuid |
| `survey_id` | uuid |
| `study_title` | string |
| `completed_at` | date-time or null |
| `first_visible_at` | date-time or null |
| `sort_at` | date-time |
| `specialty_code` | string or null |
| `specialty_label` | string or null |
| `practice_state` | string or null |
| `answers` | object of any JSON value |
| `unmapped_answer_count` | integer |

```bash curl
curl -H "Authorization: Bearer $TENDRAL_API_KEY" "https://api.tendralhealth.com/v1/client/programs/<programId>/responses"
```

## Get a response

`GET /v1/client/responses/{responseId}` · scope `client:responses:read`

One response this client can see, as exactly one row: the same row responses_page returns (response_id, survey_id, study_title, completed_at, first_visible_at, sort_at, specialty_code, specialty_label, practice_state, answers keyed by question_id, unmapped_answer_count). A response that does not exist, is not visible to the client (not complete, not identity-verified or not delivered), is in a hidden program or study, or is not this client's is refused. It never returns an email, a fraud or quality score, a payout or a rate.

### Parameters

| Parameter | In | Required | Value |
| --- | --- | --- | --- |
| `responseId` | path | yes | string uuid. |

### Response

One resource, with `object` naming it. Fields:

| Field | Type |
| --- | --- |
| `response_id` | uuid |
| `survey_id` | uuid |
| `study_title` | string |
| `completed_at` | date-time or null |
| `first_visible_at` | date-time or null |
| `sort_at` | date-time |
| `specialty_code` | string or null |
| `specialty_label` | string or null |
| `practice_state` | string or null |
| `answers` | object of any JSON value |
| `unmapped_answer_count` | integer |

```bash curl
curl -H "Authorization: Bearer $TENDRAL_API_KEY" "https://api.tendralhealth.com/v1/client/responses/<responseId>"
```

## List what changed

`GET /v1/client/changes` · scope `client:programs:read`

What changed after since (an ISO 8601 time) across every program this client can see, newest first. One row per program and kind, with program_id, program_name, occurred_at (the latest time behind the row), count and detail: responses (count = new responses the client can see; detail.by_study = [{ survey_id, title, count }]), goal_met (count = 1; detail = the goal: goal_id, target_count, due_on, label), next_step (count = 1; detail.body = the program's new "Now" line) and review_requested (count = studies; detail.studies = [{ survey_id, title }] waiting for the client's review). A response is new when it first became visible after since; one not yet stamped counts as new, so it can be counted again on a later call. No rows means nothing changed. It never returns respondent identity, a pool size, an invitation count or a rate.

### Parameters

| Parameter | In | Required | Value |
| --- | --- | --- | --- |
| `since` | query | yes | string date-time. |

### Response

A list (`object: "list"`); every row is in `data`, on one page. Each row:

When `kind` is `"responses"`:

| Field | Type |
| --- | --- |
| `program_id` | uuid |
| `program_name` | string |
| `occurred_at` | date-time |
| `kind` | `"responses"` |
| `count` | integer |
| `detail` | object (`by_study`) |

When `kind` is `"goal_met"`:

| Field | Type |
| --- | --- |
| `program_id` | uuid |
| `program_name` | string |
| `occurred_at` | date-time |
| `kind` | `"goal_met"` |
| `count` | `1` |
| `detail` | object (`goal_id`, `target_count`, `due_on`, `label`) |

When `kind` is `"next_step"`:

| Field | Type |
| --- | --- |
| `program_id` | uuid |
| `program_name` | string |
| `occurred_at` | date-time |
| `kind` | `"next_step"` |
| `count` | `1` |
| `detail` | object (`body`) |

When `kind` is `"review_requested"`:

| Field | Type |
| --- | --- |
| `program_id` | uuid |
| `program_name` | string |
| `occurred_at` | date-time |
| `kind` | `"review_requested"` |
| `count` | integer |
| `detail` | object (`studies`) |

```bash curl
curl -H "Authorization: Bearer $TENDRAL_API_KEY" "https://api.tendralhealth.com/v1/client/changes"
```
