MENU navbar-image

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.

Recent additions (5 September 2026)

Nothing was renamed or removed, so a client built before this date keeps working. Three things were added:

  1. Every case carries a code. Five characters, uppercase letters and digits, never containing O, 0, I or 1. The server assigns it at submission and never changes it. Pass it as ?search= on GET /api/v1/cases or GET /api/v1/cases/mine to 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.

  2. An admin can pin a case to the top of the feed. GET /api/v1/cases now returns pinned cases first (most recently pinned first), then the usual newest-first order. Every case payload gained is_pinned (bool), pinned_at (nullable string) and pinned_by (nullable {id, full_name, avatar_url}), so the app can badge a case as pinned by an admin. GET /api/v1/cases/mine is deliberately not reordered by pins. Admins set a pin with POST /api/v1/admin/cases/pin.

  3. Quiz results now return the whole attempt, not just the marks. POST /api/v1/quizzes/{id}/submit and GET /api/v1/quizzes/{id}/result return every question in the quiz with every option it offered, each option flagged is_correct and is_selected, plus percentage, correct_count, incorrect_count, unanswered_count and format alongside score. Questions the student skipped are included, marked is_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."
        ]
    }
}
 

Request      

POST api/v1/auth/register

Headers

Content-Type        

Example: multipart/form-data

Accept        

Example: application/json

Body Parameters

full_name   string     

The user's full name. Example: Dr Jane Doe

email   string     

The user's email address. An OTP is sent here to verify. Must be a valid email address. Example: jane@example.com

password   string     

At least 8 characters. Must be at least 8 characters. Example: password123

phone_number   string     

The user's phone number. Example: 03001234567

designation   string  optional    

Professional designation/title. Free text, no fixed list. Example: Consultant Dermatologist

bio   string  optional    

Optional short free-text bio shown on the profile. Example: Dermatologist with a special interest in dermoscopy and skin cancer screening.

province   string  optional    

Free text, no fixed list. Example: Punjab

city   string  optional    

Free text, no fixed list. Example: Lahore

pmdc_number   string  optional    

PMDC registration number, if any. Example: PMDC-12345

fellowship_number   string  optional    

Fellowship number, if any. Example: FCPS-6789

institutional_number   string  optional    

Institutional/employee number, if any. Example: INST-001

avatar   file  optional    

Profile picture. Must be an image. Must not be greater than 4096 kilobytes. Example: /tmp/php1ona0346dbo8fj0BU7s

fcm_token   string  optional    

The device's Firebase Cloud Messaging token, saved against the user for push notifications. Example: dGhpcyBpcyBhIHNhbXBsZSB0b2tlbg==

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."
        ]
    }
}
 

Request      

POST api/v1/auth/register/verify-otp

Headers

Content-Type        

Example: application/json

Accept        

Example: application/json

Body Parameters

email   string     

The email used to register. Must be a valid email address. Example: jane@example.com

otp   string     

The 6-digit code emailed to the user. Must be 6 digits. Example: 482913

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."
        ]
    }
}
 

Request      

POST api/v1/auth/register/resend-otp

Headers

Content-Type        

Example: application/json

Accept        

Example: application/json

Body Parameters

email   string     

The email used to register. Must be a valid email address. Example: jane@example.com

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."
        ]
    }
}
 

Request      

POST api/v1/auth/login

Headers

Content-Type        

Example: application/json

Accept        

Example: application/json

Body Parameters

email   string     

The user's email address. Must be a valid email address. Example: jane@example.com

password   string     

The user's password. Example: password123

fcm_token   string  optional    

The device's Firebase Cloud Messaging token, saved against the user for push notifications. Example: dGhpcyBpcyBhIHNhbXBsZSB0b2tlbg==

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."
        ]
    }
}
 

Request      

POST api/v1/auth/forgot-password

Headers

Content-Type        

Example: application/json

Accept        

Example: application/json

Body Parameters

email   string     

A registered email address. Must be a valid email address. Must match an existing stored value. Example: jane@example.com

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."
        ]
    }
}
 

Request      

POST api/v1/auth/forgot-password/verify-otp

Headers

Content-Type        

Example: application/json

Accept        

Example: application/json

Body Parameters

email   string     

A registered email address. Must be a valid email address. Must match an existing stored value. Example: jane@example.com

otp   string     

The 6-digit code emailed to the user. Must be 6 digits. Example: 482913

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."
        ]
    }
}
 

Request      

POST api/v1/auth/forgot-password/resend-otp

Headers

Content-Type        

Example: application/json

Accept        

Example: application/json

Body Parameters

email   string     

A registered email address. Must be a valid email address. Must match an existing stored value. Example: jane@example.com

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."
        ]
    }
}
 

Request      

POST api/v1/auth/reset-password

Headers

Content-Type        

Example: application/json

Accept        

Example: application/json

Body Parameters

email   string     

A registered email address. Must be a valid email address. Must match an existing stored value. Example: jane@example.com

reset_token   string     

The token returned by Verify Forgot Password OTP. Example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2

new_password   string     

At least 8 characters. Must be at least 8 characters. Example: newPassword123

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."
}
 

Request      

POST api/v1/auth/logout

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

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"
    }
}
 

Request      

GET api/v1/profile

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

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."
}
 

Request      

PUT api/v1/profile

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

Body Parameters

full_name   string  optional    

Only send fields you want to change. Example: Dr Jane A. Doe

phone_number   string  optional    

Free text. Example: 03009998888

designation   string  optional    

Free text, no fixed list. Example: Consultant Dermatologist

bio   string  optional    

Short free-text bio shown on the profile. Send an empty string to clear it back to null. Example: Dermatologist with a special interest in dermoscopy and skin cancer screening.

province   string  optional    

Free text, no fixed list. Example: Sindh

city   string  optional    

Free text, no fixed list. Example: Karachi

pmdc_number   string  optional    

PMDC registration number, if any. Example: PMDC-12345

fellowship_number   string  optional    

Fellowship number, if any. Example: FCPS-6789

institutional_number   string  optional    

Institutional/employee number, if any. Example: INST-001

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."
}
 

Request      

POST api/v1/profile/avatar

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: multipart/form-data

Accept        

Example: application/json

Body Parameters

avatar   file     

The new profile picture. Replaces and deletes the previous one. Must be an image. Must not be greater than 4096 kilobytes. Example: /tmp/phpefcfedsiri6qa1OAAhB

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."
        ]
    }
}
 

Request      

POST api/v1/change-password

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

Body Parameters

current_password   string     

The user's current password. Example: password123

new_password   string     

At least 8 characters, must differ from the current password. The value and current_password must be different. Must be at least 8 characters. Example: newPassword123

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."
        ]
    }
}
 

Request      

DELETE api/v1/account

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

Body Parameters

current_password   string     

The user's current password, to confirm this destructive action. Example: password123

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:

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:

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:

  1. Pinned cases first.
  2. Among pinned cases, most recently pinned first - by pinned_at, not by when the case was created.
  3. Everything else after, newest created first.

Notes for testing:

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."
}
 

Request      

GET api/v1/cases/mine

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

Query Parameters

skip   integer  optional    

Number of cases to skip. Defaults to 0. Example: 0

limit   integer  optional    

Max cases to return (capped at 100). Defaults to 15. Example: 15

search   string  optional    

A case code, or text to match against diagnosis or body site. Example: K7F2Q

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."
}
 

Request      

GET api/v1/cases

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

Query Parameters

skip   integer  optional    

Number of cases to skip. Defaults to 0. Example: 0

limit   integer  optional    

Max cases to return (capped at 100). Defaults to 15. Example: 15

search   string  optional    

A case code, or text to match against diagnosis or body site. Example: K7F2Q

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."
        ]
    }
}
 

Request      

POST api/v1/cases

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: multipart/form-data

Accept        

Example: application/json

Body Parameters

clinical_images   file[]  optional    

One or more clinical photo files. Repeat the key for each file. Must be an image. Must not be greater than 8192 kilobytes.

dermoscopic_images   file[]  optional    

One or more dermoscopic photo files. Repeat the key for each file. Must be an image. Must not be greater than 8192 kilobytes.

pdf   file  optional    

Optional supporting PDF, e.g. a report or histopathology sheet. One file, up to 20 MB. Must be a file. Must not be greater than 20480 kilobytes. Example: /tmp/phpdm44k43e505ncEBehv4

age   string  optional    

Patient age in years. Example: 52

gender   string  optional    

Patient gender. Example: male

fitzpatrick_skin_type   string  optional    

Fitzpatrick skin type. Example: III

body_site   string  optional    

Anatomical site of the lesion. Example: trunk

clinical_diagnosis   string  optional    

Clinical (visual) diagnosis. Example: melanoma

clinical_histopathology   string  optional    

Clinical histopathology findings, if biopsied. Example: Superficial spreading melanoma, Breslow depth 0.8mm

lesion_type   string  optional    

Lesion classification. Example: melanocytic

dermoscopic_features   string  optional    

Multi-select. Repeat the key for each value.

vascular_pattern   string  optional    

Multi-select. Repeat the key for each value.

colours_present   string  optional    

Multi-select. Repeat the key for each value.

scale   string  optional    

FotoFinder-schema dermoscopy field. Example: fine

pattern   string  optional    

FotoFinder-schema dermoscopy field. Example: reticular

image_metadata   string  optional    

Multi-select. Repeat the key for each value.

dermoscopic_diagnosis   string  optional    

Dermoscopic diagnosis. Example: melanoma

dermoscopic_histopathology   string  optional    

Dermoscopic histopathology findings, if biopsied. Example: Not biopsied

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"
}
 

Request      

GET api/v1/cases/{id}

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

id   integer     

The ID of the case. Example: 1

case   integer     

The case ID. Example: 1

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"
}
 

Request      

PUT api/v1/cases/{id}

PATCH api/v1/cases/{id}

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: multipart/form-data

Accept        

Example: application/json

URL Parameters

id   integer     

The ID of the case. Example: 1

case   integer     

The case ID. Example: 1

Body Parameters

new_clinical_images   file[]  optional    

One or more new clinical photo files to add. Repeat the key for each file. Must be an image. Must not be greater than 8192 kilobytes.

new_dermoscopic_images   file[]  optional    

One or more new dermoscopic photo files to add. Repeat the key for each file. Must be an image. Must not be greater than 8192 kilobytes.

remove_image_ids   integer[]  optional    

ID of an existing image (clinical or dermoscopic) to delete. Repeat the key for each ID. Must match an existing stored value.

pdf   file  optional    

A PDF to attach, replacing the current one if there is one. One file, up to 20 MB. Must be a file. Must not be greater than 20480 kilobytes. Example: /tmp/phponrpghuvg0sbbE0clec

remove_pdf   boolean  optional    

Send true to detach and delete the current PDF. Ignored when a new pdf is sent. Example: false

age   string  optional    

Patient age in years. Example: 53

gender   string  optional    

Patient gender. Example: male

fitzpatrick_skin_type   string  optional    

Fitzpatrick skin type. Example: III

body_site   string  optional    

Anatomical site of the lesion. Example: trunk

clinical_diagnosis   string  optional    

Clinical (visual) diagnosis. Example: melanoma, revised

clinical_histopathology   string  optional    

Clinical histopathology findings, if biopsied. Example: Superficial spreading melanoma, Breslow depth 0.8mm

lesion_type   string  optional    

Lesion classification. Example: melanocytic

dermoscopic_features   string  optional    

Multi-select. Repeat the key for each value.

vascular_pattern   string  optional    

Multi-select. Repeat the key for each value.

colours_present   string  optional    

Multi-select. Repeat the key for each value.

scale   string  optional    

FotoFinder-schema dermoscopy field. Example: fine

pattern   string  optional    

FotoFinder-schema dermoscopy field. Example: reticular

image_metadata   string  optional    

Multi-select. Repeat the key for each value.

dermoscopic_diagnosis   string  optional    

Dermoscopic diagnosis. Example: melanoma

dermoscopic_histopathology   string  optional    

Dermoscopic histopathology findings, if biopsied. Example: Not biopsied

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"
}
 

Request      

DELETE api/v1/cases/{id}

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

id   integer     

The ID of the case. Example: 1

case   integer     

The case ID. Example: 1

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."
}
 

Request      

POST api/v1/cases/{case_id}/comments

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

case_id   integer     

The ID of the case. Example: 1

case   integer     

The case ID. Example: 1

Body Parameters

body   string     

The comment text. Example: Great case, thanks for sharing.

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."
}
 

Request      

POST api/v1/comments/{comment_id}/react

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

comment_id   integer     

The ID of the comment. Example: 1

comment   integer     

The comment ID. Example: 5

Body Parameters

type   string     

like or dislike. Example: like

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

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.

Types to watch, for a statically typed client

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
    }
}
 

Request      

GET api/v1/quizzes

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

Query Parameters

page   integer  optional    

Page number. Defaults to 1. Example: 1

limit   integer  optional    

Quizzes per page, capped at 100. Defaults to 20. Example: 20

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."
}
 

Request      

GET api/v1/quizzes/{id}

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

id   integer     

The ID of the quiz. Example: 1

quiz   integer     

The quiz ID. Example: 301

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."
        ]
    }
}
 

Request      

POST api/v1/quizzes/{quiz_id}/submit

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

quiz_id   integer     

The ID of the quiz. Example: 1

quiz   integer     

The quiz ID. Example: 1

Body Parameters

answers   object[]     

One entry per question answered.

question_id   integer     

The question being answered. Example: 900

option_id   string  optional    

The id of the chosen option, from the quiz detail response. Used for single_choice, true_false, and poll. This field is required when none of answers..option_ids and answers..response are present. Example: o1

option_ids   object  optional    

Use instead of option_id for multiple_choice (several correct options).

response   object  optional    

match_following only: {"left_id": "right_id"} for every pair.

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."
}
 

Request      

GET api/v1/quizzes/{quiz_id}/result

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

quiz_id   integer     

The ID of the quiz. Example: 1

quiz   integer     

The quiz ID. Example: 1

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
    }
}
 

Request      

GET api/v1/learning

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

Query Parameters

page   integer  optional    

Page number. Defaults to 1. Example: 1

limit   integer  optional    

Items per page, capped at 100. Defaults to 20. Example: 20

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."
}
 

Request      

GET api/v1/learning/{id}

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

id   integer     

The ID of the learning. Example: 1

learning   integer     

The learning content ID. Example: 701

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
    }
}
 

Request      

POST api/v1/learning/{learning_id}/like

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

learning_id   integer     

The ID of the learning. Example: 1

learning   integer     

The learning content ID. Example: 701

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."
}
 

Request      

POST api/v1/admin/login

Headers

Content-Type        

Example: application/json

Accept        

Example: application/json

Body Parameters

username   string     

The admin account username. Example: admin

password   string     

The admin account password. Example: admin_password

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
        }
    }
}
 

Request      

GET api/v1/admin/dashboard

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

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
    }
}
 

Request      

GET api/v1/admin/users

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

Query Parameters

page   integer  optional    

Page number. Defaults to 1. Example: 1

limit   integer  optional    

Users per page, capped at 100. Defaults to 20. Example: 20

search   string  optional    

Matches name, email, phone, or PMDC number. Example: ahmed

role   string  optional    

Filter by role, e.g. student, doctor, admin. Example: student

status   string  optional    

Filter by active or inactive. Example: active

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."
        ]
    }
}
 

Request      

POST api/v1/admin/users/status

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

Body Parameters

user_id   string     

The user to update. Must match an existing stored value. Example: 101

status   string     

Either active or inactive. Example: inactive

Must be one of:
  • active
  • inactive

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
    }
}
 

Request      

GET api/v1/admin/cases

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

Query Parameters

type   string  optional    

all, clinical, or dermoscopic. Example: dermoscopic

status   string  optional    

all, pending, approved, or rejected. Example: pending

search   string  optional    

Matches the case code, diagnosis, body site, or submitter name. Example: melanoma

page   integer  optional    

Page number. Defaults to 1. Example: 1

limit   integer  optional    

Cases per page, capped at 100. Defaults to 20. Example: 20

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"
    }
}
 

Request      

POST api/v1/admin/cases/status

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

Body Parameters

case_id   string     

The case to review. Must match an existing stored value. Example: 501

status   string     

One of approved, rejected, or pending. Example: approved

Must be one of:
  • approved
  • rejected
  • pending
reason   string  optional    

Optional explanation, shown to the submitter when rejecting. Example: Insufficient case 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

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."
        ]
    }
}
 

Request      

POST api/v1/admin/cases/pin

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

Body Parameters

case_id   string     

The case to pin or unpin. Must match an existing stored value. Example: 501

pinned   boolean     

true pins the case to the top of the feed, false removes the pin. Example: true

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());

Request      

GET api/v1/admin/questions

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

Query Parameters

category   string  optional    

Exact match. Example: Dermoscopy

subcategory   string  optional    

Exact match. Example: Pigmented lesions

type   string  optional    

single_choice, multiple_choice, true_false, match_following, or poll. Example: single_choice

difficulty   integer  optional    

1 to 3. Example: 2

status   string  optional    

all, draft, pending_review, approved, or rejected. Example: pending_review

tag   string  optional    

Matches a single tag. Example: dermoscopy

search   string  optional    

Matches the question text. Example: seborrheic

page   integer  optional    

Page number. Defaults to 1. Example: 1

limit   integer  optional    

Questions per page, capped at 100. Defaults to 20. Example: 20

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());

Request      

POST api/v1/admin/questions

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: multipart/form-data

Accept        

Example: application/json

Body Parameters

category   string     

Organizes the question in the bank. Example: Dermoscopy

subcategory   string  optional    

Optional, narrower than category. Example: Pigmented lesions

question   string     

The question text. Example: Which dermoscopic feature is most suggestive of seborrheic keratosis?

type   string     

single_choice, multiple_choice, true_false, match_following, or poll. Example: single_choice

Must be one of:
  • single_choice
  • multiple_choice
  • true_false
  • match_following
  • poll
image   file  optional    

Optional image, up to 8 MB. Send as multipart/form-data. Must be an image. Must not be greater than 8192 kilobytes. Example: /tmp/phpt4raq3vfe1tecj0jpQX

explanation   string  optional    

Shown to the student after they answer. Example: Milia-like cysts are the classic dermoscopic clue for seborrheic keratosis.

reference   string  optional    

A citation or source, shown alongside the explanation. Example: Braun RP, et al. Dermoscopy of pigmented skin lesions.

tags   object  optional    

Array of free-text tags.

difficulty   integer  optional    

1 (easy) to 3 (hard). Must be between 1 and 3. Example: 2

options   object     

At least two option texts. Required for every type except match_following. Must have at least 2 items.

correct_answer   string     

The option text (or id) that is correct. Ignored for poll. Example: Milia-like cysts

correct_answers   object  optional    

Use instead of correct_answer for Multiple Correct Answers. Must have at least 1 items.

pairs   object[]  optional    

match_following only: at least two {left, right} pairs. Must have at least 2 items.

left   string  optional    

This field is required when pairs is present.

right   string  optional    

This field is required when pairs is present.

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"
    }
}
 

Request      

POST api/v1/admin/questions/status

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

Body Parameters

question_id   string     

The question to review. Must match an existing stored value. Example: 901

status   string     

One of draft, pending_review, approved, or rejected. Example: approved

Must be one of:
  • draft
  • pending_review
  • approved
  • rejected
reason   string  optional    

Optional explanation, shown to the author when rejecting. Example: Reference is missing.

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
    }
}
 

Request      

POST api/v1/admin/questions/approve-selected

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

Body Parameters

question_ids   integer[]     

The questions to approve.

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());

Request      

GET api/v1/admin/questions/{id}

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

id   integer     

The ID of the question. Example: 1

question   integer     

The question ID. Example: 901

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());

Request      

POST api/v1/admin/questions/{id}

PUT api/v1/admin/questions/{id}

PATCH api/v1/admin/questions/{id}

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: multipart/form-data

Accept        

Example: application/json

URL Parameters

id   integer     

The ID of the question. Example: 1

question   integer     

The question ID. Example: 901

Body Parameters

category   string  optional    

Organizes the question in the bank. Example: Dermoscopy

subcategory   string  optional    
question   string  optional    

The question text. Example: Which dermoscopic feature is most suggestive of seborrheic keratosis?

type   string  optional    

single_choice, multiple_choice, true_false, match_following, or poll. Example: single_choice

Must be one of:
  • single_choice
  • multiple_choice
  • true_false
  • match_following
  • poll
image   file  optional    

Replacement image, up to 8 MB. Omit to keep the current one. Must be an image. Must not be greater than 8192 kilobytes. Example: /tmp/phpko31hjspprrq89qPvpk

explanation   string  optional    
reference   string  optional    
tags   object  optional    
difficulty   integer  optional    

Must be between 1 and 3. Example: 2

options   object  optional    

Send to replace the option set. Must have at least 2 items.

correct_answer   string  optional    
correct_answers   object  optional    

Must have at least 1 items.

pairs   object[]  optional    

Must have at least 2 items.

left   string  optional    

This field is required when pairs is present.

right   string  optional    

This field is required when pairs is present.

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());

Request      

DELETE api/v1/admin/questions/{id}

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

id   integer     

The ID of the question. Example: 1

question   integer     

The question ID. Example: 901

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
    }
}
 

Request      

GET api/v1/admin/quizzes

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

Query Parameters

status   string  optional    

all, draft, or published. Example: published

format   string  optional    

all, standard, poll, or clinical_case. Example: standard

search   string  optional    

Matches the quiz title. Example: knowledge

page   integer  optional    

Page number. Defaults to 1. Example: 1

limit   integer  optional    

Quizzes per page, capped at 100. Defaults to 20. Example: 20

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());

Request      

POST api/v1/admin/quizzes

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: multipart/form-data

Accept        

Example: application/json

Body Parameters

title   string     

The quiz title. Example: Medical Knowledge Quiz

description   string  optional    

Optional summary shown to students. Example: Test your medical knowledge.

format   string  optional    

standard, poll, or clinical_case. Defaults to standard. Example: standard

Must be one of:
  • standard
  • poll
  • clinical_case
status   string  optional    

draft or published. Defaults to draft. Example: published

Must be one of:
  • draft
  • published
question_ids   integer[]  optional    

Must match an existing stored value.

clinical_image   file  optional    

Required when format is clinical_case. Send as multipart/form-data. Must be an image. Must not be greater than 8192 kilobytes. Example: /tmp/phpfucrufkuhgjlafBsCVz

dermoscopic_image   file  optional    

Optional, clinical_case only. Send as multipart/form-data. Must be an image. Must not be greater than 8192 kilobytes. Example: /tmp/phpnm71qllpt2hc8hWgYDz

history   string  optional    

clinical_case only. Shown to the student before the questions. Example: 52-year-old male, 6-month history of an enlarging pigmented lesion.

diagnosis   string  optional    

clinical_case only. Revealed after submission. Example: Superficial spreading melanoma.

management   string  optional    

clinical_case only. Revealed after submission. Example: Urgent excision with 1cm margins and staging workup.

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());

Request      

GET api/v1/admin/quizzes/{id}

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

id   integer     

The ID of the quiz. Example: 1

quiz   integer     

The quiz ID. Example: 301

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
    }
}
 

Request      

GET api/v1/admin/quizzes/{quiz_id}/submissions

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

quiz_id   integer     

The ID of the quiz. Example: 1

quiz   integer     

The quiz ID. Example: 301

Query Parameters

page   integer  optional    

Page number. Defaults to 1. Example: 1

limit   integer  optional    

Submissions per page, capped at 100. Defaults to 20. Example: 20

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"
    }
}
 

Request      

POST api/v1/admin/quizzes/{id}

PUT api/v1/admin/quizzes/{id}

PATCH api/v1/admin/quizzes/{id}

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: multipart/form-data

Accept        

Example: application/json

URL Parameters

id   integer     

The ID of the quiz. Example: 1

quiz   integer     

The quiz ID. Example: 301

Body Parameters

title   string  optional    

The quiz title. Example: Medical Knowledge Quiz

description   string  optional    
format   string  optional    

Example: standard

Must be one of:
  • standard
  • poll
  • clinical_case
status   string  optional    

draft or published. Example: published

Must be one of:
  • draft
  • published
question_ids   integer[]  optional    

Must match an existing stored value.

clinical_image   file  optional    

Must be an image. Must not be greater than 8192 kilobytes. Example: /tmp/php6kidca8p3a3d9A2ELlI

dermoscopic_image   file  optional    

Must be an image. Must not be greater than 8192 kilobytes. Example: /tmp/phpptu6cnspg90d48WIjbU

history   string  optional    
diagnosis   string  optional    
management   string  optional    

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."
}
 

Request      

DELETE api/v1/admin/quizzes/{id}

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

id   integer     

The ID of the quiz. Example: 1

quiz   integer     

The quiz ID. Example: 301

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
    }
}
 

Request      

GET api/v1/admin/learning

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

Query Parameters

status   string  optional    

all, draft, or published. Example: published

search   string  optional    

Matches the title. Example: diagnosis

page   integer  optional    

Page number. Defaults to 1. Example: 1

limit   integer  optional    

Items per page, capped at 100. Defaults to 20. Example: 20

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"
    }
}
 

Request      

POST api/v1/admin/learning

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: multipart/form-data

Accept        

Example: application/json

Body Parameters

title   string     

The article title. Example: Introduction to Clinical Diagnosis

description   string  optional    

Short summary shown in the list. Example: Basic information about clinical diagnosis.

content   string  optional    

The full article body. Example: Complete learning content here.

status   string  optional    

draft or published. Defaults to draft. Example: published

Must be one of:
  • draft
  • published
image   file     

Cover image, up to 8 MB. Send as multipart/form-data. Must be an image. Must not be greater than 8192 kilobytes. Example: /tmp/php4tnflvjn8k8ecnXYR5q

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());

Request      

GET api/v1/admin/learning/{id}

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

id   integer     

The ID of the learning. Example: 1

learning   integer     

The learning content ID. Example: 701

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"
    }
}
 

Request      

POST api/v1/admin/learning/{id}

PUT api/v1/admin/learning/{id}

PATCH api/v1/admin/learning/{id}

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: multipart/form-data

Accept        

Example: application/json

URL Parameters

id   integer     

The ID of the learning. Example: 1

learning   integer     

The learning content ID. Example: 701

Body Parameters

title   string  optional    

The article title. Example: Updated Learning Title

description   string  optional    

Short summary shown in the list. Example: Updated description

content   string  optional    

The full article body. Example: Updated learning content

status   string  optional    

draft or published. Example: published

Must be one of:
  • draft
  • published
image   file  optional    

Replacement cover image, up to 8 MB. Omit to keep the current one. Must be an image. Must not be greater than 8192 kilobytes. Example: /tmp/phpbja30f2l26fqfgPjy0J

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."
}
 

Request      

DELETE api/v1/admin/learning/{id}

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

id   integer     

The ID of the learning. Example: 1

learning   integer     

The learning content ID. Example: 701

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."
}
 

Request      

GET api/v1/notifications

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

Query Parameters

skip   integer  optional    

Number of notifications to skip. Defaults to 0. Example: 0

limit   integer  optional    

Max notifications to return (capped at 100). Defaults to 15. Example: 15

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."
}
 

Request      

POST api/v1/notifications/read-all

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

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]."
}
 

Request      

POST api/v1/notifications/{notification}/read

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

notification   string     

The notification ID. Example: 9e1f7e2a-3b1a-4b3a-8f2a-2f6a1b1e9c31

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."
}
 

Request      

GET api/v1/users/{id}

Headers

Authorization        

Example: Bearer {YOUR_SANCTUM_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

id   integer     

The ID of the user. Example: 1

user   integer     

The user ID. Example: 3