openapi: 3.1.0 info: title: Halvway REST API version: "1.0.0" description: | Halvway finds the fairest, cheapest city for two friends to meet in the middle. The web app and native clients share this API; row-level security applies as the authenticated user and is never bypassed. Auth: obtain a session from `/auth/login` (or the Supabase SDK) and send `Authorization: Bearer ` on endpoints marked with `bearerAuth`. All endpoints are CORS-enabled and answer `OPTIONS` preflights. servers: - url: https://halvway.lovable.app/api/public/v1 description: Production - url: https://project--42d9840c-12e3-4489-8ca3-a46674cc8dbf.lovable.app/api/public/v1 description: Stable production alias - url: https://project--42d9840c-12e3-4489-8ca3-a46674cc8dbf-dev.lovable.app/api/public/v1 description: Preview build tags: - name: Meta - name: Auth - name: Profile - name: People - name: Friends - name: Invites - name: Planning - name: Meetups - name: Sharing paths: /: get: tags: [Meta] summary: Machine-readable endpoint index security: [] responses: "200": description: Endpoint index content: application/json: schema: type: object properties: name: { type: string } version: { type: string } baseUrl: { type: string } auth: { type: string } endpoints: type: array items: type: object properties: method: { type: string } path: { type: string } auth: { type: boolean } body: { type: string } /auth/signup: post: tags: [Auth] summary: Create an account security: [] requestBody: required: true content: application/json: schema: type: object required: [email, password] properties: email: { type: string, format: email } password: { type: string, minLength: 6 } displayName: { type: string } responses: "201": description: Account created. `session` is null when email confirmation is pending. content: application/json: schema: type: object properties: session: oneOf: - $ref: "#/components/schemas/AuthSession" - type: "null" needsEmailConfirmation: { type: boolean } "400": { $ref: "#/components/responses/BadRequest" } "500": { $ref: "#/components/responses/ServerError" } /auth/login: post: tags: [Auth] summary: Sign in with email and password security: [] requestBody: required: true content: application/json: schema: type: object required: [email, password] properties: email: { type: string, format: email } password: { type: string } responses: "200": description: Session issued content: application/json: schema: type: object properties: session: { $ref: "#/components/schemas/AuthSession" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "500": { $ref: "#/components/responses/ServerError" } /auth/refresh: post: tags: [Auth] summary: Exchange a refresh token for a new session security: [] requestBody: required: true content: application/json: schema: type: object required: [refreshToken] properties: refreshToken: { type: string } responses: "200": description: Refreshed session content: application/json: schema: type: object properties: session: { $ref: "#/components/schemas/AuthSession" } "400": { $ref: "#/components/responses/BadRequest" } "500": { $ref: "#/components/responses/ServerError" } /cities: get: tags: [Meta] summary: Full curated city catalogue security: [] responses: "200": description: Cities ordered by hub score content: application/json: schema: type: object properties: data: type: array items: { $ref: "#/components/schemas/City" } /me: get: tags: [Profile] summary: Current profile responses: "200": description: Profile content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Profile" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "500": { $ref: "#/components/responses/ServerError" } patch: tags: [Profile] summary: Update profile fields requestBody: required: true content: application/json: schema: type: object properties: display_name: { type: string } bio: { type: string } interests: type: array items: { type: string } home_city_id: oneOf: [{ type: string, format: uuid }, { type: "null" }] responses: "200": description: Updated profile content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Profile" } "401": { $ref: "#/components/responses/Unauthorized" } "500": { $ref: "#/components/responses/ServerError" } /me/avatar: put: tags: [Profile] summary: Set the profile photo URL requestBody: required: true content: application/json: schema: type: object required: [url] properties: url: oneOf: [{ type: string, format: uri }, { type: "null" }] responses: "200": { $ref: "#/components/responses/Ok" } "401": { $ref: "#/components/responses/Unauthorized" } "500": { $ref: "#/components/responses/ServerError" } delete: tags: [Profile] summary: Clear the profile photo responses: "200": { $ref: "#/components/responses/Ok" } "401": { $ref: "#/components/responses/Unauthorized" } "500": { $ref: "#/components/responses/ServerError" } /me/verify: post: tags: [Profile] summary: Recompute the verification badge responses: "200": description: New verification state (0 none, 1 verified, 2 trusted) content: application/json: schema: type: object properties: data: type: object properties: level: { type: integer, enum: [0, 1, 2] } verified_at: oneOf: [{ type: string, format: date-time }, { type: "null" }] "401": { $ref: "#/components/responses/Unauthorized" } "500": { $ref: "#/components/responses/ServerError" } /me/pricing: get: tags: [Profile] summary: Live-fare availability responses: "200": description: Whether a live flight-price provider is configured content: application/json: schema: type: object properties: data: type: object properties: liveProviderAvailable: { type: boolean } "401": { $ref: "#/components/responses/Unauthorized" } "500": { $ref: "#/components/responses/ServerError" } patch: tags: [Profile] summary: Toggle live fares for this account requestBody: required: true content: application/json: schema: type: object required: [liveFares] properties: liveFares: { type: boolean } responses: "200": { $ref: "#/components/responses/Ok" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "500": { $ref: "#/components/responses/ServerError" } /people: get: tags: [People] summary: Search people by name parameters: - name: query in: query example: ana schema: { type: string, maxLength: 50 } responses: "200": description: Matching people (limited public fields) content: application/json: schema: type: object properties: data: type: array items: { $ref: "#/components/schemas/Profile" } "401": { $ref: "#/components/responses/Unauthorized" } "500": { $ref: "#/components/responses/ServerError" } /friends: get: tags: [Friends] summary: List friendships and pending requests responses: "200": description: Friendships content: application/json: schema: type: object properties: data: type: array items: { $ref: "#/components/schemas/Friendship" } "401": { $ref: "#/components/responses/Unauthorized" } "500": { $ref: "#/components/responses/ServerError" } post: tags: [Friends] summary: Send a friend request requestBody: required: true content: application/json: schema: type: object required: [personId] properties: personId: { type: string, format: uuid } responses: "201": description: Request created (or already existed) content: application/json: schema: type: object properties: ok: { type: boolean } alreadyExists: { type: boolean } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "500": { $ref: "#/components/responses/ServerError" } /friends/{id}: parameters: - name: id in: path required: true schema: { type: string, format: uuid } get: tags: [Friends] summary: One connection plus the trips you share description: | Returns the friendship (status, direction, the other person's profile) and every meetup between the two of you. `id` is the friendship id. responses: "200": description: Connection detail content: application/json: schema: type: object properties: data: type: object properties: friendship: { $ref: "#/components/schemas/Friendship" } trips: type: array items: { $ref: "#/components/schemas/Meetup" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "500": { $ref: "#/components/responses/ServerError" } patch: tags: [Friends] summary: Accept or decline an incoming request requestBody: required: true content: application/json: schema: type: object required: [accept] properties: accept: { type: boolean } responses: "200": { $ref: "#/components/responses/Ok" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "500": { $ref: "#/components/responses/ServerError" } delete: tags: [Friends] summary: Remove a friendship responses: "200": { $ref: "#/components/responses/Ok" } "401": { $ref: "#/components/responses/Unauthorized" } "500": { $ref: "#/components/responses/ServerError" } /invites: get: tags: [Invites] summary: List email invites you sent responses: "200": description: Invites, newest first content: application/json: schema: type: object properties: data: type: array items: { $ref: "#/components/schemas/Invite" } "401": { $ref: "#/components/responses/Unauthorized" } "500": { $ref: "#/components/responses/ServerError" } post: tags: [Invites] summary: Create an email invite requestBody: required: true content: application/json: schema: type: object required: [email] properties: email: { type: string, format: email } responses: "201": description: Invite created (existing pending invite is reused) content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Invite" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "500": { $ref: "#/components/responses/ServerError" } /invites/{id}: delete: tags: [Invites] summary: Revoke an invite parameters: - name: id in: path required: true schema: { type: string, format: uuid } responses: "200": { $ref: "#/components/responses/Ok" } "401": { $ref: "#/components/responses/Unauthorized" } "500": { $ref: "#/components/responses/ServerError" } /invites/accept: post: tags: [Invites] summary: Accept an invite token requestBody: required: true content: application/json: schema: type: object required: [token] properties: token: { type: string } responses: "200": description: Invite accepted content: application/json: schema: { $ref: "#/components/schemas/AcceptInviteResult" } "400": description: Invite invalid, expired, or your own content: application/json: schema: { $ref: "#/components/schemas/AcceptInviteResult" } "401": { $ref: "#/components/responses/Unauthorized" } "500": { $ref: "#/components/responses/ServerError" } /meetpoints/search: post: tags: [Planning] summary: Rank in-between cities for you and a friend requestBody: required: true content: application/json: schema: type: object required: [friendId, departDate, returnDate] properties: friendId: { type: string, format: uuid } departDate: { type: string, format: date } returnDate: { type: string, format: date } responses: "200": description: Ranked meet-city options content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/SearchResult" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "500": { $ref: "#/components/responses/ServerError" } /meetups: get: tags: [Meetups] summary: List your trips responses: "200": description: Trips involving you content: application/json: schema: type: object properties: data: type: array items: { $ref: "#/components/schemas/Meetup" } "401": { $ref: "#/components/responses/Unauthorized" } "500": { $ref: "#/components/responses/ServerError" } post: tags: [Meetups] summary: Propose a trip requestBody: required: true content: application/json: schema: type: object required: [friendId, cityId, departDate, returnDate] properties: friendId: { type: string, format: uuid } cityId: { type: string, format: uuid } departDate: { type: string, format: date } returnDate: { type: string, format: date } yourPrice: { type: number } friendPrice: { type: number } currency: { type: string, default: EUR } priceSource: { type: string, enum: [amadeus, estimate], default: estimate } fairness: { type: number } note: { type: string } responses: "201": { $ref: "#/components/responses/Ok" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "500": { $ref: "#/components/responses/ServerError" } /meetups/{id}: patch: tags: [Meetups] summary: Update trip status parameters: - name: id in: path required: true schema: { type: string, format: uuid } requestBody: required: true content: application/json: schema: type: object required: [status] properties: status: { type: string, enum: [accepted, declined, cancelled] } responses: "200": { $ref: "#/components/responses/Ok" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "500": { $ref: "#/components/responses/ServerError" } /meetups/{id}/comments: parameters: - name: id in: path required: true schema: { type: string, format: uuid } get: tags: [Comments] summary: Thread of messages on a trip (oldest first) responses: "200": description: Comments visible to trip participants content: application/json: schema: type: object properties: data: type: array items: { $ref: "#/components/schemas/TripComment" } "401": { $ref: "#/components/responses/Unauthorized" } "500": { $ref: "#/components/responses/ServerError" } post: tags: [Comments] summary: Post a message on a trip requestBody: required: true content: application/json: schema: type: object required: [body] properties: body: { type: string, minLength: 1, maxLength: 1000 } responses: "201": description: Created comment content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/TripComment" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "500": { $ref: "#/components/responses/ServerError" } /meetups/{id}/comments/{commentId}: parameters: - name: id in: path required: true schema: { type: string, format: uuid } - name: commentId in: path required: true schema: { type: string, format: uuid } delete: tags: [Comments] summary: Delete your own comment responses: "200": { $ref: "#/components/responses/Ok" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "500": { $ref: "#/components/responses/ServerError" } /meetups/{id}/share: parameters: - name: id in: path required: true schema: { type: string, format: uuid } post: tags: [Sharing] summary: Create (or fetch) a view-only share token responses: "201": description: Share token; the public URL is `/trip/{token}` content: application/json: schema: type: object properties: data: type: object properties: token: { type: string } "401": { $ref: "#/components/responses/Unauthorized" } "500": { $ref: "#/components/responses/ServerError" } delete: tags: [Sharing] summary: Revoke the share token responses: "200": { $ref: "#/components/responses/Ok" } "401": { $ref: "#/components/responses/Unauthorized" } "500": { $ref: "#/components/responses/ServerError" } /shared-trips/{token}: get: tags: [Sharing] summary: View a shared trip (no auth, view-only) security: [] parameters: - name: token in: path required: true schema: { type: string, maxLength: 128 } responses: "200": description: Shared trip content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/SharedTrip" } "404": { $ref: "#/components/responses/NotFound" } "500": { $ref: "#/components/responses/ServerError" } components: securitySchemes: bearerAuth: type: http scheme: bearer bearerFormat: JWT description: Supabase access token from `/auth/login`, `/auth/signup` or `/auth/refresh`. responses: Ok: description: Operation succeeded content: application/json: schema: type: object properties: ok: { type: boolean, const: true } BadRequest: description: | Invalid or missing input. `error.code` is one of `validation_error`, `invalid_json`, `missing_home_city`, `friend_missing_home_city`, `invite_invalid`, `own_invite`, `upstream_error`. content: application/json: schema: { $ref: "#/components/schemas/Error" } example: error: code: validation_error message: personId is required status: 400 Unauthorized: description: | Missing or invalid credentials. `error.code` is one of `unauthorized`, `invalid_token`, `invalid_credentials`. content: application/json: schema: { $ref: "#/components/schemas/Error" } example: error: code: invalid_token message: Invalid token status: 401 Forbidden: description: | Authenticated but not allowed. `error.code` is `forbidden` or `not_friends`. content: application/json: schema: { $ref: "#/components/schemas/Error" } example: error: code: not_friends message: You can only plan trips with accepted friends. status: 403 NotFound: description: Resource missing, revoked, or not visible to the caller (`not_found`). content: application/json: schema: { $ref: "#/components/schemas/Error" } example: error: code: not_found message: This share link is no longer valid status: 404 Conflict: description: The request conflicts with the current state (`conflict`). content: application/json: schema: { $ref: "#/components/schemas/Error" } RateLimited: description: Too many requests (`rate_limited`). content: application/json: schema: { $ref: "#/components/schemas/Error" } ServerError: description: Unexpected server failure (`server_error`). content: application/json: schema: { $ref: "#/components/schemas/Error" } example: error: code: server_error message: Unexpected server error status: 500 schemas: Error: type: object description: Standard error envelope returned by every non-2xx response. required: [error] properties: error: type: object required: [code, message, status] properties: code: $ref: "#/components/schemas/ErrorCode" message: type: string description: Human-readable message, safe to display. Do not parse it. status: type: integer description: Mirrors the HTTP status code. details: description: Optional extra context; omitted when absent. ErrorCode: type: string description: | Stable machine-readable error identifier. Clients should branch on this value rather than on `message`. enum: - validation_error - invalid_json - unauthorized - invalid_token - invalid_credentials - forbidden - not_friends - not_found - conflict - own_invite - invite_invalid - missing_home_city - friend_missing_home_city - rate_limited - upstream_error - server_error AuthSession: type: object properties: accessToken: { type: string } refreshToken: { type: string } expiresAt: oneOf: [{ type: integer, description: Unix seconds }, { type: "null" }] tokenType: { type: string, example: bearer } user: type: object properties: id: { type: string, format: uuid } email: oneOf: [{ type: string, format: email }, { type: "null" }] City: type: object properties: id: { type: string, format: uuid } name: { type: string, example: Barcelona } country: { type: string, example: Spain } iata: { type: string, example: BCN } lat: { type: number } lng: { type: number } hub_score: { type: number } blurb: { type: string } CityRef: type: object properties: name: { type: string } country: { type: string } iata: { type: string } Profile: type: object properties: id: { type: string, format: uuid } display_name: { type: string } bio: { type: string } interests: type: array items: { type: string } avatar_url: oneOf: [{ type: string, format: uri }, { type: "null" }] home_city_id: oneOf: [{ type: string, format: uuid }, { type: "null" }] use_live_fares: { type: boolean } verification_level: { type: integer, enum: [0, 1, 2] } verified_at: oneOf: [{ type: string, format: date-time }, { type: "null" }] city: oneOf: [{ $ref: "#/components/schemas/CityRef" }, { type: "null" }] Friendship: type: object properties: id: { type: string, format: uuid } status: { type: string, enum: [pending, accepted, declined] } direction: { type: string, enum: [incoming, outgoing] } person: { $ref: "#/components/schemas/Profile" } Invite: type: object properties: id: { type: string, format: uuid } email: { type: string, format: email } token: { type: string } status: { type: string, example: pending } created_at: { type: string, format: date-time } expires_at: { type: string, format: date-time } AcceptInviteResult: type: object properties: ok: { type: boolean } message: { type: string } selfInvite: { type: boolean } TravellerLeg: type: object properties: name: { type: string } originIata: { type: string } originCity: { type: string } price: { type: number } hours: { type: number } priceSource: { type: string, enum: [amadeus, estimate] } carrier: oneOf: [{ type: string }, { type: "null" }] stops: oneOf: [{ type: integer }, { type: "null" }] MeetOption: type: object properties: city: { $ref: "#/components/schemas/City" } totalPrice: { type: number } currency: { type: string } fairness: { type: number, description: 0-1, higher means more evenly split } maxHours: { type: number } you: { $ref: "#/components/schemas/TravellerLeg" } friend: { $ref: "#/components/schemas/TravellerLeg" } isEstimate: { type: boolean } SearchResult: type: object properties: departDate: { type: string, format: date } returnDate: { type: string, format: date } liveProvider: { type: boolean } liveFaresRequested: { type: boolean } origins: type: object properties: you: type: object properties: name: { type: string } city: { $ref: "#/components/schemas/City" } friend: type: object properties: name: { type: string } city: { $ref: "#/components/schemas/City" } options: type: array items: { $ref: "#/components/schemas/MeetOption" } TripComment: type: object properties: id: { type: string, format: uuid } meetup_id: { type: string, format: uuid } body: { type: string } created_at: { type: string, format: date-time } is_mine: { type: boolean } author: type: object properties: display_name: { type: string } avatar_url: { type: string, nullable: true } Meetup: type: object properties: id: { type: string, format: uuid } status: { type: string, enum: [proposed, accepted, declined, cancelled] } depart_date: { type: string, format: date } return_date: { type: string, format: date } total_price: oneOf: [{ type: number }, { type: "null" }] proposer_price: oneOf: [{ type: number }, { type: "null" }] invitee_price: oneOf: [{ type: number }, { type: "null" }] currency: { type: string } price_source: { type: string } fairness: oneOf: [{ type: number }, { type: "null" }] note: { type: string } role: { type: string, enum: [proposer, invitee] } city: oneOf: [{ $ref: "#/components/schemas/CityRef" }, { type: "null" }] counterpart: oneOf: - type: object properties: display_name: { type: string } avatar_url: oneOf: [{ type: string, format: uri }, { type: "null" }] verification_level: { type: integer } - type: "null" your_origin: oneOf: [{ $ref: "#/components/schemas/CityRef" }, { type: "null" }] counterpart_origin: oneOf: [{ $ref: "#/components/schemas/CityRef" }, { type: "null" }] SharedTrip: type: object properties: status: { type: string } departDate: { type: string, format: date } returnDate: { type: string, format: date } totalPrice: oneOf: [{ type: number }, { type: "null" }] currency: { type: string } priceSource: { type: string } fairness: oneOf: [{ type: number }, { type: "null" }] note: { type: string } city: oneOf: [{ $ref: "#/components/schemas/CityRef" }, { type: "null" }] travellers: type: array items: type: object properties: name: { type: string } city: oneOf: [{ type: string }, { type: "null" }] iata: oneOf: [{ type: string }, { type: "null" }] security: - bearerAuth: []