15 KiB
Xpensely Server — API Reference
Last updated: 2026-05-09 · Branch:
feature/security-hardening
Table of Contents
- Overview
- Authentication
- Rate Limiting
- Endpoints
- 4.1 Home
- 4.2 Users
- 4.3 Expense Lists
- Data Models
- Error Handling
- Recent Changes —
feature/security-hardening
1. Overview
Xpensely Server is a Spring Boot REST API that manages shared expense lists for pairs of users. It uses Google OAuth2 JWT tokens for authentication. All protected endpoints require a valid Bearer token in the Authorization header.
Base URL (local dev): http://localhost:8080
Content-Type: application/json for all request and response bodies.
Public endpoints (no auth required):
| Method | Path | Description |
|---|---|---|
| GET | / |
Health check — returns "Welcome" |
| POST | /api/users/createUser |
Register a new user |
| GET | /api/users/byName |
Look up a user by username |
All other endpoints require authentication (see Section 2).
2. Authentication
The server uses OAuth2 Resource Server authentication with Google ID JWT tokens.
How it works
- The client authenticates with Google and receives a Google ID JWT.
- Every protected API request must include this token in the header:
Authorization: Bearer <google-id-jwt>
- The server validates the JWT signature and extracts the
subclaim (Google User ID). - The
subvalue is used to look up the registeredAppUserin the database viaAuthenticatedUserResolver. - If no
AppUserexists for that Google ID, the request is rejected with 403 Forbidden.
Test profile
When the application runs under the test Spring profile (-Dspring.profiles.active=test), all security is disabled — every endpoint is accessible without a token. This is used for automated tests only.
User registration flow
Before a user can call any protected endpoint they must first be registered:
- Authenticate with Google to obtain a Google ID JWT.
- Call
POST /api/users/createUserwith the JWT'ssubvalue asgoogleIdand a chosenusername. - All subsequent protected calls use the same JWT — the server resolves the caller automatically.
3. Rate Limiting
All requests pass through a RateLimitFilter (implemented with Bucket4j).
| Setting | Value |
|---|---|
| Limit | 60 requests per minute |
| Window | Rolling 1-minute bucket |
| Key (authenticated) | JWT sub claim (Google User ID) |
| Key (unauthenticated) | X-Forwarded-For header, falling back to remote IP |
When the limit is exceeded the server responds with:
HTTP 429 Too Many Requests
No Retry-After header is currently returned. Clients should back off and retry after 60 seconds.
Note: Rate limiting applies in the
!testprofile only. Tests run without rate limiting.
4. Endpoints
4.1 Home
GET /
Health check. No authentication required.
Response: 200 OK
Welcome
4.2 Users
Base path: /api/users
POST /api/users/createUser — Register a user
Auth required: No
Request body:
{
"username": "alice",
"googleId": "118400012345678901234"
}
| Field | Type | Constraints |
|---|---|---|
username |
String | Required. 3–30 chars. Pattern: ^[a-zA-Z0-9_.\-]+$ |
googleId |
String | Required. Non-blank. Must match the JWT sub from Google. |
Success response: 200 OK — returns the created AppUser object.
Error responses:
| Status | Condition |
|---|---|
| 400 | Validation failure (field errors returned as {"fieldName": "message"}) |
| 409 | username already taken |
GET /api/users — Get user by ID
Auth required: Yes
Query params:
| Param | Type | Required | Description |
|---|---|---|---|
id |
Long | Yes | Database ID of the user |
Success response: 200 OK — returns AppUser.
Error responses:
| Status | Condition |
|---|---|
| 404 | No user found for id |
GET /api/users/byName — Get user by username
Auth required: No
Query params:
| Param | Type | Required | Description |
|---|---|---|---|
username |
String | Yes | Exact username (case-sensitive) |
Success response: 200 OK — returns AppUser.
Error responses:
| Status | Condition |
|---|---|
| 404 | No user found for username |
GET /api/users/byGoogleId — Get user by Google ID
Auth required: Yes
Query params:
| Param | Type | Required | Description |
|---|---|---|---|
id |
String | Yes | Google sub claim |
Success response: 200 OK — returns AppUser.
Error responses:
| Status | Condition |
|---|---|
| 404 | No user found for that Google ID |
DELETE /api/users — Delete a user
Auth required: Yes
Query params:
| Param | Type | Required | Description |
|---|---|---|---|
id |
Long | Yes | Database ID of the user to delete |
Success response: 200 OK — returns the deleted AppUser.
Error responses:
| Status | Condition |
|---|---|
| 404 | No user found for id |
4.3 Expense Lists
Base path: /api/expenselist
All endpoints in this group require authentication. The authenticated caller is resolved from the JWT and used for ownership checks.
GET /api/expenselist/mine — Get caller's expense lists
Returns all expense lists where the caller is the owner or has been shared the list.
Auth required: Yes
Request body: None
Success response: 200 OK — array of ExpenseList.
[
{
"id": 1,
"name": "Holiday Trip",
"inviteCode": null,
"owner": { "id": 1, "username": "alice" },
"sharedWith": null,
"expenses": [],
"customCategories": []
}
]
GET /api/expenselist/byId — Get expense list by ID
Auth required: Yes
Query params:
| Param | Type | Required | Description |
|---|---|---|---|
id |
Long | Yes | Expense list database ID |
The caller must be the owner or the shared user of the list.
Success response: 200 OK — returns ExpenseList.
Error responses:
| Status | Condition |
|---|---|
| 403 | Caller is neither owner nor shared user |
| 404 | No list found for id |
POST /api/expenselist/create — Create an expense list
Auth required: Yes
Request body:
{
"name": "Road Trip 2026"
}
| Field | Type | Constraints |
|---|---|---|
name |
String | Required. Max 100 chars. |
The authenticated caller becomes the owner of the new list. The list is initialised with the default Xpensely standard categories.
Success response: 201 Created — returns the created ExpenseList.
Error responses:
| Status | Condition |
|---|---|
| 400 | Validation failure |
DELETE /api/expenselist/{id} — Delete an expense list
Auth required: Yes
Path params:
| Param | Type | Description |
|---|---|---|
id |
Long | Expense list database ID |
Only the owner may delete a list. Deleting a list cascades to all its expenses and custom categories.
Success response: 204 No Content
Error responses:
| Status | Condition |
|---|---|
| 403 | Caller is not the owner |
| 404 | No list found for id |
POST /api/expenselist/{id}/add — Add an expense to a list
Auth required: Yes
Path params:
| Param | Type | Description |
|---|---|---|
id |
Long | Expense list database ID |
Request body:
{
"title": "Dinner",
"owner": "alice",
"amount": 42.50,
"personalUseAmount": 21.25,
"otherPersonAmount": 21.25,
"date": "2026-05-09",
"category": "Food"
}
| Field | Type | Constraints |
|---|---|---|
title |
String | Required. Max 100 chars. |
owner |
String | Required. Username of the person who paid. |
amount |
Double | Required. Min 0.01. |
personalUseAmount |
Double | Optional. Caller's share. |
otherPersonAmount |
Double | Optional. Other person's share. |
date |
String (ISO-8601) | Required. Format: YYYY-MM-DD. |
category |
String | Required. Non-blank category name. |
Success response: 200 OK — returns the created Expense.
Error responses:
| Status | Condition |
|---|---|
| 400 | Validation failure |
| 403 | Caller is not a member (owner or sharedWith) of the list |
| 404 | List not found |
PUT /api/expenselist/{id}/update — Update an expense
Auth required: Yes
Path params:
| Param | Type | Description |
|---|---|---|
id |
Long | Expense list database ID |
Request body:
{
"id": 7,
"title": "Dinner (updated)",
"ownerName": "alice",
"amount": 50.00,
"personalUseAmount": 25.00,
"otherPersonAmount": 25.00,
"date": "2026-05-09",
"category": "Food"
}
| Field | Type | Constraints |
|---|---|---|
id |
Long | Required. ID of the expense to update. |
title |
String | Required. Max 100 chars. |
ownerName |
String | Required. Username of the payer. |
amount |
Double | Required. Min 0.01. |
personalUseAmount |
Double | Optional. |
otherPersonAmount |
Double | Optional. |
date |
String (ISO-8601) | Required. YYYY-MM-DD. |
category |
String | Required. |
Caller must be a member of the list. Expense must belong to the specified list.
Success response: 200 OK — returns the updated Expense.
Error responses:
| Status | Condition |
|---|---|
| 400 | Validation failure or expense does not belong to this list |
| 403 | Caller is not a member of the list |
| 404 | List or expense not found |
DELETE /api/expenselist/{id}/delete — Remove an expense from a list
Auth required: Yes
Path params:
| Param | Type | Description |
|---|---|---|
id |
Long | Expense list database ID |
Query params:
| Param | Type | Required | Description |
|---|---|---|---|
expenseId |
Long | Yes | ID of the expense to remove |
Caller must be a member of the list.
Success response: 200 OK — returns the deleted Expense.
Error responses:
| Status | Condition |
|---|---|
| 403 | Caller is not a member of the list |
| 404 | List or expense not found |
POST /api/expenselist/{listId}/invite — Generate an invite code
Generates (or refreshes) a 6-character uppercase invite code for the list, valid for 1 week.
Auth required: Yes
Path params:
| Param | Type | Description |
|---|---|---|
listId |
Long | Expense list database ID |
Caller must be the owner of the list.
Success response: 200 OK — returns the invite code as a plain string.
AB3X7Q
Error responses:
| Status | Condition |
|---|---|
| 403 | Caller is not the owner |
| 404 | List not found |
POST /api/expenselist/accept-invite — Accept an invite
Joins the caller to a shared expense list using an invite code.
Auth required: Yes
Request body:
{
"inviteCode": "AB3X7Q"
}
| Field | Type | Constraints |
|---|---|---|
inviteCode |
String | Required. Exactly 6 characters. |
Success response: 200 OK — returns the ExpenseList the caller joined.
Error responses:
| Status | Condition |
|---|---|
| 400 | Validation failure or invite code not found / expired |
| 403 | Caller is already the owner of this list |
5. Data Models
AppUser
Returned by all /api/users endpoints. Sensitive fields (googleId, createdAt) are hidden from API responses via @JsonIgnore.
{
"id": 1,
"username": "alice"
}
| Field | Type | Notes |
|---|---|---|
id |
Long | Auto-generated primary key |
username |
String | Unique. 3–30 chars. |
ExpenseList
{
"id": 1,
"name": "Road Trip",
"inviteCode": "AB3X7Q",
"owner": { "id": 1, "username": "alice" },
"sharedWith": { "id": 2, "username": "bob" },
"xpenselyStandardCategories": {
"id": 1,
"categories": [
{ "id": 1, "name": "Food", "colorCode": "#FF5733" }
]
},
"customCategories": [],
"expenses": []
}
| Field | Type | Notes |
|---|---|---|
id |
Long | Auto-generated primary key |
name |
String | Display name of the list |
inviteCode |
String | null | 6-char code; null if not yet generated or expired |
owner |
AppUser | User who created the list |
sharedWith |
AppUser | null | Second member of the list; null until an invite is accepted |
xpenselyStandardCategories |
XpenselyStandardCategories | Default category set assigned at creation |
customCategories |
XpenselyCustomCategory[] | User-defined categories, ordered A→Z |
expenses |
Expense[] | All expenses, ordered by date ASC then id ASC |
inviteCodeExpirationis hidden from API responses (@JsonIgnore).
Expense
{
"id": 7,
"title": "Dinner",
"owner": { "id": 1, "username": "alice" },
"amount": 42.50,
"personalUseAmount": 21.25,
"otherPersonAmount": 21.25,
"category": "Food",
"date": "2026-05-09"
}
| Field | Type | Notes |
|---|---|---|
id |
Long | Auto-generated |
title |
String | Description of the expense |
owner |
AppUser | Who paid |
amount |
Double | Total amount. Min 0.01. |
personalUseAmount |
Double | null | Caller's share |
otherPersonAmount |
Double | null | Other member's share |
category |
String | Category name (free text — must match a category on the list) |
date |
String (ISO-8601) | YYYY-MM-DD |
The
expenseListback-reference is excluded via@JsonBackReferenceto prevent circular serialisation.
XpenselyStandardCategories
A named group of standard categories assigned to every new expense list.
{
"id": 1,
"categories": [
{ "id": 1, "name": "Food", "colorCode": "#FF5733" },
{ "id": 2, "name": "Transport", "colorCode": "#3498DB" }
]
}
XpenselyCustomCategory
A user-defined category attached to a specific expense list.
{
"id": 5,
"name": "Souvenirs",
"colorCode": "#9B59B6"
}
| Field | Type | Notes |
|---|---|---|
id |
Long | Auto-generated |
name |
String | Category label |
colorCode |
String | 7-char hex string e.g. #RRGGBB |
The
expenseListback-reference is excluded via@JsonBackReference.
6. Error Handling
TODO
7. Recent Changes — feature/security-hardening
TODO