# 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:` | The record that carries your own key | | `email:
` | A person, by their address. People only | | `` | The record's own `id`, as the API states it | A reference is accepted in a path (`GET /customers/{ref}`), in every field of a body that is named for a record (`customer`, `product`, `user`, `invoice`, `series`) and in the `customer` filter of a list. A field that ends in `_id` states an id, and is what a response answers. ```bash # Read a customer by your own key. curl https://www.tabba.io/api/v1/customers/ext:crm:acme \ -H "Authorization: Bearer $TABBA_API_KEY" # Change one field of it. curl -X PATCH https://www.tabba.io/api/v1/customers/ext:crm:acme \ -H "Authorization: Bearer $TABBA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "email": "new-billing@acme.example" }' ``` URL-encode a reference in a path when it contains a character a path cannot carry, such as `/`, `?`, `#` or a space. An `external_id` is text of your own choosing, 1 to 255 characters. A customer's and a product's `id` is the accounting system's handle for the record: opaque text, not always a UUID. A person's `id`, and the `id` of an invoice, a recurring series and a credit note, is a UUID. A path that names a record the company does not hold is answered `404` with `NOT_FOUND`. A customer, a product or a person named in a body that the company does not hold is a `422` with a code under the field: `CUSTOMER_NOT_FOUND`, `PRODUCT_NOT_FOUND` or `ASSIGNEE_NOT_FOUND`. ## Create, or update what carries the id [#create-or-update-what-carries-the-id] Send `external_id` in the body of a create: ```bash curl -X POST https://www.tabba.io/api/v1/customers \ -H "Authorization: Bearer $TABBA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "external_id": "crm:acme", "name": "ACME ApS", "country_key": "DK", "is_person": false, "send_method": "email", "email": "billing@acme.example" }' ``` The answer is `201` and the customer, with its `external_id`. Send the same request again, with another name, and the answer is `200`: the customer that carries the id was updated. There is no `Idempotency-Key` header. The `external_id` is the one mechanism. What a repeated create does depends on the record: | Create | Under an `external_id` that is taken | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `POST /customers` | Updates that customer. `200` | | `POST /products` | Updates that product. `200` | | `POST /invoices` | Replaces that draft: a line that states its `id` stays the line it was, with the work registered on it. `200`. An issued invoice is refused with `INVOICE_ALREADY_ISSUED` | | `POST /recurring-series` | Saves the header of that series and leaves its lines as they are. `200` | | `POST /credit-notes` | Is the same credit note, whatever the body says the second time. Booked, it is answered as it stands, `200`, and nothing is booked twice | ## Where external\_id is required [#where-external_id-is-required] Required on the creates of Tabba's own documents: * `POST /invoices` * `POST /recurring-series` * `POST /credit-notes` Optional elsewhere: * `POST /customers` and `POST /products`. A create without one is not safe to send again: it makes a second record. * People are not created through the API. Tag one with `PATCH /users/{ref}`, whose only field is `external_id`. * `PUT /work-registrations` takes one as a label for the month's registration. The registration is idempotent without it, because it states totals. ## Changing and clearing an id [#changing-and-clearing-an-id] `PATCH /customers/{ref}`, `PATCH /products/{ref}`, `PATCH /recurring-series/{ref}` and `PUT /invoices/{ref}` take `external_id`: a value sets it, `null` clears it, and left out it stays as it is. An id that another record of the same kind already carries is refused with `DUPLICATE_EXTERNAL_ID` (`409`). An `external_id` is unique per company and kind of record, so a customer and a product can carry the same string. A person's is your organisation's key for them, unique in the organisation: a person who also belongs to another organisation keeps that organisation's id apart from yours, and neither key reads or writes the other's. ## Retrying a create [#retrying-a-create] **One create runs at a time under an `external_id`.** A second request while the first is still running is answered `409` with `CREATE_IN_PROGRESS` and a `Retry-After` header. Nothing was written and the accounting system was not asked. Send the same request again after the interval, and it is answered the record the first one made. **A customer create whose answer was lost can be sent again.** When the accounting system could not be reached or did not answer, the request is answered `502` with `PLATFORM_API_ERROR`: the customer may or may not exist. Send the same request again, with the same body and within the hour, and it is answered the customer the first request made, if it made one. A create with no `external_id` has no such guard. **A create the accounting system did not finish names its record.** The answer is `502` with `CREATE_UNFINISHED`, and `record_id` is the `id` of the customer that was made. It is in the catalog under its `external_id`. Send the same request again, or a `PATCH` by that id, to finish it. **A product number names one product.** A create whose `product_number` the accounting system already holds is answered that product, `200`, and nothing is written to it. The product is given your `external_id` unless it carries another one, which is refused with `DUPLICATE_PRODUCT_NUMBER` (`409`). **Two creates of one invoice or one series at the same moment make one record.** The second is answered that record, or `CREATE_IN_PROGRESS` while the first is still writing it. ## Records that leave the accounting system [#records-that-leave-the-accounting-system] The API lists and addresses only what the accounting system still offers. * **Switched off.** A customer or a product that is barred or archived in the accounting system is answered `404`. A create under its `external_id` is refused with `RECORD_ARCHIVED` (`409`): it is neither updated nor created again until the accounting system offers it once more. * **Deleted.** A record deleted in the accounting system no longer carries its `external_id`. The id finds nothing, and the next create under it makes a new record. * **Restored.** A record the accounting system brings back under the handle it had has its `external_id` again after the next import, unless you have given the id to another record in between. One id names one record. * **A reused number.** Where the accounting system gives a deleted record's number to a new record, the new record does not inherit the `external_id`. The `customer.updated` and `product.updated` events say when a record was switched off or deleted: see [Webhooks](/developers/api/webhooks). ## external\_id is not external\_reference [#external_id-is-not-external_reference] A customer and a product have a separate `external_reference` field. It is the accounting system's own reference field on the record. * `external_id`: your key for the record. It stays in Tabba, and it is what `ext:` resolves. * `external_reference`: a field of the record in the accounting system. It is not a key, and nothing is addressed by it. --- # Work registration > Setting a person's hours and completion on a line, as totals. Source: https://www.tabba.io/developers/api/work-registration Registering work tells Tabba how far one person has come on one line of an invoice. Hours and completion feed the earned, billed, deferred and work-in-progress figures the workspace shows and the month-end accrual posts from. ## The endpoint [#the-endpoint] ``` PUT /work-registrations ``` There is one work-registration endpoint. It sets one person's figures on one line per request, and it is **absolute**: you state where the person stands, not what changed. ## Totals, not increments [#totals-not-increments] Send the **total hours** the person has on the line, and the **completion rate** they stand at, as a fraction from `0` to `1`. Tabba reads what is registered and registers the signed difference. ```bash curl -X PUT https://www.tabba.io/api/v1/work-registrations \ -H "Authorization: Bearer $TABBA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "series": "ext:crm:acme-retainer", "occurrence": "2026-07-01", "line": "5f8e2c1a-3b4d-4e6f-8a9b-0c1d2e3f4a5b", "user": "email:maja@your-company.example", "hours": 12, "completion_rate": 0.6 }' ``` This says that Maja has 12 hours in total on the line, and that her part is 60 % done. ```json { "object": "work_registration", "invoice_id": "…", "invoice_line_id": "…", "user_id": "…", "accrual_month": "2026-07-01", "hours": 12, "hours_delta": 3, "completion_rate": 0.6, "external_id": null } ``` `hours` is her total on the line after the request, and `hours_delta` is what the request registered: `3` here, because 9 hours were registered before. Sending the same figures again registers nothing and answers `hours_delta: 0`, so a retried sync is safe with no idempotency key. `hours` and `completion_rate` are each optional, and a request states at least one. A figure left out, or sent as `null`, stays as it is. > [!WARNING] > **One request at a time per person and line** > > Two requests that set one person's hours on one line at the same moment both > measure against the same total, and the difference can be registered twice. > Send them one after the other. ## Naming the line [#naming-the-line] A line is named on an invoice, or on an occurrence of a recurring series. Send exactly one of the two forms: * **On an invoice**: `invoice` and `line`, where `line` is the `id` of the invoice line. * **On an occurrence**: `series`, `occurrence` and `line`. `occurrence` is the first day of the occurrence's billing period, `YYYY-MM-DD`, as `GET /recurring-series/{ref}/occurrences` states it. `line` is the `id` of the recurring line, the same for every occurrence, or the `line_id` of a one-time line as its occurrence states it. `invoice` and `series` take a reference: the `id`, or `ext:` and your own `external_id`. `line` is always an id. An occurrence that is not an invoice yet is made one by the registration, as when an admin registers work on it in the workspace. The answer's `invoice_id` is that invoice. ## Naming the person [#naming-the-person] `user` takes the same references as everywhere else: * `email:` and the person's address. * `ext:` and your own id, after you have tagged the person with `PATCH /users/{ref}`. * The person's `id`. People are not created through the API: they are invited in the workspace. `GET /users` lists the people who can work in the key's company. The person must hold a place on the line, as one of its `assignees`. A registration for someone who does not is refused with `NOT_ASSIGNED` (`409`). The registration is credited to the person it names. ## The entry month [#the-entry-month] Every registration lands in the month the request arrives in, in the Europe/Copenhagen time zone, never in a month you state. The answer's `accrual_month` is that month's first day. There is one registration per line, person and month, and it is the one the workspace reads. A lower total is a reduction. It is drawn down across the person's months, newest first. ## Refusals [#refusals] | Code | Status | What happened | | ------------------------- | ------ | -------------------------------------------------------------------------------- | | `NOT_ASSIGNED` | `409` | The person holds no place on the line | | `HOURS_BILLED_LOCKED` | `409` | The hours are of an hourly line that its invoice has billed | | `LINE_NOT_FOUND` | `404` | The invoice or the occurrence has no such line | | `INVOICE_NOT_FOUND` | `404` | `invoice` names no invoice of the company | | `SERIES_NOT_FOUND` | `404` | `series` names no recurring series of the company | | `OCCURRENCE_OUT_OF_RANGE` | `404` | `occurrence` is not the first day of a billing period of the series | | `OCCURRENCE_SKIPPED` | `409` | An admin skipped the occurrence | | `NOT_FOUND` | `404` | `user` names no person who can work in the company | | `DUPLICATE_EXTERNAL_ID` | `409` | Another month's registration of the person on the line carries the `external_id` | | `VALIDATION_FAILED` | `422` | See `field_errors` | One request raises a person's total by at most 1,000 hours, a guard against a slipped decimal. A larger rise is a `VALIDATION_FAILED` with `HOURS_CHANGE_TOO_LARGE` under `hours`. --- # Issuance and auto-send > Issuing an invoice yourself or leaving it to the daily run, a booked invoice that was not delivered, and credit notes. Source: https://www.tabba.io/developers/api/issuance A draft invoice is Tabba's own record. Issuing it makes the accounting system book the invoice and send it to the customer. From then on the invoice is frozen. There are two ways to issue, and both behave the same on either accounting system: see [Accounting platforms](/developers/api/platforms). ## Issue it yourself [#issue-it-yourself] Call the issue endpoint when your own workflow says the invoice is ready: ```bash # A one-time invoice. curl -X POST https://www.tabba.io/api/v1/invoices/ext:crm:acme-migration/issue \ -H "Authorization: Bearer $TABBA_API_KEY" # An occurrence of a recurring series, named by the first day of its period. curl -X POST https://www.tabba.io/api/v1/recurring-series/ext:crm:acme-retainer/occurrences/2026-07-01/issue \ -H "Authorization: Bearer $TABBA_API_KEY" ``` The two endpoints are `POST /invoices/{ref}/issue` and `POST /recurring-series/{ref}/occurrences/{period}/issue`. Neither takes a body. The accounting system books and sends the invoice inside the request, and the answer is the issued invoice: `status` is `issued`, and `issued_at`, `platform_ref` and `platform_number` are set. It is the explicit "bill this one now": an invoice with `auto_send` off is issued all the same, and one with no `issue_date`, or a date ahead, is dated today. **Sending it again is safe.** An invoice that is issued already is answered as it stands, `200`, and nothing is booked twice. While another attempt holds the invoice, the answer is `409` with `ISSUE_IN_PROGRESS` and a `Retry-After` header: send the same request again after the interval. An occurrence that is not an invoice yet is made one, and that invoice is issued. `{period}` is the first day of the occurrence's billing period, as `GET /recurring-series/{ref}/occurrences` states it. Both endpoints are counted against the `platform_write` rate limit as well as the default one: see [Errors](/developers/api/errors). ## Leave it to the daily run [#leave-it-to-the-daily-run] Every invoice carries an `auto_send` boolean. While it is on, the daily run issues the invoice without your involvement: * An invoice with an `issue_date` is issued on that date. * An invoice with no `issue_date` is issued once all the work on it is complete. `auto_send` is **on for a new invoice** unless the create says otherwise. Subscribe to the `invoice.issued` event to learn of the booking: ```js // Your webhook receiver, after the signature is verified. app.post('/webhooks/tabba', async (req, res) => { const event = req.body if (event.type === 'invoice.issued') { await syncInvoiceToYourSystem(event.data) } res.status(200).end() }) ``` To turn it off on a draft, replace the draft with `PUT /invoices/{ref}` and `auto_send: false`. The body of a `PUT` is the whole draft: a line that states its `id` stays the line it was, and a line the body leaves out is removed. Left out, `auto_send` stays as it is. ## Booked, and not delivered [#booked-and-not-delivered] Issuing is two things: the accounting system **books** the invoice, and it **sends** it to the customer. Booking is what puts the invoice in the books. When the booking succeeds and the delivery is not known to have succeeded, the answer is still `200`, with a `send_error`: ```json { "object": "invoice", "id": "…", "status": "issued", "issued_at": "2026-07-01T08:00:00.000Z", "platform_ref": "…", "platform_number": 1042, "send_error": { "reason": "…", "platform_reason": "…" } } ``` `reason` is a code of Tabba's own, and `platform_reason` is the accounting system's own words, where it gave any. On an invoice whose delivery succeeded, `send_error` is `null`. **Do not issue it again.** The invoice is in the books, and a second call answers the same invoice. The `invoice.send_failed` event is sent beside `invoice.issued` for such an invoice. Following up on the delivery is done in the workspace: the API has no endpoint that sends a booked invoice again. ## When an invoice is not issued [#when-an-invoice-is-not-issued] Anything short of issued is an error with its own code, never a `200`: | Code | Status | What happened | | ------------------------------------------------- | ------ | ----------------------------------------------------------------- | | `HOURLY_INCOMPLETE` | `409` | A person on an hourly line is below full completion | | `NOTHING_TO_BILL` | `409` | The invoice has no hours to bill | | `CUSTOMER_GONE`, `PRODUCT_GONE` | `409` | The accounting system no longer holds what the invoice names | | `ISSUE_IN_PROGRESS` | `409` | Another attempt holds the invoice. Retry after `Retry-After` | | `MISSING_LINES` | `422` | The invoice has no lines | | `INVALID_SEND_METHOD`, `MISSING_EINVOICE_DETAILS` | `422` | The customer cannot be sent to. Checked before anything is booked | | `ISSUE_REFUSED` | `422` | The accounting system refused to book. `refusal` says why | | `PLATFORM_API_ERROR` | `502` | The accounting system could not be reached, or did not answer | Issuing an occurrence adds three: `OCCURRENCE_SKIPPED` (`409`) for an occurrence an admin skipped, `OCCURRENCE_HAS_NO_LINES` (`422`) where the series states no line for the occurrence, and `OCCURRENCE_OUT_OF_RANGE` (`404`) for a date that starts no period of the series. ## Changing an issued invoice [#changing-an-issued-invoice] An issued invoice is frozen. `PUT /invoices/{ref}` and `DELETE /invoices/{ref}` on one are refused with `INVOICE_ALREADY_ISSUED` (`409`). To take it back, in full or in part, raise a **credit note**: ```bash curl -X POST https://www.tabba.io/api/v1/credit-notes \ -H "Authorization: Bearer $TABBA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "external_id": "crm:acme-migration:credit-1", "invoice": "ext:crm:acme-migration", "kind": "partial", "credit_date": "2026-07-15", "lines": [ { "invoice_line_id": "5f8e2c1a-3b4d-4e6f-8a9b-0c1d2e3f4a5b", "amount": 2500 } ] }' ``` * `kind: "full"` takes the whole invoice back and reads no `lines`. It is refused with `INVOICE_ALREADY_CREDITED` once anything was credited. * `kind: "partial"` names lines of the invoice, each by no more than is left on it. Amounts are before VAT. Too much is a `VALIDATION_FAILED` with `AMOUNT_EXCEEDS_BILLED` under the line. The accounting system books and sends the credit note inside the request, and the answer is the booked credit note, `201`. As with an invoice, a credit note that was booked and not delivered is a success that carries a `send_error`. **The `external_id` is the credit note.** Sending the request again under the same `external_id` books nothing twice: a booked credit note is answered as it stands, `200`, and an attempt that did not book is taken up again. An invoice that is not issued is refused with `INVOICE_NOT_ISSUED`. While another credit of the invoice is running the answer is `CREDIT_IN_PROGRESS` with `Retry-After`, and an attempt the accounting system gave no definite answer to holds every other credit of the invoice back (`CREDIT_ATTEMPT_UNRESOLVED`) until the request that made it is sent again. `GET /invoices/{ref}/credit-notes` lists the credit notes booked against an invoice. There is no endpoint that reads a single credit note. ## Occurrences of a recurring series [#occurrences-of-a-recurring-series] An occurrence of a recurring series is read as an invoice once it is one: `GET /invoices` lists it with its `series_id`, `period_from` and `period_to`. It is written through its series. `PUT /invoices/{ref}` and `DELETE /invoices/{ref}` on an occurrence are refused with `INVOICE_IS_OCCURRENCE` (`409`): change its lines through `PATCH /recurring-series/{ref}/lines/{lineRef}` instead. A series created through the API is active at once, and the occurrences whose period has started are invoices from that moment. ## Issuing many invoices [#issuing-many-invoices] There is no batch endpoint. To issue several invoices, call the issue endpoint for each: every call is safe to repeat, so a run that stopped halfway can be started again from the top. The `platform_write` limit bounds how fast one key can issue. For invoices that should go out on a date without a call from you, leave `auto_send` on and let the daily run issue them. --- # Webhooks > Signed, at-least-once events for changes your integration did not make. Source: https://www.tabba.io/developers/api/webhooks Webhooks are how your integration learns of **changes it did not make**: an invoice the daily run issued, a month-end accrual entry the close booked, a customer changed in the accounting system. Tabba posts a signed event to an HTTPS address of yours, and tries again when your server does not take it. ## Registering an endpoint [#registering-an-endpoint] An admin or a bookkeeper of the company registers a webhook endpoint in the company's settings, under Developers (`/workspace/settings/company/developers`). An endpoint is bound to that company, and subscribes to one or more event types. It can be disabled and enabled again. The endpoint's **signing secret**, `whsec_` and 32 characters, is shown once, when the endpoint is registered. Copy it into your integration's secret store. The address must be a public HTTPS host name. An address that is not `https`, that carries a user name or a password, that is an IP address, or whose host name resolves only inside a network (`localhost`, `.local`, `.internal` and the like) is refused. A host name that resolves to an address that is not public is never connected to. ## Events [#events] | Type | Sent when | | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `invoice.issued` | The accounting system booked an invoice: by the daily run, by an admin in the workspace, or by a call to the issue endpoint. Sent once per invoice | | `invoice.send_failed` | An invoice was booked, and its delivery to the customer is not known to have succeeded. Sent beside `invoice.issued`, never instead of it | | `credit_note.issued` | The accounting system booked a credit note: one an admin or a key asked for, or one the end of a recurring series queued. Sent once per credit note | | `accrual.booked` | The month-end close posted a customer's accrual entry. Not sent for a month the accounting system had closed, which a later month carries | | `customer.updated` | A customer was created, changed, switched off or deleted, in Tabba or in the accounting system | | `product.updated` | A product was created, changed, switched off or deleted, in Tabba or in the accounting system | A change made in the accounting system is seen at the next import, and the event is sent then. A change your own key made is announced like any other. ## The event [#the-event] ```json { "object": "event", "id": "evt_3f9c1e0a7b2d4c5e8f6a1b0c9d8e7f6a", "type": "invoice.issued", "created_at": "2026-07-01T08:00:00.000Z", "data": { "object": "invoice", "id": "…", "external_id": "crm:acme-migration", "status": "issued", "issued_at": "2026-07-01T08:00:00.000Z", "platform_ref": "…", "platform_number": 1042, "send_error": null, "lines": [ … ] } } ``` * `id`: `evt_` and 32 hex digits. It is the same for every endpoint the event went to, and for every retry and replay of it. Tell a repeat by this id. * `type`: one of the six above. * `created_at`: when the event was announced. * `data`: the whole record, as its `GET` endpoint answers it, so you can act on the event without a second request. An invoice is as `GET /invoices/{ref}` answers it, a customer as `GET /customers/{ref}`, a product as `GET /products/{ref}`, and an accrual entry as `GET /accruals`. ### What a customer or a product event adds [#what-a-customer-or-a-product-event-adds] The API lists and reads only records the accounting system still offers, so the event is how you learn that one was switched off or deleted. In `customer.updated` and `product.updated`, `data` carries two more fields: * `archived_at`: when the accounting system stopped offering the record, switched off or deleted. `null` while it offers it. * `deleted_at`: when the accounting system stopped holding the record. `null` while it holds it. A deleted record has `external_id: null`: the id is free for another record. Name it by its `id`. Where Tabba holds nothing of a deleted record but its id, `data` is only `object`, `id`, `external_id`, `archived_at` and `deleted_at`. Two changes to one customer in quick succession may both carry the later state. ## Verifying the signature [#verifying-the-signature] Every delivery carries two headers: ``` Tabba-Signature: t=1782892800,v1=e2b1c3… Tabba-Event-Id: evt_3f9c1e0a7b2d4c5e8f6a1b0c9d8e7f6a ``` `t` is when the attempt was signed, in unix seconds. `v1` is the HMAC-SHA256, in hex, of the timestamp, a period and the request body, under the endpoint's signing secret. Compute it over the body byte for byte as it arrived, not over a body you parsed and serialised again. Verify before you trust the payload: ```js import { createHmac, timingSafeEqual } from 'node:crypto' function verify(header, rawBody, secret) { const parts = Object.fromEntries( header.split(',').map((part) => part.trim().split('=', 2)), ) const t = parts.t const v1 = parts.v1 if (!t || !v1 || !/^\d+$/.test(t)) return false // Refuse a timestamp more than 300 seconds from your own clock. if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest() const presented = Buffer.from(v1, 'hex') return expected.length === presented.length && timingSafeEqual(expected, presented) } ``` The timestamp is what stops an old request from being replayed against you: refuse one more than 300 seconds from your own clock. The event type is in the body only. There is no header for it. ## Delivery and retries [#delivery-and-retries] Answer with a `2xx` within ten seconds. What your server answers decides what happens next: | Your answer | What Tabba does | | ----------------------------------------- | -------------------------- | | `2xx` | The delivery is delivered | | `5xx`, `408`, `429`, or no answer in time | Tries again later | | Any other status, a redirect among them | Fails the delivery at once | A delivery is tried again eight times, after waits of 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, 2 hours, 4 hours and 8 hours: about sixteen hours in all. After the last one the delivery is failed. A `4xx` such as `400`, `401` or `404` is read as the endpoint's own configuration, so another post would change nothing and none is made. A redirect is not followed. Delivery is **at least once**. A server that took the post and did not answer in time is posted to again, under the same event id. A retry carries the same body, byte for byte, under a new signature. An admin reads an endpoint's deliveries, with the status each was answered with, on the Developers page, and **replays** one from there. A replay posts the same body under the same event id, with a new signature. Take the event, answer `200`, and do the work afterwards: queue it inside your own system so that a slow database cannot run past the ten seconds. Verify the signature before you queue. ## Rotating the signing secret [#rotating-the-signing-secret] An admin rotates the secret on the Developers page. The new secret is shown once. **There is no overlap**: the old secret signs nothing from the next delivery on. A delivery signed with the new secret before your receiver holds it fails your verification. If the receiver answers such a delivery with a `4xx`, the delivery is failed at once, and an admin replays it once the new secret is deployed. --- # Pagination > Cursors, the order of a list, and following a list with updated_since. Source: https://www.tabba.io/developers/api/pagination Every list endpoint (`GET /customers`, `GET /invoices`, and the others) answers one page in the same envelope: ```json { "object": "list", "data": [ … ], "has_more": true, "next_cursor": "eyJzIjoi…" } ``` `next_cursor` is `null` on the last page. ## Reading a whole list [#reading-a-whole-list] Pass the `next_cursor` of one page as the `cursor` of the next, until `has_more` is `false`: ```js let cursor = null const all = [] do { const url = new URL('https://www.tabba.io/api/v1/customers') url.searchParams.set('limit', '100') if (cursor) url.searchParams.set('cursor', cursor) const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.TABBA_API_KEY}` }, }) const page = await res.json() all.push(...page.data) cursor = page.has_more ? page.next_cursor : null } while (cursor) ``` A cursor is opaque: pass it back as you were given it. A value that is not a cursor of the API is refused with `VALIDATION_FAILED` (`422`). ## Order [#order] A list is ordered by when a record last changed, **oldest change first**, then by id. The cursor names the record a page ended on, so the next page is everything after it in that order. A record that changes while you walk the list moves to the end of it and is met again. None is skipped. Write your reader so that meeting a record twice is harmless: key it by `id`, and let the later copy win. The occurrences of a recurring series are the exception: they are listed in the order of their periods. ## Page size [#page-size] A page holds 25 records unless you say otherwise, and at most 100. Pass `limit`: ```bash curl "https://www.tabba.io/api/v1/customers?limit=100" \ -H "Authorization: Bearer $TABBA_API_KEY" ``` ## Following a list with updated\_since [#following-a-list-with-updated_since] `updated_since` narrows a list to the records changed **at or after** an instant. It is an ISO 8601 timestamp with an offset, such as `2026-07-01T00:00:00Z`. ```bash curl "https://www.tabba.io/api/v1/customers?updated_since=2026-07-01T00:00:00Z" \ -H "Authorization: Bearer $TABBA_API_KEY" ``` How to follow a list depends on whose record it is. **Customers, products and people.** Remember the newest `updated_at` your last walk was answered, and ask from it. That record comes again, with everything changed after it. For customers and products this misses nothing and needs no overlap: a change is stamped as it is written, in the order changes become visible. The instant is one Tabba handed out, so your own clock is never held against Tabba's. **Invoices, recurring series and credit notes.** The change stamp is taken when the record is written. Follow the list from the instant your last walk started. No overlap is needed. Three things do not move a change stamp: * A change of a customer's or a product's `external_id` alone. * A change to a line of a recurring series alone. The series' own `updated_at` stays where it was. * An import from the accounting system that found nothing changed. A customer or a product that was switched off or deleted in the accounting system is no longer listed, so a followed list does not tell you that it left. The `customer.updated` and `product.updated` events do: see [Webhooks](/developers/api/webhooks). ## Filters [#filters] | Filter | Lists | Meaning | | --------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------ | | `external_id` | users, customers, products, invoices, recurring series | Only the record that carries this external id | | `customer` | invoices, recurring series | Only records of this customer: its `id`, or `ext:` and its external id | | `status` | invoices, recurring series | `draft` or `issued` for an invoice, `draft` or `active` for a series | | `updated_since` | every list but occurrences | Only records changed at or after the instant | | `from`, `to` | occurrences | Only occurrences whose period starts on or after `from`, and on or before `to` | Filters and `cursor` combine. Keep the filters the same from page to page of one walk. `GET /invoices` lists every invoice of the company, the occurrences of recurring series among them. An occurrence states its `series_id`. `GET /invoices/{ref}/credit-notes` lists only booked credit notes. ## Occurrences have no last page [#occurrences-have-no-last-page] `GET /recurring-series/{ref}/occurrences` lists the occurrences of a series from its start, or from `from`, to its end date, or to `to`. A series with no end date has no last occurrence, so `has_more` never turns `false` on it. Bound the walk with `to`, or stop when you have what you need. Reading an occurrence never makes it an invoice. ## Rate limits [#rate-limits] A walk spends one request per page against the key's limit. Ask for 100 records a page when you read a whole list. The limits and their headers are on the [Errors](/developers/api/errors) page. --- # Errors > The error envelope, every code, what to retry, rate limits, and the versioning policy. Source: https://www.tabba.io/developers/api/errors The API answers with real HTTP status codes, so `res.ok` and ordinary error handling work. Every failure has the same body: ```json { "error": { "type": "invalid_request_error", "code": "VALIDATION_FAILED", "message": "The customer failed validation.", "field_errors": { "email": ["EMAIL_REQUIRED_FOR_EMAIL_SEND"], "vat_zone_ref": ["VAT_ZONE_REQUIRED"] }, "request_id": "req_…" } } ``` | Field | Meaning | | -------------- | ------------------------------------------------------------------------------------------------------------------ | | `type` | The family: `authentication_error`, `permission_error`, `invalid_request_error`, `rate_limit_error` or `api_error` | | `code` | The stable code to branch on | | `message` | English, for a log or a developer. Not for an end user | | `field_errors` | Per field of the request, the codes of what to fix. Present where a field is at fault | | `refusal` | Why the accounting system refused, where it did | | `record_id` | On `CREATE_UNFINISHED` only: the `id` of the record the request did create | | `request_id` | The id of the request | **Branch on `code`, never on `message`.** Codes are added and never renamed. An unknown code should fall through to a generic handler. A success is the bare resource, with an `object` field that names its kind, and has no envelope around it. ## Status codes [#status-codes] | Status | Meaning | Codes | | ------ | ------------------------------------------------------------------ | -------------------------------------------------------------------------- | | `400` | The request is malformed | `BAD_REQUEST` | | `401` | The key is missing, unknown or revoked | `UNAUTHORIZED`, `INVALID_API_KEY` | | `403` | The key lacks the scope the endpoint needs | `SCOPE_INSUFFICIENT` | | `404` | The company holds no such record | `NOT_FOUND` and the resource's own, below | | `409` | The request runs into the state of the record | see below | | `422` | The request failed validation, or the accounting system refused it | `VALIDATION_FAILED` and the refusals below | | `429` | Over a rate limit | `RATE_LIMITED` | | `500` | An error on Tabba's side | `INTERNAL_ERROR`, `DB_ERROR`, `MIRROR_WRITE_FAILED`, `REGISTRATION_FAILED` | | `502` | The accounting system did not answer, or did not finish | `PLATFORM_API_ERROR`, `CREATE_UNFINISHED` | ## Every code [#every-code] ### The key and the request [#the-key-and-the-request] | Code | Status | Meaning | | -------------------- | ------ | --------------------------------------------------------------- | | `UNAUTHORIZED` | `401` | The request carries no `Authorization: Bearer` header | | `INVALID_API_KEY` | `401` | The key is malformed, unknown or revoked | | `SCOPE_INSUFFICIENT` | `403` | A `read` key on an endpoint that needs `read_write` | | `RATE_LIMITED` | `429` | Over a rate limit. Retry after `Retry-After` seconds | | `BAD_REQUEST` | `400` | The body is not JSON, or a reference is not in one of its forms | | `VALIDATION_FAILED` | `422` | The body or the query failed validation. See `field_errors` | | `NOT_FOUND` | `404` | A path names a record the company does not hold | ### Customers, products and people [#customers-products-and-people] | Code | Status | Meaning | | ------------------------------ | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `DUPLICATE_EXTERNAL_ID` | `409` | Another record of the same kind carries the `external_id` | | `DUPLICATE_PRODUCT_NUMBER` | `409` | A product with the number exists under another `external_id` | | `DELETE_BLOCKED_BY_REFERENCES` | `409` | An invoice, a recurring series or a credit note of the company names the record. The accounting system was not asked, and nothing was deleted | | `DELETE_BLOCKED_BY_BOOKINGS` | `409` | The accounting system holds bookings against the record. Nothing was deleted | | `RECORD_ARCHIVED` | `409` | The `external_id` names a record the accounting system has switched off. It is neither updated nor created again until it is offered once more | | `CREATE_IN_PROGRESS` | `409` | Another request under the same `external_id` is still creating the record. Carries `Retry-After` | | `CREATE_UNFINISHED` | `502` | The accounting system made the record and the create did not finish. `record_id` names it | ### Invoices [#invoices] | Code | Status | Meaning | | -------------------------- | ------ | ------------------------------------------------------------------------------------- | | `INVOICE_NOT_FOUND` | `404` | A body names an invoice the company does not hold | | `INVOICE_ALREADY_ISSUED` | `409` | The invoice is issued and frozen. Raise a credit note | | `INVOICE_NOT_ISSUED` | `409` | A credit note was asked for an invoice that is not issued | | `INVOICE_IS_OCCURRENCE` | `409` | The invoice is an occurrence of a recurring series, and is written through its series | | `ISSUE_IN_PROGRESS` | `409` | Another attempt holds the invoice. Carries `Retry-After` | | `HOURLY_INCOMPLETE` | `409` | An hourly line is not fully complete. Nothing was issued | | `NOTHING_TO_BILL` | `409` | The invoice has no hours to bill. Nothing was issued | | `CUSTOMER_GONE` | `409` | The accounting system no longer holds the customer | | `PRODUCT_GONE` | `409` | The accounting system no longer holds a product of the invoice | | `MISSING_LINES` | `422` | The invoice has no lines to issue | | `INVALID_SEND_METHOD` | `422` | The customer cannot be sent to this way. Nothing was issued | | `MISSING_EINVOICE_DETAILS` | `422` | The customer lacks e-invoice details. Nothing was issued | | `ISSUE_REFUSED` | `422` | The accounting system refused to book the invoice. See `refusal` | ### Credit notes [#credit-notes] | Code | Status | Meaning | | --------------------------- | ------ | -------------------------------------------------------------------------------------------------- | | `INVOICE_ALREADY_CREDITED` | `409` | A full credit was asked for an invoice that already carries a credit | | `NOTHING_TO_CREDIT` | `409` | Nothing the invoice billed is left to credit | | `CREDIT_IN_PROGRESS` | `409` | Another credit of the invoice is running. Carries `Retry-After` | | `CREDIT_ATTEMPT_UNRESOLVED` | `409` | An earlier credit of the invoice got no definite answer. Send the request that made it again first | | `CREDIT_REFUSED` | `422` | The accounting system refused to book the credit note. See `refusal` | ### Recurring series and occurrences [#recurring-series-and-occurrences] | Code | Status | Meaning | | -------------------------------- | ------ | --------------------------------------------------------------------------------------------------------- | | `SERIES_NOT_FOUND` | `404` | A body names a recurring series the company does not hold | | `SERIES_LINE_NOT_FOUND` | `404` | The line is not on that occurrence of the series | | `OCCURRENCE_OUT_OF_RANGE` | `404` | The date starts no billing period of the series | | `SERIES_NOT_ACTIVE` | `409` | The series is a draft, and nothing is invoiced from it | | `SERIES_SETUP_LOCKED` | `409` | The series has issued an invoice: its customer, currency, schedule and title are locked | | `OCCURRENCE_ISSUED` | `409` | The occurrence is issued and its lines are frozen | | `OCCURRENCE_SKIPPED` | `409` | An admin skipped the occurrence | | `DELETE_EMPTIES_OCCURRENCE` | `409` | Taking the line away would leave an occurrence that is an invoice with no lines | | `SCHEDULE_DROPS_REGISTERED_WORK` | `409` | The change leaves out occurrences with registered work. Send `acknowledge_dropped_work: true` to go ahead | | `END_HAS_PASSED` | `409` | The end date of the series has passed and cannot be moved | | `END_CREDITED` | `409` | A credit note has settled the end of the series, and it cannot be moved | | `END_BLOCKED_BY_CREDIT` | `409` | An invoice of the series carries a credit that is not finished | | `SERIES_END_CONFLICT` | `409` | The series changed while its end was being set. Nothing was written | | `OCCURRENCE_HAS_NO_LINES` | `422` | The series states no line for the occurrence | | `END_BEFORE_START` | `422` | The end date lies before the start of the series | ### Work registrations [#work-registrations] | Code | Status | Meaning | | --------------------- | ------ | ----------------------------------------------------------------- | | `LINE_NOT_FOUND` | `404` | The invoice or the occurrence has no such line | | `NOT_ASSIGNED` | `409` | The person holds no place on the line | | `HOURS_BILLED_LOCKED` | `409` | The hours were billed when the invoice was issued, and are locked | | `REGISTRATION_FAILED` | `500` | The registration could not be saved | ### The accounting system, and Tabba's own side [#the-accounting-system-and-tabbas-own-side] | Code | Status | Meaning | | ------------------------- | ------ | ----------------------------------------------------------------------------------------------------- | | `PLATFORM_REFUSED` | `422` | The accounting system refused the write. Nothing was written. See `refusal` | | `PLATFORM_NOT_CONFIGURED` | `409` | The company's connection is missing a setting the write needs. An admin completes it 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 | | `MIRROR_WRITE_FAILED` | `500` | The accounting system took the write, and Tabba could not record it | | `DB_ERROR` | `500` | A read or a write on Tabba's side failed | | `INTERNAL_ERROR` | `500` | An error on Tabba's side | No code names an accounting system: see [Accounting platforms](/developers/api/platforms). ## Field errors [#field-errors] `field_errors` holds, per field of the request, the codes of what to fix. A field is named by its dotted path as the request spells it, such as `lines.0.agreed_value`, and `_` stands for the value as a whole. Where a rule has a code of its own the entry is that code, such as `VAT_ZONE_REQUIRED`. Otherwise it is a sentence that says what the field should have been. A customer, a product or a person that a body names and the company does not hold is reported here, as `CUSTOMER_NOT_FOUND`, `PRODUCT_NOT_FOUND` or `ASSIGNEE_NOT_FOUND` under the field. ## Refusals by the accounting system [#refusals-by-the-accounting-system] A refusal is data, not an outage. Where the accounting system said no, the error carries what it said: ```json { "error": { "type": "invalid_request_error", "code": "ISSUE_REFUSED", "message": "The platform refused the invoice. Nothing was booked.", "refusal": { "reason": "…", "params": {}, "platform_reason": "…" }, "request_id": "req_…" } } ``` `reason` is a code of Tabba's own, `params` are its parameters, and `platform_reason` is the accounting system's own words, or `null` where it gave none. Show `platform_reason` to a person as the accounting system's words. An outage is `PLATFORM_API_ERROR`, never a refusal. ## What to retry [#what-to-retry] | Answer | What to do | | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `429` | Wait `Retry-After` seconds, then send the request again | | `409` with `CREATE_IN_PROGRESS`, `ISSUE_IN_PROGRESS` or `CREDIT_IN_PROGRESS` | Wait `Retry-After` seconds, then send the same request again. It finds what the other request came to | | `502` with `PLATFORM_API_ERROR` | Retry with backoff. A create sent under an `external_id` is safe to send again unchanged within the hour. A create sent without one is not: it can make a second record | | `502` with `CREATE_UNFINISHED` | Send the same request again under its `external_id`, or a `PATCH` by `record_id` | | `500` | Retry with backoff, and quote the `request_id` if it persists | | `400`, `401`, `403`, `404`, `422`, and every other `409` | Do not retry unchanged. The same request is refused again | Issuing an invoice and creating a credit note are safe to send again whatever the first answer was: an issued invoice and a booked credit note are answered as they stand. ## request\_id [#request_id] Every response carries the id of its request in the `X-Request-Id` header, and every error repeats it in the body as `request_id`. It is `req_` and a random string. Log it beside your own trace id, and quote it when you ask about a call. Every call under a key is in the account's activity log under the same id. ## Rate limits [#rate-limits] Limits are counted per key, in a sliding window of 60 seconds. | Bucket | Limit | Counted for | | ----------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `default` | 300 requests per 60 seconds, per key | Every endpoint | | `platform_write` | 30 requests per 60 seconds, per key | On top of `default`, the endpoints that make the accounting system book something inside the request: `POST /invoices/{ref}/issue`, `POST /recurring-series/{ref}/occurrences/{period}/issue` and `POST /credit-notes` | | `unauthenticated` | 60 requests per 60 seconds, per client address | A request with no key, or with a key that is not a live key of Tabba's | Every metered response carries three headers. They state the bucket with the least room left: ``` X-RateLimit-Limit: 300 X-RateLimit-Remaining: 287 X-RateLimit-Reset: 1782892860 ``` `X-RateLimit-Reset` is when the window has room again, in seconds since the epoch. Past a limit the answer is `429` with `RATE_LIMITED`, and one header more: ``` Retry-After: 12 ``` The separate `platform_write` bucket means that a retry loop on an issue spends its own budget and leaves your reads alone. A request refused for its scope spends nothing. A request that carries no key, or a key that is unknown or revoked, is counted by the address it came from. Past that limit it is answered `429` in place of the `401`. A request under your live key is not counted there, whatever else the same address sends. ## Versioning [#versioning] Changes within `/api/v1` are additive. Within it, Tabba may: * Add endpoints. * Add optional fields to a request body. * Add fields to a response body. * Add event types to webhooks. * Add error codes. Within `/api/v1`, an endpoint, a field and an error code are not renamed, and a code is not moved to another status. Write your client so that an unknown field is ignored and an unknown `code` reaches a generic handler. --- # Overview > Every /api/v1 endpoint and every webhook event, generated from the OpenAPI document. Source: https://www.tabba.io/developers/reference These pages are generated from the OpenAPI 3.1 document the API serves at [`/api/v1/openapi.json`](/api/v1/openapi.json). Each page is built from the schemas its route registers, so the reference states what the API does. Every endpoint takes a Bearer `tba_` key and answers under `https://www.tabba.io/api/v1`. To make a first call, read the [quickstart](/developers/api/quickstart). The webhooks group states each event that Tabba posts to an endpoint you register: read [webhooks](/developers/api/webhooks) for how one is verified. - [Accruals](/developers/reference/accruals/getAccruals) - [Config](/developers/reference/config/getConfig) - [Credit notes](/developers/reference/credit-notes/postCreditNotes) - [Customers](/developers/reference/customers/getCustomers) - [Invoices](/developers/reference/invoices/getInvoices) - [Products](/developers/reference/products/getProducts) - [Recognition](/developers/reference/recognition/getRecognition) - [Recurring series](/developers/reference/recurring-series/getRecurringSeries) - [Users](/developers/reference/users/getUsers) - [Webhooks](/developers/reference/webhooks/onInvoiceIssued) - [Work registrations](/developers/reference/work-registrations/putWorkRegistrations) --- # API reference: every operation > One line per endpoint and per webhook event. The schemas are not repeated here: read https://www.tabba.io/api/v1/openapi.json for the whole document, or the address on a line for that one operation with its schemas. Markdown of the overview: https://www.tabba.io/developers/reference.md - `GET /accruals`: Read the accrual entry of one customer for one month (https://www.tabba.io/developers/reference/accruals/getAccruals.md) - `GET /config`: Read the configuration of the company the key is bound to (https://www.tabba.io/developers/reference/config/getConfig.md) - `POST /credit-notes`: Credit an issued invoice, in full or in part (https://www.tabba.io/developers/reference/credit-notes/postCreditNotes.md) - `DELETE /customers/{ref}`: Delete a customer (https://www.tabba.io/developers/reference/customers/deleteCustomersRef.md) - `GET /customers`: List the customers the company invoices (https://www.tabba.io/developers/reference/customers/getCustomers.md) - `GET /customers/{ref}`: Read one customer (https://www.tabba.io/developers/reference/customers/getCustomersRef.md) - `PATCH /customers/{ref}`: Change the fields of a customer that the body states (https://www.tabba.io/developers/reference/customers/patchCustomersRef.md) - `POST /customers`: Create a customer, or update the one that carries the external_id (https://www.tabba.io/developers/reference/customers/postCustomers.md) - `DELETE /invoices/{ref}`: Delete a draft invoice (https://www.tabba.io/developers/reference/invoices/deleteInvoicesRef.md) - `GET /invoices`: List the invoices of the company (https://www.tabba.io/developers/reference/invoices/getInvoices.md) - `GET /invoices/{ref}`: Read one invoice (https://www.tabba.io/developers/reference/invoices/getInvoicesRef.md) - `GET /invoices/{ref}/credit-notes`: List the credit notes booked against an invoice (https://www.tabba.io/developers/reference/invoices/getInvoicesRefCreditNotes.md) - `GET /invoices/{ref}/recognition`: Read the revenue recognition of one invoice (https://www.tabba.io/developers/reference/invoices/getInvoicesRefRecognition.md) - `POST /invoices`: Create a draft invoice, or replace the draft that carries the external_id (https://www.tabba.io/developers/reference/invoices/postInvoices.md) - `POST /invoices/{ref}/issue`: Issue an invoice now (https://www.tabba.io/developers/reference/invoices/postInvoicesRefIssue.md) - `PUT /invoices/{ref}`: Replace a draft invoice (https://www.tabba.io/developers/reference/invoices/putInvoicesRef.md) - `DELETE /products/{ref}`: Delete a product (https://www.tabba.io/developers/reference/products/deleteProductsRef.md) - `GET /products`: List the products the company offers (https://www.tabba.io/developers/reference/products/getProducts.md) - `GET /products/{ref}`: Read one product (https://www.tabba.io/developers/reference/products/getProductsRef.md) - `PATCH /products/{ref}`: Change the fields of a product that the body states (https://www.tabba.io/developers/reference/products/patchProductsRef.md) - `POST /products`: Create a product, or update the one that carries the external_id (https://www.tabba.io/developers/reference/products/postProducts.md) - `GET /recognition`: Read the revenue recognition of one customer (https://www.tabba.io/developers/reference/recognition/getRecognition.md) - `DELETE /recurring-series/{ref}/lines/{lineRef}`: Take a line of a recurring series away (https://www.tabba.io/developers/reference/recurring-series/deleteRecurringSeriesRefLinesLineRef.md) - `GET /recurring-series`: List the recurring series of the company (https://www.tabba.io/developers/reference/recurring-series/getRecurringSeries.md) - `GET /recurring-series/{ref}`: Read one recurring series (https://www.tabba.io/developers/reference/recurring-series/getRecurringSeriesRef.md) - `GET /recurring-series/{ref}/occurrences`: List the occurrences of a recurring series (https://www.tabba.io/developers/reference/recurring-series/getRecurringSeriesRefOccurrences.md) - `GET /recurring-series/{ref}/occurrences/{period}`: Read one occurrence of a recurring series (https://www.tabba.io/developers/reference/recurring-series/getRecurringSeriesRefOccurrencesPeriod.md) - `PATCH /recurring-series/{ref}`: Change the header fields of a recurring series that the body states (https://www.tabba.io/developers/reference/recurring-series/patchRecurringSeriesRef.md) - `PATCH /recurring-series/{ref}/lines/{lineRef}`: Change a line of a recurring series (https://www.tabba.io/developers/reference/recurring-series/patchRecurringSeriesRefLinesLineRef.md) - `POST /recurring-series`: Create a recurring series, or save the header of the one that carries the external_id (https://www.tabba.io/developers/reference/recurring-series/postRecurringSeries.md) - `POST /recurring-series/{ref}/cancel`: Cancel a recurring series: set its end date (https://www.tabba.io/developers/reference/recurring-series/postRecurringSeriesRefCancel.md) - `POST /recurring-series/{ref}/lines`: Add a line to a recurring series (https://www.tabba.io/developers/reference/recurring-series/postRecurringSeriesRefLines.md) - `POST /recurring-series/{ref}/occurrences/{period}/issue`: Issue an occurrence of a recurring series now (https://www.tabba.io/developers/reference/recurring-series/postRecurringSeriesRefOccurrencesPeriodIssue.md) - `GET /users`: List the people of the company (https://www.tabba.io/developers/reference/users/getUsers.md) - `GET /users/{ref}`: Read one person of the company (https://www.tabba.io/developers/reference/users/getUsersRef.md) - `PATCH /users/{ref}`: Set or clear a person's external_id (https://www.tabba.io/developers/reference/users/patchUsersRef.md) - `accrual.booked`: A month-end accrual entry was booked (https://www.tabba.io/developers/reference/webhooks/onAccrualBooked.md) - `credit_note.issued`: A credit note was booked (https://www.tabba.io/developers/reference/webhooks/onCreditNoteIssued.md) - `customer.updated`: A customer changed (https://www.tabba.io/developers/reference/webhooks/onCustomerUpdated.md) - `invoice.issued`: An invoice was issued (https://www.tabba.io/developers/reference/webhooks/onInvoiceIssued.md) - `invoice.send_failed`: An issued invoice was not sent (https://www.tabba.io/developers/reference/webhooks/onInvoiceSendFailed.md) - `product.updated`: A product changed (https://www.tabba.io/developers/reference/webhooks/onProductUpdated.md) - `PUT /work-registrations`: Set a person’s hours and completion on a line (https://www.tabba.io/developers/reference/work-registrations/putWorkRegistrations.md)