Introduction
JSON API backend for the Dots App Flutter client: authentication (email OTP registration, login, forgot/reset password), account management, and clinical case submission.
This documentation aims to provide all the information you need to work with our API.
Response shape
Every endpoint is under /api/v1, including the admin API at /api/v1/admin.
Keys are never omitted. An entity has the same keys wherever it appears, so one client model parses it everywhere. If a field does not apply to the current record, the key is still there with an empty or null value.
- Lists are always lists.
images,comments,options,answersand the rest are always arrays,[]when empty, nevernulland never absent. - Counts are always integers.
comments_count,cases_count,likes_countanddislikes_countare0when empty, nevernull. nullmeans "not set". Optional scalars such asbio,age,pdf,rejection_reason,avatar_url,pinned_at,pinned_byandmy_reactionarenullwhen the value was never set. Declare these nullable in your client model (String?,int?);nulland""are not interchangeable, and a field beingnullis never an error.- Branch on
type, not on which keys exist. A quiz question always carriesoptions,leftandright; a quiz answer always carriesselected_option_ids,correct_option_ids,responseandcorrect_pairs. Which pair is populated depends on the question'stype; the others are empty. - Two envelopes, split by endpoint group. Auth, Account, Users and Cases
return Laravel's
{"data": ...}shape. Quizzes, Learning and the admin API return{"success": bool, "message": string, "data": ...}, with a separatepaginationblock on list endpoints. Each endpoint's examples below show which one it uses. codeand pin fields are on every case. See the Cases group for what they mean and how to search by a code.
Recent additions (5 September 2026)
Nothing was renamed or removed, so a client built before this date keeps working. Three things were added:
-
Every case carries a
code. Five characters, uppercase letters and digits, never containingO,0,Ior1. The server assigns it at submission and never changes it. Pass it as?search=onGET /api/v1/casesorGET /api/v1/cases/mineto pull up that one case; any other search term is matched against diagnosis and body site instead. Full rules and every scenario are in the Cases group. -
An admin can pin a case to the top of the feed.
GET /api/v1/casesnow returns pinned cases first (most recently pinned first), then the usual newest-first order. Every case payload gainedis_pinned(bool),pinned_at(nullable string) andpinned_by(nullable{id, full_name, avatar_url}), so the app can badge a case as pinned by an admin.GET /api/v1/cases/mineis deliberately not reordered by pins. Admins set a pin withPOST /api/v1/admin/cases/pin. -
Quiz results now return the whole attempt, not just the marks.
POST /api/v1/quizzes/{id}/submitandGET /api/v1/quizzes/{id}/resultreturn every question in the quiz with every option it offered, each option flaggedis_correctandis_selected, pluspercentage,correct_count,incorrect_count,unanswered_countandformatalongsidescore. Questions the student skipped are included, markedis_answered: false. Every key these endpoints returned before is unchanged. The Quizzes group has a key-by-key table and the scenarios to test.
Authenticating requests
To authenticate requests, include an Authorization header with the value "Bearer {YOUR_SANCTUM_TOKEN}".
All authenticated endpoints are marked with a requires authentication badge in the documentation below.
Obtain a token from Register -> Verify OTP, Login, or Reset Password - each returns a token field. Send it as Authorization: Bearer {token}.
Authentication
Registration, login, and password recovery. None of these endpoints require an Authorization header except Logout.
Register
Creates a user (unverified) and emails a 6-digit OTP. role defaults to
student server-side. All fields except full_name, email, password, and
phone_number are optional. Registering again with an email that hasn't been
verified yet updates that pending record and resends the OTP, rather than
failing.
Nothing else from this response is needed by the client - verify-otp and
resend-otp both look the account up by email, and the full profile isn't
available until the account is actually verified.
Example request:
curl --request POST \
"https://www.dots.mhn.services/api/v1/auth/register" \
--header "Content-Type: multipart/form-data" \
--header "Accept: application/json" \
--form "full_name=Dr Jane Doe"\
--form "email=jane@example.com"\
--form "password=password123"\
--form "phone_number=03001234567"\
--form "designation=Consultant Dermatologist"\
--form "bio=Dermatologist with a special interest in dermoscopy and skin cancer screening."\
--form "province=Punjab"\
--form "city=Lahore"\
--form "pmdc_number=PMDC-12345"\
--form "fellowship_number=FCPS-6789"\
--form "institutional_number=INST-001"\
--form "fcm_token=dGhpcyBpcyBhIHNhbXBsZSB0b2tlbg=="\
--form "avatar=@/tmp/php1ona0346dbo8fj0BU7s" const url = new URL(
"https://www.dots.mhn.services/api/v1/auth/register"
);
const headers = {
"Content-Type": "multipart/form-data",
"Accept": "application/json",
};
const body = new FormData();
body.append('full_name', 'Dr Jane Doe');
body.append('email', 'jane@example.com');
body.append('password', 'password123');
body.append('phone_number', '03001234567');
body.append('designation', 'Consultant Dermatologist');
body.append('bio', 'Dermatologist with a special interest in dermoscopy and skin cancer screening.');
body.append('province', 'Punjab');
body.append('city', 'Lahore');
body.append('pmdc_number', 'PMDC-12345');
body.append('fellowship_number', 'FCPS-6789');
body.append('institutional_number', 'INST-001');
body.append('fcm_token', 'dGhpcyBpcyBhIHNhbXBsZSB0b2tlbg==');
body.append('avatar', document.querySelector('input[name="avatar"]').files[0]);
fetch(url, {
method: "POST",
headers,
body,
}).then(response => response.json());Example response (201, Registered, pending verification):
{
"email": "jane@example.com",
"message": "Registered successfully. Please verify the OTP sent to your email."
}
Example response (422, Email already registered and verified):
{
"message": "The email has already been taken.",
"errors": {
"email": [
"The email has already been taken."
]
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Verify Registration OTP
Verifies the OTP and marks the account verified. The client should send the user to the login screen next - this does not issue a token or auto-login.
Example request:
curl --request POST \
"https://www.dots.mhn.services/api/v1/auth/register/verify-otp" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"email\": \"jane@example.com\",
\"otp\": \"482913\"
}"
const url = new URL(
"https://www.dots.mhn.services/api/v1/auth/register/verify-otp"
);
const headers = {
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"email": "jane@example.com",
"otp": "482913"
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());Example response (200, Verified):
{
"email": "jane@example.com",
"message": "Email verified successfully. Please log in."
}
Example response (422, Invalid or expired OTP):
{
"message": "The provided OTP is invalid or has expired.",
"errors": {
"otp": [
"The provided OTP is invalid or has expired."
]
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Resend Registration OTP
Invalidates the previous registration OTP and sends a new one. Only works for accounts that have not yet been verified.
Example request:
curl --request POST \
"https://www.dots.mhn.services/api/v1/auth/register/resend-otp" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"email\": \"jane@example.com\"
}"
const url = new URL(
"https://www.dots.mhn.services/api/v1/auth/register/resend-otp"
);
const headers = {
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"email": "jane@example.com"
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());Example response (200, OTP resent):
{
"message": "A new OTP has been sent to your email."
}
Example response (422, Already verified):
{
"message": "This email is already verified.",
"errors": {
"email": [
"This email is already verified."
]
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Login
Authenticates a verified user and issues a new Sanctum token. Fails if the account has not completed OTP verification yet.
Example request:
curl --request POST \
"https://www.dots.mhn.services/api/v1/auth/login" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"email\": \"jane@example.com\",
\"password\": \"password123\",
\"fcm_token\": \"dGhpcyBpcyBhIHNhbXBsZSB0b2tlbg==\"
}"
const url = new URL(
"https://www.dots.mhn.services/api/v1/auth/login"
);
const headers = {
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"email": "jane@example.com",
"password": "password123",
"fcm_token": "dGhpcyBpcyBhIHNhbXBsZSB0b2tlbg=="
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());Example response (200, Success):
{
"email": "jane@example.com",
"token": "6|To5i3l8yWR3Mb3voEv0mX0W4NK4iLQCxsmuARXfDbe3d9eab"
}
Example response (422, Wrong email or password):
{
"message": "These credentials do not match our records.",
"errors": {
"email": [
"These credentials do not match our records."
]
}
}
Example response (422, Account not yet verified):
{
"message": "Please verify your email before logging in.",
"errors": {
"email": [
"Please verify your email before logging in."
]
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Forgot Password
Emails a 6-digit OTP for an existing account. Fails with a validation error if the email is not registered. This is step 1 of 3 in the reset flow: Forgot Password -> Verify Forgot Password OTP -> Reset Password.
Example request:
curl --request POST \
"https://www.dots.mhn.services/api/v1/auth/forgot-password" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"email\": \"jane@example.com\"
}"
const url = new URL(
"https://www.dots.mhn.services/api/v1/auth/forgot-password"
);
const headers = {
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"email": "jane@example.com"
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());Example response (200, OTP sent):
{
"message": "An OTP has been sent to your email."
}
Example response (422, Email not registered):
{
"message": "We can't find a user with that email address.",
"errors": {
"email": [
"We can't find a user with that email address."
]
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Verify Forgot Password OTP
Verifies the password-reset OTP and returns a short-lived reset_token
(60 minutes, single-use) that must be passed to Reset Password. This is
step 2 of 3 in the reset flow.
Example request:
curl --request POST \
"https://www.dots.mhn.services/api/v1/auth/forgot-password/verify-otp" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"email\": \"jane@example.com\",
\"otp\": \"482913\"
}"
const url = new URL(
"https://www.dots.mhn.services/api/v1/auth/forgot-password/verify-otp"
);
const headers = {
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"email": "jane@example.com",
"otp": "482913"
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());Example response (200, OTP verified):
{
"message": "OTP verified. Use the reset token to set a new password.",
"reset_token": "SZyXkavPnBBJTtfAKD7L89NRvmbhHfopzoDbNt8982c1B16sRHdFJu8EOzNpz8c5"
}
Example response (422, Invalid or expired OTP):
{
"message": "The provided OTP is invalid or has expired.",
"errors": {
"otp": [
"The provided OTP is invalid or has expired."
]
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Resend Forgot Password OTP
Invalidates the previous password-reset OTP and sends a new one.
Example request:
curl --request POST \
"https://www.dots.mhn.services/api/v1/auth/forgot-password/resend-otp" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"email\": \"jane@example.com\"
}"
const url = new URL(
"https://www.dots.mhn.services/api/v1/auth/forgot-password/resend-otp"
);
const headers = {
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"email": "jane@example.com"
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());Example response (200, OTP resent):
{
"message": "A new OTP has been sent to your email."
}
Example response (422, Email not registered):
{
"message": "We can't find a user with that email address.",
"errors": {
"email": [
"We can't find a user with that email address."
]
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Reset Password
Sets a new password using the reset_token from Verify Forgot Password OTP.
This is step 3 of 3 in the reset flow. Revokes all previously issued tokens
and returns a fresh one (auto-login) - store the new token.
Example request:
curl --request POST \
"https://www.dots.mhn.services/api/v1/auth/reset-password" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"email\": \"jane@example.com\",
\"reset_token\": \"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\",
\"new_password\": \"newPassword123\"
}"
const url = new URL(
"https://www.dots.mhn.services/api/v1/auth/reset-password"
);
const headers = {
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"email": "jane@example.com",
"reset_token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
"new_password": "newPassword123"
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());Example response (200, Password reset):
{
"email": "jane@example.com",
"message": "Password reset successfully.",
"token": "5|DUmbGpsWeDowDywPGdLTLsL53pceVqfWodvWwltNfaf1850e"
}
Example response (422, Invalid, expired, or already-used reset token):
{
"message": "The provided reset token is invalid or has expired.",
"errors": {
"reset_token": [
"The provided reset token is invalid or has expired."
]
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Logout
requires authentication
Revokes only the token used to authenticate this request (the current device/session). Other devices stay logged in.
Example request:
curl --request POST \
"https://www.dots.mhn.services/api/v1/auth/logout" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.dots.mhn.services/api/v1/auth/logout"
);
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "POST",
headers,
}).then(response => response.json());Example response (200, Success):
{
"message": "Logged out successfully."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Account
Managing the authenticated user's own profile, password, and account. All of
these require Authorization: Bearer {token}.
Get Profile
requires authentication
Returns the authenticated user's profile.
Example request:
curl --request GET \
--get "https://www.dots.mhn.services/api/v1/profile" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.dots.mhn.services/api/v1/profile"
);
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (200, Success):
{
"data": {
"id": 2,
"full_name": "Dr Jane Doe",
"email": "jane@example.com",
"phone_number": "03007654321",
"designation": "Consultant",
"bio": "Dermatologist with a special interest in dermoscopy.",
"province": "Punjab",
"city": "Lahore",
"pmdc_number": "PMDC-1234",
"fellowship_number": "FEL-5678",
"institutional_number": "INST-9012",
"role": "student",
"avatar_url": null,
"is_verified": true,
"created_at": "2026-07-27T10:08:49.000000Z"
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Update Profile
requires authentication
Partially updates the authenticated user's profile - only send the fields you want to change. The email cannot be changed here. To change the avatar, use Update Profile Picture instead.
Example request:
curl --request PUT \
"https://www.dots.mhn.services/api/v1/profile" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"full_name\": \"Dr Jane A. Doe\",
\"phone_number\": \"03009998888\",
\"designation\": \"Consultant Dermatologist\",
\"bio\": \"Dermatologist with a special interest in dermoscopy and skin cancer screening.\",
\"province\": \"Sindh\",
\"city\": \"Karachi\",
\"pmdc_number\": \"PMDC-12345\",
\"fellowship_number\": \"FCPS-6789\",
\"institutional_number\": \"INST-001\"
}"
const url = new URL(
"https://www.dots.mhn.services/api/v1/profile"
);
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"full_name": "Dr Jane A. Doe",
"phone_number": "03009998888",
"designation": "Consultant Dermatologist",
"bio": "Dermatologist with a special interest in dermoscopy and skin cancer screening.",
"province": "Sindh",
"city": "Karachi",
"pmdc_number": "PMDC-12345",
"fellowship_number": "FCPS-6789",
"institutional_number": "INST-001"
};
fetch(url, {
method: "PUT",
headers,
body: JSON.stringify(body),
}).then(response => response.json());Example response (200, Success):
{
"data": {
"id": 2,
"full_name": "New Name",
"email": "jane@example.com",
"phone_number": "03009998888",
"designation": "Consultant",
"bio": "Dermatologist with a special interest in dermoscopy.",
"province": "Punjab",
"city": "Karachi",
"pmdc_number": "PMDC-1234",
"fellowship_number": "FEL-5678",
"institutional_number": "INST-9012",
"role": "student",
"avatar_url": null,
"is_verified": true,
"created_at": "2026-07-27T10:08:49.000000Z"
},
"message": "Profile updated successfully."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Update Profile Picture
requires authentication
Replaces the authenticated user's avatar. Deletes the previous file, if any.
Example request:
curl --request POST \
"https://www.dots.mhn.services/api/v1/profile/avatar" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: multipart/form-data" \
--header "Accept: application/json" \
--form "avatar=@/tmp/phpefcfedsiri6qa1OAAhB" const url = new URL(
"https://www.dots.mhn.services/api/v1/profile/avatar"
);
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "multipart/form-data",
"Accept": "application/json",
};
const body = new FormData();
body.append('avatar', document.querySelector('input[name="avatar"]').files[0]);
fetch(url, {
method: "POST",
headers,
body,
}).then(response => response.json());Example response (200, Success):
{
"avatar_url": "http://dots-app.test/storage/avatars/example.png",
"message": "Profile picture updated successfully."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Change Password
requires authentication
Changes the authenticated user's password. Revokes ALL existing tokens (including the one used for this request) and returns a fresh one - the app must store the new token.
Example request:
curl --request POST \
"https://www.dots.mhn.services/api/v1/change-password" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"current_password\": \"password123\",
\"new_password\": \"newPassword123\"
}"
const url = new URL(
"https://www.dots.mhn.services/api/v1/change-password"
);
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"current_password": "password123",
"new_password": "newPassword123"
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());Example response (200, Success):
{
"message": "Password changed successfully.",
"token": "14|MxL5u7YFFFDzlMkt3weFmTMGZYN57LWhBek5XYDef754b4ea"
}
Example response (422, Wrong current password):
{
"message": "The provided password is incorrect.",
"errors": {
"current_password": [
"The provided password is incorrect."
]
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Delete Account
requires authentication
Permanently deletes the authenticated user's account after confirming the current password. Also deletes the avatar file and revokes all tokens. This is destructive and cannot be undone.
Example request:
curl --request DELETE \
"https://www.dots.mhn.services/api/v1/account" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"current_password\": \"password123\"
}"
const url = new URL(
"https://www.dots.mhn.services/api/v1/account"
);
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"current_password": "password123"
};
fetch(url, {
method: "DELETE",
headers,
body: JSON.stringify(body),
}).then(response => response.json());Example response (200, Success):
{
"message": "Account deleted successfully."
}
Example response (422, Wrong password):
{
"message": "The provided password is incorrect.",
"errors": {
"current_password": [
"The provided password is incorrect."
]
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Cases
Clinical case submission and the shared case feed. All of these require
Authorization: Bearer {token}. A case only requires at least one image
(clinical or dermoscopic); every other field is optional free text/arrays -
there's no fixed option list yet, the Flutter app owns validation and
dropdown values. Cases are stored with a pending status; there's no
review/approval workflow yet. Any authenticated user can view any case and
its comments, and can comment/react on it - this is a shared feed, not
private per-user data.
Case code
Every case carries a code, a short handle for that case:
- Exactly five characters, always uppercase letters and digits.
- Drawn from an alphabet that leaves out the characters people misread:
there is never an
O,0,Ior1in a code.K7F2Qis a real shape;K7F2Ocan never occur. - Assigned by the server when the case is created. The client never sends it, and it is never reissued: editing a case, adding or removing images, rejection and resubmission all leave it unchanged.
- Unique across every case, so a full code identifies exactly one case.
The code is on every case payload, on every endpoint, including the admin API. It is what the search box is for - see below.
Search
GET cases and GET cases/mine both accept ?search=. One parameter,
two behaviours, decided by what was typed:
| Search term | Matches |
|---|---|
A full case code, e.g. K7F2Q |
That one case, by exact code. Letter case is ignored, so k7f2q works too |
Anything else, e.g. melanoma |
A fragment of clinical.diagnosis, dermoscopic.diagnosis, or body_site |
Notes for testing:
- A partial code does not match. Searching
K7Freturns nothing unlessK7Fhappens to appear in a diagnosis or body site. - Search runs before pagination, so
meta.totalis the number of matches, not the size of the whole feed. - No match is an empty result, never a 404:
{"data": [], "meta": {"skip": 0, "limit": 15, "total": 0}}. - Search on the feed still only sees approved cases. To find your own
pending or rejected case by code, search
cases/mine.
Pinned cases
An admin can pin a case to the top of the feed, from the admin panel or
from POST /api/v1/admin/cases/pin. Every case payload carries three keys
for it, always present:
| Key | Type | Meaning |
|---|---|---|
is_pinned |
bool | true while the case is pinned. Never null |
pinned_at |
string, nullable | When it was pinned, ISO 8601. null when not pinned |
pinned_by |
object, nullable | The admin who pinned it: {id, full_name, avatar_url}. null when not pinned |
Ordering rules for GET cases, in order of precedence:
- Pinned cases first.
- Among pinned cases, most recently pinned first - by
pinned_at, not by when the case was created. - Everything else after, newest created first.
Notes for testing:
- More than one case can be pinned at a time. There is no limit and no "only one pinned case" rule.
- Pinning an already-pinned case is allowed and refreshes
pinned_at, which moves it to the front of the pinned block. - Pinning is not review. Pinning a
pendingorrejectedcase stores the pin, but the case still does not appear in the feed until it is approved; its submitter sees the pin fields incases/mine. - Unpinning clears both
pinned_atandpinned_byand drops the case back into the normal newest-first order. Nothing else about the case changes. cases/mineis deliberately not reordered by pins: a submitter's own history stays newest-first, even for a case that is pinned in the feed.- Deleting the admin account that pinned a case leaves the case pinned with
pinned_by: null. Render the badge fromis_pinned, not frompinned_by.
Response shape. Every endpoint below returns a case with exactly the same
keys, so one client model parses all of them. Keys are never omitted. A value
may be null when the field is genuinely unset (age, pdf,
rejection_reason), but lists are always lists and counts are always
integers. comments is populated only by Get Case; on the list endpoints it
is [] and comments_count is the authoritative number.
List My Cases (History)
requires authentication
List of only the authenticated user's own cases, newest first. This is
the one place a submitter sees their pending and rejected cases, so
a rejected case here carries the admin's rejection_reason; editing it
resubmits it and clears the reason.
Pins do not reorder this list: a case of yours that an admin pinned
still sits in date order here, with is_pinned: true on it.
Accepts the same search param as the feed, so a submitter can find
their own case by its code - including one still awaiting review, which
the feed search cannot see. Paginated with skip/limit query params - skip defaults to 0, limit defaults
to 15 (max 100). The client is responsible for advancing skip on
subsequent requests (e.g. skip=15 for the next page after a limit=15
first page) and for stopping once skip + limit >= meta.total.
Example request:
curl --request GET \
--get "https://www.dots.mhn.services/api/v1/cases/mine?skip=0&limit=15&search=K7F2Q" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.dots.mhn.services/api/v1/cases/mine"
);
const params = {
"skip": "0",
"limit": "15",
"search": "K7F2Q",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (200, Success - own cases, including pending and rejected):
{
"data": [
{
"id": 1,
"code": "K7F2Q",
"user": {
"id": 3,
"full_name": "Dr Jane Doe",
"avatar_url": null
},
"status": "pending",
"is_pinned": false,
"pinned_at": null,
"pinned_by": null,
"age": 52,
"gender": "male",
"fitzpatrick_skin_type": "III",
"body_site": "trunk",
"pdf": {
"url": "http://dots-app.test/storage/cases/1/pdf/report.pdf",
"name": "report.pdf",
"size": 148213
},
"clinical": {
"diagnosis": "melanoma",
"histopathology": null,
"images": [
{
"id": 1,
"url": "http://dots-app.test/storage/cases/1/clinical/example.png"
}
]
},
"dermoscopic": {
"lesion_type": "melanocytic",
"features": [
"pigment network",
"streaks"
],
"vascular_pattern": [
"dotted"
],
"colours_present": [
"black",
"brown"
],
"scale": "fine",
"pattern": "reticular",
"image_metadata": [
"polarized"
],
"diagnosis": "melanoma",
"histopathology": null,
"images": []
},
"comments_count": 2,
"comments": [],
"created_at": "2026-07-28T06:41:56.000000Z"
}
],
"meta": {
"skip": 0,
"limit": 15,
"total": 1
}
}
Example response (200, a rejected case of your own, with the reason):
{
"data": [
{
"id": 4,
"code": "P8VNC",
"user": {
"id": 3,
"full_name": "Dr Jane Doe",
"avatar_url": null
},
"status": "rejected",
"is_pinned": false,
"pinned_at": null,
"pinned_by": null,
"rejection_reason": "Photos are out of focus.",
"age": 61,
"gender": "male",
"fitzpatrick_skin_type": "IV",
"body_site": "lower limb",
"pdf": null,
"clinical": {
"diagnosis": "nevus",
"histopathology": null,
"images": [
{
"id": 30,
"url": "https://www.dots.mhn.services/storage/cases/4/clinical/blurry.png"
}
]
},
"dermoscopic": {
"lesion_type": null,
"features": [],
"vascular_pattern": [],
"colours_present": [],
"scale": null,
"pattern": null,
"image_metadata": [],
"diagnosis": null,
"histopathology": null,
"images": []
},
"comments_count": 0,
"comments": [],
"created_at": "2026-09-02T08:12:00.000000Z"
}
],
"meta": {
"skip": 0,
"limit": 15,
"total": 1
}
}
Example response (200, you have not submitted anything yet):
{
"data": [],
"meta": {
"skip": 0,
"limit": 15,
"total": 0
}
}
Example response (401, Missing or expired token):
{
"message": "Unauthenticated."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
List Cases (Feed)
requires authentication
Feed of approved cases from every user. Cases an admin pinned come
first, most recently pinned first; the rest follow newest first. Cases
awaiting review, and cases an admin rejected, are not in the feed; a
submitter still sees their own in cases/mine.
Pass search to filter the feed. A five-character case code matches
that one case exactly; any other term is matched as a fragment of the
clinical diagnosis, the dermoscopic diagnosis, or the body site.
Paginated with skip/limit query params - skip defaults to 0,
limit defaults to 15 (max 100).
The client is responsible for advancing skip on subsequent requests
(e.g. skip=15 for the next page after a limit=15 first page) and for
stopping once skip + limit >= meta.total.
Example request:
curl --request GET \
--get "https://www.dots.mhn.services/api/v1/cases?skip=0&limit=15&search=K7F2Q" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.dots.mhn.services/api/v1/cases"
);
const params = {
"skip": "0",
"limit": "15",
"search": "K7F2Q",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (200, Success - a pinned case leads the feed):
{
"data": [
{
"id": 8,
"code": "M4XTB",
"user": {
"id": 3,
"full_name": "Dr Jane Doe",
"avatar_url": null
},
"status": "approved",
"is_pinned": true,
"pinned_at": "2026-09-05T09:15:00.000000Z",
"pinned_by": {
"id": 1,
"full_name": "Dots Admin",
"avatar_url": null
},
"rejection_reason": null,
"age": 34,
"gender": "female",
"fitzpatrick_skin_type": "II",
"body_site": "face",
"pdf": null,
"clinical": {
"diagnosis": "basal cell carcinoma",
"histopathology": null,
"images": [
{
"id": 12,
"url": "https://www.dots.mhn.services/storage/cases/8/clinical/lesion.png"
}
]
},
"dermoscopic": {
"lesion_type": "non-melanocytic",
"features": [
"arborizing vessels"
],
"vascular_pattern": [
"arborizing"
],
"colours_present": [
"pink"
],
"scale": null,
"pattern": null,
"image_metadata": [],
"diagnosis": null,
"histopathology": null,
"images": []
},
"comments_count": 5,
"comments": [],
"created_at": "2026-08-14T11:02:31.000000Z"
},
{
"id": 12,
"code": "K7F2Q",
"user": {
"id": 5,
"full_name": "Dr John Roe",
"avatar_url": null
},
"status": "approved",
"is_pinned": false,
"pinned_at": null,
"pinned_by": null,
"rejection_reason": null,
"age": 52,
"gender": "male",
"fitzpatrick_skin_type": "III",
"body_site": "trunk",
"pdf": {
"url": "https://www.dots.mhn.services/storage/cases/12/pdf/report.pdf",
"name": "report.pdf",
"size": 148213
},
"clinical": {
"diagnosis": "melanoma",
"histopathology": null,
"images": [
{
"id": 18,
"url": "https://www.dots.mhn.services/storage/cases/12/clinical/example.png"
}
]
},
"dermoscopic": {
"lesion_type": "melanocytic",
"features": [
"pigment network",
"streaks"
],
"vascular_pattern": [
"dotted"
],
"colours_present": [
"black",
"brown"
],
"scale": "fine",
"pattern": "reticular",
"image_metadata": [
"polarized"
],
"diagnosis": "melanoma",
"histopathology": null,
"images": [
{
"id": 19,
"url": "https://www.dots.mhn.services/storage/cases/12/dermoscopic/example.png"
}
]
},
"comments_count": 2,
"comments": [],
"created_at": "2026-09-01T06:41:56.000000Z"
}
],
"meta": {
"skip": 0,
"limit": 15,
"total": 2
}
}
Example response (200, search by case code - one exact match):
{
"data": [
{
"id": 12,
"code": "K7F2Q",
"user": {
"id": 5,
"full_name": "Dr John Roe",
"avatar_url": null
},
"status": "approved",
"is_pinned": false,
"pinned_at": null,
"pinned_by": null,
"rejection_reason": null,
"age": 52,
"gender": "male",
"fitzpatrick_skin_type": "III",
"body_site": "trunk",
"pdf": null,
"clinical": {
"diagnosis": "melanoma",
"histopathology": null,
"images": [
{
"id": 18,
"url": "https://www.dots.mhn.services/storage/cases/12/clinical/example.png"
}
]
},
"dermoscopic": {
"lesion_type": "melanocytic",
"features": [],
"vascular_pattern": [],
"colours_present": [],
"scale": null,
"pattern": null,
"image_metadata": [],
"diagnosis": null,
"histopathology": null,
"images": []
},
"comments_count": 2,
"comments": [],
"created_at": "2026-09-01T06:41:56.000000Z"
}
],
"meta": {
"skip": 0,
"limit": 15,
"total": 1
}
}
Example response (200, search matched nothing - empty list, not a 404):
{
"data": [],
"meta": {
"skip": 0,
"limit": 15,
"total": 0
}
}
Example response (401, Missing or expired token):
{
"message": "Unauthenticated."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Submit Case
requires authentication
Submits a new case for review. At least one of clinical_images[] or
dermoscopic_images[] is required (both accept multiple files);
everything else is optional. dermoscopic_features[], vascular_pattern[],
colours_present[], and image_metadata[] are multi-select - repeat the
key for each value.
A single supporting PDF can be attached as pdf (up to 20 MB). It comes
back on every case response as a pdf object with url, name, and
size, or as null when the case has no PDF.
The response carries the server-assigned code for the new case. Show
it to the submitter after a successful submission - it is how they, or
anyone they give it to, find the case again through search. The client
never generates or sends a code, and a code sent in the request body is
ignored.
A new case is always status: "pending", is_pinned: false, and has an
empty comments list with comments_count: 0.
Example request:
curl --request POST \
"https://www.dots.mhn.services/api/v1/cases" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: multipart/form-data" \
--header "Accept: application/json" \
--form "age=52"\
--form "gender=male"\
--form "fitzpatrick_skin_type=III"\
--form "body_site=trunk"\
--form "clinical_diagnosis=melanoma"\
--form "clinical_histopathology=Superficial spreading melanoma, Breslow depth 0.8mm"\
--form "lesion_type=melanocytic"\
--form "dermoscopic_features[]=pigment network"\
--form "vascular_pattern[]=dotted"\
--form "colours_present[]=black"\
--form "scale=fine"\
--form "pattern=reticular"\
--form "image_metadata[]=polarized"\
--form "dermoscopic_diagnosis=melanoma"\
--form "dermoscopic_histopathology=Not biopsied"\
--form "clinical_images[]=@/tmp/php8bf17g02htil3XzAwfH" \
--form "dermoscopic_images[]=@/tmp/php0ek5atisdvkufWkPFNK" \
--form "pdf=@/tmp/phpdm44k43e505ncEBehv4" const url = new URL(
"https://www.dots.mhn.services/api/v1/cases"
);
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "multipart/form-data",
"Accept": "application/json",
};
const body = new FormData();
body.append('age', '52');
body.append('gender', 'male');
body.append('fitzpatrick_skin_type', 'III');
body.append('body_site', 'trunk');
body.append('clinical_diagnosis', 'melanoma');
body.append('clinical_histopathology', 'Superficial spreading melanoma, Breslow depth 0.8mm');
body.append('lesion_type', 'melanocytic');
body.append('dermoscopic_features[]', 'pigment network');
body.append('vascular_pattern[]', 'dotted');
body.append('colours_present[]', 'black');
body.append('scale', 'fine');
body.append('pattern', 'reticular');
body.append('image_metadata[]', 'polarized');
body.append('dermoscopic_diagnosis', 'melanoma');
body.append('dermoscopic_histopathology', 'Not biopsied');
body.append('clinical_images[]', document.querySelector('input[name="clinical_images[]"]').files[0]);
body.append('dermoscopic_images[]', document.querySelector('input[name="dermoscopic_images[]"]').files[0]);
body.append('pdf', document.querySelector('input[name="pdf"]').files[0]);
fetch(url, {
method: "POST",
headers,
body,
}).then(response => response.json());Example response (201, Case submitted):
{
"data": {
"id": 1,
"code": "K7F2Q",
"user": {
"id": 3,
"full_name": "Dr Jane Doe",
"avatar_url": null
},
"status": "pending",
"is_pinned": false,
"pinned_at": null,
"pinned_by": null,
"age": 52,
"gender": "male",
"fitzpatrick_skin_type": "III",
"body_site": "trunk",
"pdf": {
"url": "http://dots-app.test/storage/cases/1/pdf/report.pdf",
"name": "report.pdf",
"size": 148213
},
"clinical": {
"diagnosis": "melanoma",
"histopathology": null,
"images": [
{
"id": 1,
"url": "http://dots-app.test/storage/cases/1/clinical/example.png"
}
]
},
"dermoscopic": {
"lesion_type": "melanocytic",
"features": [
"pigment network",
"streaks"
],
"vascular_pattern": [
"dotted"
],
"colours_present": [
"black",
"brown"
],
"scale": "fine",
"pattern": "reticular",
"image_metadata": [
"polarized"
],
"diagnosis": "melanoma",
"histopathology": null,
"images": [
{
"id": 2,
"url": "http://dots-app.test/storage/cases/1/dermoscopic/example.png"
}
]
},
"comments_count": 0,
"comments": [],
"created_at": "2026-07-28T06:41:56.000000Z"
},
"message": "Case submitted for review."
}
Example response (401, Missing or expired token):
{
"message": "Unauthenticated."
}
Example response (422, No images attached):
{
"message": "Upload at least one clinical or dermoscopic image.",
"errors": {
"clinical_images": [
"Upload at least one clinical or dermoscopic image."
],
"dermoscopic_images": [
"Upload at least one clinical or dermoscopic image."
]
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Get Case
requires authentication
Shows a single case with its images and comments (each comment includes
the commenter's name/photo, like/dislike counts, and the authenticated
user's own reaction, if any, as my_reaction).
This is the only endpoint that fills comments; the list endpoints
leave it []. The case is addressed by numeric id, not by code -
to open a case from a code, search the feed for the code and use the
id you get back.
Any authenticated user can read any approved case here, so the pin
fields are visible to everyone and a viewer who is not the submitter
still sees is_pinned and pinned_by.
Example request:
curl --request GET \
--get "https://www.dots.mhn.services/api/v1/cases/1" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.dots.mhn.services/api/v1/cases/1"
);
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (200, Success - a pinned case with its comments):
{
"data": {
"id": 1,
"code": "K7F2Q",
"user": {
"id": 3,
"full_name": "Dr Jane Doe",
"avatar_url": null
},
"status": "approved",
"is_pinned": true,
"pinned_at": "2026-09-05T09:15:00.000000Z",
"pinned_by": {
"id": 1,
"full_name": "Dots Admin",
"avatar_url": null
},
"age": 52,
"gender": "male",
"fitzpatrick_skin_type": "III",
"body_site": "trunk",
"pdf": {
"url": "http://dots-app.test/storage/cases/1/pdf/report.pdf",
"name": "report.pdf",
"size": 148213
},
"clinical": {
"diagnosis": "melanoma",
"histopathology": null,
"images": [
{
"id": 1,
"url": "http://dots-app.test/storage/cases/1/clinical/example.png"
}
]
},
"dermoscopic": {
"lesion_type": "melanocytic",
"features": [
"pigment network",
"streaks"
],
"vascular_pattern": [
"dotted"
],
"colours_present": [
"black",
"brown"
],
"scale": "fine",
"pattern": "reticular",
"image_metadata": [
"polarized"
],
"diagnosis": "melanoma",
"histopathology": null,
"images": [
{
"id": 2,
"url": "http://dots-app.test/storage/cases/1/dermoscopic/example.png"
}
]
},
"comments_count": 1,
"comments": [
{
"id": 5,
"body": "Great case, thanks for sharing.",
"user": {
"id": 4,
"full_name": "Dr John Roe",
"avatar_url": null
},
"likes_count": 2,
"dislikes_count": 0,
"my_reaction": "like",
"created_at": "2026-07-29T09:00:00.000000Z"
}
],
"created_at": "2026-07-28T06:41:56.000000Z"
}
}
Example response (401, Missing or expired token):
{
"message": "Unauthenticated."
}
Example response (404, No case with that id):
{
"message": "No query results for model [App\\Models\\ClinicalCase] 999"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Update Case
requires authentication
Partially updates a case you own - only send the fields you want to
change. Add new images with new_clinical_images[] /
new_dermoscopic_images[], and remove existing ones by ID with
remove_image_ids[]. Send pdf to attach or replace the case's PDF, or
remove_pdf=true to delete it.
Editing a case an admin rejected resubmits it: its status returns to
pending and the previous rejection_reason is cleared.
An edit never changes the case's code, and never changes its pin: a
pinned case stays pinned with the same pinned_at through an edit, and
through a rejection and resubmission.
Example request:
curl --request PUT \
"https://www.dots.mhn.services/api/v1/cases/1" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: multipart/form-data" \
--header "Accept: application/json" \
--form "remove_image_ids[]=3"\
--form "remove_pdf="\
--form "age=53"\
--form "gender=male"\
--form "fitzpatrick_skin_type=III"\
--form "body_site=trunk"\
--form "clinical_diagnosis=melanoma, revised"\
--form "clinical_histopathology=Superficial spreading melanoma, Breslow depth 0.8mm"\
--form "lesion_type=melanocytic"\
--form "dermoscopic_features[]=pigment network"\
--form "vascular_pattern[]=dotted"\
--form "colours_present[]=black"\
--form "scale=fine"\
--form "pattern=reticular"\
--form "image_metadata[]=polarized"\
--form "dermoscopic_diagnosis=melanoma"\
--form "dermoscopic_histopathology=Not biopsied"\
--form "new_clinical_images[]=@/tmp/phpepgdsrp2fpe88ZLcZuy" \
--form "new_dermoscopic_images[]=@/tmp/php7q7sh1mn9i73cRr2TQZ" \
--form "pdf=@/tmp/phponrpghuvg0sbbE0clec" const url = new URL(
"https://www.dots.mhn.services/api/v1/cases/1"
);
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "multipart/form-data",
"Accept": "application/json",
};
const body = new FormData();
body.append('remove_image_ids[]', '3');
body.append('remove_pdf', '');
body.append('age', '53');
body.append('gender', 'male');
body.append('fitzpatrick_skin_type', 'III');
body.append('body_site', 'trunk');
body.append('clinical_diagnosis', 'melanoma, revised');
body.append('clinical_histopathology', 'Superficial spreading melanoma, Breslow depth 0.8mm');
body.append('lesion_type', 'melanocytic');
body.append('dermoscopic_features[]', 'pigment network');
body.append('vascular_pattern[]', 'dotted');
body.append('colours_present[]', 'black');
body.append('scale', 'fine');
body.append('pattern', 'reticular');
body.append('image_metadata[]', 'polarized');
body.append('dermoscopic_diagnosis', 'melanoma');
body.append('dermoscopic_histopathology', 'Not biopsied');
body.append('new_clinical_images[]', document.querySelector('input[name="new_clinical_images[]"]').files[0]);
body.append('new_dermoscopic_images[]', document.querySelector('input[name="new_dermoscopic_images[]"]').files[0]);
body.append('pdf', document.querySelector('input[name="pdf"]').files[0]);
fetch(url, {
method: "PUT",
headers,
body,
}).then(response => response.json());Example response (200, Success):
{
"data": {
"id": 1,
"code": "K7F2Q",
"user": {
"id": 3,
"full_name": "Dr Jane Doe",
"avatar_url": null
},
"status": "pending",
"is_pinned": false,
"pinned_at": null,
"pinned_by": null,
"age": 53,
"gender": "male",
"fitzpatrick_skin_type": "III",
"body_site": "trunk",
"pdf": {
"url": "http://dots-app.test/storage/cases/1/pdf/report.pdf",
"name": "report.pdf",
"size": 148213
},
"clinical": {
"diagnosis": "melanoma, revised",
"histopathology": null,
"images": [
{
"id": 1,
"url": "http://dots-app.test/storage/cases/1/clinical/example.png"
}
]
},
"dermoscopic": {
"lesion_type": "melanocytic",
"features": [
"pigment network",
"streaks"
],
"vascular_pattern": [
"dotted"
],
"colours_present": [
"black",
"brown"
],
"scale": "fine",
"pattern": "reticular",
"image_metadata": [
"polarized"
],
"diagnosis": "melanoma",
"histopathology": null,
"images": []
},
"comments_count": 0,
"comments": [],
"created_at": "2026-07-28T06:41:56.000000Z"
},
"message": "Case updated successfully."
}
Example response (401, Missing or expired token):
{
"message": "Unauthenticated."
}
Example response (403, Not the case owner):
{
"message": "This action is unauthorized."
}
Example response (404, No case with that id):
{
"message": "No query results for model [App\\Models\\ClinicalCase] 999"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Delete Case
requires authentication
Permanently deletes a case you own, along with its images and PDF (files and records) and its comments. This is destructive and cannot be undone.
Example request:
curl --request DELETE \
"https://www.dots.mhn.services/api/v1/cases/1" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.dots.mhn.services/api/v1/cases/1"
);
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "DELETE",
headers,
}).then(response => response.json());Example response (200, Success):
{
"message": "Case deleted successfully."
}
Example response (401, Missing or expired token):
{
"message": "Unauthenticated."
}
Example response (403, Not the case owner):
{
"message": "This action is unauthorized."
}
Example response (404, No case with that id):
{
"message": "No query results for model [App\\Models\\ClinicalCase] 999"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Add Comment
requires authentication
Adds a comment to a case. Any authenticated user may comment on any case.
Example request:
curl --request POST \
"https://www.dots.mhn.services/api/v1/cases/1/comments" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"body\": \"Great case, thanks for sharing.\"
}"
const url = new URL(
"https://www.dots.mhn.services/api/v1/cases/1/comments"
);
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"body": "Great case, thanks for sharing."
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());Example response (201, Comment added):
{
"data": {
"id": 5,
"body": "Great case, thanks for sharing.",
"user": {
"id": 4,
"full_name": "Dr John Roe",
"avatar_url": null
},
"likes_count": 0,
"dislikes_count": 0,
"my_reaction": null,
"created_at": "2026-07-29T09:00:00.000000Z"
},
"message": "Comment added."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Like/Dislike Comment
requires authentication
Sets the authenticated user's reaction on a comment to like or
dislike. Sending the same type again removes the reaction (toggle
off); sending the other type switches it. A user can only have one
reaction per comment.
Example request:
curl --request POST \
"https://www.dots.mhn.services/api/v1/comments/1/react" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"type\": \"like\"
}"
const url = new URL(
"https://www.dots.mhn.services/api/v1/comments/1/react"
);
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"type": "like"
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());Example response (200, Reaction set):
{
"data": {
"likes_count": 1,
"dislikes_count": 0,
"my_reaction": "like"
},
"message": "Reaction saved."
}
Example response (200, Reaction removed (same type sent again)):
{
"data": {
"likes_count": 0,
"dislikes_count": 0,
"my_reaction": null
},
"message": "Reaction removed."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Quizzes
Quizzes as the mobile app sees them. All of these require
Authorization: Bearer {token}.
Only published quizzes are visible. Each person gets one attempt per
quiz: a second submit returns 409, and the recorded attempt is then read
back from the result endpoint. Correct answers, explanations, and references
are never sent before submission.
The two axes: quiz format and question type
A quiz has a format, and each question inside it has a type. They
are independent, and both change how you read the response.
format is one of:
| format | Graded? | What comes back from submit |
|---|---|---|
standard |
Yes | score out of total_questions, plus per-question answers |
clinical_case |
Yes | Same as standard, plus diagnosis and management revealed |
poll |
No | Aggregate tallies. No score, no answers |
A clinical_case quiz is a case stem: clinical_image, dermoscopic_image
and history are shown up front, and diagnosis / management stay hidden
until the attempt is submitted.
type is one of single_choice, multiple_choice, true_false,
match_following, poll. Note a poll question can appear inside a
standard quiz; it simply scores nothing and its is_correct is null.
After submitting: the review
Submit and result both return the whole attempt, not just the marks. The
two endpoints return the same payload, so one parser covers both: submit
gives it to you once at 201, result gives it back any time afterwards at
200.
The top level carries the marks:
| Key | Type | Meaning |
|---|---|---|
quiz_id |
int | The quiz that was taken |
quiz_title |
string | Its title, so a result screen needs no second call |
format |
string | standard or clinical_case. A poll quiz never reaches this shape |
score |
int | Questions answered correctly |
total_questions |
int | What the attempt was scored out of. Frozen at submission time, so later edits to the quiz do not rewrite an old attempt |
percentage |
number | score / total_questions * 100, one decimal place. 0 when the quiz has no questions |
correct_count |
int | Same as score, counted from the answers |
incorrect_count |
int | Answers graded wrong. Unanswered questions are counted here too; poll questions never are |
unanswered_count |
int | Questions the student skipped |
submitted_at |
string | ISO 8601 timestamp of the attempt |
diagnosis, management |
string, nullable | Revealed for a clinical_case quiz, null otherwise |
answers |
list | One entry per question in the quiz, in the quiz's order |
Each entry in answers:
| Key | Type | Meaning |
|---|---|---|
question_id |
int | The question |
question |
string | Its text |
type |
string | single_choice, multiple_choice, true_false, match_following, poll |
image |
string, nullable | The question's image URL, null when it has none |
is_correct |
bool, nullable | true/false, or null for a poll question, which is recorded but never graded |
is_answered |
bool | false when the student skipped this question |
explanation |
string, nullable | Why the correct answer is correct. Only ever sent after submission |
reference |
string, nullable | Source for the question |
options |
list | Every option the question offered: {id, text, is_correct, is_selected}. Empty for match_following |
selected_option_ids |
list of string | What the student picked. Empty when skipped, and empty for match_following |
correct_option_ids |
list of string | The answer key. Empty for match_following and for a poll question |
response |
object | The student's {left_id: right_id} map. match_following only, [] otherwise |
correct_pairs |
object | The correct {left_id: right_id} map. match_following only, [] otherwise |
left, right |
list | Both sides of a match_following question as {id, text}, in the authored order. Empty for every other type |
The quickest way to render a reviewed choice question is to ignore the id
lists entirely and walk options: is_correct marks the right answer,
is_selected marks what the student tapped, and a question is wrong when an
option has is_selected without is_correct.
Scenarios worth testing on the review screen
- All correct:
score == total_questions,percentage: 100, every answeris_correct: true. - Some wrong: the wrong answer has one option with
is_selected: true, is_correct: falseand another withis_correct: true, is_selected: false. - Skipped question:
is_answered: false,selected_option_ids: [],is_correct: false, and no option hasis_selected. It still appears inanswers, so the review shows the whole quiz. - Multiple choice, partly right: grading is all-or-nothing. Picking one
of two correct options is
is_correct: false, andoptionsshows both correct ones so the screen can display what was missed. - match_following, partly right: also all-or-nothing. Compare
responseagainstcorrect_pairsper pair to shade the rows individually. - Poll question inside a graded quiz:
is_correct: null, every optionis_correct: false, and it contributes to neitherscorenorincorrect_count. Render it as "recorded", not as right or wrong. - Empty quiz (no questions attached):
score: 0,total_questions: 0,percentage: 0,answers: []. - Re-open later: call the result endpoint again; the payload is byte-for-byte what submit returned.
Reading the payload
Keys are never omitted, so one client model parses every question and every
answer. Branch on type and format, never on which keys are present.
- A question always carries
options,leftandright. Amatch_followingquestion fillsleft/rightand leavesoptionsempty; every other type does the reverse. rightis shuffled, seeded from the question id, so the pairing is not given away by row order but stays stable for the same student. Matchleft[i].idtoright[j].id, never by position.- An answer always carries
selected_option_ids,correct_option_ids,responseandcorrect_pairs. The first two are lists and are used by every type exceptmatch_following; the last two are objects and are used only bymatch_following. The unused pair is empty. - An answer also carries the question's own
options,leftandright, in the same shape the quiz detail endpoint uses, so the review screen can render the whole quiz back from the result alone. Each option there addsis_correctandis_selected, which is the short way to render a reviewed question without walking the id lists. - Every question in the quiz appears in
answers, in the quiz's own order, including questions the student skipped. A skipped one hasis_answeredset to false, an empty selection, andis_correctfalse. clinical_image,dermoscopic_image,history,diagnosisandmanagementare always present, and arenullunless the format isclinical_case.
Types to watch, for a statically typed client
percentageis a number, not always a decimal. JSON encoding drops a trailing.0, so a whole value serializes as0or100while a fractional one serializes as33.3. Read it as a general number and convert (in Dart,(json['percentage'] as num).toDouble()), or a whole percentage will fail a strict decimal cast.is_correcthas three states:true,false, ornullfor a poll question, which is recorded but never graded. It is nullable, not a plain boolean.responseandcorrect_pairsare objects ({"p1": "p1"}) formatch_followingand empty lists[]for every other type, so their type depends ontype.- Everything else holds to the contract in the introduction: lists are always lists, counts are always integers.
List Quizzes
requires authentication
Published quizzes, newest first. has_submitted tells the app whether
this user has already taken each one - a quiz where it is true cannot
be submitted again, only its result read.
format is here as well as on the detail endpoint, so the list can be
rendered (and the right result parser chosen) without fetching each quiz.
Example request:
curl --request GET \
--get "https://www.dots.mhn.services/api/v1/quizzes?page=1&limit=20" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.dots.mhn.services/api/v1/quizzes"
);
const params = {
"page": "1",
"limit": "20",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (200, Success):
{
"success": true,
"message": "Quizzes retrieved successfully.",
"data": [
{
"id": 1,
"title": "Dermoscopy Fundamentals",
"description": "Covers every question type.",
"format": "standard",
"total_questions": 5,
"has_submitted": false,
"created_at": "2026-09-04T06:45:07.000000Z"
},
{
"id": 2,
"title": "Pigmented Lesion on the Back",
"description": "A 52-year-old man.",
"format": "clinical_case",
"total_questions": 1,
"has_submitted": false,
"created_at": "2026-09-04T06:45:09.000000Z"
},
{
"id": 3,
"title": "Confidence Survey",
"description": "Anonymous poll.",
"format": "poll",
"total_questions": 1,
"has_submitted": true,
"created_at": "2026-09-04T06:45:09.000000Z"
}
],
"pagination": {
"current_page": 1,
"per_page": 20,
"total": 3,
"last_page": 1
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Get Quiz
requires authentication
A published quiz with its questions and options, ready to be answered.
Answer each question by sending back the id of the chosen option.
The correct answers are not included. Fetch the result endpoint after submitting to see which answers were right.
Example request:
curl --request GET \
--get "https://www.dots.mhn.services/api/v1/quizzes/1" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.dots.mhn.services/api/v1/quizzes/1"
);
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (200, standard quiz - one of every question type):
{
"success": true,
"message": "Quiz retrieved successfully.",
"data": {
"id": 1,
"title": "Dermoscopy Fundamentals",
"description": "Covers every question type.",
"format": "standard",
"clinical_image": null,
"dermoscopic_image": null,
"history": null,
"total_questions": 5,
"questions": [
{
"id": 101,
"question": "Which feature most suggests melanoma?",
"type": "single_choice",
"image": null,
"options": [
{
"id": "o1",
"text": "Blue-white veil"
},
{
"id": "o2",
"text": "Milia-like cysts"
},
{
"id": "o3",
"text": "Central white patch"
}
],
"left": [],
"right": []
},
{
"id": 102,
"question": "Which features suggest melanoma? Select all that apply.",
"type": "multiple_choice",
"image": "https://dots.mhn.services/storage/questions/102/lesion.jpg",
"options": [
{
"id": "o1",
"text": "Atypical pigment network"
},
{
"id": "o2",
"text": "Irregular streaks"
},
{
"id": "o3",
"text": "Comedo-like openings"
}
],
"left": [],
"right": []
},
{
"id": 103,
"question": "Dermoscopy improves melanoma detection versus naked-eye examination.",
"type": "true_false",
"image": null,
"options": [
{
"id": "o1",
"text": "True"
},
{
"id": "o2",
"text": "False"
}
],
"left": [],
"right": []
},
{
"id": 104,
"question": "Match each dermoscopic feature to its diagnosis.",
"type": "match_following",
"image": null,
"options": [],
"left": [
{
"id": "p1",
"text": "Milia-like cysts"
},
{
"id": "p2",
"text": "Blue-white veil"
},
{
"id": "p3",
"text": "Central white patch"
}
],
"right": [
{
"id": "p2",
"text": "Melanoma"
},
{
"id": "p3",
"text": "Dermatofibroma"
},
{
"id": "p1",
"text": "Seborrheic keratosis"
}
]
},
{
"id": 105,
"question": "How confident are you reading dermoscopy?",
"type": "poll",
"image": null,
"options": [
{
"id": "o1",
"text": "Not confident"
},
{
"id": "o2",
"text": "Somewhat"
},
{
"id": "o3",
"text": "Very confident"
}
],
"left": [],
"right": []
}
]
}
}
Example response (200, clinical_case quiz - stem shown, diagnosis withheld):
{
"success": true,
"message": "Quiz retrieved successfully.",
"data": {
"id": 2,
"title": "Pigmented Lesion on the Back",
"description": "A 52-year-old man.",
"format": "clinical_case",
"clinical_image": "https://dots.mhn.services/storage/quizzes/2/clinical.jpg",
"dermoscopic_image": "https://dots.mhn.services/storage/quizzes/2/dermoscopic.jpg",
"history": "52-year-old man, enlarging pigmented lesion on the back over 6 months.",
"total_questions": 1,
"questions": [
{
"id": 201,
"question": "What is the most likely diagnosis?",
"type": "single_choice",
"image": null,
"options": [
{
"id": "o1",
"text": "Melanoma"
},
{
"id": "o2",
"text": "Seborrheic keratosis"
}
],
"left": [],
"right": []
}
]
}
}
Example response (200, poll quiz):
{
"success": true,
"message": "Quiz retrieved successfully.",
"data": {
"id": 3,
"title": "Confidence Survey",
"description": "Anonymous poll.",
"format": "poll",
"clinical_image": null,
"dermoscopic_image": null,
"history": null,
"total_questions": 1,
"questions": [
{
"id": 301,
"question": "How confident are you reading dermoscopy?",
"type": "poll",
"image": null,
"options": [
{
"id": "o1",
"text": "Not confident"
},
{
"id": "o2",
"text": "Somewhat"
},
{
"id": "o3",
"text": "Very confident"
}
],
"left": [],
"right": []
}
]
}
}
Example response (404, Not published):
{
"success": false,
"message": "Quiz not found."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Submit Quiz
requires authentication
Grades and records this user's single attempt. The response carries the
score and the full review of the attempt: every question in the quiz, in
the quiz's own order, with every option it offered, each option flagged
as the correct one (is_correct) and as the student's own pick
(is_selected), plus the explanation and reference.
A question left out of answers counts as unanswered and scores zero.
One attempt per quiz - submitting again returns 409.
How to answer each question type, using the ids from the quiz detail response:
| type | Send |
|---|---|
single_choice, true_false, poll |
option_ids: ["o1"] (or option_id: "o1") |
multiple_choice |
option_ids: ["o1", "o2"] - every correct option, no extras |
match_following |
response: {"p1": "p1", "p2": "p2"} - one entry per pair, left id to right id |
Example request body for a quiz holding one of every type:
{
"answers": [
{ "question_id": 101, "option_ids": ["o1"] },
{ "question_id": 102, "option_ids": ["o1", "o2"] },
{ "question_id": 103, "option_ids": ["o1"] },
{ "question_id": 104, "response": { "p1": "p1", "p2": "p2", "p3": "p3" } },
{ "question_id": 105, "option_ids": ["o3"] }
]
}
The response shape depends on the quiz's format: a standard or
clinical_case quiz returns a personal score, a poll returns aggregate
tallies instead. Both scenarios are shown below.
Example request:
curl --request POST \
"https://www.dots.mhn.services/api/v1/quizzes/1/submit" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"answers\": [
{
\"question_id\": 900,
\"option_id\": \"o1\",
\"response\": {
\"p1\": \"p1\",
\"p2\": \"p2\"
}
}
]
}"
const url = new URL(
"https://www.dots.mhn.services/api/v1/quizzes/1/submit"
);
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"answers": [
{
"question_id": 900,
"option_id": "o1",
"response": {
"p1": "p1",
"p2": "p2"
}
}
]
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());Example response (201, standard quiz - graded, with the full review):
{
"success": true,
"message": "Quiz submitted successfully.",
"data": {
"quiz_id": 1,
"quiz_title": "Dermoscopy Fundamentals",
"format": "standard",
"score": 3,
"total_questions": 5,
"percentage": 60,
"correct_count": 3,
"incorrect_count": 1,
"unanswered_count": 0,
"submitted_at": "2026-09-04T06:45:09.000000Z",
"diagnosis": null,
"management": null,
"answers": [
{
"question_id": 101,
"question": "Which feature most suggests melanoma?",
"type": "single_choice",
"image": null,
"is_correct": true,
"is_answered": true,
"explanation": "Blue-white veil is the classic finding.",
"reference": "Braun RP, et al.",
"options": [
{
"id": "o1",
"text": "Blue-white veil",
"is_correct": true,
"is_selected": true
},
{
"id": "o2",
"text": "Milia-like cysts",
"is_correct": false,
"is_selected": false
},
{
"id": "o3",
"text": "Central white patch",
"is_correct": false,
"is_selected": false
}
],
"selected_option_ids": [
"o1"
],
"correct_option_ids": [
"o1"
],
"response": [],
"correct_pairs": [],
"left": [],
"right": []
},
{
"question_id": 102,
"question": "Which features suggest melanoma? Select all that apply.",
"type": "multiple_choice",
"image": "https://dots.mhn.services/storage/questions/102/lesion.jpg",
"is_correct": false,
"is_answered": true,
"explanation": "Comedo-like openings point to seborrheic keratosis.",
"reference": null,
"options": [
{
"id": "o1",
"text": "Atypical pigment network",
"is_correct": true,
"is_selected": true
},
{
"id": "o2",
"text": "Irregular streaks",
"is_correct": true,
"is_selected": false
},
{
"id": "o3",
"text": "Comedo-like openings",
"is_correct": false,
"is_selected": false
}
],
"selected_option_ids": [
"o1"
],
"correct_option_ids": [
"o1",
"o2"
],
"response": [],
"correct_pairs": [],
"left": [],
"right": []
},
{
"question_id": 103,
"question": "Dermoscopy improves melanoma detection versus naked-eye examination.",
"type": "true_false",
"image": null,
"is_correct": true,
"is_answered": true,
"explanation": "Supported by meta-analysis.",
"reference": null,
"options": [
{
"id": "o1",
"text": "True",
"is_correct": true,
"is_selected": true
},
{
"id": "o2",
"text": "False",
"is_correct": false,
"is_selected": false
}
],
"selected_option_ids": [
"o1"
],
"correct_option_ids": [
"o1"
],
"response": [],
"correct_pairs": [],
"left": [],
"right": []
},
{
"question_id": 104,
"question": "Match each dermoscopic feature to its diagnosis.",
"type": "match_following",
"image": null,
"is_correct": true,
"is_answered": true,
"explanation": null,
"reference": null,
"options": [],
"selected_option_ids": [],
"correct_option_ids": [],
"response": {
"p1": "p1",
"p2": "p2",
"p3": "p3"
},
"correct_pairs": {
"p1": "p1",
"p2": "p2",
"p3": "p3"
},
"left": [
{
"id": "p1",
"text": "Milia-like cysts"
},
{
"id": "p2",
"text": "Blue-white veil"
},
{
"id": "p3",
"text": "Central white patch"
}
],
"right": [
{
"id": "p1",
"text": "Seborrheic keratosis"
},
{
"id": "p2",
"text": "Melanoma"
},
{
"id": "p3",
"text": "Dermatofibroma"
}
]
},
{
"question_id": 105,
"question": "How confident are you reading dermoscopy?",
"type": "poll",
"image": null,
"is_correct": null,
"is_answered": true,
"explanation": null,
"reference": null,
"options": [
{
"id": "o1",
"text": "Not confident",
"is_correct": false,
"is_selected": false
},
{
"id": "o2",
"text": "Somewhat",
"is_correct": false,
"is_selected": false
},
{
"id": "o3",
"text": "Very confident",
"is_correct": false,
"is_selected": true
}
],
"selected_option_ids": [
"o3"
],
"correct_option_ids": [],
"response": [],
"correct_pairs": [],
"left": [],
"right": []
}
]
}
}
Example response (201, clinical_case quiz - diagnosis and management revealed):
{
"success": true,
"message": "Quiz submitted successfully.",
"data": {
"quiz_id": 2,
"quiz_title": "Pigmented Lesion on the Back",
"format": "clinical_case",
"score": 1,
"total_questions": 1,
"percentage": 100,
"correct_count": 1,
"incorrect_count": 0,
"unanswered_count": 0,
"submitted_at": "2026-09-04T06:45:09.000000Z",
"diagnosis": "Superficial spreading melanoma, Breslow depth 0.8mm.",
"management": "Wide local excision with 1cm margins; sentinel node discussion.",
"answers": [
{
"question_id": 201,
"question": "What is the most likely diagnosis?",
"type": "single_choice",
"image": null,
"is_correct": true,
"is_answered": true,
"explanation": "The asymmetric network and blue-white veil point to melanoma.",
"reference": null,
"options": [
{
"id": "o1",
"text": "Melanoma",
"is_correct": true,
"is_selected": true
},
{
"id": "o2",
"text": "Seborrheic keratosis",
"is_correct": false,
"is_selected": false
}
],
"selected_option_ids": [
"o1"
],
"correct_option_ids": [
"o1"
],
"response": [],
"correct_pairs": [],
"left": [],
"right": []
}
]
}
}
Example response (201, poll quiz - tallies, no score and no answers):
{
"success": true,
"message": "Poll submitted successfully.",
"data": {
"quiz_id": 3,
"quiz_title": "Confidence Survey",
"total_responses": 3,
"questions": [
{
"question_id": 301,
"question": "How confident are you reading dermoscopy?",
"total_responses": 3,
"options": [
{
"id": "o1",
"text": "Not confident",
"votes": 1,
"percentage": 33.2999999999999971578290569595992565155029296875
},
{
"id": "o2",
"text": "Somewhat",
"votes": 2,
"percentage": 66.7000000000000028421709430404007434844970703125
},
{
"id": "o3",
"text": "Very confident",
"votes": 0,
"percentage": 0
}
]
}
]
}
}
Example response (401, Missing or expired token):
{
"success": false,
"message": "Unauthenticated."
}
Example response (404, Quiz is a draft, or no quiz with that id):
{
"success": false,
"message": "Quiz not found."
}
Example response (409, Already taken - one attempt per quiz):
{
"success": false,
"message": "You have already submitted this quiz."
}
Example response (422, answers missing from the request body):
{
"success": false,
"message": "The answers field is required.",
"errors": {
"answers": [
"The answers field is required."
]
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Get Quiz Result
requires authentication
This user's own result for a quiz they have already taken: the score plus the full review of the attempt - every question, every option, which option was correct, and which one the student picked.
A question the student skipped is still listed, with is_answered set
to false and an empty selection, so the review always covers the whole
quiz.
Returns exactly the same payload the submit endpoint returned, so a
client can reuse one parser for both. As there, the shape follows the
quiz's format: a score with answers for standard and
clinical_case, aggregate tallies for poll.
Another user's result is never visible here; this is only ever the authenticated user's own attempt.
Example request:
curl --request GET \
--get "https://www.dots.mhn.services/api/v1/quizzes/1/result" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.dots.mhn.services/api/v1/quizzes/1/result"
);
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (200, standard or clinical_case quiz):
{
"success": true,
"message": "Quiz result retrieved successfully.",
"data": {
"quiz_id": 1,
"quiz_title": "Dermoscopy Fundamentals",
"format": "standard",
"score": 3,
"total_questions": 5,
"percentage": 60,
"correct_count": 3,
"incorrect_count": 2,
"unanswered_count": 1,
"submitted_at": "2026-09-04T06:45:09.000000Z",
"diagnosis": null,
"management": null,
"answers": [
{
"question_id": 101,
"question": "Which feature most suggests melanoma?",
"type": "single_choice",
"image": null,
"is_correct": true,
"is_answered": true,
"explanation": "Blue-white veil is the classic finding.",
"reference": "Braun RP, et al.",
"options": [
{
"id": "o1",
"text": "Blue-white veil",
"is_correct": true,
"is_selected": true
},
{
"id": "o2",
"text": "Milia-like cysts",
"is_correct": false,
"is_selected": false
},
{
"id": "o3",
"text": "Central white patch",
"is_correct": false,
"is_selected": false
}
],
"selected_option_ids": [
"o1"
],
"correct_option_ids": [
"o1"
],
"response": [],
"correct_pairs": [],
"left": [],
"right": []
},
{
"question_id": 106,
"question": "Which vessel pattern suggests basal cell carcinoma?",
"type": "single_choice",
"image": null,
"is_correct": false,
"is_answered": false,
"explanation": "Arborizing vessels are the classic finding.",
"reference": null,
"options": [
{
"id": "o1",
"text": "Arborizing",
"is_correct": true,
"is_selected": false
},
{
"id": "o2",
"text": "Dotted",
"is_correct": false,
"is_selected": false
}
],
"selected_option_ids": [],
"correct_option_ids": [
"o1"
],
"response": [],
"correct_pairs": [],
"left": [],
"right": []
}
]
}
}
Example response (200, poll quiz):
{
"success": true,
"message": "Poll results retrieved successfully.",
"data": {
"quiz_id": 3,
"quiz_title": "Confidence Survey",
"total_responses": 3,
"questions": [
{
"question_id": 301,
"question": "How confident are you reading dermoscopy?",
"total_responses": 3,
"options": [
{
"id": "o1",
"text": "Not confident",
"votes": 1,
"percentage": 33.2999999999999971578290569595992565155029296875
},
{
"id": "o2",
"text": "Somewhat",
"votes": 2,
"percentage": 66.7000000000000028421709430404007434844970703125
},
{
"id": "o3",
"text": "Very confident",
"votes": 0,
"percentage": 0
}
]
}
]
}
}
Example response (401, Missing or expired token):
{
"success": false,
"message": "Unauthenticated."
}
Example response (404, Not taken yet):
{
"success": false,
"message": "You have not submitted this quiz yet."
}
Example response (404, No quiz with that id):
{
"success": false,
"message": "Resource not found."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Learning
Learning articles as the mobile app sees them. All of these require
Authorization: Bearer {token}.
Only published articles are visible. Each person may like an article once; liking again removes the like.
List Learning Content
requires authentication
Published articles, newest first. The full content body is omitted here
to keep the list small; fetch a single article to read it.
Example request:
curl --request GET \
--get "https://www.dots.mhn.services/api/v1/learning?page=1&limit=20" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.dots.mhn.services/api/v1/learning"
);
const params = {
"page": "1",
"limit": "20",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (200, Success):
{
"success": true,
"message": "Learning content retrieved successfully.",
"data": [
{
"id": 701,
"image": "https://dots.mhn.services/storage/learning/cover.jpg",
"title": "Introduction to Clinical Diagnosis",
"description": "Basic information about clinical diagnosis.",
"total_likes": 125,
"has_liked": false,
"created_at": "2026-08-31T12:30:00.000000Z"
}
],
"pagination": {
"current_page": 1,
"per_page": 20,
"total": 40,
"last_page": 2
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Get Learning Content
requires authentication
A single published article, including its full content body.
Example request:
curl --request GET \
--get "https://www.dots.mhn.services/api/v1/learning/1" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.dots.mhn.services/api/v1/learning/1"
);
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (404, Not published):
{
"success": false,
"message": "Learning content not found."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Like Learning Content
requires authentication
Likes an article, or removes this user's existing like. One like per person, so calling this twice leaves the article unliked.
Example request:
curl --request POST \
"https://www.dots.mhn.services/api/v1/learning/1/like" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.dots.mhn.services/api/v1/learning/1/like"
);
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "POST",
headers,
}).then(response => response.json());Example response (200, Liked):
{
"success": true,
"message": "Learning content liked.",
"data": {
"learning_id": 701,
"has_liked": true,
"total_likes": 126
}
}
Example response (200, Like removed):
{
"success": true,
"message": "Like removed.",
"data": {
"learning_id": 701,
"has_liked": false,
"total_likes": 125
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Admin - Authentication
Admin accounts are created directly in the database; there is no sign-up
endpoint. The token returned here authorizes every other /api/v1/admin/*
endpoint via Authorization: Bearer {token}.
Admin Login
Authenticates an admin by username and password and issues a Sanctum token. Deactivated admins are rejected with the same generic message as bad credentials, so the endpoint does not disclose which accounts exist.
Example request:
curl --request POST \
"https://www.dots.mhn.services/api/v1/admin/login" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"username\": \"admin\",
\"password\": \"admin_password\"
}"
const url = new URL(
"https://www.dots.mhn.services/api/v1/admin/login"
);
const headers = {
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"username": "admin",
"password": "admin_password"
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());Example response (200, Success):
{
"success": true,
"message": "Admin login successful.",
"token": "12|To5i3l8yWR3Mb3voEv0mX0W4NK4iLQCxsmuARXfDbe3d9eab",
"admin": {
"id": 1,
"username": "admin",
"name": "Admin"
}
}
Example response (401, Wrong username or password):
{
"success": false,
"message": "Invalid username or password."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Admin - Dashboard
Dashboard Analytics
requires authentication
Platform totals, counted from live database records each time.
total_users counts every account, admins included; filter the users
list by role to break that down. total_published counts published
quizzes only, while total_submissions counts attempts across all
quizzes. total_content counts learning articles in both states.
Example request:
curl --request GET \
--get "https://www.dots.mhn.services/api/v1/admin/dashboard" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.dots.mhn.services/api/v1/admin/dashboard"
);
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (200, Success):
{
"success": true,
"message": "Dashboard data retrieved successfully.",
"data": {
"users": {
"total_users": 1250
},
"cases": {
"total_cases": 450,
"pending": 120,
"approved": 280,
"rejected": 50
},
"quizzes": {
"total_published": 25,
"total_submissions": 1840
},
"learning": {
"total_content": 40
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Admin - Users
Every registered user, and the switch that activates or deactivates an account. Requires an admin bearer token. Password hashes are never returned.
Users List
requires authentication
Paginated list of registered users with the details captured at registration. Optional filters narrow the list; omit them all to get everyone, newest first.
Example request:
curl --request GET \
--get "https://www.dots.mhn.services/api/v1/admin/users?page=1&limit=20&search=ahmed&role=student&status=active" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.dots.mhn.services/api/v1/admin/users"
);
const params = {
"page": "1",
"limit": "20",
"search": "ahmed",
"role": "student",
"status": "active",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (200, Success):
{
"success": true,
"message": "Users retrieved successfully.",
"data": [
{
"id": 101,
"profile_image": "https://dots.mhn.services/storage/avatars/101.jpg",
"name": "Dr. Ahmed Khan",
"designation": "Consultant",
"bio": "Dermatologist with a special interest in dermoscopy.",
"email": "ahmed@example.com",
"phone": "+92XXXXXXXXXX",
"province": "Punjab",
"city": "Lahore",
"pmdc_number": "PMDC12345",
"fellowship_number": "FEL12345",
"institutional_number": "INS12345",
"role": "student",
"status": "active",
"created_at": "2026-08-31T10:30:00.000000Z"
}
],
"pagination": {
"current_page": 1,
"per_page": 20,
"total": 1250,
"last_page": 63
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Update User Status
requires authentication
Activates or deactivates a user without deleting the account. Deactivating revokes the user's access tokens immediately, so the app stops working for them until they are reactivated.
An admin cannot deactivate their own account.
Example request:
curl --request POST \
"https://www.dots.mhn.services/api/v1/admin/users/status" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"user_id\": 101,
\"status\": \"inactive\"
}"
const url = new URL(
"https://www.dots.mhn.services/api/v1/admin/users/status"
);
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"user_id": 101,
"status": "inactive"
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());Example response (200, Success):
{
"success": true,
"message": "User status updated successfully.",
"data": {
"user_id": 101,
"status": "inactive"
}
}
Example response (422, Deactivating your own account):
{
"success": false,
"message": "You cannot deactivate your own account.",
"errors": {
"user_id": [
"You cannot deactivate your own account."
]
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Admin - Cases
Review queue for submitted cases. Requires an admin bearer token.
A case carries clinical photos, dermoscopic photos, or both. type=clinical
and type=dermoscopic match any case holding at least one photo of that
kind, so a case holding both appears under either filter and reports its own
case_type as all. microscopic is accepted as an alias for
dermoscopic.
Every case here also carries its code (the five-character handle the app
shows and searches on) and its pin state (is_pinned, pinned_at). The
review queue itself is always newest first - pinning changes the order of
the app feed, not of this list.
Cases List
requires authentication
Paginated review queue, newest first, filterable by photo type and
review status. Pass all or omit a filter to leave it unrestricted.
search matches a full case code (letter case ignored), or a fragment
of the clinical diagnosis, the dermoscopic diagnosis, the body site, or
the submitter's name. Unlike the app feed, this searches cases in every
status, so a pending or rejected case is findable by its code here.
Cases have no title or description: the app does not collect either, so neither is returned. Review a case from its photos and its clinical and dermoscopic detail blocks.
Example request:
curl --request GET \
--get "https://www.dots.mhn.services/api/v1/admin/cases?type=dermoscopic&status=pending&search=melanoma&page=1&limit=20" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.dots.mhn.services/api/v1/admin/cases"
);
const params = {
"type": "dermoscopic",
"status": "pending",
"search": "melanoma",
"page": "1",
"limit": "20",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (200, Success):
{
"success": true,
"message": "Cases retrieved successfully.",
"filters": {
"type": "dermoscopic",
"status": "pending"
},
"data": [
{
"id": 501,
"code": "K7F2Q",
"user_id": 101,
"user_name": "Dr. Ahmed Khan",
"user_profile_image": "https://dots.mhn.services/storage/avatars/101.jpg",
"case_type": "dermoscopic",
"status": "pending",
"rejection_reason": null,
"reviewed_at": null,
"is_pinned": true,
"pinned_at": "2026-09-05T09:15:00.000000Z",
"images": [
"https://dots.mhn.services/storage/cases/501/dermoscopic/a.jpg"
],
"pdf": {
"url": "https://dots.mhn.services/storage/cases/501/pdf/histopath.pdf",
"name": "histopath.pdf",
"size": 148213
},
"patient": {
"age": 52,
"gender": "male",
"fitzpatrick_skin_type": "III",
"body_site": "trunk"
},
"clinical": {
"diagnosis": null,
"histopathology": null,
"images": []
},
"dermoscopic": {
"lesion_type": "melanocytic",
"features": [
"pigment network"
],
"vascular_pattern": [
"dotted"
],
"colours_present": [
"brown"
],
"scale": "fine",
"pattern": "reticular",
"image_metadata": [
"polarized"
],
"diagnosis": "melanoma",
"histopathology": null,
"images": [
{
"id": 9,
"url": "https://dots.mhn.services/storage/cases/501/dermoscopic/a.jpg"
}
]
},
"comments_count": 2,
"created_at": "2026-08-31T10:30:00.000000Z"
}
],
"pagination": {
"current_page": 1,
"per_page": 20,
"total": 120,
"last_page": 6
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Approve or Reject Case
requires authentication
Sets a case to approved or rejected, recording who reviewed it and
when. A rejection may carry a reason, which the submitter sees on their
own case so they can correct it and resubmit. Approving clears any
previous rejection reason.
Only approved cases appear in the app's shared feed.
Example request:
curl --request POST \
"https://www.dots.mhn.services/api/v1/admin/cases/status" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"case_id\": 501,
\"status\": \"approved\",
\"reason\": \"Insufficient case information.\"
}"
const url = new URL(
"https://www.dots.mhn.services/api/v1/admin/cases/status"
);
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"case_id": 501,
"status": "approved",
"reason": "Insufficient case information."
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());Example response (200, Approved):
{
"success": true,
"message": "Case approved successfully.",
"data": {
"case_id": 501,
"status": "approved"
}
}
Example response (200, Rejected):
{
"success": true,
"message": "Case rejected successfully.",
"data": {
"case_id": 501,
"status": "rejected"
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Pin or Unpin Case
requires authentication
Pins a case to the top of the app's shared feed, or removes the pin.
Pinned cases come back first from GET /api/v1/cases, most recently
pinned first, each flagged with is_pinned and carrying the admin who
pinned it, so the app can label it as pinned by an admin.
Any number of cases can be pinned at once. Pinning does not review a case: only approved cases appear in the feed, so pinning one still awaiting review has no visible effect until it is approved.
Request
| Field | Type | Required | Notes |
|---|---|---|---|
case_id |
integer | yes | Must be an existing case id, in any review status |
pinned |
boolean | yes | true pins, false unpins. 1/0 and "true"/"false" are accepted |
Behaviour to test
- Pinning is idempotent-ish: pinning an already-pinned case succeeds and
refreshes
pinned_at, which moves it ahead of other pinned cases in the feed. Unpinning a case that is not pinned also succeeds and simply leaves it unpinned. - Unpinning clears
pinned_atandpinned_bytogether. A case is pinned if and only ifpinned_atis set. - Any number of cases can be pinned at the same time.
- The case's review
statusis untouched. Pinning apendingcase stores the pin but the case still will not show in the app feed until it is approved. - The pin records the admin from the bearer token, and that admin comes
back as
pinned_byhere and on every app-facing case payload.
Example request:
curl --request POST \
"https://www.dots.mhn.services/api/v1/admin/cases/pin" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"case_id\": 501,
\"pinned\": true
}"
const url = new URL(
"https://www.dots.mhn.services/api/v1/admin/cases/pin"
);
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"case_id": 501,
"pinned": true
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());Example response (200, Pinned):
{
"success": true,
"message": "Case pinned successfully.",
"data": {
"case_id": 501,
"is_pinned": true,
"pinned_at": "2026-09-05T09:15:00.000000Z",
"pinned_by": {
"id": 1,
"full_name": "Dots Admin",
"avatar_url": null
}
}
}
Example response (200, Unpinned):
{
"success": true,
"message": "Case unpinned successfully.",
"data": {
"case_id": 501,
"is_pinned": false,
"pinned_at": null,
"pinned_by": null
}
}
Example response (200, Pinned again - pinned_at is refreshed):
{
"success": true,
"message": "Case pinned successfully.",
"data": {
"case_id": 501,
"is_pinned": true,
"pinned_at": "2026-09-05T14:02:44.000000Z",
"pinned_by": {
"id": 1,
"full_name": "Dots Admin",
"avatar_url": null
}
}
}
Example response (401, Missing or expired token):
{
"success": false,
"message": "Unauthenticated."
}
Example response (403, Token belongs to a non-admin account):
{
"success": false,
"message": "This action requires an admin account."
}
Example response (422, No case with that id):
{
"success": false,
"message": "The selected case id is invalid.",
"errors": {
"case_id": [
"The selected case id is invalid."
]
}
}
Example response (422, pinned left out):
{
"success": false,
"message": "The pinned field is required.",
"errors": {
"pinned": [
"The pinned field is required."
]
}
}
Example response (422, pinned is not a boolean):
{
"success": false,
"message": "The pinned field must be true or false.",
"errors": {
"pinned": [
"The pinned field must be true or false."
]
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Admin - Questions
The reusable Question Bank. Requires an admin bearer token.
A question moves through draft -> pending_review -> approved or
rejected, mirroring the case review workflow. Only approved questions
can be attached to a quiz (see Admin - Quizzes).
Question List
requires authentication
Paginated, filterable list of bank questions.
Example request:
curl --request GET \
--get "https://www.dots.mhn.services/api/v1/admin/questions?category=Dermoscopy&subcategory=Pigmented+lesions&type=single_choice&difficulty=2&status=pending_review&tag=dermoscopy&search=seborrheic&page=1&limit=20" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.dots.mhn.services/api/v1/admin/questions"
);
const params = {
"category": "Dermoscopy",
"subcategory": "Pigmented lesions",
"type": "single_choice",
"difficulty": "2",
"status": "pending_review",
"tag": "dermoscopy",
"search": "seborrheic",
"page": "1",
"limit": "20",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Create Question
requires authentication
Adds a question to the bank at status = draft. Submit it for review
separately once it is ready.
Example request:
curl --request POST \
"https://www.dots.mhn.services/api/v1/admin/questions" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: multipart/form-data" \
--header "Accept: application/json" \
--form "category=Dermoscopy"\
--form "subcategory=Pigmented lesions"\
--form "question=Which dermoscopic feature is most suggestive of seborrheic keratosis?"\
--form "type=single_choice"\
--form "explanation=Milia-like cysts are the classic dermoscopic clue for seborrheic keratosis."\
--form "reference=Braun RP, et al. Dermoscopy of pigmented skin lesions."\
--form "tags[]=dermoscopy"\
--form "difficulty=2"\
--form "options[]=Blue-white veil"\
--form "correct_answer=Milia-like cysts"\
--form "image=@/tmp/phpt4raq3vfe1tecj0jpQX" const url = new URL(
"https://www.dots.mhn.services/api/v1/admin/questions"
);
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "multipart/form-data",
"Accept": "application/json",
};
const body = new FormData();
body.append('category', 'Dermoscopy');
body.append('subcategory', 'Pigmented lesions');
body.append('question', 'Which dermoscopic feature is most suggestive of seborrheic keratosis?');
body.append('type', 'single_choice');
body.append('explanation', 'Milia-like cysts are the classic dermoscopic clue for seborrheic keratosis.');
body.append('reference', 'Braun RP, et al. Dermoscopy of pigmented skin lesions.');
body.append('tags[]', 'dermoscopy');
body.append('difficulty', '2');
body.append('options[]', 'Blue-white veil');
body.append('correct_answer', 'Milia-like cysts');
body.append('image', document.querySelector('input[name="image"]').files[0]);
fetch(url, {
method: "POST",
headers,
body,
}).then(response => response.json());Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Review Question
requires authentication
Moves a question to pending_review, approved, rejected, or back
to draft, recording who reviewed it and when. Approving clears any
previous rejection reason. Only approved questions can be attached
to a quiz.
Example request:
curl --request POST \
"https://www.dots.mhn.services/api/v1/admin/questions/status" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"question_id\": 901,
\"status\": \"approved\",
\"reason\": \"Reference is missing.\"
}"
const url = new URL(
"https://www.dots.mhn.services/api/v1/admin/questions/status"
);
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"question_id": 901,
"status": "approved",
"reason": "Reference is missing."
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());Example response (200, Approved):
{
"success": true,
"message": "Question approved successfully.",
"data": {
"question_id": 901,
"status": "approved"
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Approve Selected Questions
requires authentication
Bulk-approves every listed question in one call.
Example request:
curl --request POST \
"https://www.dots.mhn.services/api/v1/admin/questions/approve-selected" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"question_ids\": [
901,
902
]
}"
const url = new URL(
"https://www.dots.mhn.services/api/v1/admin/questions/approve-selected"
);
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"question_ids": [
901,
902
]
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());Example response (200, Success):
{
"success": true,
"message": "2 questions approved.",
"data": {
"approved": 2
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Get Question
requires authentication
A single bank question, including its answer key.
Example request:
curl --request GET \
--get "https://www.dots.mhn.services/api/v1/admin/questions/1" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.dots.mhn.services/api/v1/admin/questions/1"
);
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Update Question
requires authentication
Partially updates a question's content. Does not change its review status - use the status endpoint for that.
Example request:
curl --request POST \
"https://www.dots.mhn.services/api/v1/admin/questions/1" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: multipart/form-data" \
--header "Accept: application/json" \
--form "category=Dermoscopy"\
--form "question=Which dermoscopic feature is most suggestive of seborrheic keratosis?"\
--form "type=single_choice"\
--form "difficulty=2"\
--form "image=@/tmp/phpko31hjspprrq89qPvpk" const url = new URL(
"https://www.dots.mhn.services/api/v1/admin/questions/1"
);
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "multipart/form-data",
"Accept": "application/json",
};
const body = new FormData();
body.append('category', 'Dermoscopy');
body.append('question', 'Which dermoscopic feature is most suggestive of seborrheic keratosis?');
body.append('type', 'single_choice');
body.append('difficulty', '2');
body.append('image', document.querySelector('input[name="image"]').files[0]);
fetch(url, {
method: "POST",
headers,
body,
}).then(response => response.json());Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Delete Question
requires authentication
Permanently deletes a question and detaches it from every quiz it was attached to. Quizzes themselves are not deleted.
Example request:
curl --request DELETE \
"https://www.dots.mhn.services/api/v1/admin/questions/1" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.dots.mhn.services/api/v1/admin/questions/1"
);
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "DELETE",
headers,
}).then(response => response.json());Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Admin - Quizzes
Authoring and monitoring of quizzes. Requires an admin bearer token.
A quiz is composed of existing, approved Question Bank entries (see
Admin - Questions), attached via question_ids. format is standard
(a plain graded quiz), poll (ungraded, aggregate results only), or
clinical_case (a case stem shown before its questions, with diagnosis
and management revealed to the student after submission).
Quiz List
requires authentication
Paginated list of quizzes with their question and submission counts.
Example request:
curl --request GET \
--get "https://www.dots.mhn.services/api/v1/admin/quizzes?status=published&format=standard&search=knowledge&page=1&limit=20" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.dots.mhn.services/api/v1/admin/quizzes"
);
const params = {
"status": "published",
"format": "standard",
"search": "knowledge",
"page": "1",
"limit": "20",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (200, Success):
{
"success": true,
"message": "Quizzes retrieved successfully.",
"data": [
{
"id": 301,
"title": "Medical Knowledge Quiz",
"description": "Test your medical knowledge.",
"format": "standard",
"total_questions": 20,
"status": "published",
"total_submissions": 145,
"created_at": "2026-08-31T11:00:00.000000Z"
}
],
"pagination": {
"current_page": 1,
"per_page": 20,
"total": 25,
"last_page": 2
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Create Quiz
requires authentication
Creates a quiz and attaches the given, already-approved bank questions in order.
Example request:
curl --request POST \
"https://www.dots.mhn.services/api/v1/admin/quizzes" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: multipart/form-data" \
--header "Accept: application/json" \
--form "title=Medical Knowledge Quiz"\
--form "description=Test your medical knowledge."\
--form "format=standard"\
--form "status=published"\
--form "question_ids[]=16"\
--form "history=52-year-old male, 6-month history of an enlarging pigmented lesion."\
--form "diagnosis=Superficial spreading melanoma."\
--form "management=Urgent excision with 1cm margins and staging workup."\
--form "clinical_image=@/tmp/phpfucrufkuhgjlafBsCVz" \
--form "dermoscopic_image=@/tmp/phpnm71qllpt2hc8hWgYDz" const url = new URL(
"https://www.dots.mhn.services/api/v1/admin/quizzes"
);
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "multipart/form-data",
"Accept": "application/json",
};
const body = new FormData();
body.append('title', 'Medical Knowledge Quiz');
body.append('description', 'Test your medical knowledge.');
body.append('format', 'standard');
body.append('status', 'published');
body.append('question_ids[]', '16');
body.append('history', '52-year-old male, 6-month history of an enlarging pigmented lesion.');
body.append('diagnosis', 'Superficial spreading melanoma.');
body.append('management', 'Urgent excision with 1cm margins and staging workup.');
body.append('clinical_image', document.querySelector('input[name="clinical_image"]').files[0]);
body.append('dermoscopic_image', document.querySelector('input[name="dermoscopic_image"]').files[0]);
fetch(url, {
method: "POST",
headers,
body,
}).then(response => response.json());Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Get Quiz
requires authentication
A single quiz with its attached questions, including the answer key. Use this to populate an edit form.
Example request:
curl --request GET \
--get "https://www.dots.mhn.services/api/v1/admin/quizzes/1" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.dots.mhn.services/api/v1/admin/quizzes/1"
);
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Quiz Submissions
requires authentication
Who has taken a quiz, and what they scored. score counts correct
answers out of total_questions. Not meaningful for a poll quiz.
Example request:
curl --request GET \
--get "https://www.dots.mhn.services/api/v1/admin/quizzes/1/submissions?page=1&limit=20" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.dots.mhn.services/api/v1/admin/quizzes/1/submissions"
);
const params = {
"page": "1",
"limit": "20",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (200, Success):
{
"success": true,
"message": "Quiz submissions retrieved successfully.",
"data": {
"quiz_id": 301,
"quiz_title": "Medical Knowledge Quiz",
"total_submissions": 145,
"submissions": [
{
"user_id": 101,
"user_name": "Dr. Ahmed Khan",
"submitted_at": "2026-08-31T12:00:00.000000Z",
"score": 18,
"total_questions": 20
}
]
},
"pagination": {
"current_page": 1,
"per_page": 20,
"total": 145,
"last_page": 8
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Update Quiz
requires authentication
Partially updates a quiz. Omit question_ids to change only the
title, description, or status, which is how publishing and
unpublishing works. Send question_ids to replace the attached set.
Example request:
curl --request POST \
"https://www.dots.mhn.services/api/v1/admin/quizzes/1" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: multipart/form-data" \
--header "Accept: application/json" \
--form "title=Medical Knowledge Quiz"\
--form "format=standard"\
--form "status=published"\
--form "question_ids[]=16"\
--form "clinical_image=@/tmp/php6kidca8p3a3d9A2ELlI" \
--form "dermoscopic_image=@/tmp/phpptu6cnspg90d48WIjbU" const url = new URL(
"https://www.dots.mhn.services/api/v1/admin/quizzes/1"
);
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "multipart/form-data",
"Accept": "application/json",
};
const body = new FormData();
body.append('title', 'Medical Knowledge Quiz');
body.append('format', 'standard');
body.append('status', 'published');
body.append('question_ids[]', '16');
body.append('clinical_image', document.querySelector('input[name="clinical_image"]').files[0]);
body.append('dermoscopic_image', document.querySelector('input[name="dermoscopic_image"]').files[0]);
fetch(url, {
method: "POST",
headers,
body,
}).then(response => response.json());Example response (200, Success):
{
"success": true,
"message": "Quiz updated successfully.",
"data": {
"id": 301,
"title": "Medical Knowledge Quiz",
"status": "published"
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Delete Quiz
requires authentication
Permanently deletes a quiz and every submission recorded against it. Attached bank questions are only detached, never deleted. This cannot be undone.
Example request:
curl --request DELETE \
"https://www.dots.mhn.services/api/v1/admin/quizzes/1" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.dots.mhn.services/api/v1/admin/quizzes/1"
);
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "DELETE",
headers,
}).then(response => response.json());Example response (200, Success):
{
"success": true,
"message": "Quiz deleted successfully."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Admin - Learning
Learning articles the app shows to users, who can like them. Requires an admin bearer token.
Create and update take multipart/form-data because of the cover image.
Learning List
requires authentication
Paginated list of learning articles with their like counts.
Example request:
curl --request GET \
--get "https://www.dots.mhn.services/api/v1/admin/learning?status=published&search=diagnosis&page=1&limit=20" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.dots.mhn.services/api/v1/admin/learning"
);
const params = {
"status": "published",
"search": "diagnosis",
"page": "1",
"limit": "20",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (200, Success):
{
"success": true,
"message": "Learning content retrieved successfully.",
"data": [
{
"id": 701,
"image": "https://dots.mhn.services/storage/learning/cover.jpg",
"title": "Introduction to Clinical Diagnosis",
"description": "Basic information about clinical diagnosis.",
"content": "Complete learning content here.",
"status": "published",
"total_likes": 125,
"created_at": "2026-08-31T12:30:00.000000Z"
}
],
"pagination": {
"current_page": 1,
"per_page": 20,
"total": 40,
"last_page": 2
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Create Learning Content
requires authentication
Send as multipart/form-data; the cover image is required.
Example request:
curl --request POST \
"https://www.dots.mhn.services/api/v1/admin/learning" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: multipart/form-data" \
--header "Accept: application/json" \
--form "title=Introduction to Clinical Diagnosis"\
--form "description=Basic information about clinical diagnosis."\
--form "content=Complete learning content here."\
--form "status=published"\
--form "image=@/tmp/php4tnflvjn8k8ecnXYR5q" const url = new URL(
"https://www.dots.mhn.services/api/v1/admin/learning"
);
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "multipart/form-data",
"Accept": "application/json",
};
const body = new FormData();
body.append('title', 'Introduction to Clinical Diagnosis');
body.append('description', 'Basic information about clinical diagnosis.');
body.append('content', 'Complete learning content here.');
body.append('status', 'published');
body.append('image', document.querySelector('input[name="image"]').files[0]);
fetch(url, {
method: "POST",
headers,
body,
}).then(response => response.json());Example response (201, Success):
{
"success": true,
"message": "Learning content created successfully.",
"data": {
"id": 701,
"image": "https://dots.mhn.services/storage/learning/cover.jpg",
"title": "Introduction to Clinical Diagnosis",
"description": "Basic information about clinical diagnosis.",
"content": "Complete learning content here.",
"status": "published",
"total_likes": 0,
"created_at": "2026-08-31T12:30:00.000000Z"
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Get Learning Content
requires authentication
A single article, for populating an edit form.
Example request:
curl --request GET \
--get "https://www.dots.mhn.services/api/v1/admin/learning/1" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.dots.mhn.services/api/v1/admin/learning/1"
);
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Update Learning Content
requires authentication
Partially updates an article. Send as multipart/form-data when
replacing the image; the old file is deleted. Omit image to keep it.
Example request:
curl --request POST \
"https://www.dots.mhn.services/api/v1/admin/learning/1" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: multipart/form-data" \
--header "Accept: application/json" \
--form "title=Updated Learning Title"\
--form "description=Updated description"\
--form "content=Updated learning content"\
--form "status=published"\
--form "image=@/tmp/phpbja30f2l26fqfgPjy0J" const url = new URL(
"https://www.dots.mhn.services/api/v1/admin/learning/1"
);
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "multipart/form-data",
"Accept": "application/json",
};
const body = new FormData();
body.append('title', 'Updated Learning Title');
body.append('description', 'Updated description');
body.append('content', 'Updated learning content');
body.append('status', 'published');
body.append('image', document.querySelector('input[name="image"]').files[0]);
fetch(url, {
method: "POST",
headers,
body,
}).then(response => response.json());Example response (200, Success):
{
"success": true,
"message": "Learning content updated successfully.",
"data": {
"id": 701,
"title": "Updated Learning Title",
"description": "Updated description",
"status": "published"
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Delete Learning Content
requires authentication
Permanently deletes an article, its cover image file, and its likes.
Example request:
curl --request DELETE \
"https://www.dots.mhn.services/api/v1/admin/learning/1" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.dots.mhn.services/api/v1/admin/learning/1"
);
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "DELETE",
headers,
}).then(response => response.json());Example response (200, Success):
{
"success": true,
"message": "Learning content deleted successfully."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Notifications
The authenticated user's in-app notification feed (a comment, a like or dislike, a case being approved or declined, and admin broadcasts like a pinned post, a new quiz, or a new article). Every notification also goes out as a push notification when the user has a saved device token; this feed is the in-app record of the same events, and is what drives the notifications KPI/badge in the app.
List Notifications
requires authentication
Paginated with skip/limit query params - skip defaults to 0,
limit defaults to 15 (max 100). Newest first.
counts reports the totals for the user's whole feed (not just the
current page), so the app can render a KPI/badge without a separate
request.
Example request:
curl --request GET \
--get "https://www.dots.mhn.services/api/v1/notifications?skip=0&limit=15" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.dots.mhn.services/api/v1/notifications"
);
const params = {
"skip": "0",
"limit": "15",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (200, Success):
{
"data": [
{
"id": "9e1f7e2a-3b1a-4b3a-8f2a-2f6a1b1e9c31",
"type": "comment_added",
"title": "New Comment",
"body": "Dr John Roe commented on your post.",
"data": {
"case_id": 12,
"comment_id": 5,
"commenter_id": 4
},
"read": false,
"read_at": null,
"created_at": "2026-09-17T06:00:00.000000Z"
}
],
"meta": {
"skip": 0,
"limit": 15,
"total": 1
},
"counts": {
"total": 1,
"unread": 1,
"read": 0
}
}
Example response (401, Missing or expired token):
{
"message": "Unauthenticated."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Mark All Notifications as Read
requires authentication
Example request:
curl --request POST \
"https://www.dots.mhn.services/api/v1/notifications/read-all" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.dots.mhn.services/api/v1/notifications/read-all"
);
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "POST",
headers,
}).then(response => response.json());Example response (200, Success):
{
"message": "All notifications marked as read."
}
Example response (401, Missing or expired token):
{
"message": "Unauthenticated."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Mark Notification as Read
requires authentication
Example request:
curl --request POST \
"https://www.dots.mhn.services/api/v1/notifications/9e1f7e2a-3b1a-4b3a-8f2a-2f6a1b1e9c31/read" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.dots.mhn.services/api/v1/notifications/9e1f7e2a-3b1a-4b3a-8f2a-2f6a1b1e9c31/read"
);
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "POST",
headers,
}).then(response => response.json());Example response (200, Success):
{
"message": "Notification marked as read."
}
Example response (401, Missing or expired token):
{
"message": "Unauthenticated."
}
Example response (404, No notification with that id for this user):
{
"message": "No query results for model [Illuminate\\Notifications\\DatabaseNotification]."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Users
Looking up another user's profile. Requires Authorization: Bearer {token}.
View User Profile
requires authentication
Public profile of any user, for when someone taps a name or avatar in the case feed or a comment thread.
This is narrower than Get Profile: email, phone number, and the
PMDC/fellowship/institutional numbers are private to the account owner
and are never returned here. cases_count counts only the user's
approved cases, matching what the feed shows.
Example request:
curl --request GET \
--get "https://www.dots.mhn.services/api/v1/users/1" \
--header "Authorization: Bearer {YOUR_SANCTUM_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.dots.mhn.services/api/v1/users/1"
);
const headers = {
"Authorization": "Bearer {YOUR_SANCTUM_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (200, Success):
{
"data": {
"id": 3,
"full_name": "Dr Jane Doe",
"designation": "Consultant",
"bio": "Dermatologist with a special interest in dermoscopy.",
"province": "Punjab",
"city": "Lahore",
"role": "student",
"avatar_url": null,
"cases_count": 4,
"created_at": "2026-07-27T10:08:49.000000Z"
}
}
Example response (404, No such user):
{
"message": "Resource not found."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.