App API
The app API is the contract between TailSpend's clients (Expo mobile, web and desktop apps) and its serverless backend. It is served by an API Gateway REST API at api.<stage>, backed by the shared core Lambda (see core-api.md), with a few exceptional routes on their own functions. Architecture context lives in architecture.md; storage lives in database.md.
Source of truth
The API is generated, not hand-written:
- Zod schemas in
packages/api-schema/srcdefine every request, response, and entity. @repo/openapiproduces an OpenAPI 3 document from those schemas.- CDK reads the OpenAPI document to provision routes.
@repo/api-clientproduces a typedopenapi-fetchclient.
Anything in this document is descriptive. The Zod schemas win on conflict.
graph LR
Z[Zod schemas] --> O[OpenAPI JSON]
O --> CDK[CDK routes]
O --> C[api-client]
C --> Web[Web app]
C --> Mobile[Mobile app]
Conventions
Base URL:
https://api.tailspend.kinesis.studio(prod),https://api.beta.tailspend.kinesis.studio(beta),https://api.dev.tailspend.kinesis.studio(dev). The custom domain is provisioned inapps/infra/lib/stacks/service-stack.ts.Transport: HTTPS only.
Encoding: JSON request and response bodies. UTF-8.
Identifiers: UUID v4 strings.
Timestamps: ISO 8601 with timezone (e.g.
2025-05-24T13:45:00Z).Money: integers in the smallest currency unit (cents, pence). Never floats.
Currency codes: ISO 4217. Every active ISO 4217 currency code is accepted; the canonical list (with minor-unit exponents) is
SUPPORTED_CURRENCIESinpackages/api-schema/src/currencies.ts. Always convert between major and minor units viagetCurrencyExponentso zero-decimal currencies (JPY, KRW) and three-decimal dinars (KWD, BHD) round correctly.Strict objects: payloads are validated with
z.strictObject; unknown fields are rejected.Empty success bodies: mutating endpoints that have nothing meaningful to return respond with
{ "success": true }and the appropriate 2xx status.Idempotency: not yet implemented. Clients should expect to retry safely on
GETandDELETEonly.Pagination: every list endpoint uses the same cursor shape:
{ "items": [/* ... */], "nextCursor": "eyJleHBlbnNlSWQiOiIuLi4ifQ==" }Clients pass
?cursor=<value>to fetch the next page.nextCursoris omitted on the final page. The cursor is opaque (base64-encodedLastEvaluatedKeyfrom DynamoDB) and must not be parsed by clients.Page size is controlled by
?limit=<n>with these defaults:Endpoint family Default Max GET /groups/{id}/expenses50 200 GET /groups/{id}/invite/expenses50 200 GET /groups/{id}/participants100 200 GET /groups/{id}/join-requests50 100 GET /users/me/groups50 100 GET /users/me/join-requests50 100 GET /groups/{id}/recurring-expenses50 200 GET /groups/{id}/settlements50 200 GET /admin/users100 200 These are caps; smaller page sizes are always allowed. Group sizes are bounded enough that participants and pending join requests typically fit in one page.
GET /admin/userstakes the largest default because it is the only route that filters after the read: the underlying Scan narrows toPROFILErows, then the service drops test accounts and applies theqsubstring match. A page can therefore return fewer rows than the limit asked for whilenextCursoris still set, so clients keep paging on the cursor rather than treating a short page as the end.
Authentication
Most endpoints require authentication. The exceptions are the public group-invite reads under /groups/{groupId}/invite/..., which are gated by the per-group invite-enabled flag rather than a bearer token (see Invite Link).
- Identity provider: Amazon Cognito User Pool federated with Google and Apple.
- Flow: OAuth 2.0 Authorization Code with PKCE against the Cognito Hosted UI.
- Tokens: clients send the Cognito ID token as
Authorization: Bearer <jwt>. - Verification: each Lambda verifies the JWT against the Cognito JWKS. The verified
subclaim is resolved to an internaluserIdvia theIdentitiestable.
Guests don't authenticate. They appear in groups and expenses as Participant records with type: "GUEST". A guest is "claimed" — flipped to type: "USER" with the claimer's userId attached — when an OWNER approves a join request submitted in TAKEOVER mode. Because expense Payments and item splits reference participantId, claiming never rewrites historical expenses.
Authorization
Authorization is enforced inside each Lambda using the Participants DynamoDB table. See database.md. The model is role-based at the group level: every action on a group, expense, or comment is gated by the caller's role in the parent group.
Roles:
OWNER: read the group, create/edit/delete any expense or comment, edit group metadata, add/remove participants, change roles, enable/disable the invite link, approve/deny join requests, delete the group.MEMBER: read the group, read all expenses and their comments, create/edit/delete their own expenses and comments.VIEWER: read-only. Can read the group, expenses, and comments. Cannot write anything.
Rules of note:
- Creating a group makes the caller
OWNER. The group always has at least oneOWNER; the API rejects any change that would leave it without one (409 Conflict). - Adding a participant creates a
Participantsrow with the requested role (defaults toMEMBER). Guests are created withtype: "GUEST"and the role they will hold post-claim. - Editing or deleting an expense or comment that you didn't create requires
OWNER. - Guests are not actors — they cannot make API calls. They appear in expenses and comments only as
participantIdreferences. - Public invite reads (
GET /groups/{groupId}/invite/...) are not authenticated against Cognito; access is gated only by the per-groupEnabledflag. When disabled, every public read returns404. The view returned is read-only regardless of the role any subsequent join request will propose.
A request that fails authorization returns 403 Forbidden. A request that fails the "at least one OWNER" invariant returns 409 Conflict.
Error model
Errors use standard HTTP status codes with a uniform body:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Human-readable summary",
"details": [
{ "path": "items[0].cost", "message": "Expected non-negative integer" }
]
}
}
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Request fails Zod validation |
| 401 | UNAUTHENTICATED |
Missing or invalid bearer token |
| 403 | FORBIDDEN |
Authenticated, but lacks required permission |
| 404 | NOT_FOUND |
Resource does not exist or is not visible to the caller |
| 409 | CONFLICT |
State conflict (e.g. removing the last owner) |
| 422 | UNPROCESSABLE |
Semantically invalid (e.g. percentages summing to ≠ 100) |
| 500 | INTERNAL |
Unhandled server error |
details is only present for VALIDATION_ERROR and UNPROCESSABLE.
Endpoints
All paths are documented METHOD /path and match the generated OpenAPI contract. Full route authorization and grant registries are checked against it at synthesis. apps/infra/lib/rest-route-handlers.ts maps only the eight exceptional Lambda routes; ordinary handlers are imported by apps/services/src/core/app-routes.ts.
Groups
| Method | Path | Purpose |
|---|---|---|
| POST | /groups |
Create a group |
| GET | /groups/{groupId} |
Read a group |
| PATCH | /groups/{groupId} |
Update group label or currency |
| DELETE | /groups/{groupId} |
Delete a group |
Participants
| Method | Path | Purpose |
|---|---|---|
| GET | /groups/{groupId}/participants |
List participants of a group |
| POST | /groups/{groupId}/participants |
Add a participant (USER or GUEST) |
| PATCH | /groups/{groupId}/participants/{participantId} |
Change a participant's role or display name |
| DELETE | /groups/{groupId}/participants/{participantId} |
Remove a participant |
| POST | /groups/{groupId}/participants/{participantId}/transfer |
Move a participant's transactions onto another, then remove them |
Adding a USER participant directly is an OWNER-only operation. A USER who hasn't been added directly normally arrives via the join-request flow.
Removing a participant who is referenced by an expense (in payments or any item split) returns 409 Conflict. The OWNER must reassign or remove those references first — POST …/transfer is the supported way to do that in bulk.
Transferring a participant
POST /groups/{groupId}/participants/{participantId}/transfer (OWNER only) moves everything the path participant is involved in onto targetParticipantId, then deletes the emptied participant (pass removeSource: false to keep it). It exists for the case where someone was added as a GUEST and later joined with their own account, leaving two identities for one person in one group.
What it rewrites:
- Expense payments — the source's
amount/chargeare added to the target's existing payment on that expense, or the entry is relabelled when the target has none. - Item splits —
AMOUNTS/PERCENTAGES/SHARESdivisions are summed; anEQUALdivision is de-duplicated, so the item is shared between one fewer person. - Settlements — repointed to the target. A settlement between the two is deleted instead: once they are one person it would be a payment to themselves, which is invalid and means nothing. Deleting it leaves the merged participant's net unchanged.
Every total is preserved, so the group's balances still sum to zero — the source's net simply lands on the target.
Not atomic, but resumable. A group can hold far more rows than DynamoDB's 25-item transaction limit, so this is a sequence of conditional per-row updates. References are rewritten before the participant is deleted (so a failure can never leave an expense pointing at a participant that no longer exists), every step is idempotent, and the response reports what moved:
{
"expensesUpdated": 3,
"settlementsUpdated": 1,
"settlementsDeleted": 1,
"sourceRemoved": true
}
A failed transfer can simply be re-run. Each expense update carries the expense's version, so a concurrent edit surfaces as 409 rather than being clobbered, and transferring away a group's last OWNER is rejected by the usual last-OWNER guard.
Invite Link
Every group has one public, read-only invite link. An OWNER reads it from GET /groups/{groupId}/invite/settings and can switch it on or off. The link carries a signed token (?invite=<token>), and clients send it in the x-tailspend-invite header. The three public-read endpoints return 404 when the token is missing, expired or out of date, or when the invite is switched off.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /groups/{groupId}/invite |
None | Read-only group view (label, currency, public participants) |
| GET | /groups/{groupId}/invite/expenses |
None | Paginated read-only expenses |
| GET | /groups/{groupId}/invite/expenses/{id} |
None | Read-only single expense and its comments |
| GET | /groups/{groupId}/invite/settings |
OWNER | Read the invite settings and the current signed link |
| PATCH | /groups/{groupId}/invite |
OWNER | Enable or disable the invite link |
To turn a viewer of the read-only invite into a participant of the group, they submit a join request. People can also ask to join without the link, but without it they can't see the group and can only ask to join as a new person.
Join Requests
Authenticated users who are not yet participants of a group can submit a join request. An OWNER approves or denies; on approve, the OWNER picks the final role and the participant write happens transactionally with the join-request row state flip.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /groups/{groupId}/join-requests |
JWT | Submit a join request (TAKEOVER or NEW) |
| GET | /groups/{groupId}/join-requests |
OWNER | List pending join requests |
| POST | /groups/{groupId}/join-requests/{requestId}/approve |
OWNER | Approve and bind the participant |
| POST | /groups/{groupId}/join-requests/{requestId}/deny |
OWNER | Deny the request |
| DELETE | /groups/{groupId}/join-requests/mine |
JWT | Cancel the caller's pending request for this group |
| GET | /users/me/join-requests |
JWT | List the caller's pending requests across all groups |
Users
| Method | Path | Purpose |
|---|---|---|
| GET | /users/me |
Read the authenticated caller's profile |
| PATCH | /users/me |
Update the caller's display name |
| GET | /users/me/groups |
List groups the caller participates in |
| GET | /users/{userId} |
Read another user's public profile |
| GET | /users/{userId}/profile |
Read another user's profile view (identity + shared groups) |
/users/me resolves to the user identified by the bearer token's sub claim (mapped through the Identities table). Returns 401 if the request is unauthenticated.
GET /users/{userId} returns the public subset (id, displayName, optional handle) to authenticated callers. A block in either direction returns the same 404 User not found as an unknown account; reading your own profile remains allowed. To read the caller's own private fields, use /users/me.
GET /users/{userId}/profile backs the in-app profile view: the same public identity plus sharedGroups, the groups the caller and the viewed user are both in (with the viewed user's role in each). The list is intersected with the caller's own memberships, so it can never reveal a group the caller isn't in — viewing a stranger returns an empty list rather than their group list. It carries nothing account-private: no email, no credit balance or ledger, no preferences.
GET /users/me/groups is paginated per the Pagination convention and returns Group documents in most-recently-updated order.
Expenses
| Method | Path | Purpose |
|---|---|---|
| POST | /groups/{groupId}/expenses |
Create an expense |
| GET | /groups/{groupId}/expenses |
List expenses in group |
| PATCH | /groups/{groupId}/expenses/{expenseId} |
Update an expense |
| DELETE | /groups/{groupId}/expenses/{expenseId} |
Delete an expense |
Comments
| Method | Path | Purpose |
|---|---|---|
| POST | /groups/{groupId}/expenses/{expenseId}/comments |
Add a comment |
| PUT | /groups/{groupId}/expenses/{expenseId}/comments/{commentId} |
Edit a comment |
| DELETE | /groups/{groupId}/expenses/{expenseId}/comments/{commentId} |
Delete a comment |
Comments are stored as their own rows in the Expenses table, sharing a partition with the expense via a hierarchical sort key. An expense and its full comment thread load in one Query; individual comment edits and deletes are single-item operations.
Entities
The shapes below mirror the Zod schemas. JSON examples elide createdAt / updatedAt for brevity.
Participant
A reference to anyone (or anything) that can appear in a group's expenses. Every participant has a stable id (server-assigned UUID) that doesn't change across guest-to-user claim. Expense payments and item splits reference this id.
{
"id": "11111111-...",
"type": "USER",
"userId": "0d9f7b8e-...",
"displayName": "Alex",
"role": "MEMBER",
"claimedAt": "2025-05-24T13:45:00Z"
}
{
"id": "22222222-...",
"type": "GUEST",
"displayName": "Sam",
"role": "MEMBER"
}
userId is present only when type is USER. claimedAt is present only on USER participants who started as guests; original USER participants and GUESTs omit it.
Group
The owner of a group isn't stored separately; it's derivable from participants[] (the participants whose role is OWNER). A group always has at least one OWNER.
{
"id": "a1b2c3d4-...",
"label": "Lisbon trip",
"currency": "GBP",
"createdAt": "2025-05-24T13:45:00Z",
"updatedAt": "2025-05-24T13:45:00Z",
"participants": [
{
"id": "11111111-...",
"type": "USER",
"userId": "0d9f7b8e-...",
"displayName": "Alex",
"role": "OWNER"
},
{
"id": "22222222-...",
"type": "GUEST",
"displayName": "Sam",
"role": "MEMBER"
}
]
}
Comment
{
"id": "c0ffee00-...",
"createdBy": "11111111-...",
"message": "Includes service charge",
"createdAt": "2025-05-24T13:45:00Z",
"updatedAt": "2025-05-24T13:45:00Z"
}
createdBy is a participantId. The special string "SYSTEM" is reserved for system-generated comments.
Expense
An expense belongs to a group, has one currency, and is composed of payments (who paid) and items (what was bought and how it splits). payments[].participantId and every divisions[].participantId reference participants of the parent group.
{
"id": "e7e7e7e7-...",
"groupId": "a1b2c3d4-...",
"label": "Dinner at Padella",
"currency": "GBP",
"createdAt": "2025-05-24T13:45:00Z",
"updatedAt": "2025-05-24T13:45:00Z",
"payments": [
{
"participantId": "11111111-...",
"amount": 4200,
"charge": 4200
}
],
"items": [
{
"id": "f1f2f3f4-...",
"label": "Pasta x3",
"cost": 3000,
"split": {
"type": "EQUAL",
"divisions": [
{ "participantId": "11111111-..." },
{ "participantId": "22222222-..." },
{ "participantId": "33333333-..." }
]
}
},
{
"id": "f5f6f7f8-...",
"label": "Wine",
"cost": 1200,
"split": {
"type": "PERCENTAGES",
"divisions": [
{ "participantId": "11111111-...", "value": 50 },
{ "participantId": "22222222-...", "value": 50 }
]
}
}
],
"comments": []
}
Payment
amountis in the expense currency.chargeis in the group currency. For same-currency expenses,amount === charge. For cross-currency expenses,chargeis the converted value at the time of entry. The conversion rate is not stored separately; clients pre-compute it.participantIdmust reference a participant of the parent group.
Item
costis in the expense currency, in the smallest unit.split.typediscriminates howcostis divided. Thedivisionsarray shape changes per type, but every division carriesparticipantId.
Splits
Four split types, all expressed via the discriminated union ItemSplit:
type |
Each division carries |
Constraint |
|---|---|---|
AMOUNTS |
participantId, amount |
sum(amount) must equal item.cost |
EQUAL |
participantId |
item cost is divided evenly across participants |
PERCENTAGES |
participantId, value (0–100) |
sum(value) must equal 100 |
SHARES |
participantId, value (≥ 0) |
item cost is divided proportionally to shares |
Rounding: when an even division would create fractional units, the remainder is allocated to participants in divisions order (one unit each from the start) until the sum matches cost. This rule is enforced server-side.
Request and response examples
Create a group
POST /groups
{ "label": "Lisbon trip", "currency": "EUR" }
201 Created
{
"id": "a1b2c3d4-...",
"label": "Lisbon trip",
"currency": "EUR",
"createdAt": "2025-05-24T13:45:00Z",
"updatedAt": "2025-05-24T13:45:00Z",
"participants": [
{
"id": "11111111-...",
"type": "USER",
"userId": "0d9f7b8e-...",
"displayName": "Alex",
"role": "OWNER"
}
]
}
Add a participant (guest)
POST /groups/{groupId}/participants
{ "type": "GUEST", "displayName": "Sam", "role": "MEMBER" }
201 Created
{
"id": "22222222-...",
"type": "GUEST",
"displayName": "Sam",
"role": "MEMBER"
}
The new participant's id is the value subsequent expense payments and item divisions use to reference them.
Create an expense
POST /groups/{groupId}/expenses
{
"label": "Dinner at Padella",
"currency": "GBP",
"payments": [
{
"participantId": "11111111-...",
"amount": 4200,
"charge": 4200
}
],
"items": [
{
"label": "Pasta x3",
"cost": 3000,
"split": {
"type": "EQUAL",
"divisions": [
{ "participantId": "11111111-..." },
{ "participantId": "22222222-..." },
{ "participantId": "33333333-..." }
]
}
}
]
}
201 Created: the full Expense document, with server-assigned id, groupId, comments: [], and timestamps.
List expenses
GET /groups/{groupId}/expenses
200 OK
{
"items": [/* Expense */],
"nextCursor": "eyJleHBlbnNlSWQiOiIuLi4ifQ=="
}
Paged in most-recent-first order. See the Pagination convention for limit and cursor semantics.
Update a comment
PUT /groups/{groupId}/expenses/{expenseId}/comments/{commentId}
{ "message": "Updated text" }
200 OK
{ "success": true }
Invite Link
Every group has one public, read-only invite link:
https://app.tailspend.kinesis.studio/groups/<groupId>/join?invite=<token>
The token is signed and lasts 90 days. It names the group and the invite's current revision. Clients store it per group and send it as the x-tailspend-invite header. Each time an OWNER switches the invite on, it gets a new revision, which retires every older link. While the invite is off, every /invite/* read returns 404.
Visiting /groups/<groupId>/join without a token shows nothing about the group. Signed-in visitors can still ask to join as a new person.
Lifecycle
- Share. An OWNER reads the signed link from
GET /groups/{groupId}/invite/settingsand shares it. - Read. Anyone with the link calls
GET /groups/{groupId}/invite(no auth) with thex-tailspend-inviteheader and gets the read-only group view. The view leaves out each participant'suserIdandclaimedAt. - Disable. An OWNER calls
PATCH /groups/{groupId}/invitewith{ "enabled": false }. From then on, public reads return404. - Re-enable. The same endpoint with
{ "enabled": true }creates a new revision and returns a new link. Older links stay dead.
Read the public group
GET /groups/{groupId}/invite
200 OK
{
"id": "a1b2c3d4-...",
"label": "Lisbon trip",
"currency": "GBP",
"createdAt": "2025-05-24T13:45:00Z",
"updatedAt": "2025-05-24T13:45:00Z",
"participants": [
{
"id": "11111111-...",
"type": "USER",
"displayName": "Alex",
"role": "OWNER"
},
{
"id": "22222222-...",
"type": "GUEST",
"displayName": "Sam",
"role": "MEMBER"
}
]
}
404 Not Found if the token is missing, expired or out of date, if the invite is disabled, or if the group does not exist.
Enable / disable the invite
PATCH /groups/{groupId}/invite (OWNER)
{ "enabled": false }
200 OK
{
"groupId": "a1b2c3d4-...",
"enabled": false,
"updatedAt": "2025-05-24T13:45:00Z"
}
Join Requests
Any signed-in user can submit a join request. An OWNER then approves or denies it. On approval, the API writes the participant and marks the request APPROVED in a single transaction.
What a request may ask for depends on whether it carries the group's invite link:
- With the link (
x-tailspend-inviteheader): either mode is allowed. - Without the link: only a
NEWrequest is allowed;TAKEOVERreturns422. These requests are capped at 20 per user per day, returning429past that. The request comes back withviaInvite: false, so owners can see it was made without the link. An unknown group returns404. A block in either direction between the applicant and any current OWNER also returns404, with or without an invite link. All roster pages are checked before creating a request or charging the quota. After an explicit denial, a persistent per-(applicant, group) guard rejects tokenless retries with409; a valid invite link permits a new request but cannot replace another pending one.
Request modes
TAKEOVER— bind the caller's account to an existingGUESTparticipant. The flip isGUEST → USER, preserving every historical expense reference.NEW— create a brand-newUSERparticipant. No historical expenses are tied to the caller; they appear from the moment of approval onwards.displayNameis optional and defaults to the caller's profile name.
The applicant proposes a role (MEMBER or VIEWER). The OWNER may override it on approve.
Lifecycle
- Submit. Authenticated caller
POST /groups/{groupId}/join-requests. Pre-checks: caller must not already be a participant of the group (409 Conflict), and must not have a pending request for this group (409 Conflict). - Review. OWNER lists pending requests via
GET /groups/{groupId}/join-requests. - Approve. OWNER
POST /groups/{groupId}/join-requests/{requestId}/approvewith optionalroleand (forNEWmode)displayNameoverrides. ATransactWriteItemsflips the request row toAPPROVEDand either claims the targeted GUEST or creates a fresh USER participant. - Deny. OWNER
POST /groups/{groupId}/join-requests/{requestId}/deny. Atomically marks the request denied and retains a per-applicant denial guard; no participant write. - Cancel. Applicant
DELETE /groups/{groupId}/join-requests/minewhile still pending. Sets the row toCANCELLED.
Decided rows (APPROVED, DENIED, CANCELLED) are retained for audit but no longer appear in the OWNER's pending list or the applicant's /users/me/join-requests view.
Failure modes
- Caller is already a participant of the group →
409 Conflict. - Caller already has a pending request for the same group →
409 Conflict. - Approve targets a
TAKEOVERparticipant who is no longer aGUEST(e.g. another OWNER claimed/removed/renamed-via-takeover already) →409 Conflict. - Approve / deny / cancel against a request whose status is no longer
PENDING→409 Conflict. - Two OWNERs race to approve the same request → exactly one wins; the other gets
409 Conflict. TAKEOVERwithout the invite link →422 Unprocessable Entity.- More than 20 requests without the link in a day →
429 Too Many Requests. - Public read endpoints when the token is missing, expired or out of date, the invite is disabled, or the group is missing →
404 Not Found.
Submit a join request
POST /groups/{groupId}/join-requests
Takeover mode:
{
"mode": "TAKEOVER",
"targetParticipantId": "22222222-...",
"proposedRole": "MEMBER"
}
New-participant mode:
{
"mode": "NEW",
"displayName": "Sam",
"proposedRole": "MEMBER"
}
201 Created
{
"id": "44444444-...",
"groupId": "a1b2c3d4-...",
"applicantUserId": "0d9f7b8e-...",
"applicantDisplayName": "Sam",
"mode": "TAKEOVER",
"targetParticipantId": "22222222-...",
"proposedRole": "MEMBER",
"status": "PENDING",
"viaInvite": true,
"createdAt": "2025-05-24T13:45:00Z",
"updatedAt": "2025-05-24T13:45:00Z"
}
Approve a join request
POST /groups/{groupId}/join-requests/{requestId}/approve (OWNER)
{ "role": "MEMBER" }
200 OK
{
"groupId": "a1b2c3d4-...",
"participantId": "22222222-...",
"role": "MEMBER"
}
For TAKEOVER, participantId is the existing GUEST participant (now claimed). For NEW, it is the freshly minted participant id. Either way, the response gives the caller exactly what they need to navigate the new participant into the UI.
Deny / cancel
POST /groups/{groupId}/join-requests/{requestId}/deny (OWNER) and DELETE /groups/{groupId}/join-requests/mine (applicant) both return { "success": true } and 200 OK. An already-decided request returns 409 Conflict. Denial retains a persistent retry guard; cancellation deletes the pending lock. Denial guards are established by decisions made after deployment; earlier audit rows are retained without a backfill.
Versioning
The API is currently unversioned. Breaking changes will be introduced behind a path prefix (/v2/...) once the first production client ships. Backwards-compatible changes (new optional fields, new endpoints) are made in-place and reflected immediately in the OpenAPI document and generated client.
Generating the contract
npm run build
This produces:
packages/openapi/dist/openapi.json— the canonical generated document.apps/infra/dist/openapi.json— consumed by CDK service-stack synthesis; fails without it.apps/infra/dist/api-docs/— the bucket source forApiDocsStack:index.html(developer getting-started guide),developer-reference.htmlandapp-reference.html(Swagger UI),developer-openapi.jsonandopenapi.json, plusreference.html,help.html, rendered repository Markdown and local diagram assets. Deployed verbatim to S3 + CloudFront on everycdk deploy.packages/api-client/src/schema.d.ts— generated TypeScript client types.
Browsing the schema (Swagger UI)
The complete static docs site includes repository Markdown at reference.html and product help at help.html. Its root is the developer API getting-started guide; developer-reference.html and app-reference.html are Swagger UI over the developer and app contracts. It is hosted on its own subdomain per stage, served by an isolated S3 + CloudFront stack (ApiDocsStack). Hosting under a separate origin from the SPA — rather than under app.tailspend.kinesis.studio/api-docs — gives the docs page a clean CSS, cookie, and service-worker scope: Swagger UI's globally-scoped styles cannot leak into the SPA, and the SPA's auth cookies cannot leak into the docs.
| Stage | URL |
|---|---|
| dev | https://docs.dev.tailspend.kinesis.studio |
| beta | https://docs.beta.tailspend.kinesis.studio |
| prod | https://docs.tailspend.kinesis.studio |
ApiDocsStack provisions: a private S3 bucket (Block Public Access, OAC origin), a CloudFront distribution with HTTPS-only viewer policy and HSTS / X-Frame-Options / referrer-policy response headers, an ACM cert in us-east-1 (CAA-pinned to amazon.com so the tree-walk short-circuits at the docs host rather than climbing the parent zones), and a Route 53 alias to the distribution. The bucket source is apps/infra/dist/api-docs/, which the normal @repo/openapi build writes from the same Zod-driven generator that emits openapi.json. Synthesis fails fast if that directory is missing.
Local development: run npm run build then serve apps/infra/dist/api-docs/ with python3 -m http.server 3001 --directory apps/infra/dist/api-docs and browse http://localhost:3001 — Swagger UI loads from the unpkg-hosted swagger-ui-dist and reads the JSON documents from the same directory. The pages are generated by packages/openapi/src/docs-site.ts.
Balance reads and pending invitees
GET /users/me/groups, group balances and People balances keep their response
contracts while using initialized materialized net balances rather than replaying
expense history. Legacy groups retain the history path until the
post-deploy migration runs.
An owner inviting a registered person reserves an assignable guest slot immediately. It grants no group access before acceptance. Accepting claims the same participant ID, preserving expenses assigned while the invitation was pending. Guests can also be added directly from People and guests → Add guest, claimed through the existing join flow, or reassigned using the existing bulk-transfer action.
Guests and Invited Expense Participants (#744)
POST /groups/{groupId}/invitations accepts optional targetParticipantId when
userIds contains exactly one account. It links an existing guest so acceptance
inherits that guest's history. An occupied/reserved slot or a conflicting pending
invitation returns 409. Invitations without a target reserve a new assignable
participant, or reuse a previous guest linked to that account.
Authenticated participants may include invitedUserId, invitationExpiresAt and
transferTargetParticipantId. These fields never grant group access. Invitation
lists include optional participantId; the invitee's inbox also carries
guestDisplayName to explain the history they will inherit. Public invite views
expose only claimable: false for unavailable guests, without the linked account.
The existing participant transfer endpoint also moves recurring templates and
may return recurringExpensesUpdated. removeSource: false retains/unlocks the
source after success. Partial transfers retain a fixed destination; retry to that
same target. Conflicting expense/template edits or financial writes referencing
a transfer's locked source return 409. See People for the lifecycle.