TailSpend Docs

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:

  1. Zod schemas in packages/api-schema/src define every request, response, and entity.
  2. @repo/openapi produces an OpenAPI 3 document from those schemas.
  3. CDK reads the OpenAPI document to provision routes.
  4. @repo/api-client produces a typed openapi-fetch client.

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

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).

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:

Rules of note:

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:

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.

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

Item

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 }

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

  1. Share. An OWNER reads the signed link from GET /groups/{groupId}/invite/settings and shares it.
  2. Read. Anyone with the link calls GET /groups/{groupId}/invite (no auth) with the x-tailspend-invite header and gets the read-only group view. The view leaves out each participant's userId and claimedAt.
  3. Disable. An OWNER calls PATCH /groups/{groupId}/invite with { "enabled": false }. From then on, public reads return 404.
  4. 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:

Request modes

The applicant proposes a role (MEMBER or VIEWER). The OWNER may override it on approve.

Lifecycle

  1. 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).
  2. Review. OWNER lists pending requests via GET /groups/{groupId}/join-requests.
  3. Approve. OWNER POST /groups/{groupId}/join-requests/{requestId}/approve with optional role and (for NEW mode) displayName overrides. A TransactWriteItems flips the request row to APPROVED and either claims the targeted GUEST or creates a fresh USER participant.
  4. Deny. OWNER POST /groups/{groupId}/join-requests/{requestId}/deny. Atomically marks the request denied and retains a per-applicant denial guard; no participant write.
  5. Cancel. Applicant DELETE /groups/{groupId}/join-requests/mine while still pending. Sets the row to CANCELLED.

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

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:

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.

View Markdown source