This file is the single source of truth (SSOT) for every official Bulutklinik SDK. All language packages (JavaScript/TypeScript, PHP, Python, Go, 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 the BulutklinikAPI source (Laravel 8.12, OAuth2/Passport) —
app/Packages/Integration/Outherandroutes/{v3,v4}/outher.php.
- Spec version: 1.2.0 —
apiUserPasswordis now a per-application password issued by the portal, not the developer's portal account password. Wire format and every method signature are unchanged; only where the value comes from. See §5.1 and §12.2. 1.1.0 restored theauthgroup. 1.0.x wrongly claimed the partner token could only be issued out of band; it is in fact obtained through the sameconnectApipassword grant every other persona uses, and it is refreshable. See §5 and §12.1. 1.0.0/1.0.1 made the SDKs single-persona: everything that required a patient login is gone, and every data method runs on the company-scoped/outherchannel. That part is unchanged. - API: BulutklinikAPI
v3(default) orv4— selectable per client. - Scope: 7 services / 31 endpoints (partner persona).
The SDKs expose the partner persona: a clinic-integration channel where the caller is a company, authenticated by a pre-issued partner token, acting on the patients of its own company.
| Service | Endpoints | Purpose |
|---|---|---|
auth |
3 | Obtain, refresh and revoke the partner access token |
doctors |
4 | Doctor discovery: search, branches, detail, city list |
slots |
1 | Doctor availability (materialized slots) |
appointments |
9 | Reserve, confirm, free-form booking, cancel, list, lookup |
measures |
8 | Health measurements for a named patient (read + write) |
laboratory |
4 | Lab results for a named patient + orderable test catalog |
diets |
2 | Diet lists written by a dietitian, for a named patient |
| partner (this SDK) | |
|---|---|
| Who authenticates | your company, via a pre-issued partner token |
| Whose data you see | patients of your own company only |
| How a patient is named | inline on every call (patient / user object) — there is no session |
| Token lifecycle | minted by auth.connect from your client id/secret + service credentials, ~30 days, refreshable (§5) |
There is no patient login, no session, and no per-user access token. Two calls for two different patients are indistinguishable to the transport — the patient reference travels in the request body.
Patient identity is carried in the body, never in the URL. A TCKN in a path
segment would land in access logs, proxy logs and Sentry breadcrumbs. This is why
several read endpoints are POST although they are semantically reads.
Not exposed, because the API has no company-scoped equivalent:
- Patient authentication and registration — patient sign-up, social sign-up, password reset. (Partner authentication is covered — see §5.)
- Payments — discount codes, the saved-card vault, 3-D Secure. Partner booking
hands payment off to a browser
url(§6.3); no partner endpoint produces a financial record. - Self-service AI — skin-lesion analysis, meal photo analysis.
- Patient address book — it exists only to feed the patient-side lab order, which is itself unavailable to partners.
- Other partner scopes that exist server-side but are separate integrations:
apilab(laboratory result write-back),apidevice(medical devices), and the plain-apiusersdoctor-calendar endpoints. These may be added later as their own resource groups.
The base URL is <api root>/<apiVersion>.
| Env | API root |
|---|---|
production |
https://api.bulutklinik.com/api |
test |
https://apitest.bulutklinik.com/api |
local |
https://api-bulutklinik.test/api (Herd) |
apiVersion |
Segment | Notes |
|---|---|---|
v3 |
/v3 |
Default. The long-standing surface. |
v4 |
/v4 |
The consolidated architecture. Route-for-route identical for /outher — the API's outher:audit-routes command enforces v3/v4 parity. |
The client accepts a named environment preset plus an apiVersion, or an
explicit baseUrl that overrides both. Defaults: production + v3.
Every path in §6 is version-agnostic (
/outher/...); only the base URL differs. SwitchingapiVersionis a configuration change, not a code change.
| 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 <partnerToken> |
On every endpoint in §6. |
Endpoints use GET, POST, PUT, DELETE as specified per endpoint in §6.
Path parameters (e.g. {type}, {period}, {doctorId}) 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 (token revoked). |
3 |
Update | Raise ApiError with an "update required" marker. |
4 |
Refresh | Access token expired. Triggers the silent refresh + single retry (§5.4). If no refresh token is held, or the refresh itself fails, raise AuthenticationError. |
Implementation note:
/outherreturnsresultType 4with HTTP401on an expired token. A bare HTTP401without a parseable envelope MUST be treated identically — both trigger the refresh path.
/outher reports most business-rule failures as HTTP 501 with
resultType 1 — "patient not found in your company", "diet list is not yours",
"slot no longer free", "doctor not bookable through your integration". It is not
a server crash. Callers should read errorMessage, not the status code alone.
The read endpoints deliberately return the same message for "this patient is not in your company" and "this patient does not exist". Distinguishing them would turn the endpoint into a TCKN-probing oracle.
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
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 after a failed/absent refresh, or resultType 2 — session revoked)
├── AuthorizationError (403 — authenticated but the token lacks the scope, or carries no company)
├── 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: revoked session (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.
403deserves a note: it is raised not only for a missingapioutherscope but also when the token resolves to a user with no company. The company boundary is derived from the authenticated principal, never from request input — so a403here means the credential itself is wrong, and retrying with different body parameters will never help.
The Bulutklinik Developer Platform issues, per approved application, four things:
| Value | Used as |
|---|---|
| Client ID | apiClientId — the OAuth2 client |
| Client Secret | apiSecretKey — the OAuth2 client secret |
| Service identity | apiUserName — a project-specific login, not the developer's e-mail |
| Application password | apiUserPassword — a per-application secret, not the developer's portal password |
All four are shown on the application's page in the portal. The password is masked but copyable, and can be regenerated there if it leaks — regenerating it leaves the client id and secret untouched, so a leaked password can be rotated without re-keying the integration. Nothing here is a ready-made bearer token: the token is minted by calling the API.
Spec 1.0.x got this wrong. It described the token as "issued out of band" with "no grant the SDK can drive", and therefore dropped
authentirely. The grant below has always existed and is what the portal's own quick-start shows.
POST /general/connectApi. Public (no Authorization), rate-limited.
| Field | Required | Notes |
|---|---|---|
apiClientId |
✓ | Client ID from the portal. |
apiSecretKey |
✓ | Client Secret from the portal. |
apiUserName |
✓ | The service identity. |
apiUserPassword |
✓ | The application password from the portal. |
loginMode |
✓ | email (what the portal documents) | identity | phone | user_id. |
Success → data: { access_token, refresh_token, password_policy }. The SDK
persists both tokens (§5.3) and returns a login result.
Under the hood this is a Passport password grant; the granted scope is read
from the authenticated row's client_scope, which is what ties a partner
application to apiouther. A partner integration therefore never sends a scope —
it is a property of the credentials.
No CAPTCHA for partners. The server only demands a CAPTCHA when the authenticating row is a patient, doctor or developer account. A partner (
user_group = api) is exempt, which is what makes this callable from a headless SDK at all.
Two-factor branch. If the account has SMS 2FA enabled, data carries a
response blob and no access_token. SDKs surface this as a typed
two-factor required result rather than an error, so the caller can collect the
code and finish the login. Partner service identities do not normally have 2FA
on, but the branch exists and must not be mistaken for a malformed response.
Tokens are read through a TokenStore on every request, so a long-lived
process can rotate credentials without being rebuilt.
| Operation | Purpose |
|---|---|
| get token | The current access token, or null/empty. |
| set token | Replace it (accepts null to unset). |
| clear | Drop everything. Called automatically on resultType 2. |
A store MAY additionally implement refresh-token persistence (get refresh token / set refresh token). This is an optional extension, not part of the
base interface — a store written against spec 1.0.x keeps working unchanged. When
the injected store does not implement it, the SDK holds the refresh token in
memory for the client's lifetime; the only consequence is that a process restart
requires a fresh connect instead of a refresh. The built-in in-memory store
implements both.
The partnerToken option remains, for callers who already hold a token and do
not want the SDK to mint one:
new Client({ clientId, clientSecret }) ⇒ call auth.connect to obtain tokens
new Client({ partnerToken: "…" }) ⇒ in-memory store seeded with a token
new Client({ tokenStore: myVaultStore }) ⇒ tokens come from your store
new Client({ partnerToken: …, tokenStore: … }) ⇒ configuration error at construction
Passing both a literal and a store is rejected rather than silently resolved: guessing which one the caller meant is how credential bugs get shipped.
If no token is available when a data request is dispatched, the SDK raises
AuthenticationError before touching the network.
POST /general/refreshApi. Public. Body: refreshToken, clientId,
clientSecretKey. Success → data: { access_token, refresh_token } — both
rotate, so persist both.
On any partner-authenticated call:
- Send it with the current access token.
- If the response is
401orresultType == 4, a refresh token is held, and this request has not already been retried: a. refresh, b. persist the new tokens, c. retry the original request once. - If the refresh fails, or
resultType == 2comes back, clear the store and raiseAuthenticationError.
The retry is bounded to one attempt. Refresh must be concurrency-safe: simultaneous 401s share a single in-flight refresh rather than stampeding.
A failed refresh answers
resultType 2with HTTP 400 — which the envelope rules (§3.1) already map to "clear the store and raise". No special case needed.
POST /general/disconnectApi. Partner bearer. Revokes the access token and
all of its refresh tokens, then the SDK clears the store.
Send an empty body. The endpoint optionally accepts a device-token cleanup (
token+device), but itsdevicemapping has no default branch — an unexpected value raises server-side. There is no partner use for it.
Access tokens last ~30 days, refresh tokens ~130. Refreshing is the normal path;
a full connect is only needed on first use or after both have lapsed.
Notation: Canonical name = language-neutral concept → per-language naming
follows §7. Every endpoint below requires the partner token except the two public
auth calls (§6.0); the scope column lists the OAuth scope the token must carry.
Two patient-reference shapes recur; both are defined in §8.1.
patientRef—{ identityNumber?, phoneNumber? }, at least one. Used by reads. Never creates anything.bookingUser—{ name, surname, phoneNumber, identityNumber?, email?, birthdate?, nationality?, price? }. Used by writes. Creates the patient in your company if absent.
Token lifecycle. Bodies and semantics in §5.
| Canonical | Method | Path | Auth |
|---|---|---|---|
connect |
POST | /general/connectApi |
public |
refresh |
POST | /general/refreshApi |
public |
disconnect |
POST | /general/disconnectApi |
partner |
connect and refresh are the only endpoints in this spec that are not
partner-authenticated — they are what produce the credential everything else
uses.
| Canonical | Method | Path | Body / params |
|---|---|---|---|
search |
POST | /outher/search |
searchParams{} (req), orderParams[] (name|order|slot), currentPage (≥1, req) |
branches |
GET | /outher/branches |
— |
detail |
GET | /outher/doctorInfos/{doctorId} |
path doctorId (req, numeric) |
locations |
GET | /outher/locations |
— |
- Results are filtered to the doctors enabled for your integration (the server
applies your partner slug), so anything returned here is bookable by you.
locationsis the exception: a global city catalogue, not company-scoped. search.searchParamsaccepts the same keys as the patient-side filtered search (withFreeText,withDoctorName,withBranchName,withBranchId,withLocationName,withLocationId,withCompanyName,withCompanyId,withGivenTreatments,withExpertyId,withInstitutionId,withNearestSlotDayRange). Response:{ foundDoctorsCount, foundDoctors: [ { doctor_id, name, surname, branch_name, … } ] }. NoteorderParamshere is narrower than the patient surface —pointis not accepted.searchParamsmust contain at least one key. Its rule isrequired|array, and PHP'srequiredrejects an empty array — so{}is a guaranteed422, not an unfiltered search. SDKs must therefore makesearchParamsa required argument and must not default it to an empty map.detaildoctor_idfeedsslots.scheduleand the booking calls.
| Canonical | Method | Path | Body |
|---|---|---|---|
schedule |
POST | /outher/doctorSlots |
doctorId (numeric, req); scheduleDate (Y-m-d, today..+21, optional); scheduleStep + schedulePage (window paging — both required when scheduleDate omitted) |
Response: data = date-keyed map → for each date
[ { slotId, slotStart "HH:mm:ss", slotEnd "HH:mm:ss", available: true } ].
Empty days are []. slotId feeds appointments.reserve; an
appointmentDate elsewhere is "Y-m-d H:i" (date key + slotStart, seconds dropped).
Unlike the patient surface there is no listType — the partner channel is online
interviews.
| Canonical | Method | Path | Body / params |
|---|---|---|---|
reserve |
POST | /outher/reservation |
slotId (req), doctorId (req), user{} = bookingUser |
reserveWithoutAgreement |
POST | /outher/reservationWithoutAgreement |
same as reserve |
instantReserve |
POST | /outher/instantReservation |
user{} = bookingUser |
create |
POST | /outher/appointment |
hash (req), outherProcessId (req, numeric) |
createWithoutSlot |
POST | /outher/appointmentWithoutSlot |
doctorId (req), startDate (Y-m-d H:i, ≥ today, req), finishDate (Y-m-d H:i, after startDate, req), isOutherDoctor (0|1), user{} = bookingUser |
cancelWithoutSlot |
DELETE | /outher/appointmentWithoutSlot |
appointment lookup (below) |
list |
POST | /outher/appointments |
phoneNumber (req), page (≥1), type (normal|instant) |
info |
POST | /outher/appointmentInfo |
appointment lookup (below) |
checkDoctor |
POST | /outher/checkDoctor |
doctorId (req, numeric), isOutherDoctor (req, 0|1) |
Appointment lookup (info, cancelWithoutSlot) addresses one appointment
either by process — hash + outherProcessId — or by coordinates —
doctorId + appointmentDate (Y-m-d H:i) + isOutherDoctor. Send one pair or
the other; the server validates them as mutually required_without.
The two booking flows:
(A) hand off to the patient reserve ──▶ data.url ──▶ patient opens it in a
browser: agreements + payment
(B) you collected the agreements reserveWithoutAgreement ──▶ data.hash
└─▶ create(hash, outherProcessId) ──▶ appointment
reserve→{ url, hash }.urlis a short link to the Bulutklinik agreement and payment page; hand it to the patient. The SDK returns it verbatim and never opens or follows it.reserveWithoutAgreement→{ hash, doctorId, slotId, phoneNumber, reservationExpired }.reservationExpired(Y-m-d H:i:s) is the hold deadline —createafter it passes fails with501.instantReserve→{ url }. No slot: the server picks an available doctor.create→ the appointment record pluslast_delete_time(the cancellation deadline).createWithoutSlotbooks a free-form range outside the slot grid, for integrations running their own calendar.cancelWithoutSlotreverses it — and only it; appointments created throughcreateare not cancellable here.listreturns the appointments you created for that phone number, not the patient's full history across the platform.checkDoctor→{ title, name, surname, branch_name, state: "1" }when the doctor is bookable through your integration;501when not. Call it before showing a doctor as reservable.
Completing a payment (
outherProcess) is not on the partner surface: those routes require apatients/bulutwebscope. Flow (A) exists precisely because the browser hand-off is where payment happens.
Reads resolve the patient inside your company and never create one. Writes create the patient if needed. Measurements are written to your own company — a value you write does not appear in the patient's Bulutklinik mobile app. That is the intended consequence of tenant isolation, not a bug.
| Canonical | Method | Path | Body / params |
|---|---|---|---|
last |
POST | /outher/lastMeasures |
patient{} = patientRef |
list |
POST | /outher/measuresList/{type} |
path type; patient{} = patientRef, currentPage (≥1), glucoseType (0|1, glucose only) |
graph |
POST | /outher/measuresGraph/{type}/{period} |
path type, period (1=day,2=week,3=month,4=year); patient{} = patientRef, currentPage, glucoseType |
addList |
POST | /outher/measures |
patient{} = bookingUser, data[] (1–200 items) — each item type + that type's fields + date_time |
add |
POST | /outher/measure/{type} |
path type; patient{} = bookingUser, date_time + type fields |
update |
PUT | /outher/measure/{type} |
path type; patient{} = patientRef, id (req) + fields + date_time |
delete |
DELETE | /outher/measure/{type} |
path type; patient{} = patientRef, id (req) |
healthInformation |
POST | /outher/healthInformation |
identity, phoneNumber, data[] — legacy flat contract, not patient{} |
addListwrites every row in one transaction, capped at 200 items — without a cap a single request would hold a transaction open across thousands of rows and block on themed_monitor_*tables.update/deletetake the read-sidepatient{}(if there is a row to change, the patient already exists) and bound the write toid+ patient + company.idforupdate/deletecomes fromlist.
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.
last returns the most recent of each type (tension splits into
hypertension/hypotension; glucose into hunger_glucose/postprandial_glucose),
each with a *Date.
healthInformationis the odd one out. It predates thepatient{}contract, needs theteusanscope instead ofapiouther, and takes a flatidentity+phoneNumber. PreferaddListfor new integrations.Its patient matching is an OR, and it is loose. The lookup is
WHERE identity = … OR phone_number = …against the global user table, taking the first row (PatientUsersModel::patientUserFindWithOr). A phone number alone can therefore resolve a person whose TCKN differs from the one you sent. Send both fields, but do not assume they are checked as a pair. This is the exact opposite of theapioutherreads in this group, which scope to your own company and fail closed on ambiguity (§8.1) — another reason to preferaddList.(Spec 0.x documented a defect here that nulled
identityoutright before it reached the lookup. That was fixed API-side on 2026-07-21; the OR remains.)
| Canonical | Method | Path | Body / params |
|---|---|---|---|
catalog |
GET | /outher/laboratoryCatalog |
— |
catalogDetail |
GET | /outher/laboratoryCatalog/{testId} |
path testId (req, numeric) |
results |
POST | /outher/laboratoryResults |
patient{} = patientRef, currentPage (≥1) |
resultDetail |
POST | /outher/laboratoryResult |
patient{} = patientRef, testId (req) |
catalog/catalogDetailare the global orderable-test catalogue — static data, no patient and no company scoping.resultDetail.testIdmust be passed back exactly asresultsreturned it: a plain number is an HBYS lab request, a-labsuffix marks a TmcLab order group (server pattern:/^\d+(-lab)?$/). The SDK does not parse or normalise it.- Ordering a test is not available to partners (it creates a financial record).
| Canonical | Method | Path | Body |
|---|---|---|---|
list |
POST | /outher/dietLists |
patient{} = patientRef, currentPage (≥1) |
detail |
POST | /outher/diet |
patient{} = patientRef, listId (req, numeric) |
list→{ foundDietsCount, foundDiets: [ { list_id, diet_date, protocol_no, patient_*, doctor_* } ] }. Page size is fixed to 20 server-side.list_idfeedsdetail.detail→ an array of meal-time groups[ { time, meals: [ { meal_time, total_calories, …, meal_details: [ { quantity, explanation, meal_name, kcal, unit } ] } ] } ]. AlistIdthat is not this patient's returns501with the generic message.
The client is a single root object exposing one accessor per service group; each group exposes the canonical methods above. There is no namespace prefix — the partner surface is the surface.
client.auth.connect(...) client.measures.addList(...)
client.doctors.search(...) client.laboratory.results(...)
client.slots.schedule(...) client.diets.list(...)
client.appointments.reserve(...)
Per-language casing & idioms:
| Language | Method case | Notes |
|---|---|---|
| JS/TS | camelCase |
client.doctors.search(). Promise-based. |
| PHP | camelCase |
$client->doctors->search(). Namespace Bulutklinik\Sdk. |
| Python | snake_case |
client.doctors.search(). Sync and async (AsyncClient). |
| Go | PascalCase |
client.Doctors.Search(ctx, …). Context-first, (T, error) returns. |
| C# | PascalCase+Async |
client.Doctors.SearchAsync(…). Task<T>, CancellationToken. |
| C++ | snake_case |
client.doctors().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.
Where a method name would collide with a language keyword, the language's own
escape applies — e.g. C++ measures.delete_measure(...), since delete is
reserved.
| Option | Default | Purpose |
|---|---|---|
environment |
production |
Named preset (production | test | local). |
apiVersion |
v3 |
v3 | v4. Combined with environment to build the base URL. |
baseUrl |
— | Explicit URL; overrides environment + apiVersion. |
lang |
tr |
Default lang header; overridable per request. |
clientId / clientSecret |
— | OAuth client credentials from the portal. Required by auth.connect and auth.refresh. |
partnerToken |
— | An already-minted access token. Seeds the default in-memory store. |
tokenStore |
in-memory | Pluggable token source (§5.3). Mutually exclusive with partnerToken. |
timeout |
sane default | Request timeout. |
httpClient |
platform default | Injectable transport (PSR-18, http.Client, HttpClient, etc.). |
clientId / clientSecret are back in 1.1.0: the partner token is minted by the
same password grant, so they are needed for connect and refresh (§5).
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, 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. /outher/branches. Leading slash included. |
auth |
partner (default) | public. 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 and C# apply the client-level lang. |
Returns the unwrapped data payload as the language's raw JSON value, and raises
the same typed errors on failure. Representative per-language signatures:
| Language | Signature |
|---|---|
| JS/TS | client.request<T>({ method, path, auth?, body?, lang? }): Promise<T> |
| Python | client.request(method, path, *, auth="partner", body=None) — plus the async client |
| PHP | $client->request(string $method, string $path, string $auth = 'partner', ?array $body = null, ?string $lang = null): mixed |
| Go | client.Do(ctx, method, path, *bk.RequestOptions) (json.RawMessage, error) (nil options ⇒ partner) |
| C# | client.RequestAsync(HttpMethod method, string path, string auth = "partner", object? body = null, CancellationToken = default) → JsonElement |
| C++ | client.request(method, path, bulutklinik::RequestOptions{}) → nlohmann::json |
auth: "public" exists for the handful of unauthenticated endpoints outside §6
that an integration may still need — e.g. GET /general/getConfig for the
cities[].districts[] list. Prefer a typed resource method when one exists.
This is the single most important behaviour on the partner surface, and the two paths are deliberately asymmetric.
Read path (patientRef — measures.last/list/graph/update/delete,
laboratory.results/resultDetail, diets.list/detail):
- Searches only
pat_patientsrows belonging to your company. It never consults the global user table and never creates anything. identityNumber(TCKN) is the primary selector.phoneNumberis the fallback — used when no TCKN was sent, or when the TCKN missed (a patient you created without a TCKN is findable once you learn it). It is accepted only when it matches exactly one row; the column is not unique, since family members share numbers. Two matches fail closed.- When the fallback fires and the matched row has a different non-empty
identity_number, the request is rejected — that is a different person. - Not found →
501with the same generic message as "not yours" (§3.2).
Write path (bookingUser — every booking call, measures.addList/add):
- Looks the person up globally by TCKN/phone and creates a password-less shadow user if absent.
- Find-or-creates the
pat_patientsrow in your company. - This is why the descriptive fields (
name,surname,phoneNumber) are required here and absent from the read shape.
The company boundary always comes from the authenticated token, never from
request input. There is no parameter that lets a partner read another company's
data — including no companyId field to send.
reserve's hash, the url it returns, and outherProcessId are server-issued
values. The SDK passes them through verbatim: it never decodes, re-encodes,
shortens or follows them. The clinic/API encryption keys are never embedded in
the SDK.
- 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. - Fail fast on a missing token — raise before dispatching (§5.3), and auto-refresh + retry once per §5.4, concurrency-safe.
- Pluggable token store and HTTP client.
- Errors per §4 with full context.
- Tested — unit tests for envelope/error/auth/config logic + at least one live
smoke path against
test. - Examples —
examples/with the end-to-end partner flow: check doctor → slots → reserve → create, and a measures read/write 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(or/v4). - Auth: an approved portal application for a test company whose credentials carry
the
apioutherscope. Credentials are per-integration; there is no shared test credential in the Postman collection. - Smoke path that needs no patient data:
auth.connect→doctors.branches→doctors.locations→laboratory.catalog. The three reads areGETand scope-gated only, so together with the login they prove the credentials, the granted scope and the base URL are all right. auth.refreshcan be exercised directly rather than waiting ~30 days for a natural expiry.- Patient-scoped reads need a patient that exists in the token's company; a TCKN that works on the patient surface will not necessarily resolve here.
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.
Nothing in the SDK surface changes: same field, same endpoint, same flow. What changes is which secret you put in it.
Until now the value was the developer's portal account password — the same one used to sign in to the developer platform, shared by every application that developer owned. It is now a per-application password, generated server-side when the application is approved and shown on the application's page in the portal.
| 1.1.x | 1.2.0 | |
|---|---|---|
| Value | developer's portal password | per-application secret |
| Shape | whatever the developer chose | 32 chars, [A-Za-z0-9] |
| Scope of a leak | the portal account + every application | one application |
| Rotation | change the portal password (breaks every application) | regenerate in the portal (one application) |
| Changing the portal password | broke every integration | no effect |
Action: read the application password from your application's page in the
portal and put it where your integration currently keeps the portal password.
Portal passwords stop authenticating against connectApi at the cutover.
A 32-character password no longer trips the server's password-strength advisory:
password_policy.must_changeis alwaysfalsefor partner accounts. Do not treat that field as actionable on a partner login.
1.0.x removed auth on the mistaken premise that a partner token could only be
issued out of band. It cannot be minted by a client-credentials grant — there is
no oauth/token route — but it is minted by the password grant at
connectApi, using the client id/secret and service identity the portal hands
out. 1.1.0 restores auth.connect / auth.refresh / auth.disconnect and the
silent refresh + retry that goes with them.
This is additive. All 28 data methods keep their paths, bodies and signatures. What changes:
| 1.0.x | 1.1.0 |
|---|---|
partnerToken was the only way in |
still supported; or call auth.connect with clientId/clientSecret |
resultType 4 → terminal AuthenticationError |
→ silent refresh + one retry; error only if that fails |
TokenStore held one token |
unchanged; refresh-token persistence is an optional extension a store may add (§5.3) |
A 1.0.x integration that seeds partnerToken and never hits an expiry keeps
working untouched.
0.6.x shipped two personas: a patient surface at the client root and a partner
surface under client.partner.*. 1.0.0 kept only the partner one and lifted
it to the root.
| 0.6.x | 1.0.0 |
|---|---|
client.partner.doctors.* |
client.doctors.* |
client.partner.slots.* |
client.slots.* |
client.partner.appointments.* |
client.appointments.* |
client.partner.measures.* |
client.measures.* |
client.partner.laboratory.* |
client.laboratory.* |
client.partner.diets.* |
client.diets.* |
Behaviour, paths and payloads of these 28 methods are unchanged.
auth (all 11 methods) · payments (5) · skin · meals · addresses (4) ·
and the patient-persona doctors/slots/appointments/measures/laboratory/diets
that lived at the root in 0.6.x. §1.2 explains why each has no partner
equivalent. An application that needs a patient session must talk to the API
directly; the SDK no longer models it.
| 0.6.x | 1.0.0 |
|---|---|
clientId, clientSecret |
removed in 1.0.x, restored in 1.1.0 (§5) |
partnerToken (optional, for 2 endpoints) |
required credential for the whole client |
token store held accessToken + refreshToken |
holds the access token; refresh persistence is an optional store extension (§5.3) |
silent refresh + retry on 401/resultType 4 |
removed in 1.0.x, restored in 1.1.0 (§5.4) |
base URL fixed at /api/v3 |
/api/v3 or /api/v4 via apiVersion |
escape hatch auth default bearer |
default partner; bearer no longer exists |
{ "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 }