API Reference
Full endpoint specification of the EduTAP Admin API
A complete reference of all EduTAP Admin API endpoints, request/response schemas, and error codes.
Common
Base URL
https://opsapi.edutap.aiAuthentication
Every endpoint except POST /ops/v1/auth/signin and POST /ops/v1/auth/refresh requires a Bearer token.
Request Headers
| Parameter | Type | Required | Description |
|---|---|---|---|
| Authorization | String | O | Token in the form `Bearer {access_token}`. Use the `access_token` returned by signin as-is; appending any extra suffix will cause the request to be rejected with a 401. |
| Content-Type | String | - | `application/json` for POST/PUT requests. |
Pagination
List endpoints share the following query parameters.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| page | Number | - | Page number (1-based, default 1) |
| page_size | Number | - | Items per page (default 10, max 100) |
The pagination object in the response body:
| Field | Type | Description |
|---|---|---|
| current_page | Number | Current page |
| total_pages | Number | Total number of pages |
| page_size | Number | Items per page |
| total_items | Number | Total number of items |
| is_next | Boolean | Whether a next page exists |
| is_prev | Boolean | Whether a previous page exists |
Date / time format
- All datetime response fields (
created_at,first_message_at,last_message_at,last_question_at, etc.): UTC ISO 8601 (e.g.2026-06-25T09:00:00Z) - Period query parameters (
start_date,end_date):YYYY-MM-DDformat, interpreted in KST (Asia/Seoul). The server converts the range to KST 00:00:00 ~ 23:59:59 and then queries in UTC.
Auth — Authentication & Token
Sign in with client_member credentials to obtain a Bearer token, and refresh an expired token via rotation.
Auth endpoints can be called without an existing token.
Sign in as client member
Request
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
| client_member_email | String | O | Client member email address |
| client_member_password | String | O | Client member password |
Request Example
curl -X POST "https://opsapi.edutap.ai/ops/v1/auth/signin" \
-H "Content-Type: application/json" \
-d '{
"client_member_email": "partner-admin@example.com",
"client_member_password": "S3cure!Pass"
}'import requests
res = requests.post(
"https://opsapi.edutap.ai/ops/v1/auth/signin",
json={
"client_member_email": "partner-admin@example.com",
"client_member_password": "S3cure!Pass",
},
timeout=10,
)
res.raise_for_status()
tokens = res.json()
# tokens["access_token"], tokens["refresh_token"]const res = await fetch("https://opsapi.edutap.ai/ops/v1/auth/signin", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_member_email: "partner-admin@example.com",
client_member_password: "S3cure!Pass",
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const tokens = await res.json();
// tokens.access_token, tokens.refresh_tokenResponse
Response Fields
| Parameter | Type | Required | Description |
|---|---|---|---|
| access_token | String | O | Bearer access token. Use it as-is in the form `Authorization: Bearer {access_token}` when calling protected endpoints. |
| refresh_token | String | O | Refresh token. Used to renew an expired access token. |
Response Example
{
"access_token": "eyJhbGciOi...payload...",
"refresh_token": "eyJhbGciOi...refresh-payload..."
}Only client_member credentials are accepted. Internal staff/superadmin credentials are rejected. Key errors: 400 NotExistMember (email not registered), 400 MismatchedPassword (password mismatch), 400 NotActivatedClientMember (inactive member or inactive client).
Refresh access token
Request
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
| refresh_token | String | O | Refresh token issued by signin. It is invalidated by this call and replaced with a new one. |
Request Example
curl -X POST "https://opsapi.edutap.ai/ops/v1/auth/refresh" \
-H "Content-Type: application/json" \
-d '{
"refresh_token": "eyJhbGciOi...refresh-payload..."
}'import requests
res = requests.post(
"https://opsapi.edutap.ai/ops/v1/auth/refresh",
json={"refresh_token": "eyJhbGciOi...refresh-payload..."},
timeout=10,
)
res.raise_for_status()
tokens = res.json()const res = await fetch("https://opsapi.edutap.ai/ops/v1/auth/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
refresh_token: "eyJhbGciOi...refresh-payload...",
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const tokens = await res.json();Response
Response Fields
| Parameter | Type | Required | Description |
|---|---|---|---|
| access_token | String | O | Newly issued Bearer access token. |
| refresh_token | String | O | Replacement refresh token from rotation. The previous value is invalidated. |
Response Example
{
"access_token": "eyJhbGciOi...new-payload...",
"refresh_token": "eyJhbGciOi...new-refresh-payload..."
}If the refresh_token is invalid or expired, the response is 401 InvalidRefreshToken. In that case, prompt the user to sign in again. Since this is a rotation, you must save the new refresh_token from the response.
Session — Sessions & Messages
Query learner chat sessions and the messages within them. Both paginated lookups and range/incremental dumps are supported, enabling dashboard displays as well as data-lake ingestion.
List sessions
Request
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| page | Number | - | Page number (1-based, default 1) |
| page_size | Number | - | Items per page (default 10, max 100) |
| first_message_at_start | String (ISO 8601) | - | Lower bound for the session's first-message timestamp |
| first_message_at_end | String (ISO 8601) | - | Upper bound for the session's first-message timestamp |
| last_message_at_start | String (ISO 8601) | - | Lower bound for the session's last-activity timestamp |
| last_message_at_end | String (ISO 8601) | - | Upper bound for the session's last-activity timestamp |
| message_count_gte | Number | - | Filter for user-message count greater than or equal to (0 or above) |
| message_count_lte | Number | - | Filter for user-message count less than or equal to (0 or above) |
| status | String | - | Session status filter |
| external_user_id | String | - | Partial-match filter on user ID |
| session_title | String | - | Partial-match filter on session title |
Request Example
curl -X GET "https://opsapi.edutap.ai/ops/v1/sessions?page=1&page_size=10&external_user_id=user_abc" \
-H "Authorization: Bearer ${ACCESS_TOKEN}"import requests
res = requests.get(
"https://opsapi.edutap.ai/ops/v1/sessions",
params={"page": 1, "page_size": 10, "external_user_id": "user_abc"},
headers={"Authorization": f"Bearer {ACCESS_TOKEN}"},
timeout=10,
)
res.raise_for_status()
data = res.json()
# data["sessions"], data["pagination"]const url = new URL("https://opsapi.edutap.ai/ops/v1/sessions");
url.searchParams.set("page", "1");
url.searchParams.set("page_size", "10");
url.searchParams.set("external_user_id", "user_abc");
const res = await fetch(url, {
headers: { Authorization: `Bearer ${ACCESS_TOKEN}` },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();Response
Response Fields
| Parameter | Type | Required | Description |
|---|---|---|---|
| sessions | Array of objects | O | Session list. See sessions item. |
| pagination | Object | O | Pagination info. See Common → Pagination. |
sessions item
| Field | Type | Description |
|---|---|---|
| chat_session_id | String | Unique chat session ID |
| client_id | String | Customer ID |
| user_id | String | Learner ID who owns the session |
| course_id | String | null | Course ID. null for free-chat sessions. |
| clip_id | String | null | Clip (content unit) ID |
| title | String | null | Session title (auto-generated by AI or set by the user) |
| course_name | String | null | Course name |
| course_category | String | null | Course category |
| course_sub_category | String | null | Course sub-category |
| status | String | Session status |
| created_at | String (ISO 8601) | Session creation time (UTC) |
| first_message_at | String (ISO 8601) | null | First message time (UTC) |
| last_message_at | String (ISO 8601) | null | Last message time (UTC) |
| message_count | Number | Cumulative message count in the session |
Response Example
{
"sessions": [
{
"chat_session_id": "sess_01HXABCD1234EFGH5678",
"client_id": "client_acme",
"user_id": "user_abc123",
"course_id": "course_math_101",
"clip_id": "clip_01",
"title": "Solving quadratic equations",
"course_name": "High School Math I",
"course_category": "Math",
"course_sub_category": "Algebra",
"status": "ACTIVE",
"created_at": "2026-06-20T03:21:00Z",
"first_message_at": "2026-06-20T03:21:30Z",
"last_message_at": "2026-06-20T03:45:10Z",
"message_count": 14
}
],
"pagination": {
"current_page": 1,
"total_pages": 14,
"page_size": 10,
"total_items": 132,
"is_next": true,
"is_prev": false
}
}Results are sorted by created_at in descending order. All datetime query parameters use ISO 8601 format, and response datetime fields are in UTC.
Dump sessions (date range)
Request
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| start_date | String (ISO 8601) | - | Start date. Defaults to 7 days before end_date when omitted. |
| end_date | String (ISO 8601) | - | End date. Defaults to the current time when omitted. |
Request Example
curl -X GET "https://opsapi.edutap.ai/ops/v1/sessions/dumps?start_date=2026-06-24&end_date=2026-06-25" \
-H "Authorization: Bearer ${ACCESS_TOKEN}"import requests
res = requests.get(
"https://opsapi.edutap.ai/ops/v1/sessions/dumps",
params={"start_date": "2026-06-24", "end_date": "2026-06-25"},
headers={"Authorization": f"Bearer {ACCESS_TOKEN}"},
timeout=30,
)
res.raise_for_status()
data = res.json()
# data["sessions"] (array, no pagination)const url = new URL("https://opsapi.edutap.ai/ops/v1/sessions/dumps");
url.searchParams.set("start_date", "2026-06-24");
url.searchParams.set("end_date", "2026-06-25");
const res = await fetch(url, {
headers: { Authorization: `Bearer ${ACCESS_TOKEN}` },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();Response
Response Fields
| Parameter | Type | Required | Description |
|---|---|---|---|
| sessions | Array of objects | O | Session list (no pagination). Same structure as the sessions item in List sessions. |
Response Example
{
"sessions": [
{
"chat_session_id": "sess_01HXABCD1234EFGH5678",
"client_id": "client_acme",
"user_id": "user_abc123",
"course_id": "course_math_101",
"clip_id": "clip_01",
"title": "Solving quadratic equations",
"course_name": "High School Math I",
"course_category": "Math",
"course_sub_category": "Algebra",
"status": "ACTIVE",
"created_at": "2026-06-20T03:21:00Z",
"first_message_at": "2026-06-20T03:21:30Z",
"last_message_at": "2026-06-20T03:45:10Z",
"message_count": 14
}
]
}Because the response is returned in one batch without pagination, large ranges may produce very large responses. Either split into shorter ranges or use the incremental dump (/dumps/since-last).
Dump sessions (since last)
Request
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| retry | Boolean | - | Default false. When true, returns the previous dump window again (for retries). |
Request Example
curl -X GET "https://opsapi.edutap.ai/ops/v1/sessions/dumps/since-last" \
-H "Authorization: Bearer ${ACCESS_TOKEN}"
# Retry (re-fetch the previous window)
curl -X GET "https://opsapi.edutap.ai/ops/v1/sessions/dumps/since-last?retry=true" \
-H "Authorization: Bearer ${ACCESS_TOKEN}"import requests
res = requests.get(
"https://opsapi.edutap.ai/ops/v1/sessions/dumps/since-last",
headers={"Authorization": f"Bearer {ACCESS_TOKEN}"},
timeout=30,
)
res.raise_for_status()
data = res.json()
# data["sessions"]const res = await fetch(
"https://opsapi.edutap.ai/ops/v1/sessions/dumps/since-last",
{ headers: { Authorization: `Bearer ${ACCESS_TOKEN}` } },
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();Response
Response Fields
Same structure as Dump sessions (date range): sessions array (no pagination).
Response Example
{
"sessions": [
{
"chat_session_id": "sess_01HXABCD1234EFGH5678",
"client_id": "client_acme",
"user_id": "user_abc123",
"course_id": "course_math_101",
"clip_id": "clip_01",
"title": "Solving quadratic equations",
"course_name": "High School Math I",
"course_category": "Math",
"course_sub_category": "Algebra",
"status": "ACTIVE",
"created_at": "2026-06-20T03:21:00Z",
"first_message_at": "2026-06-20T03:21:30Z",
"last_message_at": "2026-06-20T03:45:10Z",
"message_count": 14
}
]
}The per-caller last-call timestamp is tracked on the server at the client_member level. If ingestion fails, you can re-request the same window with retry=true.
List session messages
Request
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| session_id | String | O | Session ID to query |
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| page | Number | - | Page number (1-based, default 1) |
| page_size | Number | - | Items per page (default 10, max 100) |
Request Example
curl -X GET "https://opsapi.edutap.ai/ops/v1/sessions/sess_01HXABCD1234EFGH5678/messages?page=1&page_size=10" \
-H "Authorization: Bearer ${ACCESS_TOKEN}"import requests
session_id = "sess_01HXABCD1234EFGH5678"
res = requests.get(
f"https://opsapi.edutap.ai/ops/v1/sessions/{session_id}/messages",
params={"page": 1, "page_size": 10},
headers={"Authorization": f"Bearer {ACCESS_TOKEN}"},
timeout=10,
)
res.raise_for_status()
data = res.json()const sessionId = "sess_01HXABCD1234EFGH5678";
const url = new URL(
`https://opsapi.edutap.ai/ops/v1/sessions/${sessionId}/messages`,
);
url.searchParams.set("page", "1");
url.searchParams.set("page_size", "10");
const res = await fetch(url, {
headers: { Authorization: `Bearer ${ACCESS_TOKEN}` },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();Response
Response Fields
| Parameter | Type | Required | Description |
|---|---|---|---|
| messages | Array of objects | O | Message list. See messages item. |
| pagination | Object | O | Pagination info. See Common → Pagination. |
messages item
| Field | Type | Description |
|---|---|---|
| message_id | String | Unique message ID |
| chat_session_id | String | Session ID the message belongs to |
| role | String | Speaker role (`user` / `assistant` / `system`) |
| message | String | Message body (natural-language text) |
| created_at | String (ISO 8601) | Message creation time (UTC) |
| answer_style | String | null | Answer-style identifier (meaningful only for assistant messages) |
| clip_id | String | null | Clip ID associated with the message |
| course_title | String | null | Course title where the message occurred (always null in the current response) |
| user_id | String | null | Learner ID who sent the message (always null in the current response) |
| clip_play_head | Number | null | Clip playback position at message time (seconds) |
| is_floating | Boolean | Whether this is a floating (standalone) message |
| is_retry | Boolean | Whether the message was produced by a retry |
| is_preemptive_question | Boolean | Whether this is a preemptive question (a first question proposed by the system) |
| is_follow_up_question | Boolean | Whether this is a follow-up question |
| is_fixed_guide_message | Boolean | Whether this is a fixed guide message (function_call based) |
| references | Array | null | List of source references |
| pdf_highlight_texts | Array | null | List of PDF highlight texts |
| message_generation_time_seconds | Number | null | Time taken to generate the message (seconds, only populated for assistant messages) |
| files | Array | null | List of attached files |
| question_message_id | String | null | The `message_id` of the immediately preceding user question that this message answered (populated only for `assistant` messages; otherwise `null`) |
Response Example
{
"messages": [
{
"message_id": "msg_01HXEFGH1234ABCD5678",
"chat_session_id": "sess_01HXABCD1234EFGH5678",
"role": "user",
"message": "Please explain how to solve x^2 - 5x + 6 = 0.",
"created_at": "2026-06-20T03:21:30Z",
"answer_style": null,
"clip_id": "clip_01",
"course_title": null,
"user_id": null,
"clip_play_head": 0,
"is_floating": false,
"is_retry": false,
"is_preemptive_question": false,
"is_follow_up_question": false,
"is_fixed_guide_message": false,
"references": [],
"pdf_highlight_texts": [],
"message_generation_time_seconds": null,
"files": [],
"question_message_id": null
}
],
"pagination": {
"current_page": 1,
"total_pages": 2,
"page_size": 10,
"total_items": 14,
"is_next": true,
"is_prev": false
}
}If the session_id does not exist or belongs to another client, the response is 404 NotExistSession (cross-tenant access is rejected). Results are sorted by created_at ascending. When a message includes attached files, they are returned as presigned URLs in the per-message response.
Dump session messages (date range)
Request
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| start_date | String (ISO 8601) | - | Start date. Defaults to 7 days before end_date when omitted. |
| end_date | String (ISO 8601) | - | End date. Defaults to the current time when omitted. |
Request Example
curl -X GET "https://opsapi.edutap.ai/ops/v1/sessions/messages/dumps?start_date=2026-06-24&end_date=2026-06-25" \
-H "Authorization: Bearer ${ACCESS_TOKEN}"import requests
res = requests.get(
"https://opsapi.edutap.ai/ops/v1/sessions/messages/dumps",
params={"start_date": "2026-06-24", "end_date": "2026-06-25"},
headers={"Authorization": f"Bearer {ACCESS_TOKEN}"},
timeout=30,
)
res.raise_for_status()
data = res.json()
# data["messages"]const url = new URL(
"https://opsapi.edutap.ai/ops/v1/sessions/messages/dumps",
);
url.searchParams.set("start_date", "2026-06-24");
url.searchParams.set("end_date", "2026-06-25");
const res = await fetch(url, {
headers: { Authorization: `Bearer ${ACCESS_TOKEN}` },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();Response
Response Fields
| Parameter | Type | Required | Description |
|---|---|---|---|
| messages | Array of objects | O | Message list (no pagination). Same structure as the messages item in List session messages. |
Response Example
{
"messages": [
{
"message_id": "msg_01HXEFGH1234ABCD5678",
"chat_session_id": "sess_01HXABCD1234EFGH5678",
"role": "user",
"message": "Please explain how to solve x^2 - 5x + 6 = 0.",
"created_at": "2026-06-20T03:21:30Z",
"answer_style": null,
"clip_id": "clip_01",
"course_title": null,
"user_id": null,
"clip_play_head": 0,
"is_floating": false,
"is_retry": false,
"is_preemptive_question": false,
"is_follow_up_question": false,
"is_fixed_guide_message": false,
"references": [],
"pdf_highlight_texts": [],
"message_generation_time_seconds": null,
"files": [],
"question_message_id": null
}
]
}Because the response is returned in one batch without pagination, large ranges may produce very large responses. Either split into shorter ranges or use the incremental dump (/messages/dumps/since-last).
Dump session messages (since last)
Request
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| retry | Boolean | - | Default false. When true, returns the previous dump window again (for retries). |
Request Example
curl -X GET "https://opsapi.edutap.ai/ops/v1/sessions/messages/dumps/since-last" \
-H "Authorization: Bearer ${ACCESS_TOKEN}"
# Retry (re-fetch the previous window)
curl -X GET "https://opsapi.edutap.ai/ops/v1/sessions/messages/dumps/since-last?retry=true" \
-H "Authorization: Bearer ${ACCESS_TOKEN}"import requests
res = requests.get(
"https://opsapi.edutap.ai/ops/v1/sessions/messages/dumps/since-last",
headers={"Authorization": f"Bearer {ACCESS_TOKEN}"},
timeout=30,
)
res.raise_for_status()
data = res.json()const res = await fetch(
"https://opsapi.edutap.ai/ops/v1/sessions/messages/dumps/since-last",
{ headers: { Authorization: `Bearer ${ACCESS_TOKEN}` } },
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();Response
Response Fields
Same structure as Dump session messages (date range): messages array (no pagination).
Response Example
{
"messages": [
{
"message_id": "msg_01HXEFGH1234ABCD5678",
"chat_session_id": "sess_01HXABCD1234EFGH5678",
"role": "user",
"message": "Please explain how to solve x^2 - 5x + 6 = 0.",
"created_at": "2026-06-20T03:21:30Z",
"answer_style": null,
"clip_id": "clip_01",
"course_title": null,
"user_id": null,
"clip_play_head": 0,
"is_floating": false,
"is_retry": false,
"is_preemptive_question": false,
"is_follow_up_question": false,
"is_fixed_guide_message": false,
"references": [],
"pdf_highlight_texts": [],
"message_generation_time_seconds": null,
"files": [],
"question_message_id": null
}
]
}The per-caller last-call timestamp is tracked on the server at the client_member level. If ingestion fails, you can re-request the same window with retry=true.
Dashboard — Aggregation
Returns user / message counts aggregated daily within the specified date range.
Used to sync to your BI dashboard.
Get user count
Request
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| start_date | String | O | Start date. `YYYY-MM-DD` (KST) |
| end_date | String | O | End date. `YYYY-MM-DD` (KST) |
| course_id | String | - | Exact-match filter on `course_id` (case-sensitive). Omit to disable filtering |
| clip_id | String | - | Exact-match filter on `clip_id` (case-sensitive). Omit to disable filtering |
Request Example
curl -X GET "https://opsapi.edutap.ai/ops/v1/dashboard/user-count?start_date=2026-06-01&end_date=2026-06-25" \
-H "Authorization: Bearer ${ACCESS_TOKEN}"
# course_id/clip_id (optional filters, e.g. &course_id=course_abc&clip_id=clip_xyz)import requests
res = requests.get(
"https://opsapi.edutap.ai/ops/v1/dashboard/user-count",
params={
"start_date": "2026-06-01",
"end_date": "2026-06-25",
# "course_id": "course_abc", # optional
# "clip_id": "clip_xyz", # optional
},
headers={"Authorization": f"Bearer {ACCESS_TOKEN}"},
timeout=10,
)
res.raise_for_status()
data = res.json()
# data["count"] (null when no courses are registered)const url = new URL(
"https://opsapi.edutap.ai/ops/v1/dashboard/user-count",
);
url.searchParams.set("start_date", "2026-06-01");
url.searchParams.set("end_date", "2026-06-25");
// url.searchParams.set("course_id", "course_abc"); // optional
// url.searchParams.set("clip_id", "clip_xyz"); // optional
const res = await fetch(url, {
headers: { Authorization: `Bearer ${ACCESS_TOKEN}` },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();Response
Response Fields
| Parameter | Type | Required | Description |
|---|---|---|---|
| period_start_date | String | O | Requested start date (`YYYY-MM-DD`, KST) |
| period_end_date | String | O | Requested end date (`YYYY-MM-DD`, KST) |
| course_id | String | null | - | The requested `course_id`, stripped and echoed back. `null` if omitted or blank |
| clip_id | String | null | - | The requested `clip_id`, stripped and echoed back. `null` if omitted or blank |
| count | Number | null | - | Aggregated count. Returned as `null` when fewer than one course is registered. |
Response Example
{
"period_start_date": "2026-06-01",
"period_end_date": "2026-06-25",
"course_id": null,
"clip_id": null,
"count": 1284
}Internally the server only supports daily aggregation. An invalid date format returns 422 RequestValidationError, and a missing client returns 400 NotExistClient.
The course_id/clip_id filters use exact match and are case-sensitive. Values are trimmed; empty or whitespace-only values are treated as unset and echoed back as null. When both are provided, they are combined with AND.
Get message count
Request
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| start_date | String | O | Start date. `YYYY-MM-DD` (KST) |
| end_date | String | O | End date. `YYYY-MM-DD` (KST) |
| course_id | String | - | Exact-match filter on `course_id` (case-sensitive). Omit to disable filtering |
| clip_id | String | - | Exact-match filter on `clip_id` (case-sensitive). Omit to disable filtering |
Request Example
curl -X GET "https://opsapi.edutap.ai/ops/v1/dashboard/message-count?start_date=2026-06-01&end_date=2026-06-25" \
-H "Authorization: Bearer ${ACCESS_TOKEN}"
# course_id/clip_id (optional filters, e.g. &course_id=course_abc&clip_id=clip_xyz)import requests
res = requests.get(
"https://opsapi.edutap.ai/ops/v1/dashboard/message-count",
params={
"start_date": "2026-06-01",
"end_date": "2026-06-25",
# "course_id": "course_abc", # optional
# "clip_id": "clip_xyz", # optional
},
headers={"Authorization": f"Bearer {ACCESS_TOKEN}"},
timeout=10,
)
res.raise_for_status()
data = res.json()const url = new URL(
"https://opsapi.edutap.ai/ops/v1/dashboard/message-count",
);
url.searchParams.set("start_date", "2026-06-01");
url.searchParams.set("end_date", "2026-06-25");
// url.searchParams.set("course_id", "course_abc"); // optional
// url.searchParams.set("clip_id", "clip_xyz"); // optional
const res = await fetch(url, {
headers: { Authorization: `Bearer ${ACCESS_TOKEN}` },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();Response
Response Fields
Same structure as Get user count (OuterCountResponse).
Response Example
{
"period_start_date": "2026-06-01",
"period_end_date": "2026-06-25",
"course_id": null,
"clip_id": null,
"count": 38420
}The message count is the sum of user + assistant messages, excluding system messages.
The course_id/clip_id filters use exact match and are case-sensitive. Values are trimmed; empty or whitespace-only values are treated as unset and echoed back as null. When both are provided, they are combined with AND.
Users — Learner Activity
Returns a per-learner activity summary (session count / question count / last question time) in one batch.
/ops/v1/usersList user activity
Request
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| user_id | String | - | Partial-match search on user_id (case-insensitive) |
| start_date | String | - | Start date of the period filter. `YYYY-MM-DD` (KST). The period filter is applied only when **both** `start_date` and `end_date` are specified. |
| end_date | String | - | End date of the period filter. `YYYY-MM-DD` (KST). The period filter is applied only when **both** `start_date` and `end_date` are specified. |
| course_id | String | - | Exact-match filter on `course_id` (case-sensitive). Omit to disable filtering |
| clip_id | String | - | Exact-match filter on `clip_id` (case-sensitive). Omit to disable filtering |
Request Example
curl -X GET "https://opsapi.edutap.ai/ops/v1/users?user_id=user_abc&start_date=2026-06-01&end_date=2026-06-25" \
-H "Authorization: Bearer ${ACCESS_TOKEN}"
# course_id/clip_id (optional filters, e.g. &course_id=course_abc&clip_id=clip_xyz)import requests
res = requests.get(
"https://opsapi.edutap.ai/ops/v1/users",
params={
"user_id": "user_abc",
"start_date": "2026-06-01",
"end_date": "2026-06-25",
# "course_id": "course_abc", # optional
# "clip_id": "clip_xyz", # optional
},
headers={"Authorization": f"Bearer {ACCESS_TOKEN}"},
timeout=10,
)
res.raise_for_status()
data = res.json()
# data["users"] (array, no pagination)const url = new URL("https://opsapi.edutap.ai/ops/v1/users");
url.searchParams.set("user_id", "user_abc");
url.searchParams.set("start_date", "2026-06-01");
url.searchParams.set("end_date", "2026-06-25");
// url.searchParams.set("course_id", "course_abc"); // optional
// url.searchParams.set("clip_id", "clip_xyz"); // optional
const res = await fetch(url, {
headers: { Authorization: `Bearer ${ACCESS_TOKEN}` },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();Response
Response Fields
| Parameter | Type | Required | Description |
|---|---|---|---|
| course_id | String | null | - | The requested `course_id`, stripped and echoed back. `null` if omitted or blank |
| clip_id | String | null | - | The requested `clip_id`, stripped and echoed back. `null` if omitted or blank |
| users | Array of objects | O | User activity list (no pagination, returned in one batch). See users item. |
users item
| Field | Type | Description |
|---|---|---|
| user_id | String | Learner identifier |
| session_count | Number | Cumulative session count for the learner |
| question_count | Number | Cumulative question count for the learner (number of role='user' messages) |
| last_question_at | String (ISO 8601) | null | Last question time (latest created_at among role='user' messages, UTC) |
Response Example
{
"course_id": null,
"clip_id": null,
"users": [
{
"user_id": "user_abc123",
"session_count": 12,
"question_count": 84,
"last_question_at": "2026-06-24T15:42:00Z"
}
]
}Results are returned in one batch without pagination. When both start_date and end_date are specified, learners with no role='user' messages within that period are excluded from the results.
The course_id/clip_id filters use exact match and are case-sensitive. Values are trimmed; empty or whitespace-only values are treated as unset and echoed back as null. When both are provided, they are combined with AND.
Errors
Error response format
All errors share a common body structure. The error_code uses PascalCase (with the exception of the 422 validation error and the 500 internal error).
{
"error_code": "NotAuthorized",
"message": "Authentication required."
}422 RequestValidationError additionally includes a details array (message reflects the first validation failure reason).
{
"error_code": "RequestValidationError",
"message": "Field required",
"details": [
{ "loc": ["query", "start_date"], "msg": "Field required", "type": "missing" }
]
}Common error codes
| HTTP | error_code | Domain | Meaning | Resolution |
|---|---|---|---|---|
| 400 | NotExistMember | Auth | Unregistered client_member email | Verify the email |
| 400 | MismatchedPassword | Auth | Password mismatch | Verify the password |
| 400 | NotActivatedClientMember | Auth | Inactive member or member belonging to an inactive client | Ask an admin to activate |
| 400 | SigninFailed | Auth | Token issuance processing failed | Retry shortly |
| 400 | RefreshFailed | Auth | Token refresh processing failed | Sign in again |
| 400 | InvalidClient | Session / Users | Client in the token does not exist or is inactive | Re-check token issuance |
| 400 | NotExistClient | Dashboard | Client not found | Re-check token issuance |
| 401 | NotAuthorized | All (protected APIs) | Token missing / invalid / expired | Call `/ops/v1/auth/refresh` or sign in again |
| 401 | InvalidAuthHeader | All (protected APIs) | Authorization header does not start with `Bearer ` | Verify the `Authorization: Bearer {access_token}` format |
| 401 | InvalidRefreshToken | Auth | refresh_token invalid / expired / revoked | Prompt the user to sign in again |
| 404 | NotExistSession | Session | Session does not exist or belongs to another client | Verify session_id |
| 422 | RequestValidationError | All | Path/Query/Body validation failed | Inspect `loc` and `msg` in `details[]` |
| 500 | SERVER_ERROR | All | Internal server error | Retry with the same payload |
Retry & recovery
- 400 response — Business validation failure. Verify the client data (email/password/client of the token, etc.) and retry. Repeating the same request will produce the same error.
- 401 response — Token authentication failure. For
NotAuthorized, callPOST /ops/v1/auth/refreshonce to renew and retry the same request. ForInvalidRefreshToken, prompt the user to sign in again. - 422 response — Client payload bug. Do not retry; inspect
loc/msgindetails[]and fix the payload. - 5xx response — Retry with the same payload using exponential backoff (e.g. 1s / 2s / 4s).
