# Authentication (/docs/agents/authentication)
The MCP server and the REST API accept the same two credentials, both sent as a
bearer token:
| Credential | Best for | Acts as |
| --- | --- | --- |
| OAuth access token | AI agents and apps that people sign in to | The person who approved access, in the team they chose |
| Team API key | Scripts, scheduled jobs, and clients without OAuth | The member who created the key, in that key's team |
## OAuth [#oauth]
Osorik is an OAuth 2.1 authorization server with dynamic client registration.
Most MCP clients handle every step on their own; this section is for people
building a client.
### Discovery [#discovery]
| Metadata | URL |
| --- | --- |
| Protected resource ([RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728)) | `https://api.osorik.dev/.well-known/oauth-protected-resource/mcp` |
| Authorization server ([RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414)) | `https://api.osorik.dev/.well-known/oauth-authorization-server/api/auth` |
The authorization server's issuer is `https://api.osorik.dev/api/auth`. Use RFC 8414
discovery; `/.well-known/openid-configuration` describes a different issuer.
### Scopes [#scopes]
| Scope | Grants |
| --- | --- |
| `read` | Every read operation: `GET` routes and read-only tools |
| `write` | Every operation that creates, changes or deletes data |
| `offline_access` | A refresh token, so the client can stay signed in |
`write` does not include `read`; most clients request both. Requesting any
other scope, such as `openid`, fails with `invalid_scope`.
Without `offline_access`, the client must sign in again when the access token
expires after one hour.
### Requests [#requests]
- Register clients as public clients. Registration always returns
`token_endpoint_auth_method: none`, whatever the request asks for.
- Send `resource=https://api.osorik.dev/mcp` exactly, with no trailing slash. The token
endpoint rejects any other value.
- Sign-in and consent happen at `https://app.osorik.dev/oauth/authorize`, where the person
picks the team the client will act in.
## API keys [#api-keys]
A team API key starts with `osk_` and belongs to one member in one team.
1. In Osorik, open the team menu at the bottom of the sidebar, choose
**Team**, then open the **API keys** tab.
2. Choose **Create key** and give it a name that says where it's used, such
as _Accounting export_.
3. Pick its access: **Read only** keys get the `read` scope, **Read and
write** keys get `read` and `write`.
4. Copy the key and store it in a secret manager or environment variable.
Anyone with the key has the access of the member who created it, so treat it
like a password. Members can revoke their own keys. Owners and admins can see
and revoke every member's keys.
Send the key on every request:
```http
Authorization: Bearer osk_…
```
# Client setup (/docs/agents/clients)
Every client needs the same server URL:
```text
https://api.osorik.dev/mcp
```
Clients that support OAuth open the Osorik sign-in page the first time they
connect. Clients without OAuth can send a [team API
key](/docs/agents/authentication#api-keys) instead.
Claude Code is tested end to end. Claude and Cursor use the same OAuth flow.
If a client fails to connect, write to
[support@osorik.com](mailto:support@osorik.com) with the client name and
version.
Add the server, then sign in:
```bash
claude mcp add --transport http osorik https://api.osorik.dev/mcp
claude mcp login osorik
```
Add `--scope user` to `claude mcp add` to make Osorik available in every
project, not only the current one.
To use an API key instead of OAuth:
```bash
claude mcp add --transport http osorik https://api.osorik.dev/mcp \
--header "Authorization: Bearer $OSORIK_API_KEY"
```
In Claude on the web or desktop:
1. Open **Settings**, then **Connectors**.
2. Choose **Add custom connector**.
3. Name it `Osorik` and paste `https://api.osorik.dev/mcp` as the URL.
4. Choose **Connect**, sign in to Osorik, pick a team, and approve access.
Add Osorik to `.cursor/mcp.json` in your project, or to `~/.cursor/mcp.json`
for every project:
```json title="mcp.json"
{
"mcpServers": {
"osorik": {
"url": "https://api.osorik.dev/mcp"
}
}
}
```
Open **Cursor Settings**, then **MCP**, and choose **Connect** next to Osorik to
sign in.
Any client that supports the streamable HTTP transport can connect.
- **With OAuth:** point the client at `https://api.osorik.dev/mcp`. A request without a
token returns `401` with a `WWW-Authenticate` header that links to the
protected resource metadata, which starts discovery.
- **With an API key:** send it on every request as
`Authorization: Bearer osk_…`.
# Connect an AI agent (/docs/agents)
Osorik runs a remote MCP server. Once connected, your agent can call Osorik
tools in the conversation, acting as you inside the team you choose.
| Setting | Value |
| --- | --- |
| Server URL | `https://api.osorik.dev/mcp` |
| Transport | Streamable HTTP |
| Authentication | OAuth 2.1, or a team API key as a bearer token |
## Quick start with Claude Code [#quick-start-with-claude-code]
### Add the server [#add-the-server]
```bash
claude mcp add --transport http osorik https://api.osorik.dev/mcp
```
### Sign in [#sign-in]
```bash
claude mcp login osorik
```
Your browser opens the Osorik sign-in page. Sign in with your email code, pick
the team the agent should work in, and approve access.
### Ask about your team [#ask-about-your-team]
Start Claude Code and ask something like _"What address does Osorik have for my
team?"_ Claude calls the `get_team` tool and answers from your workspace.
For Claude, Cursor, and other clients, see [Client setup](/docs/agents/clients).
## What agents can do [#what-agents-can-do]
Agents can read and update most of the workspace:
- **Read:** the team profile, contacts, categories, bank accounts,
transactions, invoices and invoice settings, expenses, projects, and time
entries, tasks and the running timer.
- **Change:** create and update contacts, add categories, categorize,
annotate and archive transactions, draft invoices, update expenses, manage
projects, and log time or run the timer.
Agents can't issue, void or send invoices, delete contacts or expenses,
upload documents, connect banks or manage members; do those in the app. See
[MCP tools](/docs/agents/tools) for every tool and what it needs.
## How access works [#how-access-works]
- **One team per connection.** You choose the team on the consent screen. To
use another team, connect again and choose it.
- **Your role applies.** The agent can only do what you can do in that team.
- **Revoking access.** Sign out of the server in your client, for example with
`claude mcp logout osorik`. An API key stops working as soon as it is revoked
in the team settings.
See [Authentication](/docs/agents/authentication) for OAuth details and API
keys.
# MCP tools (/docs/agents/tools)
Tools run as the signed-in member, in the team chosen when the client
connected, with that member's role. Each tool does the same as the matching
[REST API](/docs/api) endpoint and returns the same data, as both text and
structured content.
Tools that read data need the `read` scope and are marked read-only. Tools that
create or change data need the `write` scope. Data follows the
[API conventions](/docs/api#conventions): IDs are strings, amounts are decimal
strings, and absent values are `null`. List tools return
`{ "data": [...], "nextCursor": ... }`; pass `nextCursor` back as `cursor` for
the next page.
| Tool | Scope | REST equivalent |
| --- | --- | --- |
| [`get_team`](#get_team) | `read` | `GET /v1/team` |
| [`list_contacts`](#list_contacts) | `read` | `GET /v1/contacts` |
| [`get_contact`](#get_contact) | `read` | `GET /v1/contacts/{contactId}` |
| [`create_contact`](#create_contact) | `write` | `POST /v1/contacts` |
| [`update_contact`](#update_contact) | `write` | `PATCH /v1/contacts/{contactId}` |
| [`list_categories`](#list_categories) | `read` | `GET /v1/categories` |
| [`create_category`](#create_category) | `write` | `POST /v1/categories` |
| [`list_bank_accounts`](#list_bank_accounts) | `read` | `GET /v1/bank-accounts` |
| [`list_transactions`](#list_transactions) | `read` | `GET /v1/transactions` |
| [`get_transaction`](#get_transaction) | `read` | `GET /v1/transactions/{transactionId}` |
| [`update_transaction`](#update_transaction) | `write` | `PATCH /v1/transactions/{transactionId}` |
| [`list_invoices`](#list_invoices) | `read` | `GET /v1/invoices` |
| [`get_invoice`](#get_invoice) | `read` | `GET /v1/invoices/{invoiceId}` |
| [`create_invoice_draft`](#create_invoice_draft) | `write` | `POST /v1/invoices` |
| [`update_invoice_draft`](#update_invoice_draft) | `write` | `PATCH /v1/invoices/{invoiceId}` |
| [`delete_invoice_draft`](#delete_invoice_draft) | `write` | `DELETE /v1/invoices/{invoiceId}` |
| [`get_invoice_settings`](#get_invoice_settings) | `read` | `GET /v1/invoice-settings` |
| [`list_expenses`](#list_expenses) | `read` | `GET /v1/expenses` |
| [`get_expense`](#get_expense) | `read` | `GET /v1/expenses/{expenseId}` |
| [`update_expense`](#update_expense) | `write` | `PATCH /v1/expenses/{expenseId}` |
| [`list_projects`](#list_projects) | `read` | `GET /v1/projects` |
| [`get_project`](#get_project) | `read` | `GET /v1/projects/{projectId}` |
| [`create_project`](#create_project) | `write` | `POST /v1/projects` |
| [`update_project`](#update_project) | `write` | `PATCH /v1/projects/{projectId}` |
| [`list_time_tasks`](#list_time_tasks) | `read` | `GET /v1/projects/{projectId}/tasks` |
| [`create_time_task`](#create_time_task) | `write` | `POST /v1/projects/{projectId}/tasks` |
| [`list_time_entries`](#list_time_entries) | `read` | `GET /v1/time-entries` |
| [`create_time_entry`](#create_time_entry) | `write` | `POST /v1/time-entries` |
| [`update_time_entry`](#update_time_entry) | `write` | `PATCH /v1/time-entries/{entryId}` |
| [`delete_time_entry`](#delete_time_entry) | `write` | `DELETE /v1/time-entries/{entryId}` |
| [`get_timer`](#get_timer) | `read` | `GET /v1/timer` |
| [`start_timer`](#start_timer) | `write` | `POST /v1/timer/start` |
| [`stop_timer`](#stop_timer) | `write` | `POST /v1/timer/stop` |
## Team [#team]
### `get_team` [#get_team]
Returns the team (workspace) the connection acts in.
| Property | Value |
| --- | --- |
| Arguments | None |
| Scope | `read` |
| REST equivalent | `GET /v1/team` |
### Team [#team-1]
| Field | Type | Description |
| --- | --- | --- |
| `id` | string | Team ID |
| `name` | string | Team name |
| `email` | string or null | Contact email |
| `taxId` | string or null | Tax identification number |
| `address` | string or null | Street address |
| `zipCode` | string or null | Postal code |
| `city` | string or null | City |
| `state` | string or null | State or region |
| `country` | string | ISO 3166-1 alpha-2 country code, such as `PT` |
| `timeZone` | string | IANA time zone, such as `Europe/Lisbon` |
| `website` | string or null | Website URL |
| `logoUrl` | string or null | URL of the team logo |
| `role` | `owner`, `admin`, or `member` | Your role in the team |
## Contacts [#contacts]
Contacts are the people and organizations the team pays (payees) or bills
(clients).
### `list_contacts` [#list_contacts]
Lists contacts by name, or by relevance when `search` is set.
| Property | Value |
| --- | --- |
| Arguments | `search`, `role` (`client` or `payee`), `limit`, `cursor` |
| Scope | `read` |
| REST equivalent | `GET /v1/contacts` |
### `get_contact` [#get_contact]
Returns one contact.
| Property | Value |
| --- | --- |
| Arguments | `contactId` |
| Scope | `read` |
| REST equivalent | `GET /v1/contacts/{contactId}` |
### `create_contact` [#create_contact]
Creates a contact. If the team already has a contact with the same tax ID and
country, email, or legal name, that contact is returned instead.
| Property | Value |
| --- | --- |
| Arguments | `name` (required), `entityType`, `industry`, `isClient`, `isPayee`, and any [contact field](#contact) below |
| Scope | `write` |
| REST equivalent | `POST /v1/contacts` |
### `update_contact` [#update_contact]
Changes the fields sent. `null` clears an optional field; `bankAccount` and
`billing` replace the whole object.
| Property | Value |
| --- | --- |
| Arguments | `contactId` and any field to change |
| Scope | `write` |
| REST equivalent | `PATCH /v1/contacts/{contactId}` |
### Contact [#contact]
| Field | Type | Description |
| --- | --- | --- |
| `id` | string | Contact ID |
| `name` | string | Display name |
| `legalName` | string | Registered name |
| `entityType` | string | `organization`, `individual`, `government`, `nonprofit`, or `other` |
| `industry` | string | Industry, such as `software_and_cloud` |
| `isClient` | boolean | Whether the team bills this contact |
| `isPayee` | boolean | Whether the team pays this contact |
| `email`, `phone`, `website` | string or null | Contact details |
| `address`, `city`, `state`, `zipCode` | string or null | Postal address |
| `country` | string or null | ISO 3166-1 alpha-2 country code |
| `taxId` | string or null | Tax identification number |
| `note` | string or null | Internal note |
| `categoryId` | string or null | Default category for the contact's transactions |
| `bankAccount` | object or null | `bankName`, `country`, `currency`, `accountNumber`, `accountNumberType` (`iban` or `local`), `bic` |
| `billing` | object or null | For clients: `email`, `bccEmails`, `address`, `city`, `state`, `zipCode`, `country`, `defaultCurrency`, `paymentTermsDays`, `reference`, `portalEnabled` |
| `createdAt` | string | ISO 8601 timestamp |
## Categories [#categories]
Accounting categories classify transactions, contacts and invoices. Only team
owners and admins can create them.
### `list_categories` [#list_categories]
Lists the team's categories, grouped by type and sorted by name. A team has at
most 100 categories, so `nextCursor` is always `null`.
| Property | Value |
| --- | --- |
| Arguments | `includeArchived` (boolean, default `false`): also return archived categories |
| Scope | `read` |
| REST equivalent | `GET /v1/categories` |
### `create_category` [#create_category]
Creates a custom category and returns it. Fails with
`CATEGORY_MANAGEMENT_FORBIDDEN` for members and `CATEGORY_LIMIT_REACHED` when
the team already has 100 categories.
| Property | Value |
| --- | --- |
| Arguments | `name` (required, up to 100 characters), `description` (required, up to 240 characters), `type` (required: `income`, `expense`, `asset`, `tax`, `financing`, `owner`, `transfer` or `other`), `accountingCode` (up to 32 characters) |
| Scope | `write` |
| REST equivalent | `POST /v1/categories` |
### Category [#category]
| Field | Type | Description |
| --- | --- | --- |
| `id` | string | Category ID |
| `name` | string | Category name |
| `description` | string or null | What belongs in the category |
| `type` | `income`, `expense`, `asset`, `tax`, `financing`, `owner`, `transfer`, or `other` | Accounting type |
| `accountingCode` | string or null | Code in the accountant's chart of accounts |
| `archived` | boolean | Whether the category is archived |
| `systemRole` | `internal_transfer` or null | Workflow role of a required category; `null` for ordinary categories |
## Bank accounts [#bank-accounts]
### `list_bank_accounts` [#list_bank_accounts]
Lists the team's bank accounts with their account number, currency and last
reported balance. All accounts come back in one page, so `nextCursor` is always
`null`.
| Property | Value |
| --- | --- |
| Arguments | None |
| Scope | `read` |
| REST equivalent | `GET /v1/bank-accounts` |
### Bank account [#bank-account]
Each item in `data`:
| Field | Type | Description |
| --- | --- | --- |
| `id` | string | Bank account ID; use it as `bankAccountId` to filter transactions |
| `displayName` | string or null | Name the team gave the account |
| `accountHolderName` | string or null | Account holder |
| `bankName` | string or null | Bank name |
| `accountType` | string or null | Account nature, such as `current`, `savings` or `card` |
| `accountNumber` | string or null | Account number, such as an IBAN |
| `accountNumberType` | string or null | `iban`, `bban`, `sort-code-account-number`, `us-routing-account-number`, `pan`, `masked-pan`, `msisdn` or `other` |
| `bic` | string or null | Swift/BIC code |
| `currency` | string or null | ISO 4217 currency code |
| `balance` | string or null | Last balance the bank reported, as a decimal string in `currency` |
## Transactions [#transactions]
### `list_transactions` [#list_transactions]
Lists the team's booked bank transactions, newest first. Archived transactions
are left out unless `archived` is `true`. A page can hold fewer items than
`limit` before the last one, so keep following `nextCursor` until it is `null`.
| Property | Value |
| --- | --- |
| Arguments | `search`, `bankAccountId`, `categoryId` (an ID or `uncategorized`), `projectId`, `dateFrom`, `dateTo` (`YYYY-MM-DD`), `direction` (`credit` or `debit`), `status`, `archived`, `limit` (1–100, default 25), `cursor`; all optional |
| Scope | `read` |
| REST equivalent | `GET /v1/transactions` |
### `get_transaction` [#get_transaction]
Returns one transaction with its category, contact, project, note and
bookkeeping status.
| Property | Value |
| --- | --- |
| Arguments | `transactionId` |
| Scope | `read` |
| REST equivalent | `GET /v1/transactions/{transactionId}` |
### `update_transaction` [#update_transaction]
Sets the category, contact, project, note or archive flag of a transaction and
keeps the fields not sent; `null` clears one. A category on one side of a linked
transfer applies to both sides, debits cannot take income categories, and
transfer categories cannot be combined with a project. Setting or clearing
`projectId` needs the owner or admin role.
| Property | Value |
| --- | --- |
| Arguments | `transactionId`; optional `categoryId`, `note` (up to 500 characters), `contactId`, `projectId`, `archived` |
| Scope | `write` |
| REST equivalent | `PATCH /v1/transactions/{transactionId}` |
### Transaction [#transaction]
| Field | Type | Description |
| --- | --- | --- |
| `id` | string | Transaction ID |
| `date` | string | Booking date, `YYYY-MM-DD` |
| `direction` | `credit`, `debit`, or `unknown` | Whether money came in or went out |
| `amount` | string | Absolute amount as a decimal string in `currency` |
| `currency` | string | Account currency |
| `instructedAmount` | string or null | Original amount when paid in another currency |
| `instructedCurrency` | string or null | Currency of `instructedAmount` |
| `title` | string or null | Display title, usually the counterparty |
| `subtitle` | string or null | Secondary description |
| `bankAccountId` | string | Bank account ID |
| `categoryId` | string or null | Category ID |
| `contactId` | string or null | Counterparty contact ID |
| `contactName` | string or null | Counterparty contact name |
| `projectId` | string or null | Project ID |
| `note` | string or null | Team note |
| `status` | `uncategorized`, `in_progress`, `complete`, `needs_review`, or `no_impact` | Bookkeeping status |
| `reconciliationStatus` | string or null | `unmatched`, `partial`, `matched`, `difference_accepted` or `review` |
| `internal` | boolean | A transfer between the team's own accounts |
| `archived` | boolean | Hidden from default lists |
| `createdAt` | string | ISO 8601 time Osorik first imported it |
## Invoices [#invoices]
Outgoing invoices the team bills its clients. Agents can read every invoice
and create, change and delete drafts. Issuing, voiding and sending invoices
stays in Osorik. Amounts are decimal strings in the invoice currency, such as
`1250.00`.
### `list_invoices` [#list_invoices]
Lists invoices without their lines, newest first or by relevance when `search` is set.
| Property | Value |
| --- | --- |
| Arguments | `status` (optional array of `draft`, `issuing`, `issue_failed`, `issued`, `void`), `clientId` (optional), `search` (optional), `limit` (1 to 100, default 25), `cursor` (optional) |
| Scope | `read` |
| REST equivalent | `GET /v1/invoices` |
Returns `{ data, nextCursor }`, where `data` is a list of
[invoices](#invoice) and `nextCursor` is `null` on the last page.
### `get_invoice` [#get_invoice]
Returns one invoice with its lines and, once it is issued, a PDF download URL.
| Property | Value |
| --- | --- |
| Arguments | `invoiceId` |
| Scope | `read` |
| REST equivalent | `GET /v1/invoices/{invoiceId}` |
Returns an [invoice with lines](#invoice-with-lines).
### `create_invoice_draft` [#create_invoice_draft]
Creates an unnumbered invoice draft for a client, filling left-out fields from the client's billing defaults and the team's invoice settings.
| Property | Value |
| --- | --- |
| Arguments | `title`, `clientId`, `lines` (1 to 200 of `{ description, quantity, unitPrice }`), `issueDate`, and optional `categoryId`, `bankAccountId`, `currency`, `dueDate`, `discountPercentage` (0 to 100), `note` |
| Scope | `write` |
| REST equivalent | `POST /v1/invoices` |
`quantity` and `unitPrice` are decimal strings with up to 6 decimals.
Left-out fields default as in the invoice editor:
- `currency`: the client's currency, then the bank account's currency, then
the team currency.
- `dueDate`: `issueDate` plus the client's payment terms, then the settings'
payment terms (30 days unless changed).
- `categoryId`: the settings' category. It must be an active income category.
- `bankAccountId`: the settings' account, then the first account with payment
instructions.
- `note`: the settings' note.
Returns the new [invoice with lines](#invoice-with-lines).
### `update_invoice_draft` [#update_invoice_draft]
Changes the fields sent on a draft and keeps the rest; `lines` replaces every line.
| Property | Value |
| --- | --- |
| Arguments | `invoiceId`, and any of `title`, `clientId`, `lines`, `issueDate`, `dueDate`, `categoryId`, `bankAccountId`, `currency`, `discountPercentage`, `note` |
| Scope | `write` |
| REST equivalent | `PATCH /v1/invoices/{invoiceId}` |
`null` clears `discountPercentage` or `note`. Invoices that are no longer
drafts fail with `INVOICE_IMMUTABLE`. Returns the updated
[invoice with lines](#invoice-with-lines).
### `delete_invoice_draft` [#delete_invoice_draft]
Permanently deletes a draft that was never numbered, with its lines.
| Property | Value |
| --- | --- |
| Arguments | `invoiceId` |
| Scope | `write` |
| REST equivalent | `DELETE /v1/invoices/{invoiceId}` |
Returns `{ id, deleted: true }`. Issued, voided and numbered invoices fail with
`INVOICE_IMMUTABLE`.
### `get_invoice_settings` [#get_invoice_settings]
Returns the team's invoice number prefix and the defaults new drafts use.
| Property | Value |
| --- | --- |
| Arguments | None |
| Scope | `read` |
| REST equivalent | `GET /v1/invoice-settings` |
### Invoice [#invoice]
| Field | Type | Description |
| --- | --- | --- |
| `id` | string | Invoice ID |
| `number` | string or null | Invoice number, such as `INV-2026-0001`; `null` for drafts |
| `title` | string | Invoice title |
| `status` | `draft`, `issuing`, `issue_failed`, `issued`, or `void` | Lifecycle state; only drafts can be changed or deleted |
| `deliveryStatus` | `not_sent`, `queued`, `sent`, or `failed` | Email delivery state |
| `paymentStatus` | `unmatched`, `partial`, `matched`, `difference_accepted`, or `review` | How far bank payments cover the total |
| `clientId` | string | Contact the invoice bills |
| `clientName` | string | Client name, frozen when the invoice is issued |
| `categoryId` | string | Income category ID |
| `bankAccountId` | string | ID of the bank account the client pays into |
| `currency` | string | ISO 4217 currency code, such as `EUR` |
| `issueDate` | string | Issue date as `YYYY-MM-DD` |
| `dueDate` | string | Due date as `YYYY-MM-DD` |
| `discountPercentage` | number or null | Discount on the line subtotal, from 0 to 100 |
| `discountAmount` | string or null | Discount amount; `null` without a discount |
| `totalAmount` | string | Total after discount |
| `paidAmount` | string | Amount matched to bank payments so far |
| `note` | string or null | Note printed on the invoice |
| `issuedAt` | string or null | When the invoice was issued, ISO 8601 |
| `voidedAt` | string or null | When the invoice was voided, ISO 8601 |
| `createdAt` | string | When the invoice was created, ISO 8601 |
| `updatedAt` | string | When the invoice last changed, ISO 8601 |
### Invoice with lines [#invoice-with-lines]
Every [invoice](#invoice) field, plus:
| Field | Type | Description |
| --- | --- | --- |
| `lines` | array | Invoice lines in order, each with the fields below |
| `lines[].description` | string | Line description |
| `lines[].quantity` | string | Decimal quantity |
| `lines[].unitPrice` | string | Decimal unit price |
| `lines[].lineTotal` | string | Quantity times unit price, rounded to the currency |
| `pdfUrl` | string or null | Temporary download URL of the issued PDF; `null` until it is rendered |
### Invoice settings [#invoice-settings]
| Field | Type | Description |
| --- | --- | --- |
| `prefix` | string | Prefix of invoice numbers, such as `INV` |
| `defaultPaymentTermsDays` | number | Days from issue date to due date for new drafts |
| `defaultCategoryId` | string or null | Income category for new drafts |
| `defaultBankAccountId` | string or null | Bank account for new drafts |
| `defaultNote` | string or null | Note for new drafts |
## Expenses [#expenses]
### `list_expenses` [#list_expenses]
Lists the team's expenses, the bills and receipts it uploaded, newest first.
Archived expenses are left out unless `archived` is `true`. A page can hold
fewer items than `limit` before the last one, so keep following `nextCursor`
until it is `null`.
| Property | Value |
| --- | --- |
| Arguments | `status` (list of statuses), `archived`, `search`, `payeeId`, `limit` (1–100, default 25), `cursor`; all optional |
| Scope | `read` |
| REST equivalent | `GET /v1/expenses` |
### `get_expense` [#get_expense]
Returns one expense with its extracted details and a short-lived `documentUrl`
to download the uploaded document.
| Property | Value |
| --- | --- |
| Arguments | `expenseId` |
| Scope | `read` |
| REST equivalent | `GET /v1/expenses/{expenseId}` |
### `update_expense` [#update_expense]
Changes the fields sent and keeps the rest; `null` clears an optional field.
Details can change only while the expense is `unmatched` (otherwise
`EXPENSE_NOT_EDITABLE`); `archived` can change in any status.
| Property | Value |
| --- | --- |
| Arguments | `expenseId`; optional `payeeId`, `reference`, `date`, `dueDate`, `currency`, `amount`, `taxType`, `taxAmount`, `archived` |
| Scope | `write` |
| REST equivalent | `PATCH /v1/expenses/{expenseId}` |
### Expense [#expense]
| Field | Type | Description |
| --- | --- | --- |
| `id` | string | Expense ID |
| `status` | `processing`, `failed`, `unmatched`, `partial`, `matched`, or `difference_accepted` | Document reading and matching state |
| `payeeId` | string or null | Payee contact ID |
| `payeeName` | string or null | Payee name |
| `reference` | string or null | Invoice or receipt number |
| `date` | string or null | Issue date, `YYYY-MM-DD` |
| `dueDate` | string or null | Due date, `YYYY-MM-DD` |
| `currency` | string or null | ISO 4217 currency code |
| `amount` | string or null | Total including tax, as a decimal string |
| `taxType` | string or null | Tax kind, such as `vat` |
| `taxAmount` | string or null | Tax amount, as a decimal string |
| `reconciledAmount` | string | Part of `amount` matched to bank transactions |
| `archived` | boolean | Hidden from default lists |
| `documentId` | string | ID of the uploaded document |
| `extractionError` | string or null | Why reading the document failed |
| `createdAt` | string | ISO 8601 upload time |
| `documentUrl` | string or null | `get_expense` only: short-lived link to the document |
## Projects [#projects]
Projects group client or internal work for time tracking and cash flow. Every
member can read them; only team owners and admins can create or change them.
### `list_projects` [#list_projects]
Lists the team's projects, most recently updated first, or by relevance when
`search` is set.
| Property | Value |
| --- | --- |
| Arguments | `status` (`planned`, `active`, `on_hold`, `completed` or `cancelled`), `clientId`, `search`, `limit` (1 to 100, default 25), `cursor` |
| Scope | `read` |
| REST equivalent | `GET /v1/projects` |
### `get_project` [#get_project]
Returns one project with its assigned team members. Members' hourly rates are
shown only to owners and admins.
| Property | Value |
| --- | --- |
| Arguments | `projectId` (required) |
| Scope | `read` |
| REST equivalent | `GET /v1/projects/{projectId}` |
### `create_project` [#create_project]
Creates a project in the `planned` state, assigns you to it at `billableRate`
and returns it with its members. `clientId` must be a contact marked as a
client.
| Property | Value |
| --- | --- |
| Arguments | `name` (required, up to 120 characters), `currency` (required, ISO 4217), `billableRate` (required decimal string, `0` when not charged), `description`, `clientId`, `startDate`, `targetEndDate` (on or after `startDate`), `timeRoundingMinutes` (`0`, `5`, `10`, `15` or `30`, default `15`) |
| Scope | `write` |
| REST equivalent | `POST /v1/projects` |
### `update_project` [#update_project]
Changes the fields sent and keeps the rest; `null` clears `description`,
`clientId`, `startDate` or `targetEndDate`. `completed` and `cancelled` close
the project. A closed project must be reopened before its other fields change,
which can happen in the same call. The client and currency cannot change once
time is tracked (`PROJECT_TIME_IDENTITY_LOCKED`).
| Property | Value |
| --- | --- |
| Arguments | `projectId` (required), `name`, `description`, `clientId`, `currency`, `startDate`, `targetEndDate`, `timeRoundingMinutes`, `status` |
| Scope | `write` |
| REST equivalent | `PATCH /v1/projects/{projectId}` |
### Project [#project]
`list_projects` returns these fields; `get_project`, `create_project` and
`update_project` add `members`.
| Field | Type | Description |
| --- | --- | --- |
| `id` | string | Project ID |
| `name` | string | Project name |
| `description` | string or null | Project description |
| `status` | `planned`, `active`, `on_hold`, `completed`, or `cancelled` | Lifecycle state |
| `clientId` | string or null | Client contact ID; `null` for internal work |
| `currency` | string | ISO 4217 currency code |
| `startDate` | string or null | Start date, `YYYY-MM-DD` |
| `targetEndDate` | string or null | Planned end date, `YYYY-MM-DD` |
| `timeRoundingMinutes` | `0`, `5`, `10`, `15`, `30`, or null | Rounding applied to billable time entries |
| `closedAt` | string or null | When the project was completed or cancelled (ISO 8601) |
| `createdAt` | string | Creation time (ISO 8601) |
| `updatedAt` | string | Last change (ISO 8601) |
| `members` | array of ProjectMember | Assigned team members (single-project tools only) |
### Project member [#project-member]
| Field | Type | Description |
| --- | --- | --- |
| `userId` | string | User ID of the member |
| `name` | string or null | Member's name |
| `billableRate` | string or null | Hourly rate as a decimal string in the project currency; `null` unless you are an owner or admin |
## Time [#time]
Time is tracked against reusable tasks on projects. Members log time only on
projects they are assigned to, and see and change only their own entries;
owners and admins see everyone's entries and billable rates.
### `list_time_tasks` [#list_time_tasks]
Lists a project's tasks, up to 200, on a single page (`nextCursor` is always
`null`). Archived tasks are left out unless `includeArchived` is `true`.
| Property | Value |
| --- | --- |
| Arguments | `projectId` (required), `includeArchived` |
| Scope | `read` |
| REST equivalent | `GET /v1/projects/{projectId}/tasks` |
### `create_time_task` [#create_time_task]
Adds a task to an open project. Task names are unique within a project,
ignoring case and spacing, and a project holds at most 200 tasks.
| Property | Value |
| --- | --- |
| Arguments | `projectId`, `name` (required); `description` |
| Scope | `write` |
| REST equivalent | `POST /v1/projects/{projectId}/tasks` |
### `list_time_entries` [#list_time_entries]
Lists entries that start between `from` and `to`, which can be at most 62 days
apart. Returns up to 1000 entries; `truncated` is `true` when more matched, so
narrow the range or add filters. Members always get only their own entries.
| Property | Value |
| --- | --- |
| Arguments | `from`, `to` (required, ISO 8601 date-times); `projectId`, `clientId`, `memberId` (owners and admins), `taskId`, `billable` |
| Scope | `read` |
| REST equivalent | `GET /v1/time-entries` |
### `create_time_entry` [#create_time_entry]
Records completed time for you on an active task of a project you are assigned
to. Billable minutes follow the project's rounding, and billable time takes
your project rate, which must be set.
| Property | Value |
| --- | --- |
| Arguments | `projectId`, `taskId`, `startAt`, `endAt` (ISO 8601 date-times), `billable`, all required |
| Scope | `write` |
| REST equivalent | `POST /v1/time-entries` |
### `update_time_entry` [#update_time_entry]
Changes the fields sent on a completed entry and recalculates its minutes and
rate. Owners and admins can move an entry to another assigned member with
`memberId`. Stop a running timer before editing it.
| Property | Value |
| --- | --- |
| Arguments | `entryId` and any of `projectId`, `taskId`, `startAt`, `endAt`, `billable`, `memberId` |
| Scope | `write` |
| REST equivalent | `PATCH /v1/time-entries/{entryId}` |
### `delete_time_entry` [#delete_time_entry]
Permanently deletes a time entry and returns `{ id, deleted: true }`.
| Property | Value |
| --- | --- |
| Arguments | `entryId` |
| Scope | `write` |
| REST equivalent | `DELETE /v1/time-entries/{entryId}` |
### `get_timer` [#get_timer]
Returns your running timer as `{ entry }`, with `entry: null` when none is
running. Each member has at most one timer.
| Property | Value |
| --- | --- |
| Arguments | None |
| Scope | `read` |
| REST equivalent | `GET /v1/timer` |
### `start_timer` [#start_timer]
Starts a timer for you now and returns the running entry. Fails with
`TIME_TIMER_RUNNING` when one is already running.
| Property | Value |
| --- | --- |
| Arguments | `projectId`, `taskId`, `billable`, all required |
| Scope | `write` |
| REST equivalent | `POST /v1/timer/start` |
### `stop_timer` [#stop_timer]
Stops your running timer now and returns the completed entry. Fails with
`TIME_TIMER_NOT_FOUND` when no timer is running.
| Property | Value |
| --- | --- |
| Arguments | None |
| Scope | `write` |
| REST equivalent | `POST /v1/timer/stop` |
### Time task [#time-task]
| Field | Type | Description |
| --- | --- | --- |
| `id` | string | Task ID |
| `projectId` | string | Project the task belongs to |
| `name` | string | Task name |
| `description` | string or null | Description |
| `archived` | boolean | Archived tasks keep their history but take no new time |
### Time entry [#time-entry]
| Field | Type | Description |
| --- | --- | --- |
| `id` | string | Entry ID |
| `projectId` | string | Project ID |
| `projectName` | string or null | Project name |
| `taskId` | string | Task ID |
| `taskName` | string or null | Task name |
| `clientId`, `clientName` | string or null | Client of the project; `null` for internal projects |
| `member` | object | `id` and `name` of the member the time belongs to |
| `status` | string | `running` or `completed` |
| `startAt` | string | ISO 8601 timestamp |
| `endAt` | string or null | ISO 8601 timestamp; `null` while running |
| `actualMinutes` | number or null | Tracked minutes; `null` while running |
| `billableMinutes` | number or null | Minutes after the project's rounding; `null` while running |
| `billable` | boolean | Whether the time is billable |
| `billableRate` | string or null | Hourly rate as a decimal string, such as `100.00`; `null` for non-billable time and for members |
| `currency` | string | ISO 4217 currency code of the rate |
## Errors [#errors]
When a tool fails, the result has `isError: true` and a text message in the
form `CODE: message`, such as
`INSUFFICIENT_SCOPE: This requires the "write" scope.` or
`PROJECT_MANAGEMENT_FORBIDDEN: Only owners and admins can manage projects.`
See the [error codes](/docs/api#errors).
# REST API (/docs/api)
The REST API serves JSON under `/v1` on the API origin. It uses the same
credentials as the MCP server: a [team API key](/docs/agents/authentication#api-keys)
or an OAuth access token.
| Setting | Value |
| --- | --- |
| Base URL | `https://api.osorik.dev/v1` |
| Reference | [`https://api.osorik.dev/v1/docs`](https://api.osorik.dev/v1/docs) |
| OpenAPI 3.1 document | [`https://api.osorik.dev/v1/openapi.json`](https://api.osorik.dev/v1/openapi.json) |
## Make a request [#make-a-request]
Send the credential as a bearer token:
```bash
curl https://api.osorik.dev/v1/team \
-H "Authorization: Bearer $OSORIK_API_KEY"
```
The [API reference](https://api.osorik.dev/v1/docs) lists every endpoint with its request
and response schemas, and builds example requests for you. It is generated
from the OpenAPI document, so it always matches what the API serves.
Writes send a JSON body. This changes one field of a transaction and leaves
the others as they are:
```bash
curl -X PATCH https://api.osorik.dev/v1/transactions/$TRANSACTION_ID \
-H "Authorization: Bearer $OSORIK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "note": "Team offsite lunch" }'
```
## What the API covers [#what-the-api-covers]
| Area | Read | Write |
| --- | --- | --- |
| Team | Profile and your role | None |
| Contacts | List, search, get | Create, update |
| Categories | List | Create |
| Bank accounts | List | None |
| Transactions | List, filter, get | Category, note, contact, project, archive |
| Invoices | List, get, invoice settings | Create, update and delete drafts |
| Expenses | List, filter, get | Update details, archive |
| Projects | List, get | Create, update, change status |
| Time | Entries, tasks, running timer | Log, edit and delete entries; add tasks; start and stop the timer |
Issuing, voiding and sending invoices, deleting contacts or expenses,
uploading documents, connecting banks and managing members stay in the app.
Your role applies: an endpoint that needs an owner or admin returns `403` for
members.
## Conventions [#conventions]
- **IDs** are strings. An ID that doesn't exist in your team returns `404`.
- **Amounts** are decimal strings in the currency's major unit, such as
`"1250.00"`, next to an ISO 4217 `currency` code.
- **Dates** are `YYYY-MM-DD`; timestamps are ISO 8601 in UTC.
- **Absent values** are `null`, never missing.
- **`PATCH`** changes only the fields you send. Send `null` to clear an
optional field.
- **Creates** return `201` with the new record; deletes return
`{ "id": "…", "deleted": true }`.
## Pagination [#pagination]
List endpoints take `limit` (1 to 100, default 25) and `cursor`, and return a
page:
```json
{
"data": [{ "id": "…" }],
"nextCursor": "eyJ…"
}
```
Pass `nextCursor` as `cursor` to get the next page; it is `null` on the last
page. A page can hold fewer than `limit` items before the last one, so keep
going until `nextCursor` is `null`.
## Errors [#errors]
Every error returns a JSON body with a stable `code` and a readable `message`:
```json
{
"code": "API_KEY_INVALID",
"message": "The API key is invalid or revoked."
}
```
| Status | Code | Meaning |
| --- | --- | --- |
| `400` | `INVALID_REQUEST` | The request failed validation |
| `400` | `INVALID_CURSOR` | The pagination cursor is invalid or expired |
| `400` | Other codes | A business rule rejected the input, such as `INVALID_DATE_RANGE` |
| `401` | `API_KEY_REQUIRED` | No bearer credential was sent |
| `401` | `API_KEY_INVALID` | The API key is invalid or revoked |
| `401` | `OAUTH_TOKEN_INVALID` | The access token is invalid or expired |
| `403` | `INSUFFICIENT_SCOPE` | The credential lacks the `read` or `write` scope the endpoint needs |
| `403` | `*_FORBIDDEN` | Your role can't do this, such as `PROJECT_MANAGEMENT_FORBIDDEN` |
| `404` | `NOT_FOUND` | No such endpoint |
| `404` | `*_NOT_FOUND` | The record doesn't exist in your team, such as `INVOICE_NOT_FOUND` |
| `409` | Conflict codes | The record's state blocks the change, such as `INVOICE_IMMUTABLE` for an issued invoice |
| `429` | `RATE_LIMITED` | Too many requests; wait for the seconds in `Retry-After` |
| `500` | `INTERNAL_ERROR` | Something went wrong on our side |
## Discovery [#discovery]
`GET https://api.osorik.dev/` returns an index that links the API, its OpenAPI document,
the reference, and the MCP endpoint.
The site publishes an [API catalog](https://www.rfc-editor.org/rfc/rfc9727) at
[`https://osorik.dev/.well-known/api-catalog`](https://osorik.dev/.well-known/api-catalog). It
links the REST API's OpenAPI document and guides, and the MCP server's
[server card](https://osorik.dev/.well-known/mcp/server-card.json), guide, and OAuth
protected resource metadata.
# Osorik docs (/docs)
Osorik is one workspace for the bank transactions, expenses, invoices, time,
clients, and documents of a small service business. These docs cover the parts
you set up outside the app: AI agents that work with your workspace through the
Model Context Protocol (MCP), and the REST API for scripts and integrations.
Add Osorik to Claude Code, Claude, Cursor, or any MCP client.
Call the API with a team API key or an OAuth access token.
## Before you start [#before-you-start]
- You need an Osorik account and a team. Osorik is invite-only for now; write
to [support@osorik.com](mailto:support@osorik.com) to request access.
- Everything an agent or script does runs as you, inside one team, with your
role in that team.
## Docs for agents [#docs-for-agents]
Every page is also available as Markdown. Add `.md` to a page URL, such as
[`/docs/agents.md`](/docs/agents.md), or send `Accept: text/markdown`. The
[`/llms.txt`](/llms.txt) index lists every page, and
[`/llms-full.txt`](/llms-full.txt) has all of them in one file.