edutap.ai developers

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.ai

Authentication

Every endpoint except POST /ops/v1/auth/signin and POST /ops/v1/auth/refresh requires a Bearer token.

Request Headers

ParameterTypeRequiredDescription
AuthorizationStringOToken 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-TypeString-`application/json` for POST/PUT requests.

Pagination

List endpoints share the following query parameters.

Query Parameters

ParameterTypeRequiredDescription
pageNumber-Page number (1-based, default 1)
page_sizeNumber-Items per page (default 10, max 100)

The pagination object in the response body:

FieldTypeDescription
current_pageNumberCurrent page
total_pagesNumberTotal number of pages
page_sizeNumberItems per page
total_itemsNumberTotal number of items
is_nextBooleanWhether a next page exists
is_prevBooleanWhether 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-DD format, 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

POST/ops/v1/auth/signin#
Sign in with client_member credentials to obtain a Bearer token.

Request

Request Body

ParameterTypeRequiredDescription
client_member_emailStringOClient member email address
client_member_passwordStringOClient 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_token

Response

Response Fields

ParameterTypeRequiredDescription
access_tokenStringOBearer access token. Use it as-is in the form `Authorization: Bearer {access_token}` when calling protected endpoints.
refresh_tokenStringORefresh 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

POST/ops/v1/auth/refresh#
Use the refresh_token issued by signin to obtain a new access/refresh token pair (rotation).

Request

Request Body

ParameterTypeRequiredDescription
refresh_tokenStringORefresh 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

ParameterTypeRequiredDescription
access_tokenStringONewly issued Bearer access token.
refresh_tokenStringOReplacement 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

GET/ops/v1/sessions#
Returns learner chat sessions matching the filter criteria, paginated.

Request

Query Parameters

ParameterTypeRequiredDescription
pageNumber-Page number (1-based, default 1)
page_sizeNumber-Items per page (default 10, max 100)
first_message_at_startString (ISO 8601)-Lower bound for the session's first-message timestamp
first_message_at_endString (ISO 8601)-Upper bound for the session's first-message timestamp
last_message_at_startString (ISO 8601)-Lower bound for the session's last-activity timestamp
last_message_at_endString (ISO 8601)-Upper bound for the session's last-activity timestamp
message_count_gteNumber-Filter for user-message count greater than or equal to (0 or above)
message_count_lteNumber-Filter for user-message count less than or equal to (0 or above)
statusString-Session status filter
external_user_idString-Partial-match filter on user ID
session_titleString-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

ParameterTypeRequiredDescription
sessionsArray of objectsOSession list. See sessions item.
paginationObjectOPagination info. See Common → Pagination.
sessions item
FieldTypeDescription
chat_session_idStringUnique chat session ID
client_idStringCustomer ID
user_idStringLearner ID who owns the session
course_idString | nullCourse ID. null for free-chat sessions.
clip_idString | nullClip (content unit) ID
titleString | nullSession title (auto-generated by AI or set by the user)
course_nameString | nullCourse name
course_categoryString | nullCourse category
course_sub_categoryString | nullCourse sub-category
statusStringSession status
created_atString (ISO 8601)Session creation time (UTC)
first_message_atString (ISO 8601) | nullFirst message time (UTC)
last_message_atString (ISO 8601) | nullLast message time (UTC)
message_countNumberCumulative 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)

GET/ops/v1/sessions/dumps#
Returns all sessions within the specified date range in one batch. Intended for data-lake ingestion and other batch sync workflows.

Request

Query Parameters

ParameterTypeRequiredDescription
start_dateString (ISO 8601)-Start date. Defaults to 7 days before end_date when omitted.
end_dateString (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

ParameterTypeRequiredDescription
sessionsArray of objectsOSession 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)

GET/ops/v1/sessions/dumps/since-last#
Returns only sessions created or updated since the previous call. Used for incremental syncs.

Request

Query Parameters

ParameterTypeRequiredDescription
retryBoolean-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

GET/ops/v1/sessions/{session_id}/messages#
Returns the chat messages of the specified session, paginated.

Request

Path Parameters

ParameterTypeRequiredDescription
session_idStringOSession ID to query

Query Parameters

ParameterTypeRequiredDescription
pageNumber-Page number (1-based, default 1)
page_sizeNumber-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

ParameterTypeRequiredDescription
messagesArray of objectsOMessage list. See messages item.
paginationObjectOPagination info. See Common → Pagination.
messages item
FieldTypeDescription
message_idStringUnique message ID
chat_session_idStringSession ID the message belongs to
roleStringSpeaker role (`user` / `assistant` / `system`)
messageStringMessage body (natural-language text)
created_atString (ISO 8601)Message creation time (UTC)
answer_styleString | nullAnswer-style identifier (meaningful only for assistant messages)
clip_idString | nullClip ID associated with the message
course_titleString | nullCourse title where the message occurred (always null in the current response)
user_idString | nullLearner ID who sent the message (always null in the current response)
clip_play_headNumber | nullClip playback position at message time (seconds)
is_floatingBooleanWhether this is a floating (standalone) message
is_retryBooleanWhether the message was produced by a retry
is_preemptive_questionBooleanWhether this is a preemptive question (a first question proposed by the system)
is_follow_up_questionBooleanWhether this is a follow-up question
is_fixed_guide_messageBooleanWhether this is a fixed guide message (function_call based)
referencesArray | nullList of source references
pdf_highlight_textsArray | nullList of PDF highlight texts
message_generation_time_secondsNumber | nullTime taken to generate the message (seconds, only populated for assistant messages)
filesArray | nullList of attached files
question_message_idString | nullThe `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)

GET/ops/v1/sessions/messages/dumps#
Returns all session messages within the specified date range in one batch. Intended for data-lake ingestion.

Request

Query Parameters

ParameterTypeRequiredDescription
start_dateString (ISO 8601)-Start date. Defaults to 7 days before end_date when omitted.
end_dateString (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

ParameterTypeRequiredDescription
messagesArray of objectsOMessage 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)

GET/ops/v1/sessions/messages/dumps/since-last#
Returns only session messages created since the previous call. Used for incremental syncs.

Request

Query Parameters

ParameterTypeRequiredDescription
retryBoolean-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

GET/ops/v1/dashboard/user-count#
Returns the learner count within the specified date range, aggregated daily.

Request

Query Parameters

ParameterTypeRequiredDescription
start_dateStringOStart date. `YYYY-MM-DD` (KST)
end_dateStringOEnd date. `YYYY-MM-DD` (KST)
course_idString-Exact-match filter on `course_id` (case-sensitive). Omit to disable filtering
clip_idString-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

ParameterTypeRequiredDescription
period_start_dateStringORequested start date (`YYYY-MM-DD`, KST)
period_end_dateStringORequested end date (`YYYY-MM-DD`, KST)
course_idString | null-The requested `course_id`, stripped and echoed back. `null` if omitted or blank
clip_idString | null-The requested `clip_id`, stripped and echoed back. `null` if omitted or blank
countNumber | 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

GET/ops/v1/dashboard/message-count#
Returns the total learner ↔ AI tutor message count within the specified date range, aggregated daily.

Request

Query Parameters

ParameterTypeRequiredDescription
start_dateStringOStart date. `YYYY-MM-DD` (KST)
end_dateStringOEnd date. `YYYY-MM-DD` (KST)
course_idString-Exact-match filter on `course_id` (case-sensitive). Omit to disable filtering
clip_idString-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.


List user activity

GET/ops/v1/users#
Returns per-learner activity summaries in one batch (no pagination).

Request

Query Parameters

ParameterTypeRequiredDescription
user_idString-Partial-match search on user_id (case-insensitive)
start_dateString-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_dateString-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_idString-Exact-match filter on `course_id` (case-sensitive). Omit to disable filtering
clip_idString-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

ParameterTypeRequiredDescription
course_idString | null-The requested `course_id`, stripped and echoed back. `null` if omitted or blank
clip_idString | null-The requested `clip_id`, stripped and echoed back. `null` if omitted or blank
usersArray of objectsOUser activity list (no pagination, returned in one batch). See users item.
users item
FieldTypeDescription
user_idStringLearner identifier
session_countNumberCumulative session count for the learner
question_countNumberCumulative question count for the learner (number of role='user' messages)
last_question_atString (ISO 8601) | nullLast 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

HTTPerror_codeDomainMeaningResolution
400NotExistMemberAuthUnregistered client_member emailVerify the email
400MismatchedPasswordAuthPassword mismatchVerify the password
400NotActivatedClientMemberAuthInactive member or member belonging to an inactive clientAsk an admin to activate
400SigninFailedAuthToken issuance processing failedRetry shortly
400RefreshFailedAuthToken refresh processing failedSign in again
400InvalidClientSession / UsersClient in the token does not exist or is inactiveRe-check token issuance
400NotExistClientDashboardClient not foundRe-check token issuance
401NotAuthorizedAll (protected APIs)Token missing / invalid / expiredCall `/ops/v1/auth/refresh` or sign in again
401InvalidAuthHeaderAll (protected APIs)Authorization header does not start with `Bearer `Verify the `Authorization: Bearer {access_token}` format
401InvalidRefreshTokenAuthrefresh_token invalid / expired / revokedPrompt the user to sign in again
404NotExistSessionSessionSession does not exist or belongs to another clientVerify session_id
422RequestValidationErrorAllPath/Query/Body validation failedInspect `loc` and `msg` in `details[]`
500SERVER_ERRORAllInternal server errorRetry 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, call POST /ops/v1/auth/refresh once to renew and retry the same request. For InvalidRefreshToken, prompt the user to sign in again.
  • 422 response — Client payload bug. Do not retry; inspect loc / msg in details[] and fix the payload.
  • 5xx response — Retry with the same payload using exponential backoff (e.g. 1s / 2s / 4s).

Next Steps