# Tabba developer documentation > The text of https://www.tabba.io/developers, in reading order. > Index: https://www.tabba.io/llms.txt. OpenAPI document: https://www.tabba.io/api/v1/openapi.json. --- # Overview > What the Tabba MCP server is, and what an agent can and cannot do with it. Source: https://www.tabba.io/developers/mcp Tabba speaks [MCP](https://modelcontextprotocol.io). Point an AI client at one URL and it reads Tabba Sales as you: your customers, invoices, recurring series, registered work and revenue recognition. It can also plan changes to them. Nothing is written until you confirm a plan. ``` https://www.tabba.io/api/mcp ``` ## What it is [#what-it-is] A remote MCP server over streamable HTTP, and stateless: every call is one `POST` that carries its own credential and is answered in JSON. There is no session to keep alive and no local process to run. `GET` and `DELETE` answer `405`, so a client that insists on opening an event stream first cannot use it. The server names itself `tabba`. Its tools describe themselves over `tools/list`, and the list is built for each request from two things: the scope of your token and your role in the company. Ask your client what it can do and the answer is the catalogue you actually have. The server reaches Sales. Nothing of Finance is on it: no tool reads a voucher, a bank transaction or an approval. ## One way to connect: OAuth [#one-way-to-connect-oauth] Every client below connects the same way. Give it the URL, sign in to Tabba in the browser it opens, pick the company it will reach, and approve. The client keeps the token and refreshes it. You never handle a secret, and nothing you put in a config file is one. The consent page is also how the server learns who you are, which is what lets it answer "my customers" with your customers and not the company's. See [Auth and scopes](/developers/mcp/auth). > [!WARNING] > **There is no path without a browser** > > A connection starts on the consent page, which takes a signed-in browser > session. There is no key, flag or grant that skips it, and an API key is > not accepted at `/api/mcp`. Work that runs unattended (CI, a scheduled job, > your own backend) belongs on the [REST API](/developers/api) at `/api/v1`, > which authenticates with a key and has no consent step. ## What an agent can do [#what-an-agent-can-do] Three kinds of tool, each behind a scope of the token. Which of them you are offered also follows your role in the company. Of the five roles, the organisation admin, the admin and the bookkeeper act as an admin here; the auditor reads as an admin and writes nothing; the standard user works on their own lines. | Tools | Token scope | Admin | Auditor | Standard user | | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ----- | ----------- | -------------------------- | | Read your own work: `get_my_work` | `mcp:read` | Yes | Yes | Yes | | Read customers, invoices, recurring series and credit notes: `list_customers`, `get_customer`, `list_invoices`, `get_invoice`, `list_series`, `get_series`, `get_series_occurrences`, `list_credit_notes`, `get_credit_note` | `mcp:read` | Yes | Yes | Yes, within their own work | | Read revenue recognition, yourself and the configuration: `get_recognition`, `get_user`, `get_config` | `mcp:read` | Yes | Yes | Yes | | Read the product catalog, the people of the company and a month-end accrual entry: `list_products`, `get_product`, `list_users`, `get_accruals` | `mcp:read` | Yes | Yes | Not offered | | Plan a work registration: `plan_register_work` | `mcp:write` | Yes | Not offered | Yes, their own work | | Plan customers, products, draft invoices and recurring series: `plan_upsert_customer`, `plan_upsert_product`, `plan_create_invoice`, `plan_delete_invoice`, `plan_create_series`, `plan_cancel_series`, `plan_setup_from_contract` | `mcp:write` | Yes | Not offered | Not offered | | Plan what books and sends: `plan_issue_invoice`, `plan_issue_occurrence`, `plan_create_credit_note` | `mcp:issue` | Yes | Not offered | Not offered | | Apply a confirmed plan: `commit` | `mcp:write` | Yes | Not offered | Yes | A tool that is not offered is not in `tools/list` and cannot be called. That gives these catalogue sizes: | Token scope | Admin | Auditor | Standard user | | ----------- | -------- | -------- | ------------- | | `mcp:read` | 17 tools | 17 tools | 13 tools | | `mcp:write` | 26 tools | 17 tools | 15 tools | | `mcp:issue` | 29 tools | 17 tools | 15 tools | > [!NOTE] > **Names, not ids** > > A tool takes the reference a person would use: a bare name such as > `Bredgade ApS`, `ext:your-id`, or the id. For a person it also takes > `email:someone@example.com` and `me`, which is you. A name that matches > several records is answered `AMBIGUOUS_REFERENCE` with the candidates, never > a guess. A recurring series and a credit note have no name: a series is > found by its customer and a credit note by its invoice. ## Your work is the default answer [#your-work-is-the-default-answer] A read answers from the work you hold a place on, not from the whole company. `get_my_work` returns your open lines in one call, grouped by customer or recurring series and ranked by what needs attention. The lists of customers, invoices, recurring series and credit notes take a `scope` argument that defaults to `mine`, and every scoped answer opens by naming the view that produced it. Widening is yours to ask for, and what you may ask for follows your role. An admin and an auditor may ask for `all`, the whole company. A standard user reads within their own work on every tool: a record outside it is answered as not found. Connecting a client gives it no more than you already have in Tabba. ## Beyond tools [#beyond-tools] Three more MCP surfaces come with the connection. Clients expose them differently, often as slash commands or as context you attach. * **Two prompts.** `setup_invoicing_from_contract` turns a contract into the invoicing it calls for. `monthly_close` starts from the work you still have outstanding and reviews what is due this period. Each is worded from the tools your connection has, so neither sends the agent to a tool you are not offered. Where the connection cannot plan a step, the prompt says that nothing is written from there and ends with what is left for a person to do. * **A resource.** `tabba://config` carries the company's name, its base currency and the ledger accounts the month-end accrual moves. It is the same answer as `get_config`. * **A form.** The contract plan, an admin's tool, can ask its open questions as a form in a client that renders MCP Apps. See [Plan and commit](/developers/mcp/propose-commit). ## What it cannot do [#what-it-cannot-do] * Write anything without a separate `commit` of a plan. See [Plan and commit](/developers/mcp/propose-commit). * Exceed the scope of the token or your role. Issuing, which books in the accounting system and sends to a customer, takes `mcp:issue` and an admin. * Run without a person. Every request is made as one. * Register a colleague's work for a standard user. An admin may, by naming a person who holds a place on the line. * Change the lines of a recurring series that exists, skip or restore an occurrence, or make a draft series active. No plan does these. They are done in Tabba. * Invite a user, change a role, connect a company or create an API key. No tool does these either. ## Connect your client [#connect-your-client] - [claude.ai](/developers/mcp/claude-ai): Add Tabba as a custom connector. - [Claude Code](/developers/mcp/claude-code): One CLI command, then the OAuth flow from /mcp. - [ChatGPT](/developers/mcp/chatgpt): A custom MCP connector, added by its URL. - [Cursor](/developers/mcp/cursor): A short mcp.json entry, per project or per user. To write the integration yourself, or to run it unattended, use the [REST API](/developers/api). --- # Connect claude.ai > Add Tabba as a custom connector in claude.ai. Source: https://www.tabba.io/developers/mcp/claude-ai Add Tabba as a custom connector and Claude reads Tabba Sales as you and plans changes to it. Nothing is written until you confirm a plan. ## Add the connector [#add-the-connector] In Claude's settings, open the connectors page, add a custom connector, and give it this URL: ``` https://www.tabba.io/api/mcp ``` Leave any OAuth client id and client secret fields empty. Those are for servers that hand out client credentials by hand. Tabba registers clients dynamically, so Claude registers itself. Claude then opens Tabba in your browser. Sign in if you are asked to. The consent page lists what Claude asked to be able to do and asks which company it reaches. Pick the company and choose **Approve**. > [!NOTE] > **Each person approves for themselves** > > If your Claude organisation manages connectors centrally, an owner may have > to add the connector before members can connect. Whoever adds it, each > member then approves in their own browser and is connected as themselves: > with their own role in Tabba, their own work, and the company they picked. > Nothing is inherited from the person who added the connector. Where the menus are, and which Claude plans offer custom connectors, is Anthropic's to say and changes. Their own documentation of custom connectors has the current steps. ## Verify it works [#verify-it-works] Ask for something that only reads: ``` What work do I have open? ``` Claude should call `get_my_work` and answer from live data: your open lines, grouped by customer or recurring series. If the answer names real customers, the connection works. Note the "I". A read answers from the work you hold a place on, and a list opens by saying which view produced it. The whole company is an admin's to ask for. See [Auth and scopes](/developers/mcp/auth). ## Then try a write [#then-try-a-write] ``` Add five hours for me on the Bredgade development line. ``` Claude calls `plan_register_work`, shows you what it would change, and waits. Only when you confirm does it call `commit`. If it comes back with a question in place of a plan (which line you meant, or whether the hours replace the total or are added to it), answer it. The tool does not guess at anything that would write a wrong number. Both tools are offered to every member whose connection may write. Naming a colleague in place of yourself is an admin's to do. A standard user who tries is refused, and the answer says so. ## Use the prompts [#use-the-prompts] The server comes with two prompts, which Claude offers alongside the connector: * **Set up invoicing from a contract.** Paste the terms and get the invoicing they call for put to you: the customer, a one-off invoice or a recurring series, and its lines. For an admin whose connection may write, it ends in one plan to confirm. * **Monthly close.** Start from the work you still have outstanding, then review what is due this period and what it has earned. Each is worded from the tools your connection has. Where the connection cannot plan a step, the prompt says that nothing is written from there and ends with what is left for a person to do. ## Troubleshooting [#troubleshooting] * **Claude asks you to approve again.** An access token lives ten minutes and is refreshed without you. A refresh token lives thirty days, so a connector you have not used for a month asks again. It also asks again after you revoked access in Tabba, after your access to the company ended, and after the company was disconnected from its accounting system. * **Claude says it cannot do something.** The tool is most likely not in your list, in which case Claude cannot name what is missing. A tool is absent when the connection's scope does not cover it or when it is an admin's and you are a standard user. The two tables in [Auth and scopes](/developers/mcp/auth) say which. * **The connector is listed but Claude does not use it.** Check that it is enabled for the conversation, and name Tabba in your message once. * **A name matches several records.** Claude shows you the candidates. Answer with the one you meant. * **You want another company.** A connection reaches the one company you picked on the consent page. Connect again and pick the other one. * **Anything else.** Every refusal the server answers carries a code and a next step, and Claude usually relays both. Quote them when you ask for help. ## Remove it [#remove-it] Remove the connector in Claude, then revoke the access in Tabba: under **Claude and other AI clients** on your profile settings, choose **Revoke access**. That ends every AI client connection of yours, not only this one. See [Auth and scopes](/developers/mcp/auth). --- # Connect Claude Code > Add the Tabba MCP server to Claude Code and authenticate with OAuth. Source: https://www.tabba.io/developers/mcp/claude-code Claude Code talks to remote MCP servers over HTTP. One command adds Tabba, and the OAuth flow opens in your browser. The browser step cannot be left out: see [Not from CI](#not-from-ci) if that is where you were heading. ## Add the server [#add-the-server] ```bash claude mcp add --transport http tabba https://www.tabba.io/api/mcp ``` Then start Claude Code and run `/mcp`. Pick **tabba** and authenticate. The browser opens Tabba: sign in if you are asked to, pick the company on the consent page, and choose **Approve**. Check that it is there: ```bash claude mcp list ``` `tabba` should be listed as connected. If it is listed as needing authentication, the entry is written and you have not completed `/mcp` yet. ## Scope it to a project or to yourself [#scope-it-to-a-project-or-to-yourself] Claude Code stores a server in one of three scopes. These are Claude Code's own, and have nothing to do with the scope of a Tabba token. | Scope | Stored in | Reaches | | --------------------- | ------------------------------------------- | --------------------------- | | `local` (the default) | `~/.claude.json`, under this project's path | Only you, only this project | | `project` | `.mcp.json` in the repository root | Everyone who clones it | | `user` | `~/.claude.json` | Only you, every project | Tabba is about a company, not about a repository, so the user scope is usually the right one: ```bash claude mcp add --transport http --scope user tabba https://www.tabba.io/api/mcp ``` > [!NOTE] > **The entry holds no secret** > > The entry records the URL. The token is kept by Claude Code, and each > person who authenticates gets their own, as themselves, on the company > they pick. > [!WARNING] > **Project scope commits a file** > > `--scope project` writes `.mcp.json` into the repository. Every > collaborator is then asked to authenticate, each with their own Tabba > sign-in. A collaborator with no access to a connected company in Tabba has > nothing to approve. A run with no person at a browser loads the entry and > never connects. ## Not from CI [#not-from-ci] The consent page takes a signed-in browser session, and Tabba accepts no other MCP credential: no key, no header, no long-lived token. A build agent, a scheduled runner or a `claude -p` job on a server cannot connect. Point that work at the [REST API](/developers/api). It authenticates with a key on `/api/v1`, reaches the same Sales data, and uses the same error codes. ## Verify it works [#verify-it-works] ``` > What work do I have open? ``` Claude Code shows the tools it called. A call to `get_my_work` that answers with your real lines means the token is live and holds `mcp:read`. ## What it answers by default [#what-it-answers-by-default] ``` > Which customers do I have? ``` You get your customers: the ones billed by the invoices and recurring series you hold a place on, not every customer of the company. The answer opens by saying so. The whole company is an admin's to ask for. See [Auth and scopes](/developers/mcp/auth). ## Two prompts come with it [#two-prompts-come-with-it] The server's two prompts appear in Claude Code as commands: one turns a contract into the invoicing it calls for, one walks the monthly close. Each is worded from the tools your connection has, and each puts a plan to you before anything is committed. ## Troubleshooting [#troubleshooting] * **The server needs authentication.** Run `/mcp`, select **tabba** and authenticate. A session with no person at a browser cannot. * **The write tools are missing.** `/mcp` shows the tools of each server. An admin is offered 17 tools on a connection that reads, 26 on one that may write and 29 on one that may issue. A standard user is offered 13, and 15 with `plan_register_work` and `commit` on a connection that may write. If the count is lower than your role allows, the token's scope is narrower than you expected: authenticate again. * **A commit times out.** A request to the server runs for at most 60 seconds. Call `commit` again with the same token. A plan is applied once: the second call resumes what the first did not finish, or answers its result. While the first is still running the answer is `COMMIT_IN_PROGRESS`: wait a few seconds and call again. * **A tool's output is too long for Claude Code.** A list of invoices in a busy company can exceed the limit Claude Code puts on a tool's output. Narrow the question (by customer, or by status), or raise that limit in Claude Code's settings. * **Two entries with one name.** A local entry is used before a project entry, and a project entry before a user entry. `claude mcp get tabba` shows the one in use. ## Remove it [#remove-it] ```bash claude mcp remove tabba ``` Add `--scope user` or `--scope project` if that is where you put it. Removing the entry does not revoke anything in Tabba. Revoke the access under **Claude and other AI clients** on your profile settings in Tabba, with **Revoke access**. That ends every AI client connection of yours, not only this one. --- # Connect ChatGPT > Add Tabba to ChatGPT as a custom MCP connector. Source: https://www.tabba.io/developers/mcp/chatgpt ChatGPT reaches a remote MCP server through a custom connector that you add by its URL. In ChatGPT that sits behind developer mode, and in a workspace an admin may have to allow it before you can start. > [!NOTE] > **Check what your ChatGPT offers first** > > Which ChatGPT plans offer custom MCP connectors, and where the settings > are, is OpenAI's to say and changes. Their own documentation of developer > mode has the current steps. The steps below state what Tabba needs from > them. ## 1. Turn on developer mode [#1-turn-on-developer-mode] Turn on developer mode in your ChatGPT settings. In a workspace, an owner may have to allow custom MCP connectors first. Until they do, the setting is not there to find. ## 2. Add the server [#2-add-the-server] Create a custom connector and give it: * **URL**: `https://www.tabba.io/api/mcp`, including the `/api/mcp` path * **Authentication**: OAuth ``` https://www.tabba.io/api/mcp ``` Leave any client id and client secret fields empty: ChatGPT registers itself with Tabba. It then opens Tabba in your browser. Sign in if you are asked to, pick the company on the consent page, and choose **Approve**. ## 3. Use it [#3-use-it] Enable the connector for a conversation, then ask in plain language: ``` Using Tabba, what work do I have open? ``` ChatGPT should call `get_my_work` and answer with your open lines. A read answers from the work you hold a place on, and a list opens by saying which view produced it. The whole company is an admin's and an auditor's to ask for. See [Auth and scopes](/developers/mcp/auth). The tool list ChatGPT shows for the connector is the one Tabba built for your connection: it follows the scope of the token and your role in the company. If ChatGPT keeps a copy of the list, refresh it after you connect again or after your role changes. Otherwise it may offer a tool the server no longer registers for you, and that call fails. ## What to expect on writes [#what-to-expect-on-writes] ChatGPT gets the same contract as every other client: a `plan_*` tool answers a written plan, and nothing changes until `commit` is called after you confirm. A standard user is offered `plan_register_work` and `commit`, and an auditor neither. The other plan tools are an admin's. See [Plan and commit](/developers/mcp/propose-commit). > [!WARNING] > **Tabba has no search or fetch tool** > > A ChatGPT feature that works only through a connector's `search` and > `fetch` tools cannot use Tabba: the server registers neither. Its read > tools are typed questions about your Sales data, not a document index. Use > a normal chat when you want Tabba to answer or to plan a change. ## Troubleshooting [#troubleshooting] * **There is no developer mode setting.** The workspace has not allowed it. An owner does that in the workspace's settings. * **The connector fails to connect.** Check that the URL ends in `/api/mcp`. `https://www.tabba.io` alone is the OAuth issuer, not the MCP endpoint, and does not answer a tool call. * **A tool is missing, or one you no longer have is still offered.** Refresh the connector's tool list. The catalogue is built per request from your token's scope and your role. * **It answers from memory and calls no tool.** Name the connector in your message ("using Tabba") and check that it is enabled for the conversation. * **A write did not happen.** A plan is not a commit. Confirm the plan in so many words. If the conversation moved on, the plan may have run out: a plan lives fifteen minutes, after which the commit is answered `PROPOSAL_EXPIRED` and the plan tool is called again. ## Remove it [#remove-it] Remove the connector in ChatGPT, then revoke the access in Tabba: under **Claude and other AI clients** on your profile settings, choose **Revoke access**. That ends every AI client connection of yours, not only this one. --- # Connect Cursor > Add the Tabba MCP server to Cursor, for one project or for your whole machine. Source: https://www.tabba.io/developers/mcp/cursor Cursor reads MCP servers from an `mcp.json` file: one for you, one per project. A remote server needs nothing but a URL. ## Add the server [#add-the-server] Add the server in Cursor's MCP settings, or write the file directly: **~/.cursor/mcp.json** ```json { "mcpServers": { "tabba": { "url": "https://www.tabba.io/api/mcp" } } } ``` **.cursor/mcp.json** ```json { "mcpServers": { "tabba": { "url": "https://www.tabba.io/api/mcp" } } } ``` A `url` is what makes the entry a remote server. Reload Cursor, find `tabba` in the MCP list, and log in. Cursor runs the OAuth flow itself: the browser opens Tabba, you sign in if you are asked to, pick the company on the consent page, and choose **Approve**. That is the only way Tabba authenticates an MCP client. > [!NOTE] > **No secret in the file** > > The entry is the URL. There is no header to add and no credential to paste. > The token is kept by Cursor, so a project's `.cursor/mcp.json` holds nothing > secret. Each collaborator logs in and is connected as themselves: with > their own role in Tabba, their own work, and the company they picked. ## Agents without a browser cannot connect [#agents-without-a-browser-cannot-connect] Signing in is a browser step, and Tabba's MCP server accepts no other credential. An agent that runs in the background, a CI job, or anything else with no person at a browser cannot authenticate. Use the [REST API](/developers/api) for that work: a key on `/api/v1`, no consent step, the same Sales data underneath. ## Verify it works [#verify-it-works] In Cursor's agent chat: ``` Using Tabba, what work do I have open? ``` Cursor shows the tool calls it made. A call to `get_my_work` that answers with your real lines means the connection works. A read answers from the work you hold a place on, and a list opens by saying which view produced it. See [Auth and scopes](/developers/mcp/auth) for what an admin may widen. ## Mind the tool budget [#mind-the-tool-budget] Tabba registers up to 29 tools: 17 that read, 11 that plan, and `commit`. That is an admin's catalogue on a connection that may issue. A standard user is offered 15 at most: 13 that read, `plan_register_work` and `commit`. If Cursor limits how many tools it sends to the model across all servers, an admin's Tabba catalogue takes a large part of it. Switch off the servers you are not using, or switch off the Tabba tools you never call in Cursor's MCP settings. The consent page has no control for granting a narrower scope than the client asked for, so the catalogue cannot be trimmed there. ## Troubleshooting [#troubleshooting] * **The tools do not appear.** Check that the JSON parses and that the server is switched on in Cursor's MCP settings. * **Every call is answered 401.** The token ran out and the refresh failed, you revoked the access in Tabba, or your access to the company ended. Log out and back in from the MCP settings. * **`406 Not Acceptable`.** Something between Cursor and Tabba is stripping the `Accept` header. Streamable HTTP has the client accept both `application/json` and `text/event-stream`, and a proxy that rewrites the header breaks the transport. * **The agent plans and never writes.** That is the design. A `plan_*` tool answers a plan, you confirm, and the agent calls `commit`. See [Plan and commit](/developers/mcp/propose-commit). * **A name matches several records.** The answer lists the candidates. Pick one, or name the record by `ext:your-id`. ## Remove it [#remove-it] Delete the entry from `mcp.json`, then revoke the access in Tabba: under **Claude and other AI clients** on your profile settings, choose **Revoke access**. That ends every AI client connection of yours, not only this one. --- # Plan and commit > Why no Tabba tool writes on its own, what a plan and its token are, and what the server holds a commit to. Source: https://www.tabba.io/developers/mcp/propose-commit Tabba works on one rule: the agent prepares, a person approves. On the MCP server that rule is two tool calls. An agent that hears "five hours" and takes seven off a line has changed what a customer is invoiced, so the server has no tool that both decides and writes. ## Two steps, always [#two-steps-always] 1. **Plan.** A `plan_*` tool resolves what was asked, holds it to the rules Tabba holds any write to, and answers a summary in plain language and an opaque `proposal_token`. Nothing is written. 2. **Commit.** The separate `commit` tool takes that token and nothing else, and applies the plan the server kept. What is written is the plan you read. The client never sends the change a second time, so it cannot commit something other than what it showed you. Which plan tools you are offered follows your role. A standard user is offered `plan_register_work` and `commit`. An auditor is offered no plan tool and no `commit`. Every other plan tool is an admin's, and the organisation admin, an admin and a bookkeeper are admins here: `plan_upsert_customer`, `plan_upsert_product`, `plan_create_invoice`, `plan_delete_invoice`, `plan_create_series`, `plan_cancel_series`, `plan_setup_from_contract`, and with `mcp:issue` also `plan_issue_invoice`, `plan_issue_occurrence` and `plan_create_credit_note`. ## What the server enforces, and what the client is asked to do [#what-the-server-enforces-and-what-the-client-is-asked-to-do] These are different things, and only the first is a guarantee. **The server enforces:** * No tool but `commit` writes. Every plan tool is marked read-only in its annotations, and `commit` is marked destructive. * `commit` applies a plan that exists on the server, belongs to you and to the company of your token, has not run out, and still describes the records as they are. * A plan is applied once. * The token's scope and your role are asked again at the commit. **The client is asked:** * To show you the plan and wait for your confirmation before it calls `commit`. The server's instructions and the description of every plan tool say so, and for a plan that books they say to name the very document. * To put a plan tool's questions to you and never answer them itself. The server cannot see whether a client showed you anything. If your client can require approval per tool, require it for `commit`: that is the one call where a change becomes real, and reading and planning stay free. ## What a plan looks like [#what-a-plan-looks-like] A plan is answered as text the agent can show you, with the same content in structured form beside it: ``` **Register work** for Jane Doe on "Development (Bredgade ApS)". | figure | now | becomes | change | |---|---|---|---| | hours | 12 h | 17 h | +5 h | An hourly line: the hours move its revenue. Nothing is written yet. Show the user this plan, and once they confirm call commit with proposal_token="mcpp_6f1c2b9e-…" (runs out 2026-10-13T09:27:33+00:00; takes mcp:write). ``` The last sentence is one line on the wire. A plan that reduces a figure says so under `Warnings`. If the summary is right, the agent calls `commit` with the token. If it is not, you say so and nothing was written. ## The token [#the-token] * **Fifteen minutes.** After that the commit is answered `PROPOSAL_EXPIRED` and the plan tool is called again. A plan describes the records as they stood when it was made. * **Applied once.** Committing the same token again writes nothing and is answered the first commit's result. A client that retries a commit that timed out cannot apply it twice. The result is kept for a day after the plan's expiry. After that the token is `PROPOSAL_NOT_FOUND`. * **Its planner's.** A token is good for the person and the company it was planned for. Another member of the company, an admin included, finds no plan under it and is answered `PROPOSAL_NOT_FOUND`. * **Opaque.** It is `mcpp_` and an id. It carries nothing of the plan. > [!NOTE] > **PROPOSAL_STALE means a record changed** > > A plan keeps the state of every record it reads or writes, and the commit > compares them again before its first write. If a colleague changed the > invoice, or registered hours on the line, after the plan was made, the > commit is refused: what you confirmed is no longer what would be written. > Call the same plan tool again, read the new plan, and commit that token. > Nothing is absorbed into the old one. A plan that books depends on more than the records it names. A plan to issue an invoice is stale once work is registered on its lines. A plan to issue an occurrence is stale once what the occurrence bills has changed. A plan to end a recurring series is stale once the end would credit, drop or issue something else than the plan listed. ## When a commit fails part-way [#when-a-commit-fails-part-way] A plan can hold several steps: the contract plan creates a customer, its products and a recurring series in one commit. Each step is kept as soon as it is applied. * If a step fails, commit the same token again. The applied steps are taken as they came out and the rest are applied. No record is created twice. * A resume compares the records again, for everything the first attempt did not itself write. A change in between is `PROPOSAL_STALE`, and the answer says that part of the plan was applied: read what is there, then plan what is left. * A resume is bounded by the plan's fifteen minutes. * Two commits of one token never both apply it. The second is answered `COMMIT_IN_PROGRESS`: wait a few seconds and commit the same token again. Do not plan again. ## When a plan tool asks back [#when-a-plan-tool-asks-back] A plan tool can answer `needs_clarification` with questions in place of a plan. The agent puts them to you and calls the same tool again with your answers. Nothing is kept on the server for a question. ``` More is needed before this can be planned: 1. [hours_mode] Jane Doe already has 12 h on "Development (Bredgade ApS)". You gave 5 h. Does that REPLACE the total, or is it ADDED to it? - replace: the total becomes 5 h (a change of -7) [answer: replace] - add: the total becomes 17 h (a change of +5) [answer: add] Ask the user, then call this tool again with the same arguments and hours_mode filled in (top-level, or under "answers"). Send an [answer: …] value exactly as written. ``` Each option carries the exact value to send back, so an agent that reads only text can still answer. ### Hours are a total [#hours-are-a-total] `plan_register_work` registers a person's total on a line, not an increment. It shows what is registered already, and it takes either form: | Argument | Meaning | | ------------- | --------------------------------------------------------- | | `hours` | The new total of hours on the line. | | `hours_delta` | A signed change: `5` adds five hours, `-2` takes two off. | | `hours_mode` | The answer to the question above: `replace` or `add`. | Passing `hours: 5` on a line where 12 are registered is a request to take seven off, so the tool stops and asks. Where nothing is registered yet the two readings are the same number, and it goes ahead. Pass `hours_delta` when you mean to add. Completion works the same way: `completion_rate` (0 to 1) or `completion_percent` (0 to 100) for the new value, `completion_rate_delta` or `completion_percent_delta` for a change, and `completion_mode` for the same question. Hours and completion are both kept on every line. The line's recognition method, `hourly` or `value`, decides which of them moves revenue. The line is named by a `line_id` from `get_my_work`. A recurring series shows the lines of its current occurrence there before the occurrence is an invoice. Registering work on such a line makes the occurrence an invoice at the commit, and the plan says so. A standard user registers their own work. Naming a colleague in `user` is refused with `REGISTER_FOR_OTHERS_FORBIDDEN`. An admin may name a colleague who holds a place on the line. ### Other scales [#other-scales] * **Shares.** The plan tools take `distribution_percentage`, a person's share of a value line, as a percentage from 0 to 100, and the shares of a line add up to 100. The read tools state the same share as a fraction: `0.6` is 60 %. An hourly line takes no share: each person on it bills at a rate. * **Money** is in major units, and an amount a plan tool takes is before VAT. `get_config` states the company's base currency. * **An occurrence** is named by `period`, the first day of its billing period, as `YYYY-MM-DD`. ## What takes mcp:issue [#what-takes-mcpissue] A token with `mcp:write` drafts and registers: nothing it commits is booked in the accounting system, and nothing is sent to a customer. That is decided by what a plan leads to, not by which tool made it. These plans take `mcp:issue`, and all of them are an admin's: | Plan | Why | | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | | `plan_issue_invoice`, `plan_issue_occurrence`, `plan_create_credit_note` | They book in the accounting system and send to the customer. | | `plan_create_invoice` with `auto_send: true` | The daily run issues the invoice on its issue date. | | `plan_create_series` or `plan_setup_from_contract` with `activate: true` | The series runs from the commit, and the daily run issues its occurrences. | | `plan_cancel_series`, where the end credits what was sent past it or issues what is due | The end books a credit note or sends an invoice. | By default a created invoice is a draft that waits until it is issued, and a created series is a draft that invoices nothing until an admin makes it active in Tabba. A plan that takes `mcp:issue` is refused at plan time for a token that holds only `mcp:write`, with `SCOPE_INSUFFICIENT`, before you are asked to confirm anything. Its commit is refused for such a token too. ## The contract plan and its form [#the-contract-plan-and-its-form] `plan_setup_from_contract`, an admin's tool, plans the whole billing setup of a contract at once: a new customer where they are not in Tabba yet, the new products it needs, and either a one-off draft invoice (`billing: "one_off"`) or a recurring series. You confirm once, and one commit writes all of it. What the contract leaves open is asked: a line's product, how a new product is filed and counted, and, as one question per line, the line's amount and who works on it at what split or rate. The agent is told not to divide a price or suggest amounts itself. In a client that renders MCP Apps, those questions are shown as a form under the tool's result. The form posts your answers as your own message in the conversation. It plans nothing and commits nothing: the agent calls the plan tool again with the answers, and you still confirm the plan before `commit`. Where a client shows no form, the same questions are asked in the conversation. Whether the form shows is the client's to decide. ## Every error says what to do next [#every-error-says-what-to-do-next] A refusal is a tool result the agent reads, not a transport fault. It carries a code, a message and a `next_step`: ``` Error [PROPOSAL_STALE]: A record the plan depends on changed after it was made. Nothing was written. Next: Call the plan_* tool again: it plans from what is there now. Show the user the new plan before committing it. ``` The codes are the ones the REST API uses, listed under [Errors](/developers/api/errors), plus a few that only a surface with a plan and an acting person can answer. Where the accounting system refused, its reason and its own words are in the message. | Code | What happened | What to do | | ------------------------------- | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------ | | `PROPOSAL_NOT_FOUND` | The token names no plan of yours on this company: never issued, another person's, or removed after its time. | Plan again and commit the new token. | | `PROPOSAL_EXPIRED` | The plan ran out before it was committed. Nothing was written. | Plan again, read the new plan, commit its token. | | `PROPOSAL_STALE` | A record the plan depends on changed after it was made. | Plan again: the tool plans from what is there now. | | `COMMIT_IN_PROGRESS` | Another commit of the same token is applying the plan. | Wait a few seconds and commit the same token again. | | `REGISTER_FOR_OTHERS_FORBIDDEN` | A standard user named a colleague in a work registration. | Register your own work, or ask an admin. | | `AMBIGUOUS_REFERENCE` | A name matched several records. | Pick from the candidates the answer lists. | | `SCOPE_INSUFFICIENT` | The plan books or sends, and the connection holds no `mcp:issue`. | Connect again with a client that asks for issuing, or issue it in Tabba. | | `ADMIN_REQUIRED` | The plan is an admin's to commit, and you are no longer one. | Ask an admin. | | `READ_ONLY_ROLE` | You are an auditor of the company now, and an auditor commits nothing. | Ask an admin or a bookkeeper to make the change. | ## What cannot be undone [#what-cannot-be-undone] Some commits cannot be taken back from Tabba: * **Issuing** books the invoice in the accounting system and sends it to the customer. An issued invoice is not changed or deleted. It is corrected with a credit note. * **A credit note** is itself booked and sent, and is not taken back. * **An invoice set to `auto_send`, an activated series, and an end of a series that credits** lead to the same bookings without a further confirmation. All of these take `mcp:issue`, so a connection without it can draft everything and book nothing. See [Auth and scopes](/developers/mcp/auth). ## A record of every commit [#a-record-of-every-commit] A commit that applies a plan is written to the activity log once, with the person it was made as, the operation and the client. A second commit of the same token writes no entry. --- # Auth and scopes > OAuth is the only MCP credential: the flow, the three scopes, the five roles, whose work an answer is about, and how access ends. Source: https://www.tabba.io/developers/mcp/auth The MCP server takes one credential: an OAuth access token that Tabba's own authorization server issued to your client, after you approved it in your browser. There is nothing to paste and nothing to create by hand. An API key is not a credential here. A `tba_` key sent to `/api/mcp` gets the same `401` as no credential at all. Keys authenticate the REST API at `https://www.tabba.io/api/v1`: see [Authentication](/developers/api/authentication). ## Why the server insists on a person [#why-the-server-insists-on-a-person] A conversation is held with someone. "Which customers do I have?" is a question in the first person, and a credential that names only a company cannot answer it. So every MCP request has an **acting user**: the person who approved the client. That is what lets `me` resolve, what makes a read answer from your own work, and what puts a person in the activity log behind every commit. ## The OAuth flow [#the-oauth-flow] Tabba is the resource server and its own authorization server, both on `https://www.tabba.io`. A client discovers everything it needs. There is no client id to register by hand. 1. The client calls `https://www.tabba.io/api/mcp` with no credential and gets a `401` that names the resource's metadata (RFC 9728). 2. It reads the discovery documents and registers itself at `POST /api/oauth/register` (RFC 7591). A public client gets no secret and uses PKCE. 3. It sends your browser to `GET /api/oauth/authorize`. Tabba signs you in if you are not, and shows the consent page. 4. You pick the company and approve. The client is sent back with an authorization code, good once and for sixty seconds. 5. It exchanges the code at `POST /api/oauth/token`, with its PKCE verifier, for an access token and a refresh token. You can watch step 1 from a terminal: ```bash curl -i -X POST https://www.tabba.io/api/mcp \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' ``` ``` HTTP/2 401 www-authenticate: Bearer resource_metadata="https://www.tabba.io/.well-known/oauth-protected-resource/api/mcp" {"error":{"code":"UNAUTHORIZED","message":"Missing or invalid bearer credential for the MCP resource."}} ``` That header tells the client where to read the metadata, and the metadata names the authorization server: ``` https://www.tabba.io/.well-known/oauth-protected-resource/api/mcp https://www.tabba.io/.well-known/oauth-authorization-server https://www.tabba.io/.well-known/jwks.json ``` All three are public. Fetch them when a client will not connect. Between them they name the authorization, token and registration endpoints, the key set that access tokens are signed with, `S256` as the only code challenge method, and the three scopes below. ### What the authorization server requires of a client [#what-the-authorization-server-requires-of-a-client] For anyone writing a client, or working out why one is refused: * **PKCE with `S256`**, on every authorization request. * **`response_type=code`.** The grants are `authorization_code` and `refresh_token`. There is no client credentials grant. * **A `resource` parameter** equal to `https://www.tabba.io/api/mcp`. A request without it, or with another value, is refused with `invalid_target`. A token is issued for that resource and is refused anywhere else. * **A registered redirect address**, matched exactly. A registration takes one to ten: `https` anywhere, `http` on the loopback interface, or an app's own scheme, with no fragment. * **Client authentication** at the token endpoint is `none` for a public client, or `client_secret_post`. Registration is open and grants nothing by itself. It is limited to 100 registrations an hour per address, and a registration nobody has used for ninety days is removed. A client that meets an unknown `client_id` registers again. > [!WARNING] > **Both Accept types, or 406** > > Streamable HTTP has the client accept `application/json` and > `text/event-stream`, though this server always answers in JSON. A request > with a valid token and only one of them is answered `406`. MCP clients send > both. A hand-written request needs the header. ## The consent page [#the-consent-page] The page names the client, lists what it asked to be able to do, and asks which company it reaches. Its answer is one of two buttons, **Approve** or **Deny**. * **You pick one company.** A connection reaches that company and no other. With several companies none is preselected. To reach another, connect the client again. * **You are asked every time.** No approval is remembered, and no client skips the page. * **The scopes are the client's request.** The page shows what the client asked for and takes one answer for all of it. It has no control for granting less than was asked. Where the request leaves something out, the page says so: "It cannot issue an invoice or a credit note." Only a company that is connected to its accounting system, and that you can open in Tabba, is offered. ## Token lifetimes [#token-lifetimes] | Token | Lives | Notes | | ------------- | ---------- | ----------------------------------------------------------------------------------- | | Access token | 10 minutes | Sent with each request. It names you, the company and the scope. | | Refresh token | 30 days | Spent by being used: each refresh issues a successor and retires the one presented. | The client refreshes on its own. A client that has not refreshed for thirty days holds no live token and asks you to approve it again. > [!NOTE] > **A refresh token is used once** > > A spent refresh token that is presented again ends the whole connection, > for every holder of its tokens: it means a copy is in someone else's hands. > Two refreshes sent at the same moment with one token are treated the same > way. If a client keeps asking you to approve again, that is the first thing > to suspect. ## There is no path without a browser [#there-is-no-path-without-a-browser] The consent page takes a signed-in browser session. CI, a scheduled job, a container and a server-side agent cannot complete it, and no grant skips it. | Surface | Where the work runs | | ----------------------------------------- | ----------------------------------------------------- | | MCP, over OAuth | A person's own client | | [REST API](/developers/api), on `/api/v1` | CI, a scheduled job, a queue worker, your own backend | The REST API reaches the same Sales data with the same error codes. It has no consent step because it has no person: it authenticates with a key an admin creates, bound to one company. ## The three scopes [#the-three-scopes] A token holds one of three scopes, each containing the one before it. The labels are the ones the consent page shows. | Scope | On the consent page | What it allows | | ----------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | `mcp:read` | Read | See invoices, credit notes, customers, products, recurring series, registered work and earned revenue. | | `mcp:write` | Draft and register | Draft and edit invoices, customers, products and recurring series, and register work. Nothing is booked, and nothing is sent to a customer. | | `mcp:issue` | Issue | Issue invoices and credit notes: they are booked in your accounting system and sent to your customers. | A client that names no scope in its request asks for what it registered for, and a client that registered without naming scopes is registered for all three. A scope Tabba does not know is dropped, not refused. `mcp:write` covers every plan that only drafts, and `commit`. What takes `mcp:issue` is decided by what a plan leads to, not by the tool that made it: see [Plan and commit](/developers/mcp/propose-commit). ## Your role [#your-role] You hold one of five roles in each company, and the role is per company: you can be an admin in one and an auditor in another. On this server the five act as three: | Your role in the company | Acts as | Reads | Writes | | ------------------------------------- | --------------- | ---------------------------------------------------- | ------------------------------------- | | Organisation admin, admin, bookkeeper | An admin | The whole company | Every plan | | Auditor | An auditor | The whole company, with every read tool an admin has | Nothing: no plan tool and no `commit` | | Standard user | A standard user | Their own work | Their own registered work | ## Two gates decide the catalogue [#two-gates-decide-the-catalogue] The tool list is built for each request. A tool is registered only when the token holds its scope and your role admits it: a plan and `commit` are never an auditor's, and a tool that is an admin's is offered to an admin, or, when it only reads, to an auditor too. A tool that is not registered is not in `tools/list` and cannot be called, so there is nothing for an agent to attempt. | Token scope | An admin is offered | An auditor is offered | A standard user is offered | | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | -------------------------------------------------------------------------------------- | | `mcp:read` | All 17 read tools | All 17 read tools | 13 read tools: all but `list_products`, `get_product`, `list_users` and `get_accruals` | | `mcp:write` | Also `commit`, `plan_register_work`, `plan_upsert_customer`, `plan_upsert_product`, `plan_create_invoice`, `plan_delete_invoice`, `plan_create_series`, `plan_cancel_series` and `plan_setup_from_contract` | Nothing more | Also `plan_register_work` and `commit` | | `mcp:issue` | Also `plan_issue_invoice`, `plan_issue_occurrence` and `plan_create_credit_note` | Nothing more | Nothing more | Your role is not part of the token. It is read from Tabba on every request, so a role that changed is in force on the next call, and the catalogue of two people in one company differs. A client should hold a tool list against the connection, not against the server. ## Whose work an answer is about [#whose-work-an-answer-is-about] The scope of the token says what a connection may do. A second thing, the **view**, says whose work a read answers from. It is asked with an argument that is also called `scope`, on `get_my_work` and on the four lists whose rows can be related to a person: `list_customers`, `list_invoices`, `list_series` and `list_credit_notes`. | `scope` | On a list | On `get_my_work` | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------- | | `mine` (the default) | The rows your own places reach: the invoices you hold a place on a line of, the recurring series you are on, and the customers those bill. | Your own open lines. | | `related` | The same rows as `mine`. | Also the colleagues on the work you share. | | `all` | The whole company. An admin's and an auditor's. | Every person's open lines. An admin's and an auditor's. | `mine` is the default because a question asked in the first person is a question about your own work. `get_my_work` answers that question in one call: the lines you hold a place on, on invoices that still ask for something, grouped by customer or recurring series and ranked by what needs attention. Each line carries the `line_id` a work registration takes. The `scope` enum a tool advertises is built from your role: `mine` and `related` for a standard user, all three for an admin and an auditor. A request for a view your role does not reach is refused with `SCOPE_NOT_PERMITTED`. Every scoped answer opens with a line that says which view produced it. An admin and an auditor are also told how many rows it left out: ``` Scope "mine", reached through your own places: 12 customers in scope, all shown; 228 beyond this scope. Widen with scope:"all" for the whole company. ``` A standard user is told that there is no wider list, and is given no count of the rest: ``` Scope "mine", reached through your own places: 12 customers in scope, all shown. This is the widest list you may ask for; a company-wide answer needs an admin. ``` > [!NOTE] > **Read that line** > > A narrowed answer that does not say it was narrowed is worse than a broad > one, because nothing signals what is missing. The line is written for the > agent to relay. If yours summarises the rows and drops it, you are reading > a narrowed answer with nothing to say so. ### What a standard user reads [#what-a-standard-user-reads] A standard user reads within their own work on every tool, not only on the lists: * A single record outside it is answered `NOT_FOUND`, whether it was named by id, by `ext:` or by name, and it is never offered as a candidate. A record that does not exist is answered in the same words, so the answer does not say which of the company's records exist. * `get_recognition` for an invoice within their work is theirs to read. For a whole customer it is answered `ADMIN_REQUIRED`. * `get_user` answers for themselves. Naming anyone else needs an admin. * The product catalog, the list of people and the month-end accrual entries are an admin's, and those tools are not in a standard user's list. ### Roles and writes [#roles-and-writes] A standard user's only plan tool is `plan_register_work`, and it registers their own work: naming a colleague is refused with `REGISTER_FOR_OTHERS_FORBIDDEN`. An admin may name a colleague in `user`. Every other plan is an admin's, and the role is asked again when the plan is committed. An auditor makes no plan, and a person made an auditor since their plan was made cannot commit it: `commit` is no longer offered to them, and where it is reached it answers `READ_ONLY_ROLE`. `me` is a valid reference wherever a person is named. ## The company of a connection [#the-company-of-a-connection] A token carries one company. No tool takes a company as an argument, so nothing an agent passes can move a request to another one. If you work in several companies, that is one connection each. ## Revoking access [#revoking-access] Your connections are listed in Tabba under the heading **Claude and other AI clients**: on your profile settings, and for an admin also under Settings, Developers. Each row is one client acting as you on one company. **Revoke access** ends every connection of yours at once. There is no control that ends a single one, and an admin cannot see or end a colleague's. After a revoke: * A client is refused at its next refresh. A client that is working at that moment can go on for up to 10 minutes, until its access token runs out. * To use a client again, connect it and approve it again. * The company's API keys are untouched. Those are the company's, and an admin's to revoke. Removing the server from your client does not revoke anything in Tabba. Do both. A connection also ends without you: | What happened | When it takes effect | | ------------------------------------------------------- | -------------------------------------------------------------------------------------- | | Your role in the company changed | The next request runs with the new role. | | You lost access to the company, or were removed from it | The next request is refused with `401`. | | The company was disconnected from its accounting system | The next request is refused. The company's connections end, and its plans are deleted. | | A spent refresh token was presented again | The connection ends for every holder. | --- # Getting started > Base URL, your first call, what a key reaches, and the no-sandbox note. Source: https://www.tabba.io/developers/api The Tabba Sales API lets your own systems operate the Sales module of one company: its customers and products, its invoices and recurring series, work registration, credit notes, and the revenue recognition and month-end accrual that read them. It is the same service the workspace calls. A write goes to the company's accounting system first, exactly as a write in the workspace does. ## Base URL [#base-url] ``` https://www.tabba.io/api/v1 ``` Every endpoint lives under this prefix. Changes within `/api/v1` are additive: fields, endpoints and error codes are added, and none is renamed. The contract is published as an OpenAPI 3.1 document at `https://www.tabba.io/api/v1/openapi.json`. It takes no key. The [API reference](/developers/reference) is built from it. ## Your first call [#your-first-call] An admin or a bookkeeper of the company mints an API key in the company's settings, under Developers (`/workspace/settings/company/developers`). The raw `tba_…` key is shown once, when it is minted. Store it in your integration's secret manager and send it as a bearer token. ```bash curl https://www.tabba.io/api/v1/config \ -H "Authorization: Bearer $TABBA_API_KEY" ``` ```js const res = await fetch('https://www.tabba.io/api/v1/config', { headers: { Authorization: `Bearer ${process.env.TABBA_API_KEY}` }, }) const config = await res.json() console.log(config.company_name, config.base_currency) ``` `GET /config` answers the company the key is bound to, the base currency every figure of the company is stated in, and the two accounts the month-end accrual moves. Both accounts are `null` until an admin has confirmed them. ## What a key reaches [#what-a-key-reaches] A key is bound to **one connected company** and has a **scope**. The company is on one of two accounting systems, and every endpoint behaves the same on either: see [Accounting platforms](/developers/api/platforms). | Scope | Methods | Use | | ------------ | ------- | -------------------------------------------------------- | | `read` | `GET` | Dashboards, reporting, a one-way sync out of Tabba | | `read_write` | all | Create and change records, register work, issue invoices | A `read` key on a write endpoint is answered `403` with the code `SCOPE_INSUFFICIENT`. ## What the API covers [#what-the-api-covers] | Resource | What a key does with it | | --------------------- | ------------------------------------------------------------------------------ | | Config | Reads the company, its base currency and its accrual accounts | | Users | Lists the people who can work in the company, and sets their `external_id` | | Customers, products | Lists, creates, changes and deletes them, in the accounting system first | | Invoices | Writes drafts, issues them, reads their revenue recognition | | Recurring series | Creates a series, changes its lines, reads and issues its occurrences, ends it | | Work registrations | Sets a person's hours and completion on a line | | Credit notes | Credits an issued invoice in full or in part | | Recognition, accruals | Reads what a customer has earned and billed, and a month's accrual entry | ## There is no sandbox [#there-is-no-sandbox] There is no test mode and no test key. Build against a company you control, with a throwaway customer and product, and delete them when you are done. Three facts make that safe to do: * **A draft invoice is Tabba's own record.** Nothing is asked of the accounting system until the invoice is issued, and a draft is deleted without a trace in the books. * **A customer and a product are written to the accounting system at once.** Delete the ones you made for testing. * **A new invoice has `auto_send` on unless you say otherwise.** The daily run issues such an invoice on its issue date. Send `auto_send: false` on every invoice you create while testing. ## Where to go next [#where-to-go-next] * **[Quickstart](/developers/api/quickstart)**: from a key to recognised revenue, in curl, Node and Python. * **[Authentication](/developers/api/authentication)**: the key, its scope, the company it is bound to, rotation. * **[Accounting platforms](/developers/api/platforms)**: what is identical on both accounting systems, and the few fields and refusals a platform decides. * **[external\_id and idempotency](/developers/api/external-id)**: how you address every record by your own key, and why a retried create makes nothing twice. * **[Work registration](/developers/api/work-registration)**: sending hours and completion into the figures the workspace shows. * **[Issuance and auto-send](/developers/api/issuance)**: issuing an invoice yourself, or leaving it to the daily run. * **[Webhooks](/developers/api/webhooks)**: signed events for changes your integration did not make. * **[Pagination](/developers/api/pagination)**: cursors, and following a list with `updated_since`. * **[Errors](/developers/api/errors)**: the error envelope, every code, the rate limits. --- # Quickstart > From a key to recognised revenue, in curl, Node and Python. Source: https://www.tabba.io/developers/api/quickstart This page takes a key from its first call to recognised revenue: prove the key works, create a customer and a product, draft an invoice, register work on it and read back what it earned. Everything runs against a real company, since there is no sandbox. Use a throwaway customer and product, and delete them when you are done. ## 1. Mint a key [#1-mint-a-key] An admin or a bookkeeper of the company mints a key in the company's settings, under Developers (`/workspace/settings/company/developers`). Select the company you will test against in the top bar first, and choose the scope `read_write`. The raw `tba_…` key is shown once: put it in an environment variable before you close the dialog. ```bash export TABBA_API_KEY="tba_…" ``` ## 2. Read the configuration [#2-read-the-configuration] `GET /config` confirms that the key is live and which company it is bound to. It also answers the company's base currency, which the invoice in step 7 is written in. **curl** ```bash curl https://www.tabba.io/api/v1/config \ -H "Authorization: Bearer $TABBA_API_KEY" ``` **Node** ```js const res = await fetch('https://www.tabba.io/api/v1/config', { headers: { Authorization: `Bearer ${process.env.TABBA_API_KEY}` }, }) if (!res.ok) throw new Error(`${res.status} ${await res.text()}`) const config = await res.json() console.log(config.company_name, config.base_currency) // "Your company ApS" "DKK" ``` **Python** ```python import os import httpx res = httpx.get( "https://www.tabba.io/api/v1/config", headers={"Authorization": f"Bearer {os.environ['TABBA_API_KEY']}"}, ) res.raise_for_status() config = res.json() print(config["company_name"], config["base_currency"]) # "Your company ApS" "DKK" ``` A `401` means the key is missing (`UNAUTHORIZED`), or unknown or revoked (`INVALID_API_KEY`). A `403` with `SCOPE_INSUFFICIENT` means a `read` key on a write endpoint. See [Errors](/developers/api/errors). ## 3. Write the client once [#3-write-the-client-once] The rest of this page is one call per step, so wrap the boilerplate now. On a failure, read the `code` and the `request_id`: `code` is what you branch on, and `request_id` is what you quote when you ask about a call. **curl** ```bash # A shell function is enough: base URL, key, JSON. tabba() { local method="$1" path="$2" shift 2 curl -sS -X "$method" "https://www.tabba.io/api/v1$path" \ -H "Authorization: Bearer $TABBA_API_KEY" \ -H "Content-Type: application/json" "$@" } ``` **Node** ```js async function tabba(method, path, body) { const res = await fetch(`https://www.tabba.io/api/v1${path}`, { method, headers: { Authorization: `Bearer ${process.env.TABBA_API_KEY}`, 'Content-Type': 'application/json', }, body: body === undefined ? undefined : JSON.stringify(body), }) const json = await res.json() if (!res.ok) { const { code, message, request_id: requestId } = json.error throw new Error(`${code}: ${message} (request_id=${requestId})`) } return json } ``` **Python** ```python import os import httpx client = httpx.Client( base_url="https://www.tabba.io/api/v1", headers={"Authorization": f"Bearer {os.environ['TABBA_API_KEY']}"}, ) def tabba(method: str, path: str, body: dict | None = None) -> dict: res = client.request(method, path, json=body) payload = res.json() if res.is_error: err = payload["error"] raise RuntimeError( f"{err['code']}: {err['message']} (request_id={err['request_id']})" ) return payload ``` ## 4. Create a customer [#4-create-a-customer] Stamp your own key on it. `external_id` is how you name the customer from here on, and repeating the call with the same `external_id` updates that customer instead of making a second one. `name`, `country_key`, `is_person` and `send_method` are required, and `send_method: "email"` requires an `email` to send to. The customer is written to the accounting system first, so it is a real customer there. **curl** ```bash tabba POST /customers -d '{ "external_id": "quickstart:test-corp", "name": "Test Corp", "country_key": "DK", "is_person": false, "send_method": "email", "email": "billing@test.example" }' ``` **Node** ```js const customer = await tabba('POST', '/customers', { external_id: 'quickstart:test-corp', name: 'Test Corp', country_key: 'DK', is_person: false, send_method: 'email', email: 'billing@test.example', }) ``` **Python** ```python customer = tabba("POST", "/customers", { "external_id": "quickstart:test-corp", "name": "Test Corp", "country_key": "DK", "is_person": False, "send_method": "email", "email": "billing@test.example", }) ``` The answer is `201` for a new customer and `200` when the `external_id` was taken and the customer was updated. > [!NOTE] > **If the create is refused with 422** > > One of the two accounting systems files every customer in a VAT zone and a > customer group. There the create is refused with `VALIDATION_FAILED`, and > `field_errors` names `vat_zone_ref` and `customer_group_ref`. Read a customer > the company already has with `GET /customers`, and send the same two values. > See [Accounting platforms](/developers/api/platforms). Read the customer back by your own key at any time. `ext:` in the path names it: ```bash tabba GET /customers/ext:quickstart:test-corp ``` ## 5. Create a product [#5-create-a-product] A product states the revenue account a line that bills it posts to. `account_number` is an account of the company's chart, and `unit` is one the accounting system knows, so take all three of `account_number`, `account_ref` and `unit` from a product the company already has: ```bash tabba GET /products ``` Then create yours with those values: **curl** ```bash tabba POST /products -d '{ "external_id": "quickstart:consulting", "product_number": "QS-001", "name": "Consulting", "account_number": 1000, "account_ref": "1000", "unit": "hours", "default_price": 1200 }' ``` **Node** ```js const product = await tabba('POST', '/products', { external_id: 'quickstart:consulting', product_number: 'QS-001', name: 'Consulting', account_number: 1000, account_ref: '1000', unit: 'hours', default_price: 1200, }) ``` **Python** ```python product = tabba("POST", "/products", { "external_id": "quickstart:consulting", "product_number": "QS-001", "name": "Consulting", "account_number": 1000, "account_ref": "1000", "unit": "hours", "default_price": 1200, }) ``` ## 6. Find who does the work [#6-find-who-does-the-work] Every invoice line names at least one person, because revenue is recognised per person, per line. People are invited in the workspace and never created through the API. Read the people who can work in the key's company and find yourself: **curl** ```bash tabba GET /users # { "object": "list", "data": [ { "object": "user", "id": "…", "email": "…", … } ], … } ``` **Node** ```js const { data: users } = await tabba('GET', '/users') const me = users.find((u) => u.email === 'you@your-company.example') ``` **Python** ```python users = tabba("GET", "/users")["data"] me = next(u for u in users if u["email"] == "you@your-company.example") ``` You do not need the person's `id` for what follows: a person is named by `email:
` wherever a request names one. ## 7. Draft an invoice [#7-draft-an-invoice] One hourly line, with you on it at 1,200 per hour. The invoice is a draft: nothing is asked of the accounting system and no customer hears from you until it is issued. > [!NOTE] > **A request names a record by reference** > > A field named for a record (`customer`, `product`, `user`, `invoice`, > `series`) takes a reference: the record's `id`, or `ext:` and your own > `external_id`. A person is also named by `email:` and their address. A field > that ends in `_id` states an id, and is what a response answers. Write the invoice in the `base_currency` step 2 answered, and send `auto_send: false`. A new invoice has `auto_send` on unless the request says otherwise, and the daily run issues such an invoice by itself. **curl** ```bash tabba POST /invoices -d '{ "external_id": "quickstart:inv-001", "customer": "ext:quickstart:test-corp", "currency": "DKK", "title": "Quickstart", "auto_send": false, "lines": [{ "product": "ext:quickstart:consulting", "recognition_method": "hourly", "description_override": "Consulting, July", "assignees": [{ "user": "email:you@your-company.example", "hourly_rate_override": 1200 }] }] }' # Keep the id of the line from the answer: .lines[0].id export LINE_ID="…" ``` **Node** ```js const invoice = await tabba('POST', '/invoices', { external_id: 'quickstart:inv-001', customer: 'ext:quickstart:test-corp', currency: 'DKK', title: 'Quickstart', auto_send: false, lines: [{ product: 'ext:quickstart:consulting', recognition_method: 'hourly', description_override: 'Consulting, July', assignees: [{ user: 'email:you@your-company.example', hourly_rate_override: 1200, }], }], }) const lineId = invoice.lines[0].id ``` **Python** ```python invoice = tabba("POST", "/invoices", { "external_id": "quickstart:inv-001", "customer": "ext:quickstart:test-corp", "currency": "DKK", "title": "Quickstart", "auto_send": False, "lines": [{ "product": "ext:quickstart:consulting", "recognition_method": "hourly", "description_override": "Consulting, July", "assignees": [{ "user": "email:you@your-company.example", "hourly_rate_override": 1200, }], }], }) line_id = invoice["lines"][0]["id"] ``` > [!NOTE] > **Two kinds of line** > > An `hourly` line bills registered hours at each person's rate: every person > on it states `hourly_rate_override`, and the line states no `agreed_value`. > A `value` line bills a fixed `agreed_value` and is recognised by completion: > its people share it by `distribution_percentage`, a fraction from `0` to `1` > that sums to `1` over the line, or evenly where nobody states one. ## 8. Register the work [#8-register-the-work] This is the call that moves revenue. `hours` is the person's **total** on the line, not an increment: Tabba registers the difference against what is registered already, so sending the same total again registers nothing. **curl** ```bash tabba PUT /work-registrations -d '{ "invoice": "ext:quickstart:inv-001", "line": "'"$LINE_ID"'", "user": "email:you@your-company.example", "hours": 12 }' ``` **Node** ```js await tabba('PUT', '/work-registrations', { invoice: 'ext:quickstart:inv-001', line: lineId, user: 'email:you@your-company.example', hours: 12, }) ``` **Python** ```python tabba("PUT", "/work-registrations", { "invoice": "ext:quickstart:inv-001", "line": line_id, "user": "email:you@your-company.example", "hours": 12, }) ``` The answer states the person's total after the request (`hours`) and what the request registered (`hours_delta`). ## 9. Read the revenue back [#9-read-the-revenue-back] Twelve hours at 1,200 is 14,400 earned. The invoice has not been issued, so nothing is billed, and all of it is work in progress. **curl** ```bash tabba GET /invoices/ext:quickstart:inv-001/recognition ``` **Node** ```js const rec = await tabba('GET', '/invoices/ext:quickstart:inv-001/recognition') console.log(rec.earned, rec.billed, rec.deferred, rec.wip) // 14400 0 0 14400 ``` **Python** ```python rec = tabba("GET", "/invoices/ext:quickstart:inv-001/recognition") print(rec["earned"], rec["billed"], rec["deferred"], rec["wip"]) # 14400 0 0 14400 ``` `earned` is what the registered work has earned, and `billed` is what issued invoices have billed, net of booked credit notes. The difference is `deferred` (billed ahead of the work) or `wip` (earned ahead of the invoice). The figures are in the company's base currency and are the ones the month-end accrual posts from. Issuing the invoice moves the amount from `wip` to `billed`: see [Issuance and auto-send](/developers/api/issuance). > [!WARNING] > **Clean up after yourself** > > The customer and the product you made are real records in the accounting > system. Delete the invoice first, then the customer and the product: a > customer or a product that an invoice still names is refused with > `DELETE_BLOCKED_BY_REFERENCES`. A draft invoice is deleted with its lines and > the work registered on them. An issued invoice is never deleted. ```bash tabba DELETE /invoices/ext:quickstart:inv-001 tabba DELETE /customers/ext:quickstart:test-corp tabba DELETE /products/ext:quickstart:consulting ``` ## Where to go next [#where-to-go-next] * **[Authentication](/developers/api/authentication)**: scope, the company a key is bound to, and rotation. * **[external\_id and idempotency](/developers/api/external-id)**: the upsert contract you just relied on, in full. * **[Work registration](/developers/api/work-registration)**: totals, the entry month, and registering on an occurrence of a recurring series. * **[Webhooks](/developers/api/webhooks)**: how you hear of changes your integration did not make. * **[Connect an AI client instead](/developers/mcp)**: the same module over MCP, where every write is a plan first and a separate commit applies it. --- # Authentication > Key format, scope, the company a key is bound to, rotation. Source: https://www.tabba.io/developers/api/authentication Every request to `/api/v1` authenticates with an **API key**, sent as a bearer token. An admin or a bookkeeper of the company mints it in the company's settings, under Developers (`/workspace/settings/company/developers`). ``` Authorization: Bearer tba_… ``` The header is the only credential the API reads. A session cookie is never read. ## Key format [#key-format] A key is `tba_` followed by 32 URL-safe characters. There is no environment segment, because there is no sandbox to tell apart. The raw key is shown once, when it is minted. Tabba stores only its SHA-256 hash, and the Developers page shows a key by its prefix and its last four characters. A lost key cannot be recovered: revoke it and mint a new one. ## Scope [#scope] A key is minted with one of two scopes: * **`read`**: every `GET` endpoint. For dashboards and a one-way sync out of Tabba. * **`read_write`**: everything a `read` key does, and every `POST`, `PUT`, `PATCH` and `DELETE`. A `read` key on a write endpoint is refused: ```json HTTP/1.1 403 Forbidden { "error": { "type": "permission_error", "code": "SCOPE_INSUFFICIENT", "message": "This endpoint needs a read_write key; this key is read.", "request_id": "req_…" } } ``` The refusal comes before the rate limit is counted, so it spends nothing of the key's budget. ## One key, one company [#one-key-one-company] A key is bound to **one connected company** when it is minted, and it can be minted only for a company that is connected. No URL and no body of the API names a company: the key does. `GET /config` answers which one, as `integration_id` and `company_name`. A record of any other company does not exist as far as the request can tell: a path that names one is answered `404` with `NOT_FOUND`. If you operate several companies, mint one key for each and route requests in your client. **Disconnecting a company revokes its keys.** A revocation is final, so after the company is connected again an admin or a bookkeeper mints a new key. ## Refused keys [#refused-keys] | Request | Status | Code | | ------------------------------------------- | ------ | ----------------- | | No `Authorization: Bearer` header | `401` | `UNAUTHORIZED` | | A key that is malformed, unknown or revoked | `401` | `INVALID_API_KEY` | A revoked key and an unknown key are answered alike. Revocation takes effect at once: the next request under the key is refused. Requests that carry no key of Tabba's are counted per client address and are answered `429` with `RATE_LIMITED` past their limit. A request under your live key is not counted there. See [Errors](/developers/api/errors). ## Rotation [#rotation] To rotate a key with no downtime: 1. Mint a new key for the same company beside the current one. A company can have several live keys. 2. Deploy your integration with the new key. 3. Check on the Developers page that the new key's last use is advancing. 4. Revoke the old key. ## What the API does not do [#what-the-api-does-not-do] These stay in the workspace, where an admin does them from a signed-in session: * Inviting and removing people, and changing a person's role. * Connecting and disconnecting an accounting system. * Minting and revoking API keys. * Registering a webhook endpoint, rotating its signing secret and replaying a delivery. ## Keep the key on a server [#keep-the-key-on-a-server] The key acts for the whole company within its scope. The API sends no CORS header: it is called by servers. Do not embed a key in a frontend bundle or send it from a browser. Every call under a key is written to the account's activity log with the key and the `request_id` of the call. A registration of work is credited to the person the request names, since a key is not a person. --- # Accounting platforms > One API over Dinero and e-conomic: what is identical, the fields that name a platform's own choice, and what a platform can refuse. Source: https://www.tabba.io/developers/api/platforms A key is bound to one connected company, and that company keeps its books in one accounting system: **Dinero** or **e-conomic**. Which one it is, is not something your integration has to know. Every endpoint under `/api/v1` behaves the same on either: the same request bodies, the same response shapes, the same status codes and the same error codes. No field, no code and no message of the API names a platform. You write the integration once, and it keeps working for a company on the other system. This page is where the two are named, because the few differences below are yours to act on. ## What stays the same [#what-stays-the-same] * **Field names.** `platform_ref` and `platform_number` on an invoice, a credit note and an accrual entry are the handle and the number the accounting system gave the booked document, whichever system that is. * **Error codes.** `PLATFORM_API_ERROR` is the `502` for an accounting system that could not be reached or did not answer. A refusal by the accounting system is data under `refusal`, with a reason code of Tabba's own and the platform's words beside it. * **`send_error`.** `null` on a document whose delivery succeeded, and an object with a `reason` and the platform's own words (`platform_reason`) when the document was booked and its delivery is not known to have succeeded. * **Amounts, dates, currencies, recognition and the month-end accrual** are Tabba's own arithmetic and are identical by construction. ## Ids are the platform's handles [#ids-are-the-platforms-handles] A customer's and a product's `id` is the handle the accounting system knows the record by: a GUID on Dinero, the customer or product number on e-conomic. Treat it as opaque text. It is not always a UUID, and a number can be given to another record once its own is deleted in the accounting system. Address records by your own `external_id` and you never depend on the handle: see [external\_id and idempotency](/developers/api/external-id). ## Fields that name a platform's own choice [#fields-that-name-a-platforms-own-choice] A few fields of a customer and a product name an entry of a list the accounting system keeps, or a fact only one system holds. | Field | On Dinero | On e-conomic | | ---------------------------------------- | ----------------------------------------------- | --------------------------------------------------------- | | `vat_zone_ref` (customer) | not kept | **required**: the VAT zone the customer is placed in | | `customer_group_ref` (customer) | not kept | **required**: the customer group the customer is filed in | | `payment_terms_ref` (customer) | not kept | an entry of the company's list of payment terms | | `layout_ref` (customer) | not kept | the customer's document layout | | `payment_mean` (customer) | **required** when `send_method` is `einvoice` | not asked for | | `invoice_mail_out_option_key` (customer) | `VAT`, `GLN`, `SE` or `P` | `GLN` | | `account_ref` (product) | the account number as text, and may be left out | the number of the product group the revenue is filed in | Where a required value is missing, the write is answered `422` with `VALIDATION_FAILED`, and `field_errors` carries a code under the field's name: ```json HTTP/1.1 422 Unprocessable Entity { "error": { "type": "invalid_request_error", "code": "VALIDATION_FAILED", "message": "The customer failed validation.", "field_errors": { "vat_zone_ref": ["VAT_ZONE_REQUIRED"], "customer_group_ref": ["CUSTOMER_GROUP_REQUIRED"] }, "request_id": "req_…" } } ``` To find valid values, read a customer or a product the company already has (`GET /customers`, `GET /products`): each states the references the accounting system holds for it. Two rules keep a sync that does not model these fields from moving a record: * `POST /customers` under an `external_id` that is taken keeps `vat_zone_ref`, `payment_terms_ref`, `layout_ref` and `customer_group_ref` as they are when the body leaves them out. * `POST /products` under an `external_id` that is taken keeps `account_ref` when the body leaves it out and `account_number` is unchanged. A product whose `account_ref` names no product group of the company is refused by the platform, with the reason under `refusal`. ## What a platform can refuse [#what-a-platform-can-refuse] | Code | Status | What happened | | ------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | | `PLATFORM_REFUSED` | `422` | The accounting system refused the write of a customer or a product. Nothing was written. `refusal` says why. | | `ISSUE_REFUSED` | `422` | The accounting system refused to book an invoice. Nothing was booked. | | `CREDIT_REFUSED` | `422` | The accounting system refused to book a credit note. Nothing was booked. | | `PLATFORM_NOT_CONFIGURED` | `409` | The company's connection is missing a setting the write needs. Nothing about your request is wrong: an admin completes the setup in the workspace. | | `PLATFORM_UNAVAILABLE` | `409` | The company has no accounting system to write to. | | `PLATFORM_API_ERROR` | `502` | The accounting system could not be reached, or did not answer. It may or may not hold the change. | A refusal is not an outage. Do not retry a `422` with the same body: read `refusal.reason` and `refusal.platform_reason`, and correct the record. Retry a `502` with backoff. A create sent under an `external_id` is safe to send again unchanged within the hour: see [external\_id and idempotency](/developers/api/external-id). ## E-invoices [#e-invoices] A customer whose `send_method` is `einvoice` states how it is routed, in `invoice_mail_out_option_key`, and the identifier that channel reads. The channels a platform can route are in the table above. A channel the company's platform cannot route is refused when the customer is written, as `VALIDATION_FAILED` with `MAILOUT_OPTION_UNAVAILABLE` under `invoice_mail_out_option_key`. The delivery channel is checked again when an invoice is issued, **before anything is booked**: a customer that cannot be sent to is refused with `INVALID_SEND_METHOD`, and one that lacks its e-invoice details with `MISSING_EINVOICE_DETAILS`. Both are `422`, and in both the fix is on the customer. ## Differences that never reach a refusal [#differences-that-never-reach-a-refusal] * **A credit note on e-conomic is a booked invoice with negative amounts.** Crediting works on both platforms and `POST /credit-notes` is identical. What differs is the document that lands in the books. * **e-conomic sends a document as part of booking it, and keeps no readable record of a send.** A document booked there therefore states `send_error: null`. On Dinero the send is a step of its own after the booking, and a failed one is reported in `send_error`. * **A booked document cannot be sent again through the API**, on either platform. Following up on a failed delivery is done in the workspace. --- # external_id and idempotency > Naming every record by your own key, and why a retried create makes nothing twice. Source: https://www.tabba.io/developers/api/external-id The API is built so that **you name a record by your own key**. You stamp the id your system already has, a CRM account id or a project key, on a record as its `external_id`, and address the record afterwards as `ext:` and that id. Creating again under an `external_id` that is taken never makes a second record, which is what makes a retried sync safe. ## References [#references] Wherever a request names a record, it takes a reference in one of three forms: | Form | Names | | ------------------- | ------------------------------------------- | | `ext: