{"openapi":"3.1.0","info":{"title":"map-tracker API","version":"0.5.0","description":"Multi-company delivery API: auth and teams, company settings and billing, orders, couriers, plans, cash, realtime and public tracking. Every authenticated request is scoped to the session company."},"servers":[{"url":"http://127.0.0.1:4000"}],"security":[{"bearerAuth":[]}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"JWT"}},"schemas":{"Problem":{"type":"object","required":["type","title","status"],"properties":{"type":{"type":"string"},"title":{"type":"string"},"status":{"type":"integer"}}},"LatLon":{"type":"object","required":["lat","lon"],"properties":{"lat":{"type":"number","minimum":38.8,"maximum":41.4},"lon":{"type":"number","minimum":43.4,"maximum":46.7}}},"OrderStatus":{"type":"string","enum":["draft","ready","assigned","picked_up","arrived","delivered","failed","cancelled"]},"MembershipRole":{"type":"string","enum":["owner","dispatcher","courier"],"description":"Role inside one company. An owner can do everything a dispatcher can, plus team, settings and billing."},"CompanyPlan":{"type":"string","enum":["start","city","network"]},"Session":{"type":"object","description":"Signed-in session. Every request is scoped to `company`; switch with POST /auth/switch-company.","required":["user","company","role","memberships"],"properties":{"user":{"type":"object","required":["id","email","displayName","role","companyRole","companyId","courierId"],"properties":{"id":{"type":"string"},"email":{"type":["string","null"]},"phone":{"type":["string","null"]},"displayName":{"type":"string"},"role":{"type":"string","enum":["dispatcher","courier"],"description":"Which app the user belongs in: owners use the dispatcher console."},"companyRole":{"$ref":"#/components/schemas/MembershipRole"},"companyId":{"type":"string"},"courierId":{"type":["string","null"]}}},"company":{"type":"object","required":["id","name","city","plan","brandColor"],"properties":{"id":{"type":"string"},"name":{"type":"string"},"city":{"type":"string"},"plan":{"$ref":"#/components/schemas/CompanyPlan"},"brandColor":{"type":"string"}}},"role":{"$ref":"#/components/schemas/MembershipRole"},"memberships":{"type":"array","items":{"type":"object","required":["companyId","companyName","role","status"],"properties":{"companyId":{"type":"string"},"companyName":{"type":"string"},"role":{"$ref":"#/components/schemas/MembershipRole"},"status":{"type":"string","enum":["active","disabled"]}}}},"accessToken":{"type":"string","description":"Present on sign-in responses, not on GET /me."},"refreshToken":{"type":"string"},"tokenType":{"type":"string","enum":["Bearer"]},"expiresIn":{"type":"integer"}}},"Company":{"type":"object","required":["id","name","city","plan","brandColor","logoUrl","showCourierPhone"],"properties":{"id":{"type":"string"},"name":{"type":"string"},"city":{"type":"string"},"phone":{"type":["string","null"]},"plan":{"$ref":"#/components/schemas/CompanyPlan"},"trialEndsAt":{"type":["string","null"],"format":"date-time"},"brandColor":{"type":"string","pattern":"^#[0-9A-Fa-f]{6}$"},"logoUrl":{"type":["string","null"],"description":"External https logo, or a short-lived link to the uploaded one."},"hasUploadedLogo":{"type":"boolean"},"smsSender":{"type":["string","null"]},"smsTemplate":{"type":["string","null"],"description":"Late-delivery text with {company}, {minutes} and {link}."},"supportPhone":{"type":["string","null"]},"showCourierPhone":{"type":"boolean","description":"Show the courier phone on the tracking page while the parcel is on its way."},"createdAt":{"type":"string","format":"date-time"}}},"Depot":{"type":"object","required":["id","name","address","point","isDefault"],"properties":{"id":{"type":"string"},"name":{"type":"string"},"address":{"type":"string"},"point":{"$ref":"#/components/schemas/LatLon"},"isDefault":{"type":"boolean"},"createdAt":{"type":"string","format":"date-time"}}},"Billing":{"type":"object","required":["plan","priceAmd","limits","usage","trialEndsAt","trialActive","invoices","plans"],"properties":{"plan":{"$ref":"#/components/schemas/CompanyPlan"},"priceAmd":{"type":["integer","null"],"description":"Monthly price; null when priced on request."},"limits":{"type":"object","properties":{"deliveriesPerMonth":{"type":["integer","null"]},"couriers":{"type":["integer","null"]}},"description":"null means unlimited. Not enforced yet."},"usage":{"type":"object","properties":{"deliveriesThisMonth":{"type":"integer"},"activeCouriers":{"type":"integer"},"periodStart":{"type":"string","format":"date-time"}}},"trialEndsAt":{"type":["string","null"],"format":"date-time"},"trialActive":{"type":"boolean"},"invoices":{"type":"array","maxItems":0,"description":"Always empty until a payment provider is connected."},"plans":{"type":"array","items":{"type":"object","properties":{"plan":{"$ref":"#/components/schemas/CompanyPlan"},"deliveriesPerMonth":{"type":["integer","null"]},"couriers":{"type":["integer","null"]},"priceAmd":{"type":["integer","null"]}}}}}},"Member":{"type":"object","required":["userId","name","role","status"],"properties":{"userId":{"type":"string"},"name":{"type":"string"},"email":{"type":["string","null"]},"phone":{"type":["string","null"]},"role":{"$ref":"#/components/schemas/MembershipRole"},"status":{"type":"string","enum":["active","disabled"]},"courierId":{"type":["string","null"]},"vehicle":{"type":["string","null"]},"joinedAt":{"type":"string","format":"date-time"}}},"Invite":{"type":"object","required":["id","role","name","expiresAt"],"properties":{"id":{"type":"string"},"role":{"$ref":"#/components/schemas/MembershipRole"},"name":{"type":"string"},"phone":{"type":["string","null"]},"email":{"type":["string","null"]},"vehicle":{"type":["string","null"]},"invitedBy":{"type":["object","null"],"properties":{"userId":{"type":"string"},"name":{"type":["string","null"]}}},"createdAt":{"type":"string","format":"date-time"},"expiresAt":{"type":"string","format":"date-time"}}},"Eta":{"type":"object","required":["courierId","origin","method","computedAt","stops"],"properties":{"courierId":{"type":"string"},"origin":{"type":["object","null"],"properties":{"lat":{"type":"number"},"lon":{"type":"number"},"source":{"type":"string","enum":["gps","depot"]},"capturedAt":{"type":["string","null"],"format":"date-time"}},"description":"Last GPS fix, else the company depot; null when there is neither."},"method":{"type":"string","enum":["osrm","straight_line"],"description":"Road routing through OSRM, or a straight-line estimate when routing is unavailable."},"computedAt":{"type":"string","format":"date-time"},"stops":{"type":"array","items":{"type":"object","required":["orderId","etaSeconds","eta"],"properties":{"orderId":{"type":"string"},"publicId":{"type":"string"},"sequence":{"type":["integer","null"]},"status":{"$ref":"#/components/schemas/OrderStatus"},"etaSeconds":{"type":["integer","null"]},"eta":{"type":["string","null"],"format":"date-time"}}}}}},"DraftPlan":{"type":"object","required":["routes"],"properties":{"routes":{"type":"array","minItems":1,"maxItems":20,"items":{"type":"object","required":["courierId","stops"],"properties":{"courierId":{"type":"string"},"stops":{"type":"array","maxItems":200,"items":{"type":"object","required":["orderId"],"properties":{"orderId":{"type":"string"}}}}}}}}},"TimedPlan":{"type":"object","required":["routes","method"],"properties":{"routes":{"type":"array","items":{"type":"object","required":["courierId","start","stops","durationSeconds"],"properties":{"courierId":{"type":"string"},"start":{"type":"object","properties":{"lat":{"type":"number"},"lon":{"type":"number"},"source":{"type":"string","enum":["gps","depot","city_centre"]}}},"stops":{"type":"array","items":{"type":"object","properties":{"orderId":{"type":"string"},"etaSeconds":{"type":["integer","null"]}}}},"durationSeconds":{"type":"integer"}}}},"method":{"type":"string","enum":["osrm","straight_line"]}}}}},"paths":{"/healthz":{"get":{"security":[],"summary":"Gateway readiness for OSRM, VROOM, Postgres and tiles.","responses":{"200":{"description":"All upstreams healthy"},"503":{"description":"One or more upstreams degraded"}}}},"/auth/login":{"post":{"security":[],"summary":"Password sign-in (kept for the demo accounts). Lands in the company used last.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["email","password"],"properties":{"email":{"type":"string","format":"email"},"password":{"type":"string","minLength":8}}}}}},"responses":{"200":{"description":"Session with tokens","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Session"}}}},"401":{"description":"Invalid credentials"},"403":{"description":"Account has no company"}}}},"/auth/refresh":{"post":{"security":[],"summary":"Rotate a refresh token and issue a new access token.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["refreshToken"],"properties":{"refreshToken":{"type":"string"}}}}}},"responses":{"200":{"description":"Rotated tokens"},"401":{"description":"Invalid refresh token"}}}},"/auth/logout":{"post":{"summary":"Revoke the active refresh token.","responses":{"204":{"description":"Logged out"},"401":{"description":"Authentication required"}}}},"/auth/me":{"get":{"summary":"Same as GET /me (kept for older clients).","responses":{"200":{"description":"Current user"},"401":{"description":"Authentication required"}}}},"/orders":{"get":{"summary":"List orders, newest first. Dispatcher only. Without `date`: the latest 200 (or `limit`). With `date`: one Yerevan working day, meaning orders created, due or finished that day plus open work carried over from earlier days (default limit 1000). Each order carries lastAttempt.courierId/courierName/collectedAmountAmd once a courier delivered or failed it.","parameters":[{"name":"date","in":"query","required":false,"schema":{"type":"string","format":"date"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":2000}}],"responses":{"200":{"description":"{ orders, hasMore }: hasMore is true when the page was cut off at the limit"},"400":{"description":"Invalid date or limit"},"403":{"description":"Forbidden"}}},"post":{"summary":"Create a pin-first draft order. Dispatcher only.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["recipientName","recipientPhone","addressLabelHy","pin"],"properties":{"recipientName":{"type":"string"},"recipientPhone":{"type":"string"},"amountAmd":{"type":"integer","minimum":0,"default":0},"otpRequired":{"type":"boolean","default":false,"description":"Require a server-generated delivery code from the recipient (shown on the tracking page) before the courier can complete the delivery."},"isCod":{"type":"boolean","default":false,"description":"Cash on delivery. When true and codAmountAmd is omitted, it defaults to amountAmd."},"codAmountAmd":{"type":"integer","minimum":0},"weightKg":{"type":"number","minimum":0},"deliveryWindowStart":{"type":"string","format":"date-time"},"deliveryWindowEnd":{"type":"string","format":"date-time"},"addressLabelHy":{"type":"string"},"addressLabelEn":{"type":"string"},"addressLabelRu":{"type":"string"},"pin":{"$ref":"#/components/schemas/LatLon"}}}}}},"responses":{"201":{"description":"Created draft order"},"403":{"description":"Forbidden"},"422":{"description":"Coordinate bounds violation"}}}},"/orders/{id}":{"get":{"summary":"Fetch an order. Dispatcher only.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Order detail"},"404":{"description":"Order not found"}}},"patch":{"summary":"Edit draft or ready order fields (including weightKg and the delivery window). Dispatcher only.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Updated order"},"409":{"description":"Order no longer editable"}}}},"/orders/{id}/events":{"get":{"summary":"Fetch immutable order event history. Dispatcher only.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Order audit trail"},"403":{"description":"Forbidden"},"404":{"description":"Order not found"}}}},"/orders/{id}/proof/photo-url":{"get":{"summary":"Short-lived presigned URL for the latest proof-of-delivery photo (CA-4.6). Dispatcher only; the bucket is never public.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Presigned download URL"},"403":{"description":"Forbidden"},"404":{"description":"No proof photo on file"},"503":{"description":"Photo storage unavailable"}}}},"/orders/{id}/tracking":{"post":{"summary":"Issue a public tracking link for an order (dispatcher). Revokes the previous link; the token is returned once.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"ttlHours":{"type":"integer","minimum":1,"maximum":720,"default":72}}}}}},"responses":{"201":{"description":"token, path (/t/<token>) and expiresAt"},"404":{"description":"Order not found"}}}},"/track/{token}":{"get":{"security":[],"summary":"Public, unauthenticated recipient tracking, branded with the delivering company. Privacy-minimised: no recipient identity or address while the parcel is moving (the delivered receipt repeats the address to the recipient); courier position is coarsened to about 110 m and only present while the parcel is on its way. Unknown, expired and revoked tokens are an identical 404.","parameters":[{"name":"token","in":"path","required":true,"schema":{"type":"string","minLength":20,"maxLength":200}}],"responses":{"200":{"description":"Tracking state","content":{"application/json":{"schema":{"type":"object","required":["company","state","stage","timeline","proof","receipt","rating","failureReason","stopsAhead","otpCode","courierName","courierLine","courierPhone","courier","courierUpdatedAt","destination","etaSeconds","route","expiresAt"],"properties":{"company":{"type":"object","required":["name","brandColor","logoUrl","supportPhone"],"description":"The delivering company, so the page wears its brand.","properties":{"name":{"type":"string"},"brandColor":{"type":"string"},"logoUrl":{"type":["string","null"]},"supportPhone":{"type":["string","null"]}}},"proof":{"type":["object","null"],"description":"Only once delivered.","properties":{"photoUrl":{"type":["string","null"],"description":"Short-lived link to the delivery photo."},"receivedBy":{"type":["string","null"],"description":"First name only."},"method":{"type":"string","enum":["code","photo","name"]},"deliveredAt":{"type":"string","format":"date-time"}}},"receipt":{"type":["object","null"],"description":"Only once delivered.","properties":{"orderNumber":{"type":"string","description":"The order public id"},"address":{"type":"object","properties":{"hy":{"type":"string"},"en":{"type":["string","null"]},"ru":{"type":["string","null"]}}},"payment":{"type":"object","properties":{"method":{"type":"string","enum":["cash","prepaid"]},"amountAmd":{"type":"integer"}}}}},"rating":{"type":["object","null"],"properties":{"ok":{"type":"boolean"},"createdAt":{"type":"string","format":"date-time"}}},"courierPhone":{"type":["string","null"],"description":"Only while on the way, and only when the company enabled showCourierPhone."},"state":{"type":"string","enum":["preparing","on_the_way","delivered","failed","cancelled"],"description":"Coarse recipient-facing state. An arrived courier is still on_the_way."},"stage":{"type":"string","enum":["created","assigned","picked_up","arrived","delivered","failed","cancelled"],"description":"Fine-grained progress for the status timeline."},"timeline":{"type":"array","description":"Stages reached by the current attempt, oldest first.","items":{"type":"object","required":["stage","at"],"properties":{"stage":{"type":"string","enum":["created","assigned","picked_up","arrived","delivered","failed","cancelled"]},"at":{"type":"string","format":"date-time"}}}},"failureReason":{"type":["string","null"],"enum":["recipient_absent","refused","wrong_address","other",null],"description":"Only when state is failed."},"stopsAhead":{"type":["integer","null"],"minimum":0,"description":"How many of the same courier's stops come before this one. A bare count; null when the run has no planned order."},"otpCode":{"type":["string","null"],"description":"Delivery code, only while the parcel is on its way and the order requires one."},"courierName":{"type":["string","null"],"description":"First name only."},"courierLine":{"type":["integer","null"],"minimum":0,"maximum":7,"description":"Index of the courier's line colour; not the courier id."},"courier":{"oneOf":[{"$ref":"#/components/schemas/LatLon"},{"type":"null"}]},"courierUpdatedAt":{"type":["string","null"],"format":"date-time"},"destination":{"$ref":"#/components/schemas/LatLon"},"etaSeconds":{"type":["integer","null"]},"route":{"type":["string","null"],"description":"OSRM polyline6 from the coarsened courier point to the delivery pin."},"expiresAt":{"type":"string","format":"date-time"}}}}}},"404":{"description":"Tracking link is not available (unknown, expired or revoked)"},"429":{"description":"Rate limited"}}}},"/orders/{id}/status":{"post":{"summary":"Move an order through the explicit lifecycle.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["status"],"properties":{"status":{"$ref":"#/components/schemas/OrderStatus"},"reason":{"type":"string"}}}}}},"responses":{"200":{"description":"Transition accepted"},"409":{"description":"Invalid transition"}}}},"/orders/{id}/assign":{"post":{"summary":"Assign a ready order, or reassign an already-assigned order, to an active courier. Reassignment cancels the current active assignment and emits assignment.cancelled + assignment.created plus unassigned/assigned audit events.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["courierId"],"properties":{"courierId":{"type":"integer"}}}}}},"responses":{"201":{"description":"Assignment created or reassigned"},"404":{"description":"Order or courier not found"},"409":{"description":"Order not ready/assigned, or already assigned to this courier"}}}},"/plans/apply":{"post":{"summary":"Apply a reviewed suggested plan as one atomic mutation: assigns/resequences every stop or changes nothing. Rejects a stale plan with 409 rather than overwriting work that moved on. Dispatcher only.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["routes"],"properties":{"routes":{"type":"array","minItems":1,"maxItems":20,"items":{"type":"object","required":["courierId","stops"],"properties":{"courierId":{"type":"integer"},"stops":{"type":"array","minItems":1,"maxItems":200,"items":{"type":"object","required":["orderId","sequence"],"properties":{"orderId":{"type":"integer"},"sequence":{"type":"integer","minimum":1}}}}}}}}}}}},"responses":{"200":{"description":"Plan applied; every stop assigned in the accepted sequence"},"404":{"description":"Plan references unknown orders or an inactive courier"},"409":{"description":"Plan is stale — some orders are no longer assignable"},"422":{"description":"Duplicate order in the plan, or stop sequence is not 1..n"}}}},"/couriers":{"get":{"summary":"List couriers with latest locations, the open shift, the last finished shift (lastShift: startedAt, endedAt, endedByName when a dispatcher closed it) and outstanding cash. Dispatcher only.","responses":{"200":{"description":"Courier list"},"403":{"description":"Forbidden"}}},"post":{"summary":"Create a courier login profile. Dispatcher only.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["email","password","displayName"],"properties":{"email":{"type":"string","format":"email"},"password":{"type":"string","minLength":8},"displayName":{"type":"string"},"phone":{"type":"string"},"vehicleLabel":{"type":"string"}}}}}},"responses":{"201":{"description":"Courier created"},"403":{"description":"Forbidden"}}}},"/couriers/{id}":{"get":{"summary":"Fetch courier profile and latest location. Dispatcher only.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Courier detail"},"404":{"description":"Courier not found"}}},"patch":{"summary":"Update courier metadata or active state. Dispatcher only.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Courier updated"},"404":{"description":"Courier not found"}}}},"/courier/location":{"post":{"summary":"Submit up to 50 timestamped GPS points for the authenticated courier. Idempotent: points are deduplicated by (courier, capturedAt), so an offline queue may safely resend a batch.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["points"],"properties":{"points":{"type":"array","minItems":1,"maxItems":50,"items":{"type":"object","required":["lat","lon","capturedAt"],"properties":{"lat":{"type":"number","minimum":38.8,"maximum":41.4},"lon":{"type":"number","minimum":43.4,"maximum":46.7},"capturedAt":{"type":"string","format":"date-time"},"accuracyM":{"type":"integer","minimum":0},"source":{"type":"string"}}}}}}}}},"responses":{"202":{"description":"Points accepted; current stays at newest capturedAt"},"422":{"description":"Bounds or batch limit violation"}}}},"/courier/me":{"get":{"summary":"Authenticated courier's own profile, current shift and latest location. Courier only. (Milestone Courier App v1, CA-2)","responses":{"200":{"description":"Courier profile"},"403":{"description":"Forbidden"}}},"patch":{"summary":"Courier edits own profile: displayName, phone, vehicleLabel, photoUrl, preferredLanguage (hy/ru/en), onboardingCompleted.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"displayName":{"type":"string"},"phone":{"type":"string","nullable":true},"vehicleLabel":{"type":"string","nullable":true},"photoUrl":{"type":"string","format":"uri","nullable":true},"preferredLanguage":{"type":"string","enum":["hy","ru","en"]},"onboardingCompleted":{"type":"boolean"}}}}}},"responses":{"200":{"description":"Updated profile"},"400":{"description":"No changes or invalid language"},"403":{"description":"Forbidden"}}}},"/courier/shift/start":{"post":{"summary":"Open a shift for the authenticated courier (goes online). One open shift per courier. Courier only. (CA-3)","responses":{"201":{"description":"Shift opened"},"403":{"description":"Forbidden"},"409":{"description":"A shift is already open"}}}},"/courier/shift/break":{"post":{"summary":"Toggle break on the open shift (active ↔ on_break). Courier only.","responses":{"200":{"description":"Break toggled"},"403":{"description":"Forbidden"},"409":{"description":"No open shift"}}}},"/courier/shift/end":{"post":{"summary":"End the open shift (goes offline). Courier only.","responses":{"200":{"description":"Shift ended"},"403":{"description":"Forbidden"},"409":{"description":"No open shift"}}}},"/courier/orders":{"get":{"summary":"List the authenticated courier's active assigned work (orders in assigned, picked_up or arrived).","responses":{"200":{"description":"Assigned work list"},"403":{"description":"Forbidden"}}}},"/courier/orders/{id}/status":{"post":{"summary":"Courier-driven transition: assigned → picked_up → (arrived) → delivered, or → failed with a reason. Proof (recipientName, photoKey, notes) is recorded on delivered/failed. When the order requires a delivery code, delivered must carry otpCode; it is verified server-side (422 wrong/missing, 423 locked after repeated misses) and otpVerified from the client is ignored. Pass idempotencyKey so a retried offline flush replays safely instead of failing with 409.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["status"],"properties":{"status":{"type":"string","enum":["picked_up","arrived","delivered","failed"]},"failureReason":{"type":"string","enum":["recipient_absent","refused","wrong_address","other"],"description":"Required when status is failed."},"recipientName":{"type":"string","minLength":1,"maxLength":200},"photoKey":{"type":"string","minLength":1,"maxLength":500},"otpVerified":{"type":"boolean","deprecated":true,"description":"Ignored. Verification is decided by the server from otpCode."},"otpCode":{"type":"string","minLength":1,"maxLength":32,"description":"Delivery code read out by the recipient. Required on delivered when the order has otpRequired."},"collectedAmountAmd":{"type":"integer","minimum":0,"description":"Cash collected on a COD delivery (CA-5)."},"notes":{"type":"string","maxLength":1000},"idempotencyKey":{"type":"string","minLength":8,"maxLength":128}}}}}},"responses":{"200":{"description":"Transition applied, or replayed:true when the idempotency key was already settled"},"404":{"description":"Assigned order not found"},"409":{"description":"Illegal transition for the current status"},"422":{"description":"Delivery code missing or wrong"},"423":{"description":"Delivery code attempts exhausted"}}}},"/courier/orders/{id}/otp/verify":{"post":{"summary":"Check the recipient delivery code while online. Shares the attempt budget with the delivered transition.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["code"],"properties":{"code":{"type":"string"}}}}}},"responses":{"200":{"description":"{ required, valid }"},"404":{"description":"Assigned order not found"},"422":{"description":"Wrong or missing code"},"423":{"description":"Attempts exhausted"}}}},"/courier/orders/{id}/proof/upload-url":{"post":{"summary":"Presigned PUT the courier app uploads the proof photo to directly (CA-4.2). Returns { key, url, expiresIn }; pass the key back as photoKey on the delivered/failed status.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["contentType"],"properties":{"contentType":{"type":"string","enum":["image/jpeg","image/png","image/webp"]}}}}}},"responses":{"200":{"description":"Presigned upload URL"},"404":{"description":"Assigned order not found"},"415":{"description":"Unsupported photo type"},"503":{"description":"Photo storage unavailable"}}}},"/courier/cash":{"get":{"summary":"Courier cash-on-delivery balance (CA-5): outstanding unsettled amount, any open settlement, and recent collections.","responses":{"200":{"description":"Cash balance and collections"},"403":{"description":"Forbidden"}}}},"/courier/cash/settle":{"post":{"summary":"Courier submits their outstanding cash for reconciliation. 400 when nothing is outstanding, 409 when a settlement is already awaiting confirmation.","responses":{"201":{"description":"Settlement submitted"},"400":{"description":"Nothing to settle"},"409":{"description":"Settlement already awaiting confirmation"}}}},"/cash/settlements":{"get":{"summary":"Dispatcher: list cash settlements (default status=submitted). Each carries expectedAmd (what to count), receivedAmd and shortfallAmd once confirmed, acceptedByName, and orders: the collections it covers (orderId, publicId, amountAmd, collectedAt).","parameters":[{"name":"status","in":"query","required":false,"schema":{"type":"string","enum":["submitted","confirmed"]}}],"responses":{"200":{"description":"Settlements"},"403":{"description":"Forbidden"}}}},"/cash/settlements/{id}/confirm":{"post":{"summary":"Dispatcher confirms cash handed over; settles the collections outstanding at submit time. Accepting less records the shortfall (a note is required), which stays on the courier balance until a later handover covers it.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"receivedAmd":{"type":"integer","minimum":0,"description":"Defaults to the submitted amount."},"note":{"type":"string"}}}}}},"responses":{"200":{"description":"{ settlement (with receivedAmd, shortfallAmd), courierOutstandingAmd }"},"404":{"description":"Settlement not found"},"409":{"description":"Not awaiting confirmation"},"422":{"description":"More than submitted, or short without a note"}}}},"/courier/earnings":{"get":{"summary":"Courier earnings (CA-7, per-delivery pay): today / this week / all-time delivered counts and amounts (Asia/Yerevan day/week).","responses":{"200":{"description":"Earnings summary"},"403":{"description":"Forbidden"}}}},"/couriers/{id}/earnings":{"get":{"summary":"Dispatcher view of one courier's earnings (same figures as /courier/earnings).","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Earnings summary"},"404":{"description":"Courier not found"}}}},"/courier/history":{"get":{"summary":"Courier delivery history: every attempt (delivered or failed), newest first.","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":200}}],"responses":{"200":{"description":"Delivery attempt history"},"403":{"description":"Forbidden"}}}},"/courier/location/current":{"get":{"summary":"Return current location for authenticated courier.","responses":{"200":{"description":"Current courier location"}}}},"/events/latest-cursor":{"get":{"summary":"Latest realtime event id, used to seed a new dispatcher session so it does not replay history.","responses":{"200":{"description":"{ cursor: number }"},"403":{"description":"Forbidden"}}}},"/events":{"get":{"summary":"Dispatcher SSE stream with cursor catch-up.","parameters":[{"name":"cursor","in":"query","required":false,"schema":{"type":"integer","minimum":0}}],"responses":{"200":{"description":"text/event-stream of typed realtime events"},"403":{"description":"Forbidden"}}}},"/me":{"get":{"summary":"The signed-in user, the active company and every company the user belongs to.","responses":{"200":{"description":"Session","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Session"}}}},"401":{"description":"Authentication required"}}}},"/auth/switch-company":{"post":{"summary":"Issue a session for another company the user belongs to. Pass the current refreshToken to revoke it.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["companyId"],"properties":{"companyId":{"type":"string"},"refreshToken":{"type":"string"}}}}}},"responses":{"200":{"description":"Session with new tokens","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Session"}}}},"403":{"description":"Not an active member of that company"}}}},"/auth/signup":{"post":{"security":[],"summary":"Register a company with its owner and first depot. No session is returned: the owner signs in through the emailed link.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["company","owner","depot"],"properties":{"company":{"type":"object","required":["name","phone"],"properties":{"name":{"type":"string","minLength":2,"maxLength":120},"city":{"type":"string","default":"Yerevan"},"phone":{"type":"string"}}},"owner":{"type":"object","required":["name","email"],"properties":{"name":{"type":"string"},"email":{"type":"string","format":"email"}}},"depot":{"type":"object","required":["name","address","point"],"properties":{"name":{"type":"string"},"address":{"type":"string"},"point":{"$ref":"#/components/schemas/LatLon"}}}}}}}},"responses":{"202":{"description":"{ company: { id, name }, emailSent }"},"422":{"description":"Depot outside Armenia"},"429":{"description":"Rate limited"}}}},"/auth/magic-link":{"post":{"security":[],"summary":"Email a one-time sign-in link (15 minutes). Always 202, whether or not the address has an account.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["email"],"properties":{"email":{"type":"string","format":"email"}}}}}},"responses":{"202":{"description":"{ sent: true }"},"429":{"description":"Rate limited"}}}},"/auth/magic-link/verify":{"post":{"security":[],"summary":"Exchange a sign-in link token for a session. Each link works once.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["token"],"properties":{"token":{"type":"string"}}}}}},"responses":{"200":{"description":"Session with tokens","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Session"}}}},"401":{"description":"Invalid, used or expired link"},"403":{"description":"Account has no company"}}}},"/auth/invite/{token}":{"get":{"security":[{}],"summary":"Preview an invite or sign-in link before accepting it: company branding, role, who invited, expiry. Public and rate-limited.","parameters":[{"name":"token","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ kind: invite|sign_in, company: { name, brandColor, logoUrl }, role, name, invitedBy, expiresAt }"},"404":{"description":"No such link"},"410":{"description":"code=used or code=expired"}}}},"/auth/invite/accept":{"post":{"security":[{},{"bearerAuth":[]}],"summary":"Join a company through an invite link. Without a session a new account is created (couriers also get a courier profile); with a Bearer token the signed-in account is attached. A sign-in link (kind=sign_in) signs in the member it was issued for.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["token"],"properties":{"token":{"type":"string"}}}}}},"responses":{"200":{"description":"Session with tokens in the inviting company","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Session"}}}},"404":{"description":"Invite invalid, used or expired"},"409":{"description":"code=sign_in_required (the invite email has an account: sign in first) or code=already_member"}}}},"/company":{"get":{"summary":"The active company profile. Dispatcher or owner.","responses":{"200":{"description":"{ company }","content":{"application/json":{"schema":{"type":"object","properties":{"company":{"$ref":"#/components/schemas/Company"}}}}}}}},"patch":{"summary":"Edit the company profile, branding and tracking settings. Owner only. Setting logoUrl clears an uploaded logo and the other way round.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"city":{"type":"string"},"phone":{"type":["string","null"]},"brandColor":{"type":"string","pattern":"^#[0-9A-Fa-f]{6}$"},"logoUrl":{"type":["string","null"],"format":"uri","description":"https only"},"logoKey":{"type":["string","null"],"description":"Key from POST /company/logo/upload-url"},"smsSender":{"type":["string","null"],"maxLength":11},"smsTemplate":{"type":["string","null"],"maxLength":480},"supportPhone":{"type":["string","null"]},"showCourierPhone":{"type":"boolean"}}}}}},"responses":{"200":{"description":"{ company }"},"403":{"description":"Owner only"},"422":{"description":"Logo key not uploaded for this company"}}}},"/company/logo/upload-url":{"post":{"summary":"Presigned PUT for the company logo (jpeg, png or webp). Save the returned key with PATCH /company { logoKey }. Owner only.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["contentType"],"properties":{"contentType":{"type":"string"}}}}}},"responses":{"200":{"description":"{ key, url, expiresIn }"},"415":{"description":"Unsupported image type"}}}},"/company/depots":{"get":{"summary":"Depots, default first. Couriers without GPS start their run at the default depot.","responses":{"200":{"description":"{ depots: Depot[] }"}}},"post":{"summary":"Add a depot. The first one, or one created with isDefault, becomes the default. Owner only.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name","address","point"],"properties":{"name":{"type":"string"},"address":{"type":"string"},"point":{"$ref":"#/components/schemas/LatLon"},"isDefault":{"type":"boolean"}}}}}},"responses":{"201":{"description":"{ depot }"},"422":{"description":"Outside Armenia"}}}},"/company/depots/{id}":{"patch":{"summary":"Edit a depot or make it the default. Owner only.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"address":{"type":"string"},"point":{"$ref":"#/components/schemas/LatLon"},"isDefault":{"const":true}}}}}},"responses":{"200":{"description":"{ depot }"},"404":{"description":"Depot not found"}}},"delete":{"summary":"Remove a depot; removing the default promotes the oldest remaining one. Owner only.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"204":{"description":"Removed"},"404":{"description":"Depot not found"}}}},"/company/billing":{"get":{"summary":"Plan, placeholder limits (start 500 deliveries / 5 couriers / 15000 AMD; city 3000 / 25 / 39000 AMD; network unlimited), this month usage and the trial. Owner only.","responses":{"200":{"description":"{ billing }","content":{"application/json":{"schema":{"type":"object","properties":{"billing":{"$ref":"#/components/schemas/Billing"}}}}}},"403":{"description":"Owner only"}}}},"/company/plan":{"patch":{"summary":"Change the plan. No payment is taken yet. Owner only.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["plan"],"properties":{"plan":{"$ref":"#/components/schemas/CompanyPlan"}}}}}},"responses":{"200":{"description":"{ billing }"}}}},"/company/members":{"get":{"summary":"Team members (owners, dispatchers, couriers) and pending invites.","responses":{"200":{"description":"{ members: Member[], invites: Invite[] }"}}}},"/company/members/{userId}/login-link":{"post":{"summary":"A new one-time sign-in link (24 h) for a member with no password and no confirmed email, such as a courier who joined with a phone only. Dispatchers for couriers, owners for couriers and dispatchers, never for owners. Replaces older unused links for the member.","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"201":{"description":"{ link, expiresAt }; the link opens /invite/{token}"},"403":{"description":"Target is an owner, or a dispatcher and the caller is not an owner"},"404":{"description":"Not a member of this company"},"409":{"description":"code=has_sign_in (use email or password), code=other_company or code=disabled"}}}},"/company/members/{userId}":{"patch":{"summary":"Change a member role or switch access off and on. Owners change anything; a dispatcher may only toggle a courier. A company always keeps one active owner. Ends the member sessions on a role change or disable.","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"integer"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"role":{"$ref":"#/components/schemas/MembershipRole"},"status":{"type":"string","enum":["active","disabled"]}}}}}},"responses":{"200":{"description":"{ member }"},"403":{"description":"Not allowed for this role"},"404":{"description":"Member not found"},"409":{"description":"Would leave no active owner"}}}},"/company/invites":{"post":{"summary":"Invite someone by a shareable https link (7 days); emailed too when an email is given. Dispatchers may invite couriers only.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["role","name"],"properties":{"role":{"$ref":"#/components/schemas/MembershipRole"},"name":{"type":"string"},"phone":{"type":"string"},"email":{"type":"string","format":"email"},"vehicle":{"type":"string"}}}}}},"responses":{"201":{"description":"{ invite: Invite, link, emailed }. The link is shown once."},"403":{"description":"Only an owner can invite dispatchers and owners"}}}},"/company/invites/{id}":{"delete":{"summary":"Revoke a pending invite. Dispatchers may revoke courier invites only.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"204":{"description":"Revoked"},"404":{"description":"Invite not found"}}}},"/orders/{id}/notify-late":{"post":{"summary":"Text for telling the recipient the delivery runs late, with a fresh tracking link (the previous link is revoked). Records a late_notified event. Nothing is sent: there is no SMS provider yet.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"delayMinutes":{"type":"integer","minimum":1,"maximum":600},"language":{"type":"string","enum":["hy","ru","en"],"default":"hy"},"ttlHours":{"type":"integer","minimum":1,"maximum":720}}}}}},"responses":{"201":{"description":"{ message, recipientPhone, trackingLink: { url, path, token, expiresAt } }"},"404":{"description":"Order not found"},"409":{"description":"Order is not on its way"}}}},"/track/{token}/rating":{"post":{"security":[],"summary":"The recipient rates a delivered order: one rating per order.","parameters":[{"name":"token","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["ok"],"properties":{"ok":{"type":"boolean"},"comment":{"type":"string","maxLength":1000}}}}}},"responses":{"201":{"description":"{ rating: { ok, comment, createdAt } }"},"404":{"description":"Tracking link is not available"},"409":{"description":"Not delivered yet, or already rated"},"429":{"description":"Rate limited"}}}},"/couriers/{id}/shift/end":{"post":{"summary":"The dispatcher ends a courier shift (for example when the courier forgot to). Records who ended it.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"{ courier }"},"404":{"description":"Courier not found"},"409":{"description":"No open shift"}}}},"/couriers/{id}/eta":{"get":{"summary":"Arrival estimate for each remaining stop of a courier, in run order, from the last position (else the depot) through OSRM, with a straight-line fallback.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"ETA","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Eta"}}}},"404":{"description":"Courier not found"}}}},"/courier/eta":{"get":{"summary":"The signed-in courier own per-stop arrival estimates.","responses":{"200":{"description":"ETA","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Eta"}}}}}}},"/plans/suggest":{"post":{"summary":"Solver suggestion over the company ready orders and active couriers (or the given ones). Couriers without GPS start at the company depot. Nothing is written.","requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"orderIds":{"type":"array","items":{"type":"integer"}},"courierIds":{"type":"array","items":{"type":"integer"}},"capacity":{"type":"integer","default":10},"serviceMinutes":{"type":"number","default":5},"shiftHours":{"type":"number","default":8}}}}}},"responses":{"200":{"description":"{ suggested: true, routes: [{ courierId, start, stops: [{ orderId, etaSeconds }] }], unassigned: string[] }"},"404":{"description":"Unknown order or courier"},"422":{"description":"Nothing to plan"},"502":{"description":"Solver failed"}}}},"/plans/recompute":{"post":{"summary":"Re-time a draft plan along the dispatcher sequence. Nothing is written.","requestBody":{"required":true,"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/DraftPlan"},{"type":"object","properties":{"serviceMinutes":{"type":"number"}}}]}}}},"responses":{"200":{"description":"Timed plan","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TimedPlan"}}}},"404":{"description":"Unknown order or courier"},"422":{"description":"Duplicate order"}}}},"/plans/move-stop":{"post":{"summary":"Move one stop of a draft plan to another courier (or position) and re-time every lane. Nothing is written until /plans/apply.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["plan","orderId","toCourierId"],"properties":{"plan":{"$ref":"#/components/schemas/DraftPlan"},"orderId":{"type":"string"},"toCourierId":{"type":"string"},"toIndex":{"type":"integer","minimum":0,"description":"Position in the target lane; last when omitted."},"serviceMinutes":{"type":"number"}}}}}},"responses":{"200":{"description":"Timed plan","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TimedPlan"}}}},"404":{"description":"Unknown order or courier"},"422":{"description":"Order not in the plan"}}}},"/geo/route":{"post":{"summary":"Validated OSRM route proxy. Requires a dispatcher or courier token.","responses":{"200":{"description":"Route result"}}}},"/geo/matrix":{"post":{"summary":"Validated OSRM matrix proxy. Requires a dispatcher or courier token.","responses":{"200":{"description":"Matrix result"}}}},"/geo/optimize":{"post":{"summary":"Validated VROOM optimization proxy. Requires a dispatcher or courier token.","responses":{"200":{"description":"Suggested optimization plan"}}}}}}