This file is the single source of truth (SSOT) for every official Bulutklinik SDK. All language packages (JavaScript/TypeScript, PHP, Python, Go, Java, C#, C++) are hand-written but MUST implement exactly the contract described here. The canonical copy lives at
dev-kits/DESIGN.md; an identical copy is vendored into each language repository and re-synced whenever this file changes.Wire contract is derived from
dev-kits/Bulutklinik.postman_collection.json("Bulutklinik API — Randevu & Ödeme Akışı"), validated against the BulutklinikAPI source (Laravel 8.12, OAuth2/Passport).
- Spec version: 0.6.0 (completes the registration flows and closes flow-gaps found in the API audit: adds
auth.confirmRegistrationEmail(the e-mail-branch middle step thatregisteractually needs), the social sign-up pairauth.verifyRegistrationSocial/auth.registerSocial, the password-reset pairauth.forgotPassword/auth.resetPassword,appointments.list/appointments.reservations(the source of theevent_idthatcancelneeds), and the newaddressesgroup required bylaboratory.order; 0.5.0 addedauth.verifyRegistration; 0.4.0 addedlaboratory+diets; 0.3.0 addedskin+meals; 0.2.0 added the §7.2 escape hatch) - API: BulutklinikAPI v3
- Scope: 11 services / 48 endpoints (patient persona). Designed to grow.
The SDKs cover the patient appointment-and-payment flow, health measurements, and AI image analysis:
| Service | Endpoints | Purpose |
|---|---|---|
auth |
11 | Login, 2FA, refresh, registration (verify/e-mail-confirm/create), social sign-up, password reset, logout |
doctors |
5 | Branches, locations, quick/filtered search, detail |
slots |
1 | Doctor availability (materialized slots) |
appointments |
5 | Reserve, physical appointment, cancel, list, reservations |
payments |
5 | Discount check, saved cards, pay (3DS) |
measures |
8 | Health measurements (CRUD, list, graph, partner) |
skin |
1 | AI skin-lesion analysis ("Cildimde Neyim Var") |
meals |
1 | AI meal-photo calorie/nutrition estimation |
laboratory |
5 | Lab results, orderable test catalog, test pre-order |
diets |
2 | Diet lists (list + detail) written by the dietitian |
addresses |
4 | The patient's saved addresses (needed by laboratory.order) |
Out of scope for this collection (may be added later): "Anlık randevu" (programs), video-call (calls). The SDK surface is designed so new services slot in as new resource groups without breaking existing ones.
| Env | Base URL |
|---|---|
production |
https://api.bulutklinik.com/api/v3 |
test |
https://apitest.bulutklinik.com/api/v3 |
local |
https://api-bulutklinik.test/api/v3 (Herd) |
The client accepts either a named environment preset or an explicit base URL.
Default: production.
| Header | Value | Notes |
|---|---|---|
Accept |
application/json |
Always. |
Content-Type |
application/json |
On requests with a body. |
lang |
tr (default), en, de, az |
Configurable per-client and per-request. |
Authorization |
Bearer <accessToken> |
Protected endpoints only. Omitted on public endpoints; partner endpoint uses the partner token. |
Endpoints use GET, POST, PUT, DELETE as specified per endpoint in §6.
Path parameters (e.g. {id}, {type}, {page}) are URL segments, not query
string. Request bodies are JSON.
Every API response is a JSON envelope:
errorTypeis polymorphic (verified live): some endpoints return a string label (e.g."validation"), others return a numeric code (e.g.1). SDKs must accept both — only treaterrorTypeas a refinement hint when it is a string; never assume it is a string (e.g. don't call string methods on it unguarded).
A call is successful when the HTTP status is 2xx and resultType == 0.
SDKs unwrap and return data to the caller on success; otherwise they raise a
typed error (§4).
| Value | Name | SDK behavior |
|---|---|---|
0 |
Success | Return data. |
1 |
Error | Raise ApiError (or a more specific subtype based on HTTP status / errorType). |
2 |
Logout | Clear the token store, raise AuthenticationError (session revoked). |
3 |
Update | Raise ApiError with an "update required" marker (client/app too old). |
4 |
Refresh | Token expired. Not returned by refreshApi; returned by the global handler on any protected call that receives an expired/invalid token (HTTP 401). Triggers the auto-refresh+retry flow (§5.4). |
Implementation note:
resultType 4is the canonical refresh signal, but a bare HTTP401(without a parseable envelope) MUST be treated identically.
All SDKs expose one error hierarchy with a common base. Names follow each
language's convention (e.g. BulutklinikError / ApiError in TS, exceptions in
PHP/Python, error values implementing an interface in Go, exception classes in
Java/C#/C++).
BulutklinikError (base — all SDK errors derive from this)
├── TransportError (network failure, timeout, DNS, TLS — no HTTP response)
└── ApiError (got an HTTP response that wasn't a success)
├── ValidationError (422, or errorType=validation)
├── AuthenticationError (401 / resultType 2 logout / failed refresh)
├── AuthorizationError (403 — authenticated but not permitted/scoped)
├── NotFoundError (404)
└── RateLimitError (429 — throttled; carries Retry-After if present)
Each ApiError carries: httpStatus, resultType, errorType, errorMessage,
the raw data, and the originating request (method + path) for debugging.
Mapping precedence: logout (resultType == 2) → string errorType == "validation"
→ HTTP status (401→Auth, 403→Authz, 404→NotFound, 422→Validation, 429→RateLimit)
→ otherwise (incl. numeric errorType, or success HTTP with resultType != 0) → ApiError.
Because errorType may be numeric (§3), guard before string-matching it.
OAuth2 via Laravel Passport. Access token lifetime ~30 days, refresh token ~130
days. The token grant happens server-side inside connectApi (no direct
oauth/token HTTP call from the SDK).
POST /general/connectApi (also aliased at root /connectApi). Public (no Bearer).
Request body:
| Field | Required | Notes |
|---|---|---|
apiUserName |
✓ | Identifier per loginMode (email / TC / phone / user_id). |
apiUserPassword |
✓* | Required except social / afterRegister modes. |
apiClientId |
✓ | OAuth client id. |
apiSecretKey |
✓ | OAuth client secret. |
loginMode |
✓ | email | identity | phone | user_id | social | afterRegister. |
withPhoneNumber |
— | Some installs require it in phone mode. |
loginMode social / afterRegister skip password validation
(validateForPassportPasswordGrant).
Success → data: { access_token, refresh_token, password_policy }. The SDK
persists both tokens via the token store (§5.5).
2FA branch: if SMS 2FA is enabled (sms_2fa_status=1), data.access_token is
absent and data.response carries an encrypted blob. The SDK surfaces this as a
two-factor challenge (typed result, not an error) so the caller can collect the
SMS code and call auth.connectWithTwoFactor.
POST /general/connectApiWithTwoFactor. Public (middleware verifies the SMS code
inside the encrypted blob).
Request body:
| Field | Required | Notes |
|---|---|---|
smsVerificationCode |
✓ | The code the user received by SMS. |
response |
✓ | The encrypted blob from connect's data.response. |
(The collection also sends tokenInfo, but the server ignores it — the real token
is decrypted from response. SDKs send only smsVerificationCode + response.)
Success → data: { access_token, refresh_token }. Token is not re-minted here;
it was minted during connect and is returned now.
POST /general/refreshApi. Public. Uses the Passport refresh_token grant.
Request body: { refreshToken, clientId, clientSecretKey }.
Success → data: { access_token, refresh_token } (both rotated; persist both).
On any protected call:
- Send the request with the current access token.
- If the response is
401orresultType == 4, and a refresh token exists, and this request has not already been retried: a. Callauth.refreshwith the stored refresh token + client credentials. b. Persist the new tokens. c. Retry the original request once. - If the refresh call itself fails, or
resultType == 2(logout), clear the token store and raiseAuthenticationError. - Auto-refresh must be concurrency-safe: simultaneous 401s share a single in-flight refresh (no refresh stampede). Single-threaded SDKs (e.g. plain JS) gate on one shared promise; threaded SDKs (Java/C#/Go/C++) use a mutex.
The retry is bounded to one attempt to prevent loops.
A TokenStore abstraction holds the access + refresh tokens. Default
implementation is in-memory. Consumers may inject a custom store (file, DB,
secure storage). Required operations (named per language):
- get access token / get refresh token
- set tokens (access, refresh) — atomically
- clear (on logout / revoked session)
Registration is a multi-call flow. verifyRegistration returns confirmationType:
"sms"→ feed itsresponse+ the SMS code straight intoregister."email"→ the user gets the code by e-mail; callconfirmRegistrationEmailfirst (it verifies the e-mail code, sends an SMS code, and returns a freshresponseblob), then feed that blob + the SMS code intoregister.
"email". The SMS branch of
verifyAddingNewPatient only triggers when an appversion header ≤ 5.27 is present;
SDKs send no such header, so confirmRegistrationEmail is a required middle step,
not optional. register (guarded by checkPhoneVerificationSmsCode, which requires
smsVerificationCode/smsVerificationExpire in the blob) cannot consume the e-mail
blob directly — it returns 501. (Social sign-up uses a separate 2-step pair, §5.6.4.)
POST /patients/verifyAddingNewPatient. Not public — guarded by
auth:apiusers, so it uses the SDK's partner token (the same apiusers bearer as
partnerHealthInformation, no specific scope required here), plus a
throttle:perHourFifty limit. This is the reason the step needs a configured
partnerToken; a patient bearer will not satisfy the guard.
Request body: name, surname, phoneNumber, phone_code, email, password,
passwordAgain, acceptUserAgreement, one of g-recaptcha-response-v2 / captcha,
optional userAgreements[].
Rules (validated in VerifyAddingNewPatientRequest):
phoneNumbermust match^[+]([0-9\s\(\)]*)$and be unique inmbl_users(this is where duplicate-account detection happens).phone_codemust match^\+\d{1,3}$(e.g.+90).emailis required (modern app versions) and unique inmbl_users.passwordAgainmust equalpassword; the SDKs auto-fill it frompassword.- CAPTCHA is mandatory (
g-recaptcha-response-v2orcaptcha, required-without each other), validated last via a live Cloudflare/Google call. A pure server-side caller cannot mint this token — it must come from a browser/human. The SDK method is therefore a thin passthrough: the caller supplies the captcha token.
Success → data: { response: "<hashedCode>", confirmationType: "sms" | "email" }.
The SDK returns data verbatim; feed response (and the code the user receives) into
register. The response blob is opaque and passed through unchanged (§8.2).
POST /patients/emailConfirmationRegister. Public (guarded by
checkEmailVerificationCode + throttle). Called only when verifyRegistration
returned confirmationType: "email".
Request body: verificationCode (the e-mailed code), response (the blob from
verifyRegistration), optional userAgreements[]. The rest of the profile is
carried inside the encrypted blob and merged server-side (prepareForValidation),
so the SDK only sends these fields.
Success → data: { response: "<new SMS blob>", confirmationType: "sms" }. Feed that
response + the SMS code into register.
POST /patients/addNewPatient. Public but guarded by SMS verification
(checkPhoneVerificationSmsCode) + throttle.
Request body: name, surname, apiUserName, phoneNumber, password,
smsVerificationCode, response (the SMS blob from the previous step),
acceptUserAgreement (1), apiClientId, apiSecretKey.
Rules (validated):
phoneNumbermust match^[+]([0-9\s\(\)]*)$— i.e. start with+and country code (e.g.+90 555 111 22 33). Bare digits are rejected.apiUserNameis used as theafterRegistertoken username; send the same+CCvalue asphoneNumber, otherwise auto-login mints a wrong/empty token.- Password is stored as
Hash::make(BULUT_API_ENC_KEY . password)(bcrypt rounds=12).
Success → patient created + automatic afterRegister login → data: { access_token, refresh_token }.
A separate public 2-step pair for users who authenticate via a social provider.
Unlike verifyRegistration, both are public — no CAPTCHA and no partner token.
verifyRegistrationSocial→POST /patients/verifyAddingNewPatientSocial. Body:name,surname,phoneNumber,password,passwordAgain,socialType,key, optionalemail,acceptUserAgreement,userAgreements[]. Sends the SMS code →data: { response }(note: noconfirmationType).registerSocial→POST /patients/addNewPatientWithSocial(guarded bycheckPhoneVerificationSmsCode). Body:smsVerificationCode,response(blob from the verify step), optionaluserAgreements[]; the profile is merged from the blob. Creates the social patient and itssec_social_loginlink row. Does not auto-login — obtain tokens afterwards withconnect({ loginMode: "social" }).
POST /general/disconnectApi. Bearer required (auth:patients,apiusers,doctors).
Revokes the current access + refresh tokens server-side. The SDK then clears the
token store. Optional device-token fields (firebase/ios) may be added to the body.
A public 2-step self-service reset flow.
forgotPassword→POST /patients/forgotPassword. Body:phoneNumber(must be a registered number), optionalbirthdate(YYYY-MM-DD; some installs verify it), and one ofg-recaptcha-response-v2/captcha— CAPTCHA is mandatory outside the local environment (browser-minted, likeverifyRegistration). Sends the SMS confirm code →data: { response }.resetPassword→PUT /patients/forgotPassword(guarded bycheckForgotPasswordConfirmSmsCode). Body:smsConfirmCode,response(blob fromforgotPassword),password,passwordAgain. Sets the new password (keyed by the phone/birthdate carried in the blob). Terminal — returns a success message, no tokens.
Notation: Canonical name = language-neutral concept → per-language naming
follows §7. [public] = no auth; [bearer] = access token; [partner] = partner
token; [scope:…] = required OAuth scope.
| Canonical | Method | Path | Auth |
|---|---|---|---|
connect |
POST | /general/connectApi |
public |
connectWithTwoFactor |
POST | /general/connectApiWithTwoFactor |
public |
refresh |
POST | /general/refreshApi |
public |
verifyRegistration |
POST | /patients/verifyAddingNewPatient |
partner |
confirmRegistrationEmail |
POST | /patients/emailConfirmationRegister |
public |
register |
POST | /patients/addNewPatient |
public* |
verifyRegistrationSocial |
POST | /patients/verifyAddingNewPatientSocial |
public |
registerSocial |
POST | /patients/addNewPatientWithSocial |
public* |
forgotPassword |
POST | /patients/forgotPassword |
public |
resetPassword |
PUT | /patients/forgotPassword |
public |
disconnect |
POST | /general/disconnectApi |
bearer |
(Bodies and responses in §5.)
| Canonical | Method | Path | Body / params |
|---|---|---|---|
branches |
GET | /patients/allBranches |
— |
locations |
GET | /patients/allLocations |
— |
quickSearch |
POST | /patients/quickSearch |
searchText (3–100, req), listType (interview|appointment|null), location (null) |
search |
POST | /patients/filteredSearch |
searchParams{}, orderParams[], otherParams[], currentPage (≥1, req), perPageLimit (10–100) |
detail |
GET | /patients/doctorDetail/{id}/{corporate?} |
path id (req), optional corporate |
quickSearchresponse:{ searchedBranches, searchedDoctors, searchedCompanies, searchedGivenTreatments, searchedBlogs, queryText }; each item{ result_id, result_text, result_url, result_sub_text, result_type, result_image }.search.searchParamskeys:withFreeText,withDoctorName,withBranchName,withBranchId(-1excludes psychology/diet),withLocationName,withLocationId,withCompanyName,withCompanyId,withGivenTreatments,withExpertyId,withInstitutionId,withNearestSlotDayRange.orderParams:name|point|slot|order.otherParams:isKizilay|isQuestionable|isInterviewable|isAppointmentable. Response:data: { foundDoctorsCount, foundDoctors: [ { doctor_id, name, surname, branch_name, star_rate, nearest_slot, isInterviewable, isAppointmentable, url, user_image, … } ] }.detailreturnsdoctorGeneralInfo(prices, session length, branch), education, languages, reviews, videos, special services, related clinics. Thedoctor_idhere feeds later steps.
| Canonical | Method | Path | Body |
|---|---|---|---|
schedule |
POST | /patients/doctorScheduler |
doctorId (numeric, req); scheduleDate (Y-m-d, today..+21, optional); scheduleStep + schedulePage (window paging — both required when scheduleDate omitted); listType (req: interview → online slot_type 1,2; else physical slot_type 0,2) |
Response: data = date-keyed map → for each date [ { slotId, slotStart "HH:mm:ss", slotEnd "HH:mm:ss", available: true } ]. Empty days are [].
Next step's appointmentDate = "Y-m-d H:i" (date key + slotStart, drop seconds).
| Canonical | Method | Path | Body / params |
|---|---|---|---|
reserveInterview |
POST | /patients/addInterviewDateReservation |
doctorId (numeric, req), appointmentDate (Y-m-d H:i, today..+21, req), appointmentType (interview|appointment, default interview) |
addPhysical |
POST | /patients/addNewAppointment |
doctorId (numeric, req), appointmentDate (Y-m-d H:i, req). No appointmentType. |
cancel |
DELETE | /patients/deleteUserAppointment/{eventId} |
path eventId (= cln_events.id) |
list |
GET | /patients/userAppointments/{page?} |
optional path page (paging disabled — page 1 = full list) |
reservations |
GET | /patients/userReservations |
— |
reserveInterview success → { resultType: 0, data: null }; failure → 501.
cancel → 501 for insurance appointments, past cancel-window, or not found.
Slot is resolved server-side from doctorId + appointmentDate (no slotId in request).
list→data: { foundAppointmentsCount, foundAppointments: [ { event_id, event_start_date, doctor_id, doctor_name, doctor_surname, status, amount, online_call, … } ] }.event_idis the idcanceltakes; rows withevent_id == "0"are paid-order/refund entries (not cancellable) — filter them out.reservations→ a bare array of active online-slot holds:{ appoinment_date, doctor_id, doctor_name, doctor_surname, medical_branch_name, minute_diff, second_diff }(pairminute_diff+second_difffor a countdown).
| Canonical | Method | Path | Auth | Notes |
|---|---|---|---|---|
checkDiscountCode |
POST | /patients/checkDiscountCode |
bearer | patients prefix, not payments. |
getCards |
GET | /payments/getCards |
bearer | |
saveCard |
POST | /payments/saveCard |
bearer | Flat fields (not nested). |
pay |
POST | /payments/interviewPayment |
bearer | Throttle 20/h/IP. Returns payment3DUrl. |
deleteCard |
DELETE | /payments/deleteCard/{cardId} |
bearer | path cardId |
checkDiscountCodebody:checkType(question|appointment|lab|special|physicallyAppointment|tmcLab|program),doctorId(required except lab/tmcLab/program),discountCode(req), plusorderId/specialServiceId/programSlugper type. Valid →data: { discount_code, discount_title, discount_id, prices }.getCards→data.cards[]: { id, card_holder_name, card_number (masked), card_type, created_at }.id→cardId.saveCardbody (flat,SavePatientCardRequest):cardHolder,cardNumber,cardExpMonth(m),cardExpYear(Y),cardCvv— all required.paybody:doctorId(req),appointmentDate(Y-m-d H:i, req),appointmentType(interview→order_type 0 /appointment→3),is3D(bool, req),termsAccept(accepted, req),saveCard(1=tokenize),discountCode(opt),caseDetail(opt, encrypted), and eithercardInfo{ cardHolder, cardNumber, cardExpMonth, cardExpYear, cardCvv }(all-or-none) orcardId(saved card). Amount is computed server-side (noamountin request).payresponse: see §8.1 (payment3DUrlhandling).
Patient endpoints [bearer] [scope:patients]; partner endpoint [partner] [scope:teusan].
Records are written to the authenticated patient (bas_com_company_id from token).
| Canonical | Method | Path | Body / params |
|---|---|---|---|
addList |
POST | /patients/addNewUserMeasures |
data[] — each item: type + that type's fields + date_time. Primary "submit health data" endpoint. |
add |
POST | /patients/addNewUserMeasures/{type} |
path type; body: date_time + type fields |
update |
PUT | /patients/updateUserMeasures/{type} |
path type; body: id (req) + fields + date_time |
delete |
DELETE | /patients/deleteUserMeasures/{type} |
path type; body: id (req) |
last |
GET | /patients/measuresList |
Latest value per type. |
list |
GET | /patients/userMeasuresList/{type}/{page}/{glucoseType?} |
path; glucoseType 0/1 only for glucose |
graph |
GET | /patients/userMeasuresGraph/{type}/{period}/{page}/{glucoseType?} |
period 1=day,2=week,3=month,4=year |
partnerHealthInformation |
POST | /outher/healthInformation |
partner token; body: identity, phoneNumber, data[] |
addList runs in a DB transaction; submit multiple measurements in one call.
last returns the most-recent of each type (tension splits into hypertension/hypotension; glucose splits into hunger_glucose/postprandial_glucose), each with a *Date.
Measure type schema (every record also requires date_time = "Y-m-d H:i"):
type |
Fields |
|---|---|
tension |
hypertension (systolic), hypotension (diastolic) |
glucose |
glucose, glucose_type (0=fasting, 1=postprandial) |
pulse |
pulse |
fever |
fever |
weight |
weight (BMI auto-computed) |
length |
length (BMI auto-computed) |
waist |
waist |
hip |
hip |
fat |
fat |
muscle |
muscle |
calorie |
calorie |
step |
step |
sleep |
sleep (hours; stored to sleep_time) |
Value rules: numeric; tension/pulse digits 1–10; glucose 0–99999.99 + glucose_type 0|1; weight/length 0–99999.99; etc.
Known API bug (document, don't replicate): for the partner endpoint,
AddNewUserMeasuresListRequest::prepareForValidationreadsidentityfrom$this->messageinstead of$this->identity, nulling it during validation; in practice matching falls back tophoneNumber. The SDK sends the correct contract (identity+phoneNumber) and notes this in the README.
"Cildimde Neyim Var" — AI skin-lesion analysis. Submit one or more skin photos; each is
classified (lesion label), given a patient-friendly Turkish AI comment, image-quality
flags, a confidence, possible ICD hints and an opaque case_detail blob.
| Canonical | Method | Path | Body |
|---|---|---|---|
analyze |
POST | /patients/imageCheck |
images[] — each item { image (base64, req), branch_id? } |
Request: { "images": [ { "image": "<base64>", "branch_id"?: <int> } ] }. image is a
base64-encoded JPEG/PNG/WebP/HEIC (a data:…;base64, prefix is accepted). branch_id
optionally tags the stored media with a clinic branch. Mirrors measures.addList — a
loose array of records.
Response data: { status: [ { id, isClear, isBright, label, comment, confidence, image, error, possible_icd, case_detail } ] } — one entry per submitted image, id = 1-based index:
label— lesion class from the classifier (may be empty).comment— patient-friendly Turkish AI summary.isClear/isBright— image-quality flags.confidence— classifier confidence (0–1) or null.image— stored media relative path.possible_icd— candidate ICD code(s) or null.case_detail— opaque base64-encrypted blob identifying the saved case (§8.2); can be forwarded verbatim as a payment'scaseDetail.error— per-image error message or null.
The SDK returns data verbatim (a mostly-untyped map) and never decrypts case_detail.
On a gateway failure the API still returns status entries with empty label/comment, so
callers should treat all fields as optional.
AI meal-photo calorie/nutrition estimation — sibling of skin (same controller, different
domain).
| Canonical | Method | Path | Body |
|---|---|---|---|
analyze |
POST | /patients/imageAnalyzeMeal |
image (base64, req), portionSize (req), portionGrams?, mealType (req), note? |
The SDK input names map to the API's snake_case body
{ image, portion_size, portion_grams?, meal_type, note? } (like payments.pay, a typed
single input):
portion_size∈small | medium | large | custom(required).portion_grams— required only whenportion_sizeiscustom.meal_type∈breakfast | lunch | dinner | snack(required).note— optional free text (≤1000 chars); the model reads Turkish preparation/portion modifiers.
Response data: { status: { comment: "<json string>" } } — comment is the model's
nutrition breakdown (a JSON-object string, per the server prompt); the SDK returns it
verbatim.
The patient's own laboratory results, the orderable test catalog, and test pre-ordering.
The controller (v3\General\Laboratory) is @hideFromAPIDocumentation, so this group is
hand-written from the API source, not the auto-docs.
| Canonical | Method | Path | Body / params |
|---|---|---|---|
results |
GET | /patients/userLabTestList/{page?} |
optional page (default 1). The patient's completed/in-progress lab results. |
resultDetail |
GET | /patients/userLabTestDetail/{testId} |
path testId (string) — pass the id from a results item verbatim. |
catalog |
GET | /patients/allLaboratoryTests |
— (orderable test-group catalog). |
catalogDetail |
GET | /patients/laboratoryTestDetail/{id} |
path id (numeric) — one catalog group. |
order |
POST | /patients/addNewLaboratoryTest |
testId (numeric, req), addressId (numeric, req), laboratoryId (numeric, req). |
resultsdata:{ foundTestsCount, foundTests: [ { id, created_at, company_name, test_name, test_state, test_state_text, test_type, test_type_text } ] }.test_state0=Numune Alınıyor, 1=Çalışıyor, 2=Onaylandı;test_type1=Normal, 2=Grup, 3=Alt Parametre. The list also unions in TMC-lab-ordered tests whoseidcarries a-labsuffix (e.g."4821-lab").resultDetail:{testId}may be a plain id ("123", DB path) or"<id>-lab"(TMC-lab path).datafields:test_name, protocol_no, id, created_at, company_name, result, result_unit, test_state, test_state_text, test_type, test_type_text, result_type_text, sub_tests. Fortest_type == 1the payload addsnormal_lower_limit, normal_upper_limit, panic_lower_limit, panic_upper_limit(age/gender-aware). Fortest_type == 2,sub_tests[]carries per-parameter results plus those four limit fields.catalogreturnstest_groups[]: each{ id, name, image, background, desc (HTML), tests[]{id,name}, laboratories[]{ company_id, name, doctor_id, branch_id, prices{ real_price, discount_rate, discounted_price, discount_code, discount_title, discount_id }, cities[]{id,name} } }. Served fromconfig/laboratory.php, not the DB.catalogDetailreturns the single matching group; forpatusers the per-laboratorypricesare recomputed through the discount provider.ordersuccess →data: { preOrderId }. Validated against the catalog (test_not_found,laboratory_not_found), the user address (user_address_not_found,invalid_user_address_for_lab— the address city must be served by the lab), and duplicate open orders. Business failures return HTTP 501.- Not exposed: the deprecated
POST /patients/addNewLabTest(superseded byorder), and theresultsendpoint's optionalcompanyIdquery filter (SDKs use path params only, and that server-side filter is known-buggy).
The patient's diet lists (a dietitian's "Diyet Listesi"). Controller v3\General\Diets,
@hideFromAPIDocumentation. JSON only — the server's PDF export (dietFile) is out of SDK scope
(it returns a binary application/pdf, not the envelope).
| Canonical | Method | Path | Body / params |
|---|---|---|---|
list |
GET | /patients/dietLists/{page?} |
optional page (default 1). Page size is fixed to 10 server-side. |
detail |
GET | /patients/diet/{listId} |
path listId (numeric) = a list_id from a list item. |
listdata:{ foundDietsCount, foundDiets: [ { list_id, diet_date, protocol_no, patient_name, patient_surname, patient_birthdate, patient_identity_no, doctor_company_name, doctor_name, doctor_surname, doctor_title, doctor_branch_name, doctor_image } ] }. One entry per diet-program group;list_idfeedsdetail.detaildata: an array of meal-time groups[ { time, meals: [ { meal_time, total_calories, protocol_no, patient_*, doctor_company_name, diet_date, doctor_name, doctor_surname, doctor_title, doctor_image, doctor_certified_number, doctor_branch_name, meal_details: [ { quantity, explanation, meal_name, kcal, unit } ] } ] } ]. An empty diet returns HTTP 501.
The patient's saved addresses. Required by laboratory.order, whose addressId
must reference one of these (and whose city_id must be in the lab's served cities).
All four verbs are the same path /patients/userAddress (distinguished by method).
| Canonical | Method | Path | Body / params |
|---|---|---|---|
list |
GET | /patients/userAddress |
— |
add |
POST | /patients/userAddress |
title (req), cityId (req, numeric), districtId (req, numeric), address (req), locationLat (req), locationLng (req), description?, isDefault? (0|1) |
update |
PUT | /patients/userAddress |
id (req); title/cityId/districtId/address/locationLat/locationLng are required_without:isDefault; description?, isDefault? |
delete |
DELETE | /patients/userAddress |
id (req, in the body — not a path segment) |
list→ a bare array (default first):{ id, title, description, city_id, district_id, address, location_lat, location_lng, is_default, distinct_name }.idis theaddressIdused byupdate/delete/laboratory.order. Returns 501 when the patient has no addresses (treat as "empty"). Only the district name is joined — mapcity_id→name viadoctors.locations.add→data: { addressId }. The first address is forced default; settingisDefault: 1demotes the previous default.update/delete→ message only (nodata). The default address cannot be deleted (reassign viaupdatefirst), nor can an address already used on an order.cityIdcomes fromdoctors.locations(location_id);districtIdcomes fromGET /getConfig(cities[].districts[].district_id), reachable via the §7.2 escape hatch.
The client is a single root object exposing one accessor per service group; each group exposes the canonical methods above.
client.auth.connect(...) client.payments.pay(...)
client.doctors.search(...) client.measures.addList(...)
client.slots.schedule(...) client.appointments.reserveInterview(...)
client.skin.analyze(...) client.meals.analyze(...)
client.laboratory.results(...) client.diets.list(...)
Per-language casing & idioms:
| Language | Method case | Notes |
|---|---|---|
| JS/TS | camelCase |
client.doctors.quickSearch(). Promise-based. |
| PHP | camelCase |
$client->doctors->quickSearch(). Namespace Bulutklinik\Sdk. |
| Python | snake_case |
client.doctors.quick_search(). Sync and async (AsyncClient). |
| Go | PascalCase |
client.Doctors.QuickSearch(ctx, …). Context-first, (T, error) returns. |
| Java | camelCase |
client.doctors().quickSearch(…). Builder for config; checked vs unchecked TBD in Faz 3. |
| C# | PascalCase+Async |
client.Doctors.QuickSearchAsync(…). Task<T>, CancellationToken. |
| C++ | snake_case |
client.doctors().quick_search(…). Namespace bulutklinik. cpr + nlohmann/json. |
Request inputs are typed structures (objects/records/structs) per language;
responses are typed where practical, otherwise a typed envelope + parsed data.
| Option | Default | Purpose |
|---|---|---|
environment / baseUrl |
production |
Named preset or explicit URL. |
lang |
tr |
Default lang header; overridable per request. |
clientId / clientSecret |
— | Needed for refresh (and passed by connect). |
tokenStore |
in-memory | Pluggable persistence. |
timeout |
sane default | Request timeout. |
httpClient |
platform default | Injectable transport (PSR-18, http.Client, HttpClient, etc.). |
Not every endpoint has a typed resource method, and the API grows faster than the SDK surface. Every SDK therefore exposes one generic request method on the root client for calling any Bulutklinik API endpoint directly. It is not a separate HTTP client: it reuses the same transport, so default headers, the chosen auth mode, silent token refresh + retry (§5.4), envelope unwrapping (§3) and the typed error hierarchy (§4) all still apply.
Concept:
client.request(method, path, { auth, body, lang }) -> data
| Param | Notes |
|---|---|
method |
GET | POST | PUT | DELETE. |
path |
Relative to the configured base URL, e.g. /patients/allBranches. Leading slash included. |
auth |
public | bearer (default) | partner. Accepted as a string or an existing public enum/const per language. |
body |
Optional JSON payload (object/map/dict). Omitted on GET. |
lang |
Optional per-request lang override, where the SDK's transport supports one (JS, PHP, Go, C++). Python / Java / C# apply the client-level lang. |
Returns the unwrapped data payload as the language's raw JSON value (the same
type a future typed resource method would parse from), and raises the same typed
errors on failure. Representative per-language signatures (idiomatic, return the
raw data):
| Language | Signature |
|---|---|
| JS/TS | client.request<T>({ method, path, auth?, body?, lang? }): Promise<T> |
| Python | client.request(method, path, *, auth="bearer", body=None) — plus the async client |
| PHP | $client->request(string $method, string $path, string $auth = 'bearer', ?array $body = null, ?string $lang = null): mixed |
| Go | client.Do(ctx, method, path, *bk.RequestOptions) (json.RawMessage, error) (nil options ⇒ bearer) |
| Java | client.request(String method, String path, String auth, Object body) → JsonNode |
| C# | client.RequestAsync(HttpMethod method, string path, string auth = "bearer", object? body = null, CancellationToken = default) → JsonElement |
| C++ | client.request(method, path, bulutklinik::RequestOptions{}) → nlohmann::json |
This is the supported extension point for endpoints outside the 29 in §6. Prefer a
typed resource method when one exists; reach for request only for the gaps.
pay success response: { resultType: 0, data: { payment3DUrl: "<url>" } }.
payment3DUrl is a browser URL the SDK returns verbatim — it is one of:
(A) the bank's direct URL_3DS, or
(B) {APP_URL}/api/v3/payments/threeDUrl/<token> (our endpoint serving the 3DS HTML form).
The SDK does not open, follow, or parse it. 3DS completion ("provizyon
kapatma" / capture) happens browser↔bank↔server via the
POST /api/v3/threeD/appointmentPaymentComplete/{trxId}/{driver} callback
(trxId = "{orderId}.{transactionUuid}.{processId}") — outside SDK scope.
If is3D = false, data is the inline-completed order result (no payment3DUrl).
connect's data.response (2FA), register's response, caseDetail, and the
case_detail returned by skin.analyze are opaque encrypted blobs. The SDK passes
them through verbatim and never encrypts or decrypts — a skin.analyze case_detail
may be forwarded unchanged as a payment's caseDetail. The clinic/API encryption keys
are never embedded in the SDK.
- Public (no
Authorization):connect,connectWithTwoFactor,refresh,register,confirmRegistrationEmail,verifyRegistrationSocial,registerSocial,forgotPassword,resetPassword. (forgotPasswordstill requires a browser CAPTCHA token; the social pair does not.) - Bearer (access token): everything else (incl.
appointments.*and theaddressesgroup). - Partner:
partnerHealthInformation(scope:teusan) andverifyRegistration(auth:apiusers, no specific scope) use the separately-configured partner token, not the patient access token. Only these two need the partner token — the social registration verify step is public, unlike the non-socialverifyRegistration.
- Idiomatic, hand-written — no codegen; match each ecosystem's conventions.
- Minimal dependencies — prefer the platform HTTP client; pin the documented stack per language (see §7 / PLAN.md).
- Typed — public API and
datapayloads typed where the language supports it. - Auto-refresh + retry per §5.4, concurrency-safe.
- Pluggable token store and HTTP client.
- Errors per §4 with full context.
- Tested — unit tests for envelope/error/refresh logic + at least one live
smoke path against
testenv (Faz 1–2). - Examples —
examples/with the end-to-end flow: login → search → slot → reserve → (pay) and a measures example. - Self-contained repo — README, LICENSE (MIT), DESIGN.md copy, CI.
- Versioning — semver; tag
vX.Y.Zper repo.
- Base:
https://apitest.bulutklinik.com/api/v3 - OAuth client:
Patients_Web_Mobile— id96b630b3-f62a-4e67-b33c-b58802dca5af(secret in the collection / env file). - Test patient:
hackathon@bulutklinik.test(loginMode: email). - Bookable
doctorIdexamples:8282(interview + physical),168896(interview). - Known env limits (request is correct, server/env is the cause):
quickSearchreturns HTTP 404 /resultType 1ontest— the search driver (Elasticsearch) is unavailable there; the controller catches onlyQueryExceptionso other exceptions surface as a generic 404.filteredSearch(doctors.search) works and is the production search path.interviewPaymentmay 404 if POS isn't configured for the company; 3DS capture can't run from a non-browser client. SDK validation asserts the request shape +payment3DUrlreturn, not the bank capture.
- TS reference live result (2026-06-17,
test): 8/9 steps OK —auth.connect,doctors.branches(136),doctors.locations(81),doctors.search,doctors.detail,slots.schedule,measures.last,auth.disconnectall pass; onlyquickSearchfails for the env reason above.
This file is canonical. When it changes:
- Bump the spec version (§ top).
- Copy it into every language repo (
<repo>/DESIGN.md). - Reconcile each SDK against the change; note breaking changes in repo CHANGELOGs.
If an SDK must diverge from this spec, fix the spec first (or record the divergence here) — code and SSOT must never silently disagree.
{ "resultType": 0, // integer state code (see §3.1) "errorType": "validation", // optional; string label OR numeric code (see note) "errorMessage": "…", // optional, human-readable (localized via `lang`) "successMessage": "…", // optional "data": { /* payload */ } // endpoint-specific; may be null, object, array, or string }