API reference
77 endpoints across 15 controllers. Swagger UI is served at /swagger in Development, with the OpenAPI document at /swagger/v1/swagger.json.
Conventions
Response envelope
Every response uses the same shape:
{ "success": true, "data": { } }
{ "success": false, "message": "Nedovoljno tokena. Vaš balans: 1,5, potrebno: 3." }
This holds for validation failures too. [ApiController] would normally return RFC 7807 ValidationProblemDetails, which is a different shape and led to the client rendering strings like NewPassword: Lozinka mora imati…. InvalidModelStateResponseFactory is overridden to return the first validation message inside the standard envelope.
Error messages are in Serbian — they are written to be shown to the user as-is.
Paged responses
Any endpoint taking page and pageSize returns:
{
"items": [],
"total": 137,
"page": 1,
"pageSize": 20,
"pages": 7
}
pageSize defaults to 20 and is clamped server-side — 50 for listing search, 100 for admin lists.
Authentication
Authorization: Bearer <access token>
SignalR hubs take the same token as ?access_token= on the query string, because a WebSocket handshake cannot send headers. See realtime.md.
| Marker | Meaning |
|---|---|
| public | no token required |
| auth | any authenticated, active account |
| Admin | Admin role required |
Every authenticated request additionally verifies that the account is still active — a deactivated or deleted account stops working immediately rather than at token expiry. See security.md.
Enums
Serialized as strings, never as integers: "Pending", not 0.
Media URLs
Image fields are stored relative and expanded to absolute URLs on serialization, using App:BaseUrl. Clients always receive a fully qualified URL.
Auth — /api/auth
| Method | Path | Access | Rate limit |
|---|---|---|---|
| POST | /api/auth/register |
public | |
| POST | /api/auth/login |
public | auth |
| GET | /api/auth/verify-email |
public | |
| POST | /api/auth/refresh |
public | |
| POST | /api/auth/logout |
public | |
| POST | /api/auth/resend-verification |
public | |
| POST | /api/auth/forgot-password |
public | |
| POST | /api/auth/reset-password |
public | auth |
Registration accepts an optional referralCode. Login is refused until the email is confirmed. Password reset uses a six-digit code delivered by email and stored hashed. Rate limits: auth is 5 requests per minute per IP, email is 3 per 15 minutes per IP.
Users — /api/users
| Method | Path | Access | Rate limit |
|---|---|---|---|
| GET | /api/users/me |
auth | |
| PUT | /api/users/me |
auth | |
| DELETE | /api/users/me |
auth | auth |
| POST | /api/users/me/avatar |
auth | |
| GET | /api/users/{id} |
public |
DELETE /me deactivates rather than deleting rows — the other side of every past booking, review and conversation still references the account. It is rate limited because it is destructive.
Provider — /api/provider
| Method | Path | Access |
|---|---|---|
| POST | /api/provider/activate |
auth |
| GET | /api/provider/me |
auth |
| PUT | /api/provider/me |
auth |
| POST | /api/provider/me/cover |
auth |
| GET | /api/provider/{id} |
public |
| GET | /api/provider/{id}/listings |
public |
Activation requires a confirmed email, and it is what triggers the second referral instalment for whoever invited this user. Public profiles are cached for 5 minutes.
Categories — /api/categories
| Method | Path | Access |
|---|---|---|
| GET | /api/categories |
public |
| POST | /api/categories |
Admin |
| PUT | /api/categories/{id} |
Admin |
| DELETE | /api/categories/{id} |
Admin |
GET returns the full tree (188 seeded categories), cached in Redis with a 6-hour TTL and explicitly invalidated on every write. Each write also publishes a pub/sub message so all instances drop their in-memory folded-name index.
Listings — /api/listings
| Method | Path | Access |
|---|---|---|
| GET | /api/listings |
public |
| GET | /api/listings/{id} |
public |
| GET | /api/listings/my |
auth |
| POST | /api/listings |
auth |
| PUT | /api/listings/{id} |
auth |
| PATCH | /api/listings/{id}/status |
auth |
| DELETE | /api/listings/{id} |
auth |
| POST | /api/listings/{id}/images |
auth |
| DELETE | /api/listings/{id}/images/{imageId} |
auth |
| POST | /api/listings/{id}/boost |
auth |
GET /api/listings is the search endpoint:
| Parameter | Type | Notes |
|---|---|---|
q |
string | free text — diacritic-, script- and typo-tolerant |
categorySlug |
string | a parent slug also matches its children |
city |
string | Serbian municipality; folded prefix match, so beograd covers Beograd — Vračar |
page |
int | default 1 |
pageSize |
int | default 20, max 50 |
The full four-tier algorithm is documented in search.md. Results are always ordered boosted-first; there is no parameter to request only boosted listings.
POST /{id}/boost takes tokensToSpend and durationDays (3, 7 or 14). Token deduction is atomic — see token-economy.md.
Bookings — /api/bookings
| Method | Path | Access |
|---|---|---|
| POST | /api/bookings |
auth |
| GET | /api/bookings/incoming |
auth |
| GET | /api/bookings/outgoing |
auth |
| PATCH | /api/bookings/{id}/confirm |
auth |
| PATCH | /api/bookings/{id}/reject |
auth |
| PATCH | /api/bookings/{id}/cancel |
auth |
| POST | /api/bookings/{id}/execute |
auth |
stateDiagram-v2
[*] --> Pending: client books
Pending --> Confirmed: provider confirms
Pending --> Rejected: provider rejects
Pending --> Cancelled: client cancels
Confirmed --> Completed: provider executes<br/>(≥ 3 days after acceptance)
Completed --> [*]
incoming is the provider's view, outgoing the client's. execute is refused until three days after AcceptedAt, and is idempotent afterwards. It pays the client a service reward token.
Reviews
| Method | Path | Access |
|---|---|---|
| POST | /api/reviews |
auth |
| GET | /api/listings/{id}/reviews |
public |
| GET | /api/provider/{id}/reviews |
public |
| GET | /api/provider/{id}/reviews/summary |
public |
One review per author per listing, enforced by a unique index. You cannot review your own listing. If bookingRequestId is supplied it must be a completed booking belonging to the author. Every new review recalculates the provider's AverageRating and TotalReviews.
Conversations — /api/conversations
| Method | Path | Access |
|---|---|---|
| GET | /api/conversations |
auth |
| POST | /api/conversations |
auth |
| GET | /api/conversations/{id}/messages |
auth |
| POST | /api/conversations/{id}/messages |
auth |
| PATCH | /api/conversations/{id}/read |
auth |
The REST message endpoints exist alongside ChatHub so a client with a dropped WebSocket can still send and read. Message bodies are stored encrypted and returned as plaintext. History older than MessageRetentionDays (14) is deleted nightly.
Discount offers — /api/discount-offers
| Method | Path | Access |
|---|---|---|
| POST | /api/discount-offers |
auth |
| GET | /api/discount-offers/incoming |
auth |
| GET | /api/discount-offers/outgoing |
auth |
| PATCH | /api/discount-offers/{id}/accept |
auth |
| PATCH | /api/discount-offers/{id}/reject |
auth |
The balance check at creation is advisory; the binding, atomic check happens on accept, since the balance can change in between.
Tokens — /api/tokens
| Method | Path | Access |
|---|---|---|
| GET | /api/tokens/balance |
auth |
| GET | /api/tokens/transactions |
auth |
The transaction ledger is paged, newest first, and every row carries balanceAfter.
Referrals — /api/referrals
| Method | Path | Access |
|---|---|---|
| GET | /api/referrals/my-code |
auth |
| GET | /api/referrals/stats |
auth |
Stats break down invitees by status: Pending, Registered (first instalment paid), Rewarded (both paid).
Favorites — /api/favorites
| Method | Path | Access |
|---|---|---|
| POST | /api/favorites/listings/{id} |
auth |
| POST | /api/favorites/providers/{id} |
auth |
| GET | /api/favorites/listings |
auth |
| GET | /api/favorites/providers |
auth |
| GET | /api/favorites/listings/{id}/status |
auth |
| GET | /api/favorites/providers/{id}/status |
auth |
POST is a toggle — it adds if absent, removes if present, and returns the resulting isFavorited. Unique constraints make concurrent double-taps safe.
Notifications — /api/notifications
| Method | Path | Access |
|---|---|---|
| GET | /api/notifications |
auth |
| PATCH | /api/notifications/{id}/read |
auth |
| PATCH | /api/notifications/read-all |
auth |
Notifications are persisted before being pushed, so an offline recipient receives them on the next GET. Twelve NotificationKind values, each with an optional referenceType/referenceId pointer the client uses to deep-link.
Locations — /api/locations
| Method | Path | Access |
|---|---|---|
| GET | /api/locations/cities |
public |
Serbian municipalities, served from a static in-code list.
Admin — /api/admin
Every endpoint requires the Admin role, applied at controller level.
| Method | Path |
|---|---|
| GET | /api/admin/users |
| PATCH | /api/admin/users/{id}/deactivate |
| POST | /api/admin/users/{id}/grant-tokens |
| GET | /api/admin/listings |
| PATCH | /api/admin/listings/{id}/archive |
| POST | /api/admin/providers/{id}/verify |
| GET | /api/admin/tokens |
| GET | /api/admin/stats |
| GET | /api/admin/analytics |
User search runs over the folded SearchName column, so milos finds Miloš and djordje finds Đorđe; email is matched directly since addresses are ASCII anyway. analytics takes a days parameter for the time series behind the dashboard charts. Granted tokens appear in the ledger as AdminGrant, like any other movement.
Health
| Path | Question it answers | Includes Redis |
|---|---|---|
/health |
may I take traffic? | no |
/health/full |
is everything healthy? | yes |
Two endpoints, deliberately. With a single one running every check, stopping Redis returned 503 even though categories and search kept working normally — Docker would mark the container unhealthy and a load balancer would pull it from rotation because of a cache. /health therefore excludes checks tagged cache. /health/full returns per-check status, tags and timing as JSON, for monitoring and manual diagnosis.
Rate limits
| Policy | Limit | Applies to |
|---|---|---|
auth |
5 requests / 60 s per IP | login, reset-password, delete account |
email |
3 requests / 900 s per IP | register, resend-verification, forgot-password |
Partitioned by IP so one attacker cannot exhaust the quota for everyone. Rejections return 429 with the standard envelope. Both limits are configuration rather than constants — production can tune them without a rebuild, and the test suite raises them (all test requests share one IP) while one dedicated test class lowers them to prove the limiter works.
Related
- Security — the auth flow behind these endpoints
- Real-time — the SignalR surface alongside this one
- Token economy — the rules behind the wallet, boost and offer endpoints