Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .husky/pre-push

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Running a full type analysis on every push is slow, even for docs-only changes. Could this move to CI, or at least only run when src/server/api/** changed?

Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Keep the committed OpenAPI spec in sync with the API router: regenerate it
# and abort the push when the regenerated file would change, so the spec can
# never go stale. Compare against both the working tree and the index so the
# check passes only after the file is actually committed.
pnpm gen:openapi
if [ -n "$(git status --porcelain -- src/server/api/openapi.generated.json)" ]; then
echo "✖ src/server/api/openapi.generated.json is out of date."
echo " Run: pnpm gen:openapi && git add src/server/api/openapi.generated.json && git commit"
exit 1
fi
94 changes: 94 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# SplitPro Public REST API

SplitPro exposes a small, versioned REST API for integrations (e.g. syncing
expenses to a budgeting tool). The API is served at `/api/v1` and documented
interactively at **`/api/docs`** (Scalar), backed by the machine-readable
OpenAPI 3.1 spec at **`/api/openapi.json`**.

## Authentication

1. In the app, open **Account → API keys** and create a key. Optionally set an
expiry date. The plaintext key (`spro_…`) is shown exactly once; only a
SHA-256 hash is stored server-side.
2. Send the key with every request:

```
Authorization: Bearer spro_…
```

The `X-API-Key: spro_…` header is also accepted. Requests without a valid,
unexpired key receive `401`.

API keys act with your user's permissions: every endpoint only returns data
you are part of, and writes run the same permission checks as the app.

## Endpoints

| Endpoint | Method | Description |
| ------------------------------- | ------ | ----------------------------------------------- |
| `user.me` | GET | Authenticated user profile |
| `user.getFriends` | GET | Friends with balances |
| `user.getOwnExpenses` | GET | Expenses paid by you **(paginated)** |
| `user.getBalancesWithFriend` | GET | Per-group balances with a friend |
| `user.getFriend` | GET | Friend profile + default split |
| `group.getAllGroups` | GET | All groups you belong to |
| `group.getGroupDetails` | GET | Single group with members and balances |
| `expense.getBalances` | GET | Per-friend balances across all groups |
| `expense.getAllExpenses` | GET | All expenses you participate in **(paginated)** |
| `expense.getExpensesWithFriend` | GET | Expense history with a friend **(paginated)** |
| `expense.getGroupExpenses` | GET | All expenses in a group **(paginated)** |
| `expense.getExpenseDetails` | GET | Full expense detail |
| `expense.addOrEditExpense` | POST | Create or update expenses (array body) |

All requests go to `https://<your-host>/api/v1/<endpoint>`, e.g.
`GET /api/v1/user.me`.

### Input formats

GET endpoints accept individual query params (`?groupId=150&limit=20`), a
plain JSON object (`?input={"groupId":150}`), or a native superjson envelope.
POST bodies are plain JSON; a superjson envelope also works when calling from
a tRPC client.

Because JSON has no BigInt or Date type, monetary amounts accept numbers or
numeric strings (strings keep full precision beyond `Number.MAX_SAFE_INTEGER`)
and are returned as numbers; timestamps are ISO 8601 strings.

### Pagination

List endpoints use offset pagination:

- Input: `?limit=20&offset=0` (defaults `limit=20`, `offset=0`, max `limit=100`)
- Response: `{ items: […], pagination: { total, limit, offset, hasMore } }`

### Example

```bash
curl -H "Authorization: Bearer spro_…" \
'https://splitpro.example.com/api/v1/expense.getGroupExpenses?groupId=16&limit=10'
```

```bash
curl -X POST -H "Authorization: Bearer spro_…" -H "Content-Type: application/json" \
-d '[{"name":"Lunch","currency":"EUR","groupId":null,"amount":4200,"paidBy":1,
"category":"general","splitType":"EQUAL",
"participants":[{"userId":1,"amount":2100},{"userId":2,"amount":-2100}],
"expenseDate":"2026-10-25T12:00:00Z"}]' \
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
'https://splitpro.example.com/api/v1/expense.addOrEditExpense'
```

Note the participant amounts follow the app's stored sign convention: the
payer's entry is `amount − their share` (positive), every other participant's
entry is their negated share. The example above is a `4200` expense split
equally between user 1 (payer) and user 2, so each share is `2100`.

## Maintenance (for developers)

- The API router is `src/server/api/apiRouter.ts` — a curated subset of the
app procedures, shared via the exported method records in the router files.
- Regenerate the OpenAPI spec after changing any procedure with
`pnpm gen:openapi`. The pre-push hook fails if the committed
`src/server/api/openapi.generated.json` is out of date, and `pnpm build`
regenerates it.
- `/api/docs` loads the Scalar bundle pinned to an exact version with a
subresource-integrity hash (see `src/pages/api/docs.ts` for upgrade steps).
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
"private": true,
"type": "module",
"scripts": {
"build": "next build",
"build": "pnpm gen:openapi && next build",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Running gen:openapi in build makes every build depend on devDeps and on the TS analysis step. Could we keep build as next build and add a CI check that fails if the spec is stale?

"just-build": "next build",
"gen:openapi": "cross-env SKIP_ENV_VALIDATION=true tsx scripts/generate-openapi.ts",
"db:push": "prisma db push && prisma generate",
"db:studio": "prisma studio",
"db:dev": "prisma migrate dev && prisma generate",
Expand Down Expand Up @@ -46,6 +47,7 @@
"@tanstack/react-query": "^5.79.2",
"@trpc/client": "^11.2.0",
"@trpc/next": "^11.2.0",
"@trpc/openapi": "11.18.0-alpha",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an alpha with peer deps @trpc/server@11.18.0 and zod>=4, but we're on 11.8.0 and zod 3.25. Can we pin to a stable version, or confirm it works with our versions? Also, it's only used at build time, so it should go in devDependencies.

"@trpc/react-query": "^11.2.0",
"@trpc/server": "^11.2.0",
"boring-avatars": "^1.10.1",
Expand Down Expand Up @@ -102,6 +104,7 @@
"@types/react-dom": "19.3.0",
"@types/web-push": "^3.6.3",
"babel-plugin-react-compiler": "^1.0.0",
"cross-env": "^10.1.0",
"esbuild": "^0.25.12",
"husky": "^9.1.7",
"jest": "30.5.2",
Expand Down
48 changes: 48 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 22 additions & 0 deletions prisma/migrations/20260715120000_add_api_key/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
-- CreateTable
CREATE TABLE "ApiKey" (
"id" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"hashedKey" TEXT NOT NULL,
"partialKey" TEXT NOT NULL,
"lastUsedAt" TIMESTAMP(3),
"expiresAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "ApiKey_hashedKey_key" ON "ApiKey"("hashedKey");

-- CreateIndex
CREATE INDEX "ApiKey_userId_idx" ON "ApiKey"("userId");

-- AddForeignKey
ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
16 changes: 16 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,27 @@ model User {
friendDefaultSplitsA FriendDefaultSplit[] @relation("FriendDefaultSplitUserA")
friendDefaultSplitsB FriendDefaultSplit[] @relation("FriendDefaultSplitUserB")
sessions Session[]
apiKeys ApiKey[]
hiddenFriendIds Int[] @default([])

@@schema("public")
}

model ApiKey {
id String @id @default(cuid())
userId Int
name String
hashedKey String @unique
partialKey String
lastUsedAt DateTime?
expiresAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)

@@index([userId])
@@schema("public")
}

model VerificationToken {
identifier String
token String @unique
Expand Down
21 changes: 21 additions & 0 deletions public/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,27 @@
"title": "Edit details"
},
"follow_on_x": "Follow us on X",
"api_keys": {
"title": "API keys",
"description": "Use API keys to access the SplitPro API. Send the key as",
"docs_prefix": "Documentation is at",
"copy_now_notice": "Copy your key now — it won't be shown again.",
"name_label": "New key name",
"name_placeholder": "e.g. Actual Budget",
"expires_label": "Expires (optional)",
"create": "Create",
"last_used": "used {date}",
"never_used": "never used",
"expires_on": "expires {date}",
"empty": "No API keys yet.",
"messages": {
"name_required": "Please enter a name for the key",
"create_failed": "Failed to create API key",
"revoke_failed": "Failed to revoke API key",
"copied": "Copied to clipboard",
"copy_failed": "Failed to copy"
}
},
"import_from_splitwise": "Import from Splitwise",
"import_from_splitwise_details": {
"choose_file": "Choose file",
Expand Down
Loading