# Invoia developer documentation > The full text of https://invoia.io/developers, in reading order. > Index: https://invoia.io/llms.txt · OpenAPI: https://invoia.io/api/v1/openapi.json --- # Overview > What the Invoia MCP server is, and what an agent can and cannot do with it. Source: https://invoia.io/developers/mcp Invoia speaks [MCP](https://modelcontextprotocol.io). Point an AI client at one URL and it can read your invoicing — customers, invoices, retainers, logged hours, revenue recognition — and propose changes to it. Nothing is written until you say so. ``` https://invoia.io/api/mcp ``` ## What it is [#what-it-is] A remote MCP server over streamable HTTP, stateless: every call is one `POST` carrying its own credential, so there is no session to keep alive and no local process to run. `GET` and `DELETE` return `405` — if your client insists on opening an SSE stream first, it is talking to the wrong kind of server. The server registers itself as `invoia`. Its tools self-describe over `tools/list`, so this documentation does not repeat the catalogue — ask your client what it can do and it will tell you, accurately, for the scopes you granted. ## One way to connect: OAuth [#one-way-to-connect-oauth] Every client below connects the same way. Paste the URL, sign in at invoia.io, approve the scopes you are willing to grant. The client keeps the token and refreshes it on its own; you never handle a secret, and nothing you paste into a config file is one. The consent screen is also how the server learns **who you are** — which is what lets it answer "my clients" with your clients rather than the account's. See [Auth and scopes](/developers/mcp/auth). > [!WARNING] > **There is no headless path** > > Consent needs a live browser session, so CI, cron jobs, containers and > server-side agents cannot connect over MCP at all — there is no key, flag > or long-lived token that skips it. Run that work against the > [REST API](/developers/api) on `/api/v1`, which authenticates with a Bearer > key and has no consent step. Existing REST integrations are untouched by > any of this and need no action. ## What an agent can do [#what-an-agent-can-do] * **Read** — sixteen `list_*` / `get_*` tools covering the acting user's own work, customers, products, invoices and their lines, recurring series and occurrences, logged work, revenue recognition and month-end accruals. * **Propose** — eleven `plan_*` tools that draft a change and hand back a plan for you to read, from `plan_upsert_customer` to `plan_register_work` to `plan_issue_invoice`. * **Commit** — one `commit` tool that applies a plan you approved. Twenty-eight tools on a full-scope connection; sixteen on a read-only one, because the rest are never registered. > [!NOTE] > **Names, not UUIDs** > > Every tool takes the reference a human would use — `Bredgade SEO`, > `ext:your-id`, `email:someone@example.com` — so you never have to look > up an id to ask a question. `me` is one of them: it is you, resolved > without a lookup. An ambiguous name comes back as a list of candidates > rather than a guess. ## Your work is the default answer [#your-work-is-the-default-answer] Reads answer from the work you are assigned to, not from the whole account. `get_my_work` returns your lines in one call, ranked by what needs attention; the customer, invoice, retainer and credit-note rosters take a `scope`, defaulting to `mine`, and every result opens by saying which view produced it and how many rows sit outside it. Widening is yours to ask for, and which views you may ask for follows your role in the app — the account-wide one is an admin's. Connecting a client grants no more authority than you already have. ## Beyond tools [#beyond-tools] Two more MCP surfaces come with the connection, and clients expose them differently — often as slash commands or an attachable context menu. * **Prompts** — `setup_invoicing_from_contract` turns a contract into a recurring series; `monthly_close` walks the occurrences due this period. Both are written to stop and show you a plan before anything is committed. * **A resource** — `invoia://config` carries the connected integration's base currency and its WIP / deferred-revenue account numbers. Pin it and the agent stops guessing which currency your figures are in. ## What it cannot do [#what-it-cannot-do] * Write anything without a separate, explicit commit — see [The propose → commit contract](/developers/mcp/propose-commit). * Exceed the scopes you granted. Issuing (booking to Dinero and emailing a customer) is its own scope you can withhold — see [Auth and scopes](/developers/mcp/auth). * Run without a person. Every request is authenticated as one, which is the same fact as the headless limit above. * Register another person's hours, unless you are an admin and name them. * Invite users, change roles, or alter billing. Those stay in the app, behind a signed-in admin session. ## Connect your client [#connect-your-client] - [claude.ai](/developers/mcp/claude-ai): Add a custom connector in the web app, desktop or mobile. - [Claude Code](/developers/mcp/claude-code): One CLI command, then the OAuth flow from /mcp. - [ChatGPT](/developers/mcp/chatgpt): Developer mode, on a Business or Enterprise workspace. - [Cursor](/developers/mcp/cursor): A four-line mcp.json entry, per project or per user. Prefer to write the integration yourself, or need it to run unattended? The same product is a REST API — start at [Getting started](/developers/api). --- # Connect claude.ai > Add Invoia as a custom connector in claude.ai. Source: https://invoia.io/developers/mcp/claude-ai Add Invoia as a connector and Claude can read your invoicing and propose changes to it. Nothing is written until you commit. The connector follows your account, so it works in the web app, the desktop app and mobile once it is added. ## Add the connector [#add-the-connector] On a personal plan, open **Customize → Connectors**, click **+ → Add custom connector**, and paste this URL: ``` https://invoia.io/api/mcp ``` Leave the **Advanced settings** OAuth fields empty. They exist for servers that hand out client credentials by hand; Invoia supports dynamic registration, so Claude registers itself. Claude then opens an invoia.io sign-in and shows a consent screen listing the scopes it is asking for. Approve the ones you are willing to grant — you can withhold issuing and still get everything else. > [!NOTE] > **On Team and Enterprise, an owner adds it first** > > An owner adds the connector once under **Organization settings → > Connectors → Add → Custom → Web**. Everyone else then finds it in > **Customize → Connectors** and clicks **Connect** to authenticate as > themselves. Each member's grant is their own — scopes are not > inherited from the owner. Custom connectors are available on Free, Pro, Max, Team and Enterprise plans; a Free account is limited to one at a time. Anthropic's [connector documentation](https://support.claude.com/en/articles/11175166-about-custom-connectors-remote-mcp) has the current details if the menu has moved. ## Verify it works [#verify-it-works] Ask for something read-only first. Claude should answer from live data, not from memory: ``` List my five most recent invoices with their status. ``` If the answer names real customers, the connection is live. Note the *my*: reads answer from the work you are assigned to, and the answer says which view produced it and how many rows sit outside it. Widening to the whole account is something you ask for, and admins only — see [Auth and scopes](/developers/mcp/auth). ## Then try a write [#then-try-a-write] ``` Register 12 hours for me on the Bredgade SEO retainer for July. ``` Claude calls `plan_register_work`, shows you what it intends to change, and waits. Only when you approve does it call `commit`. If it comes back with a question instead of a plan — which line you meant, whether those hours replace the total or add to it — answer it; the tool refuses to guess at anything that would write a wrong number. Naming a colleague instead of yourself is an admin's move. If you are not one, the refusal says so and offers you `me`. ## Use the prompts [#use-the-prompts] The connector ships two prompts, which claude.ai surfaces from the connector's menu in the chat composer: * **Set up invoicing from a contract** — paste the terms, get a recurring series proposed, with any new products it needs. * **Monthly close** — walk the occurrences due this period, one approval at a time. Both stop and show you a plan before anything is committed. ## Troubleshooting [#troubleshooting] * **The connector asks you to sign in again** — an access token lives ten minutes and refreshes silently; a refresh token lives thirty days. A connection you have not used in over a month will genuinely ask. If it asks repeatedly within a session, remove and re-add the connector. * **Claude says it cannot do something** — it is almost always a missing scope, and the tool is not merely blocked but absent, so Claude has no way to name what it is missing. Check what you approved in [Auth and scopes](/developers/mcp/auth). Re-adding the connector is how you grant more; there is no way to widen an existing grant in place. * **The connector is listed but Claude never uses it** — make sure it is enabled for the conversation, and say "using Invoia" once. Claude attaches connectors per conversation, not globally. * **A name matches several rows** — Claude will show you the candidates. Answer with the one you meant, or use `ext:your-id`. * **You want to switch Invoia accounts** — a connector is bound to the account you signed in with. Remove it and add it again; the sign-in is where the account is chosen. * **Anything else** — every failure the server returns carries a code and a next step, and Claude will usually relay both verbatim. Quote them when you ask for help. --- # Connect Claude Code > Add the Invoia MCP server to Claude Code and authenticate with OAuth. Source: https://invoia.io/developers/mcp/claude-code Claude Code talks to remote MCP servers over HTTP. One command adds Invoia; the OAuth flow opens in your browser. That browser step is not optional — 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 invoia https://invoia.io/api/mcp ``` Then start Claude Code and run `/mcp`. Pick **invoia** and authenticate — the browser opens an invoia.io sign-in followed by the consent screen. Check where it landed: ```bash claude mcp list ``` `invoia` should read `✔ Connected`. `! Needs authentication` means the entry is written but you have not run `/mcp` yet. ## Scope it to a project or your user [#scope-it-to-a-project-or-your-user] Claude Code stores servers in one of three scopes: | Scope | Stored in | Reaches | | ----------------- | ------------------------------------------- | --------------------------- | | `local` (default) | `~/.claude.json`, under this project's path | Only you, only this project | | `project` | `.mcp.json` in the repo root | Everyone who clones it | | `user` | `~/.claude.json` | Only you, every project | Invoia is account-wide, not repo-specific, so the user scope is usually the right one: ```bash claude mcp add --transport http --scope user invoia https://invoia.io/api/mcp ``` > [!NOTE] > **Nothing here holds a secret** > > The entry records the URL only — the token lives in Claude Code's own > credential store, and each person who authenticates gets their own. That > makes `--scope project` safe to commit, with one caveat below. > [!WARNING] > **Project scope commits a file** > > `--scope project` writes `.mcp.json` into the repository. Every > collaborator will be prompted to authenticate against your Invoia account, > and non-interactive runs (`claude -p`, the Agent SDK, cloud sessions) load > it without asking — where it will simply never connect, because none of > them can complete a browser sign-in. Reset the approvals with > `claude mcp reset-project-choices`. ## Not from CI [#not-from-ci] There is no headless way in. The OAuth consent screen needs a live browser session, and Invoia accepts no other MCP credential — no key, no `--header`, no long-lived token. A build agent, a cron runner or a `claude -p` job on a server has no path to a connection. Point that work at the [REST API](/developers/api) instead: it authenticates with a Bearer key on `/api/v1`, reaches the same product, and speaks the same error vocabulary. If you already have a REST integration, nothing about it changed. ## Verify it works [#verify-it-works] ``` > How much have we recognised on the Bredgade SEO retainer this quarter? ``` Claude Code lists the tools it used. A read answer that names real figures means the credential is live and carries `mcp:read`. ## What it answers by default [#what-it-answers-by-default] ``` > What clients do I have? ``` You get *your* clients — the customers reached through the invoice lines you are assigned to — not every customer on the account, and the answer says so along with how many sit outside the view. Ask for the account-wide version explicitly if you need it; that one is available to admins only. See [Auth and scopes](/developers/mcp/auth). ## Two prompts come with it [#two-prompts-come-with-it] `/mcp` also exposes the server's prompts as commands — one to turn a contract into a recurring series, one to walk a monthly close. Both show you a plan and wait before committing anything. ## Troubleshooting [#troubleshooting] * **`! Needs authentication`** — run `/mcp`, select **invoia**, and authenticate. A non-interactive session cannot, and will not connect. * **The write tools are missing** — `/mcp` shows the tool count per server. Sixteen means a read-only grant; twenty-eight is the full set. Re-authenticate and grant more. * **A tool call times out** — an HTTP server gets 60 seconds to first byte by default, and issuing waits on Dinero. Raise it for this server only with a `timeout` field in the config entry (milliseconds), or set `MCP_TOOL_TIMEOUT`. * **"MCP tool output exceeds 10,000 tokens"** — a `list_invoices` across a busy account will do that. Ask for a narrower query, or raise `MAX_MCP_OUTPUT_TOKENS` (the default ceiling is 25,000). * **Two entries, one name** — local beats project beats user, and the entries are not merged. `claude mcp get invoia` shows which one won. ## Remove it [#remove-it] ```bash claude mcp remove invoia ``` Add `--scope user` or `--scope project` if that is where you put it. Removing the entry does not revoke the grant — do that in **Settings → Developers** in the app. --- # Connect ChatGPT > Add Invoia to ChatGPT as a custom MCP connector in developer mode. Source: https://invoia.io/developers/mcp/chatgpt ChatGPT reaches a remote MCP server through a **custom connector**, which lives behind developer mode. It is the fussiest of the four clients to set up and the only one where an admin may have to act before you can start. > [!NOTE] > **Check your plan first** > > Full MCP connectors and developer mode are a beta, offered on ChatGPT > Business and Enterprise/Edu workspaces, on the web. Availability moves > — OpenAI's > [developer mode article](https://help.openai.com/en/articles/12584461-developer-mode-and-mcp-apps-in-chatgpt) > is the current word. ## 1. Turn on developer mode [#1-turn-on-developer-mode] For yourself, in **Settings → Security and login**, turn on **Developer mode**. On a Business or Enterprise workspace an owner has to allow it first, under **Workspace settings → Permissions & roles → Connected data → Developer mode / Create custom MCP connectors**. Until they do, the toggle is not there to find. ## 2. Add the server [#2-add-the-server] In the apps and connectors settings, use the **+** button to create a developer-mode app, and give it: * **URL** — `https://invoia.io/api/mcp`, including the `/mcp` path * **Authentication** — OAuth ``` https://invoia.io/api/mcp ``` ChatGPT registers itself and sends you to an invoia.io sign-in and consent screen. Approve the scopes you want it to have. The new app appears under **Drafts** until you publish it to the workspace. ## 3. Use it [#3-use-it] Enable the connector for a conversation, then ask in plain language: ``` Using Invoia, show me every draft invoice older than 30 days. ``` You get the ones you are assigned to, and the answer says so — reads default to your own work, and widening to the whole account is explicit and admin-only ([Auth and scopes](/developers/mcp/auth)). Its details page lists the tools it pulled from the server, with a toggle each. If you change what you granted, use **Refresh** there — ChatGPT caches the tool list, its descriptions and the server instructions, and will otherwise keep offering tools your new token no longer carries. ## What to expect on writes [#what-to-expect-on-writes] ChatGPT gets the same propose → commit contract as every other client: a `plan_*` tool returns a written plan, and nothing changes until `commit` runs on your say-so. See [The propose → commit contract](/developers/mcp/propose-commit). > [!WARNING] > **Deep research cannot use this connector** > > Deep research and company-knowledge lookups only call a connector's > `search` and `fetch` tools. Invoia implements neither: its read tools > are typed queries against your books, not a document index, and > pretending otherwise would hand a research run a licence to roam. Use > a normal chat when you want Invoia to answer or propose a change. ## Troubleshooting [#troubleshooting] * **No developer mode toggle** — the workspace has not enabled it. Only an owner can, in workspace settings. * **"Failed to connect"** — check the URL ends in `/mcp`. The base `https://invoia.io` is the OAuth issuer, not the MCP endpoint, and will not answer a tool call. * **Tools you granted are missing, or removed ones linger** — hit **Refresh** on the app's details page. The catalogue is built from your token's scopes at connect time and ChatGPT caches it. * **It answers from memory instead of calling a tool** — name the connector in the message ("using Invoia") and confirm it is enabled for that conversation. * **A write did not happen** — a plan is not a commit. Confirm the plan explicitly; if the conversation moved on, proposals expire after fifteen minutes and the tool has to be re-run. --- # Connect Cursor > Add the Invoia MCP server to Cursor, per project or for your whole machine. Source: https://invoia.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] Open **Customize** in the sidebar to manage MCP servers, or write the file directly: **~/.cursor/mcp.json** ```json { "mcpServers": { "invoia": { "url": "https://invoia.io/api/mcp" } } } ``` **.cursor/mcp.json** ```json { "mcpServers": { "invoia": { "url": "https://invoia.io/api/mcp" } } } ``` There is no `type` field for a remote server — a `url` is what makes it remote. Reload Cursor, find `invoia` in the MCP list, and log in. Cursor runs the OAuth flow itself — the browser opens an invoia.io sign-in and the consent screen, and that is the only way Invoia authenticates an MCP client. > [!NOTE] > **No secret in the file** > > Those four lines are the whole entry — there is no header to add and no > credential to paste. The token lives in Cursor's own credential store, so > a project-level `.cursor/mcp.json` is safe to commit; each collaborator > logs in and is authenticated as themselves, with their own scopes and > their own work. ## Cursor's own agents cannot connect [#cursors-own-agents-cannot-connect] Signing in is a browser step, and Invoia's MCP server accepts no other credential — there is no headless variant of this entry. A background agent, a CI job or anything else running without a person at a browser has no way to authenticate. Use the [REST API](/developers/api) for that work: a Bearer key on `/api/v1`, no consent step, the same product underneath. A REST integration you already run is unaffected. ## Verify it works [#verify-it-works] In Agent mode: ``` @invoia list the retainers that renew this month ``` Cursor shows the tool calls it made. If it returns real series names, the connection is live — and it returns *your* retainers, the ones you are assigned to, with a line saying how many sit outside that view. See [Auth and scopes](/developers/mcp/auth) for how to widen it. ## Mind the tool budget [#mind-the-tool-budget] Invoia registers **28 tools** on a full-scope connection — sixteen reads, eleven `plan_*`, and `commit`. Cursor caps the total number of tools it will send to the model across *all* servers (commonly reported at 40), so Invoia alone is most of it. If tools start disappearing, you have three levers: disable other servers in **Customize**, use a tool allowlist to trim the ones you never call, or approve only `mcp:read` at the consent screen — that registers sixteen tools and nothing else, which is enough for every question that does not write. ## Troubleshooting [#troubleshooting] * **Tools do not appear at all** — check the JSON parses and that the server is toggled on. A disabled server does not load or show up in chat. * **Every call 401s** — the token expired and the refresh failed, or the grant was revoked. Log out and back in from the MCP settings. * **`406 Not Acceptable`** — something between Cursor and Invoia is stripping the `Accept` header. Streamable HTTP requires both `application/json` and `text/event-stream`; a proxy that rewrites it breaks the transport before the credential is read. * **The agent proposes but never writes** — that is the design. A `plan_*` tool returns a plan; you approve, and it calls `commit`. See [The propose → commit contract](/developers/mcp/propose-commit). * **A name matches several rows** — the error lists the candidates. Pick one, or address it as `ext:your-id`. --- # The propose → commit contract > Why no Invoia tool writes on its own, and what the two steps look like. Source: https://invoia.io/developers/mcp/propose-commit Invoia moves money and books to an accounting ledger. An agent that misreads "five hours" and silently deletes seven is not a support ticket, it is a wrong VAT return. So the server has no tool that both decides and writes. ## Two steps, always [#two-steps-always] 1. **Propose.** A `plan_*` tool reads the current state, works out the change, and returns a plan in plain language plus an opaque `proposal_token`. Nothing has happened yet. 2. **Commit.** The separate `commit` tool takes that token and applies the plan exactly as written. The agent is instructed to show you the plan and wait. Because the two steps are different tools, a client that requires approval for writes can gate `commit` alone and leave reading and drafting free. ## What a plan looks like [#what-a-plan-looks-like] A ready proposal comes back as text the agent can read to you, with the structured form alongside it: ``` Set Jane Doe to 12.0 h (was 7.0) and 60% complete on line "SEO retainer — July" of invoice INV-2026-0142. To apply, confirm with the user, then call `commit` with proposal_token="mcpp_3f0c…" (expires 2026-08-07T09:12:33.000Z; requires mcp:write). ``` You read the summary. If it is right, the agent calls `commit` with the token. If it is not, you say so and nothing was ever written. ## The token has a short life [#the-token-has-a-short-life] * **Fifteen minutes.** After that the token is `PROPOSAL_EXPIRED` and the `plan_*` tool has to run again. This is deliberate: a plan describes the world as it was when it was built, and an hour-old plan is a guess. * **Single use.** Committing flips the proposal from pending to committed in one conditional write, so a client that retries a timed-out commit cannot apply it twice. * **Bound to your account.** A token from one connection means nothing on another. > [!NOTE] > **PROPOSAL_STALE means the ground moved** > > Every plan records the `updated_at` of each row it depends on, and > commit re-checks them. If someone edited that invoice in the app while > you were reading the plan, the commit is refused rather than applied to > a record it no longer describes. Re-run the same `plan_*` tool, read the > new summary, commit that token. ## When the agent does not have enough to go on [#when-the-agent-does-not-have-enough-to-go-on] A `plan_*` tool can come back with `needs_clarification` and a list of questions instead of a plan. The agent must ask you and re-call the same tool with your answers — it is not allowed to guess: ``` I need a bit more information before preparing this change: 1. [line_ref] Which line should the hours go on? - Dev hours (hourly, invoice INV-2026-0142) [answer: line_7] - SEO retainer — July (value, invoice INV-2026-0142) [answer: line_9] Ask the user, then call this tool again with line_ref filled in. ``` Each option carries the exact value to send back, so a text-only agent is never stuck able to read a choice but unable to express it. The most common trigger is a scale ambiguity: * **hours** is an absolute cumulative total, not an increment. Sending `hours: 5` to a line that already has 12 logged is a request to remove seven — so the tool stops and asks which you meant. Use `hours_delta` to add. * **completion\_rate** is `0`–`1`; **completion\_percent** is `0`–`100`. * **money** is in major units — kroner, not øre — in the integration's base currency. ## Every failure says what to do next [#every-failure-says-what-to-do-next] Tool failures come back as readable errors, not transport faults, and each carries a code and a next step: ``` Error [INVOICE_ALREADY_ISSUED]: This invoice is already issued. Next: An issued invoice is frozen. Raise a credit note with plan_create_credit_note instead of editing it. ``` The codes are the same vocabulary the REST API uses, so an integration that touches both surfaces branches on one set of strings. The ones specific to this contract: | Code | What happened | What to do | | --------------------- | --------------------------------------- | ------------------------------- | | `PROPOSAL_EXPIRED` | Older than fifteen minutes | Re-run the `plan_*` tool | | `PROPOSAL_STALE` | A record it depends on changed | Re-run, re-read, re-commit | | `PROPOSAL_NOT_FOUND` | Already committed, or another account's | Re-run the `plan_*` tool | | `AMBIGUOUS_REFERENCE` | A name matched several rows | Pick from the listed candidates | ## Irreversible steps are still irreversible [#irreversible-steps-are-still-irreversible] Commit is where the change becomes real, and some changes cannot be undone from Invoia: * **Issuing** books the invoice into Dinero and emails your customer. Correcting it means raising a credit note, not editing the invoice. * **Credit notes** are themselves bookings. Issuing lives behind its own scope, so an agent can be given the run of everything else without it — see [Auth and scopes](/developers/mcp/auth). --- # Auth and scopes > OAuth is the only MCP credential — the flow, the three scopes, whose work an answer is about, and why there is no headless path. Source: https://invoia.io/developers/mcp/auth The MCP server takes one credential: an **OAuth access token**, obtained by your client through a consent screen in your browser. There is nothing to paste and nothing to mint. That is a change from earlier versions of this page, which described a second path. An `ivk_…` key is no longer a credential here — presented at `/api/mcp` it gets the same `401` as no credential at all. It is untouched on the REST API, and if that is where your integration lives, nothing below applies to you: skip to [What did not change](#what-did-not-change). ## Why the server insists on a person [#why-the-server-insists-on-a-person] Because a conversation is held with someone. A consultant who asks "what clients do I have?" is not being vague — the first person *is* the specification — and a credential that authenticates an organisation cannot answer it. So every MCP request now names an **acting user**: the person who signed in at the consent screen. That is what lets `me` resolve, what lets reads default to your own work, and what puts a human rather than a secret in the audit trail behind every write. ## The OAuth flow [#the-oauth-flow] The server is an OAuth 2.1 resource server *and* its own authorization server, both on `https://invoia.io`. Clients discover everything they need; there is no client id to register by hand. 1. The client calls `https://invoia.io/api/mcp` unauthenticated and gets a `401` naming the protected resource (RFC 9728). 2. It reads the discovery documents and registers itself dynamically at `/api/oauth/register` (RFC 7591) — public clients use PKCE and get no secret. 3. It sends you to invoia.io to sign in and approve scopes. 4. It receives a token whose audience is bound to `https://invoia.io/api/mcp`, so the token cannot be replayed against anything else. You can watch step 1 happen from a terminal: ```bash curl -i -X POST https://invoia.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://invoia.io/.well-known/oauth-protected-resource/api/mcp" {"error":{"code":"UNAUTHORIZED","message":"Missing or invalid bearer credential for the MCP resource."}} ``` That header is the whole handshake: it tells the client where to read the metadata, and the metadata names the authorization server. ``` https://invoia.io/.well-known/oauth-protected-resource/api/mcp https://invoia.io/.well-known/oauth-authorization-server ``` Both are public. Fetch them if you are debugging a client that will not connect — between them they name the authorization, token and registration endpoints, the JWKS, `S256` as the only challenge method, and the three scopes below. > [!WARNING] > **Both Accept types or 406** > > Streamable HTTP requires the client to accept `application/json` **and** > `text/event-stream`, even though this server always answers with JSON. > Omit either and the transport returns `406` before your credential is > ever looked at. Real MCP clients send both; curl needs telling. ## Token lifetimes [#token-lifetimes] Access tokens live **10 minutes**; refresh tokens live **30 days** and are exchanged transparently by the client. A connection you have not touched in a month asks you to sign in again — that is the refresh token expiring, not a fault. ## There is no headless path [#there-is-no-headless-path] Step 3 above is a consent page, and it needs a live browser session. CI, a scheduled job, a container, a server-side agent — none of them can complete it, and there is no flag, no client-credentials grant and no long-lived token that skips it. **Headless MCP is not possible.** This is a real loss and it is not worked around. Plan for it: | Surface | Where the work runs | | ----------------------------------------- | --------------------------------------------------------------- | | MCP, over OAuth | A person's own client — claude.ai, Claude Code, ChatGPT, Cursor | | [REST API](/developers/api), on `/api/v1` | CI, cron, a queue worker, your own backend | The REST API reaches the same product with the same error vocabulary. It has no consent step because it has no person: it authenticates with a Bearer key your admin mints, and any write that must be attributed to someone names them explicitly. Start at [Getting started](/developers/api). ## The three scopes [#the-three-scopes] Granted at the consent screen, and layered — granting one grants those beneath it. | Scope | Grants | | ----------- | ------------------------------------------------------------------------------------------------------------------------ | | `mcp:read` | View invoices, customers, products, recurring series and revenue recognition. | | `mcp:write` | Draft and edit invoices, customers, products and series, register work, and commit a plan. Nothing is booked or emailed. | | `mcp:issue` | Issue invoices — book to Dinero and email your customers — and raise credit notes. | > [!NOTE] > **Withholding issue is the point** > > The split exists so you can hand an agent the whole drafting workflow > while keeping the irreversible act for yourself. A token without > `mcp:issue` does not merely fail on those tools — it never sees them. ## Scope gating is by construction [#scope-gating-is-by-construction] The tool catalogue is assembled per request from the token's scopes. A `mcp:read` connection's `tools/list` contains sixteen read tools and nothing else, so there is nothing for an agent to attempt and no error path to mishandle. Adding `mcp:write` brings the eight drafting `plan_*` tools and `commit`; `mcp:issue` brings the remaining three — `plan_issue_invoice`, `plan_issue_occurrence` and `plan_create_credit_note` — for twenty-eight in all. ## Whose work an answer is about [#whose-work-an-answer-is-about] The scopes above say what a *credential* may do. A second, unrelated thing decides what a read is *about*, and it is worth knowing before your first call: reads answer from **your own work**, not from the whole account. The four rosters that can be related to a person — `list_customers`, `list_invoices`, `list_series`, `list_credit_notes` — take a `scope` argument: | `scope` | The rows | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `mine` (default) | Everything your own line assignments reach — the invoices carrying a line of yours, the customers those invoices bill. A recurring series is reached through its *own* lines instead, so a draft series with no invoice yet is still yours. | | `related` | The same rows. What widens is who on them counts as your work, one level below anything a list renders — so a `related` list returns exactly what `mine` did. | | `all` | The whole account. | `mine` is the default because a question asked in the first person is a question about your own work. There is also `get_my_work`, which answers the whole intent in one call: your assigned lines on open invoices, grouped by customer or retainer, ranked by what needs attention, each carrying the line id that work registration targets. It takes no arguments — you are the subject, not a filter — and it holds only unissued invoices, because an issued one is frozen and its lines are not something to register against. **Widening is explicit, and bounded by your role in the app.** A standard user may ask for `mine` and `related`; only an admin may ask for `all`. The enum an agent reads from `tools/list` is built from your role, so it cannot formulate a request that would be refused — which also means the catalogue differs between two people on the same account. Cache it against the token, not against the server. Every scoped result opens by saying which view produced it and both counts — one line on the wire, wrapped here: ``` Scope "mine" — reached through your own assignments: 12 customers in scope, all shown; 228 beyond this scope. Widen with scope:"all" for the whole account. ``` That last sentence is an admin's. A standard user is told the opposite, so that an empty result never reads as something a wider view might fill: ``` … This is the widest list you may ask for; an account-wide answer needs an admin. ``` > [!NOTE] > **That line is load-bearing** > > A filtered answer that does not admit it was filtered is a worse failure than > an over-broad one, because nothing signals the omission. It is written for the > agent to relay verbatim — if yours summarises the rows and drops the line, you > are reading a narrowed answer with nothing to say it was narrowed. Role also governs writes, but not through scope: `plan_register_work` targets one person, so an admin widens by *naming* them (`user: "Jane"`), and a standard user registering for anyone but themselves is refused with the reason. `me` is a valid reference anywhere a user is accepted. If you connected an agent before this shipped and relied on account-wide reads, this is the one behaviour that changed: the same call now answers from your own work. The line above is how you will notice. ## The bound account [#the-bound-account] A credential carries one account and one connected accounting integration. The MCP surface never takes an account or integration parameter — your credential *is* the tenant. If you operate several entities, that is one connection each. ## Revoking access [#revoking-access] Remove the connector in your client, and revoke the grant in **Settings → Developers** in the app. Revocation is immediate: the next call the credential makes gets the `401` above. A role change lands just as fast — it is read from your profile on every request, so nobody has to reconnect when their permissions change. ## What did not change [#what-did-not-change] Nothing on `/api/v1`. Keys are minted the same way, carry the same scopes, authenticate the same endpoints and return the same bodies — a shipped REST integration needs no action, and none of the scope behaviour above reaches it. See [Authentication](/developers/api/authentication). --- # Getting started > Base URL, your first call, and the no-sandbox note. Source: https://invoia.io/developers/api The Invoia Public API lets you drive invoicing and revenue recognition from your own tooling — the same product you operate through our UI, now callable from your PM tool or automation stack. ## Base URL [#base-url] ``` https://invoia.io/api/v1 ``` All endpoints live under this prefix. The URL major (`/v1`) is additive-only — new fields and endpoints may be added, but nothing you already integrate against will break within a major. ## Your first call [#your-first-call] Mint an API key in **Settings → Developers** (an admin only permission). The raw `ivk_…` secret is shown once at creation. Store it in your integration's secret manager and drop the value into an `Authorization: Bearer …` header. ```bash curl https://invoia.io/api/v1/config \ -H "Authorization: Bearer $INVOIA_API_KEY" ``` ```js const res = await fetch('https://invoia.io/api/v1/config', { headers: { Authorization: `Bearer ${process.env.INVOIA_API_KEY}` }, }) const config = await res.json() console.log(config.base_currency) // "DKK" ``` ## What each key can do [#what-each-key-can-do] A key is bound to **one connected Dinero integration** and to a **scope**: | Scope | Verbs | Use case | | ------------ | ----- | ----------------------------------------------------- | | `read` | `GET` | Dashboards, BI, read-only sync from PM tool | | `read_write` | all | Full drive: create/edit/issue invoices, register work | A `read` key on a write endpoint returns `403` with a typed error code — no ambiguity to parse. ## There is no sandbox [#there-is-no-sandbox] Invoia does not host a test mode. There are no `ivk_test_` keys and no hosted fake-Dinero sandbox. **Build against your live integration using a throwaway customer and product** — a name like `Test Corp` and a product `INTEGRATION-TEST` are fine. When your integration is working, delete those records the same way you would any other resource. We chose no-sandbox deliberately: a sandbox that drifts from production is worse than a live account you can control. Every failure mode you would hit in test — an unbookable credit note, a rate limit, a Dinero timeout — is a failure mode you can hit in production, and there is no substitute for exercising the real path. ## Where to go next [#where-to-go-next] * **[Quickstart](/developers/api/quickstart)** — the same first call in curl, Node and Python, end to end. * **[Authentication](/developers/api/authentication)** — key format, headers, rotation. * **[external\_id and idempotency](/developers/api/external-id)** — how Invoia correlates your resources with theirs without you ever holding a Invoia UUID. * **[Work registration](/developers/api/work-registration)** — sending hours and completion into the same recognition engine the UI uses. * **[Issuance and auto-send](/developers/api/issuance)** — driving invoice issuance explicitly, or letting the cron do it. * **[Webhooks](/developers/api/webhooks)** — signed events for changes your integration did not trigger (auto-issued invoices, month-end accruals, Dinero reconcile writes). * **[Errors](/developers/api/errors)** — the machine-readable envelope and every code you can branch on. * **[Pagination](/developers/api/pagination)** — cursor-based lists with `updated_since` filtering. --- # Quickstart > From a key to recognised revenue, in curl, Node and Python. Source: https://invoia.io/developers/api/quickstart Eight minutes, end to end: prove a key works, draft an invoice, register the work against it, and read back the revenue that moved. Everything runs against your live account — there is no sandbox — so use a throwaway customer and delete it when you are done. ## 1. Mint a key [#1-mint-a-key] **Settings → Developers → API keys**, as an admin. Pick `read_write` if you intend to follow this page to the end. The raw `ivk_…` secret is shown once; put it in an environment variable before you close the dialog. ```bash export INVOIA_API_KEY="ivk_…" ``` ## 2. Read your configuration [#2-read-your-configuration] `GET /config` is the cheapest authenticated call in the API. It returns the account's base currency, VAT defaults and the connected accounting organisation — enough to confirm the key is live and bound to the integration you expected. **curl** ```bash curl https://invoia.io/api/v1/config \ -H "Authorization: Bearer $INVOIA_API_KEY" ``` **Node** ```js const res = await fetch('https://invoia.io/api/v1/config', { headers: { Authorization: `Bearer ${process.env.INVOIA_API_KEY}` }, }) if (!res.ok) throw new Error(`${res.status} ${await res.text()}`) const config = await res.json() console.log(config.base_currency) // "DKK" ``` **Python** ```python import os import httpx res = httpx.get( "https://invoia.io/api/v1/config", headers={"Authorization": f"Bearer {os.environ['INVOIA_API_KEY']}"}, ) res.raise_for_status() print(res.json()["base_currency"]) # "DKK" ``` A `401` means the key is wrong or revoked; a `403` means it is a `read` key on a write endpoint. Both carry a typed `code` — 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 it is worth wrapping the boilerplate now. Read the typed `code` and the `request_id` on failure — `code` is what you branch on, `request_id` is what you quote when something is wrong at our end. **curl** ```bash # A shell function is enough: base URL, auth, JSON. invoia() { local method="$1" path="$2" shift 2 curl -sS -X "$method" "https://invoia.io/api/v1$path" \ -H "Authorization: Bearer $INVOIA_API_KEY" \ -H "Content-Type: application/json" "$@" } ``` **Node** ```js async function invoia(method, path, body) { const res = await fetch(`https://invoia.io/api/v1${path}`, { method, headers: { Authorization: `Bearer ${process.env.INVOIA_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://invoia.io/api/v1", headers={"Authorization": f"Bearer {os.environ['INVOIA_API_KEY']}"}, ) def invoia(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 id on it. `external_id` is how you address the row from here on, so you never have to store an Invoia UUID — and repeating the call with the same `external_id` updates rather than duplicates. `name`, `country_key`, `is_person` and `send_method` are required — `send_method: "email"` then requires an `email` to send to. **curl** ```bash invoia 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 invoia('POST', '/customers', { external_id: 'quickstart:test-corp', name: 'Test Corp', country_key: 'DK', is_person: false, send_method: 'email', email: 'billing@test.example', }) console.log(customer.id) // the UUID — you need it in step 7 ``` **Python** ```python customer = invoia("POST", "/customers", { "external_id": "quickstart:test-corp", "name": "Test Corp", "country_key": "DK", "is_person": False, "send_method": "email", "email": "billing@test.example", }) print(customer["id"]) # the UUID — you need it in step 7 ``` Read it back by your own id any time — `ext:` in the path resolves it: ```bash invoia GET /customers/ext:quickstart:test-corp ``` ## 5. Create a product [#5-create-a-product] A product carries the revenue account the line books to. `account_number` is a **Dinero ledger account**, not an Invoia id, so take a real one from a product you already have rather than inventing it: ```bash invoia GET /products ``` Then create yours with the same account number: **curl** ```bash invoia POST /products -d '{ "external_id": "quickstart:consulting", "product_number": "QS-001", "name": "Consulting", "account_number": 1000, "unit": "hours", "default_price": 1200 }' ``` **Node** ```js const product = await invoia('POST', '/products', { external_id: 'quickstart:consulting', product_number: 'QS-001', name: 'Consulting', account_number: 1000, unit: 'hours', default_price: 1200, }) ``` **Python** ```python product = invoia("POST", "/products", { "external_id": "quickstart:consulting", "product_number": "QS-001", "name": "Consulting", "account_number": 1000, "unit": "hours", "default_price": 1200, }) ``` ## 6. Find who is doing the work [#6-find-who-is-doing-the-work] Every invoice line needs at least one assignee — recognition is per person, per line. Users are invited in the app and never created over the API, so read the roster and pick yourself: **curl** ```bash invoia GET /users # → { "object": "list", "data": [ { "id": "…", "email": "…", … } ], … } ``` **Node** ```js const { data: users } = await invoia('GET', '/users') const me = users.find((u) => u.email === 'you@your-agency.example') ``` **Python** ```python users = invoia("GET", "/users")["data"] me = next(u for u in users if u["email"] == "you@your-agency.example") ``` ## 7. Draft an invoice [#7-draft-an-invoice] One line, billed by the hour at 1,200 per hour, all of it assigned to you. The invoice is a draft — nothing is booked to Dinero and no customer hears from you until you issue it. > [!NOTE] > **Paths take ext:, bodies take UUIDs** > > `ext:` refs work wherever a resource is addressed in the **URL**. > Inside a request body, ids are UUIDs — `customer_id`, `product_id` and > `user_id` here come from the responses in steps 4 to 6. It is the one > place you do hold Invoia ids. **curl** ```bash invoia POST /invoices -d '{ "external_id": "quickstart:inv-001", "customer_id": "'"$CUSTOMER_ID"'", "currency": "DKK", "title": "Quickstart", "lines": [{ "product_id": "'"$PRODUCT_ID"'", "recognition_method": "hourly", "hourly_rate": 1200, "agreed_value": null, "discount": null, "description_override": "Consulting — July", "position": 0, "assignees": [{ "user_id": "'"$USER_ID"'", "distribution_percentage": 100, "hourly_rate_override": null }] }] }' ``` **Node** ```js const invoice = await invoia('POST', '/invoices', { external_id: 'quickstart:inv-001', customer_id: customer.id, currency: 'DKK', title: 'Quickstart', lines: [{ product_id: product.id, recognition_method: 'hourly', hourly_rate: 1200, agreed_value: null, discount: null, description_override: 'Consulting — July', position: 0, assignees: [{ user_id: me.id, distribution_percentage: 100, hourly_rate_override: null, }], }], }) const lineId = invoice.lines[0].id ``` **Python** ```python invoice = invoia("POST", "/invoices", { "external_id": "quickstart:inv-001", "customer_id": customer["id"], "currency": "DKK", "title": "Quickstart", "lines": [{ "product_id": product["id"], "recognition_method": "hourly", "hourly_rate": 1200, "agreed_value": None, "discount": None, "description_override": "Consulting — July", "position": 0, "assignees": [{ "user_id": me["id"], "distribution_percentage": 100, "hourly_rate_override": None, }], }], }) line_id = invoice["lines"][0]["id"] ``` > [!NOTE] > **distribution_percentage is 0–100** > > It splits the line's revenue between assignees, so the percentages on a > line should add up to 100. Not to be confused with `completion_rate` in > the next step, which is `0`–`1`. ## 8. Register the work [#8-register-the-work] This is the call that moves revenue. `hours` is an **absolute cumulative total** for that person on that line — not an increment — so a retried sync is safe without an idempotency key. Name the user by email; users are invited in the app, never created over the API. **curl** ```bash invoia PUT /work-registrations -d '{ "invoice": "ext:quickstart:inv-001", "line": "'"$LINE_ID"'", "user": "email:you@your-agency.example", "hours": 12, "completion_rate": 0.6 }' ``` **Node** ```js await invoia('PUT', '/work-registrations', { invoice: 'ext:quickstart:inv-001', line: lineId, user: 'email:you@your-agency.example', hours: 12, completion_rate: 0.6, }) ``` **Python** ```python invoia("PUT", "/work-registrations", { "invoice": "ext:quickstart:inv-001", "line": line_id, "user": "email:you@your-agency.example", "hours": 12, "completion_rate": 0.6, }) ``` ## 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 yet — which makes the whole 14,400 work in progress. **curl** ```bash invoia GET /invoices/ext:quickstart:inv-001/recognition ``` **Node** ```js const rec = await invoia('GET', '/invoices/ext:quickstart:inv-001/recognition') console.log(rec.earned, rec.billed, rec.deferred, rec.wip) // 14400 0 0 14400 ``` **Python** ```python rec = invoia("GET", "/invoices/ext:quickstart:inv-001/recognition") print(rec["earned"], rec["billed"], rec["deferred"], rec["wip"]) # 14400 0 0 14400 ``` Those four figures are the product. `earned` is what you have delivered, `billed` is what you have invoiced, and the gap falls into `deferred` (billed ahead of delivery) or `wip` (delivered ahead of billing). Issue the invoice and the same 14,400 moves from `wip` to `billed` — see [Issuance and auto-send](/developers/api/issuance). > [!WARNING] > **Clean up after yourself** > > The customer, product and invoice you just made are real. Delete the > invoice and then the customer — `DELETE /invoices/ext:quickstart:inv-001` > and `DELETE /customers/ext:quickstart:test-corp` — or remove them from > the app. A draft invoice deletes cleanly; an issued one never does. ## Where to go next [#where-to-go-next] * **[Authentication](/developers/api/authentication)** — scope, integration binding, and zero-downtime rotation. * **[external\_id and idempotency](/developers/api/external-id)** — the upsert contract you just relied on, in full. * **[Work registration](/developers/api/work-registration)** — absolute semantics, entry-month bucketing, and registering against a recurring series instead of a one-off invoice. * **[Webhooks](/developers/api/webhooks)** — how you hear about changes your integration did not make. * **[Connect an AI agent instead](/developers/mcp)** — the same product over MCP, where the agent proposes and you approve. --- # Authentication > Key format, scope, integration binding, rotation. Source: https://invoia.io/developers/api/authentication Every request to `/api/v1` authenticates with a **Bearer API key** minted in Settings → Developers. ``` Authorization: Bearer ivk_LiveKey0123456789abcdefghij… ``` ## Key format [#key-format] Keys look like `ivk_` — no environment segment (there is no sandbox to distinguish). The random suffix carries the full entropy of the secret. Invoia stores only the SHA-256 hash of the key plus a **prefix** and the **last four characters** for display. If you lose the raw secret, it cannot be recovered — revoke the key and mint a new one. ## Scope [#scope] Each key is created with one of two scopes: * **`read`** — every `GET` endpoint. Use for dashboards and one-way read-out sync. * **`read_write`** — everything a `read` key can do plus `POST`, `PATCH`, `PUT`, `DELETE`. Use for your primary sync job. Trying to write with a `read` key returns: ```json HTTP/1.1 403 Forbidden { "error": { "type": "forbidden", "code": "INSUFFICIENT_SCOPE", "message": "This key does not have write access.", "request_id": "req_…" } } ``` ## Integration binding [#integration-binding] A key is bound to **one connected Dinero integration** — the account's integration is baked into the key at mint time. The API never accepts an `integration_id` in the URL or body: your key IS the integration. This means a compromised or misconfigured key can never write to the wrong org. If you operate several integrations (e.g. multiple entities), mint one key per integration and route requests in your client. ## Rotation [#rotation] The recommended zero-downtime rotation: 1. Mint a new key alongside the current one (multiple active keys per integration are supported). 2. Deploy your integration with the new key. 3. Verify the new key's `last_used_at` is advancing in Settings → Developers. 4. Revoke the old key. Revocation is immediate: the next request the revoked key makes returns `401 UNAUTHENTICATED`. ## What the API cannot do [#what-the-api-cannot-do] Deliberately outside the API: * Invite, deactivate, or change roles of users. * Change account billing. * Connect a Dinero organisation (an OAuth browser flow that stays in the UI). These stay in the Invoia UI where an admin can perform them from a signed-in session. ## Never expose the key to a browser [#never-expose-the-key-to-a-browser] The credential is a server-to-server admin key. There is no CORS allowance; do not embed the key in a frontend bundle or send it from a customer's browser. Every call is audit-logged with the key id and a `request_id`. --- # external_id and idempotency > Correlating your world with Invoia without ever holding an Invoia UUID. Source: https://invoia.io/developers/api/external-id Invoia's public API is designed so **you never need to store an Invoia UUID**. Instead you stamp your own id — the ClickUp task id, the Monday board id, the Jira ticket key — onto every resource via `external_id`, and address it later by `ext:your-id`. ## Creating a resource [#creating-a-resource] Send `external_id` in the create body: ```bash curl -X POST https://invoia.io/api/v1/customers \ -H "Authorization: Bearer $INVOIA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "external_id": "clickup:cust_ACME", "name": "ACME Corp", "country_key": "DK", "is_person": false, "send_method": "email", "email": "billing@acme.example" }' ``` Response echoes the `external_id`: ```json { "object": "customer", "id": "…", "external_id": "clickup:cust_ACME", "name": "ACME Corp", … } ``` ## Upsert semantics [#upsert-semantics] **Repeating a create with the same `external_id` upserts.** A retried sync is safe by construction — no separate `Idempotency-Key` header, no duplicate rows. ```bash # Second call with the same external_id — updates the same row. curl -X POST https://invoia.io/api/v1/customers \ -H "Authorization: Bearer $INVOIA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "external_id": "clickup:cust_ACME", "name": "ACME Corp (Denmark)", "country_key": "DK", "is_person": false, "send_method": "email", "email": "billing@acme.example" }' ``` This is the ONE idempotency primitive. Stripe's `Idempotency-Key` header is **not** supported — `external_id` is the sole mechanism. ## Addressing by ext: [#addressing-by-ext] Everywhere the API takes a resource ref (in URLs, in bodies), it accepts three forms: * `ext:your-id` — your `external_id`. * `email:someone@example.com` — users only. * A raw UUID — Invoia's internal id. ```bash # Read your customer by ext id — no UUID round-trip needed. curl https://invoia.io/api/v1/customers/ext:clickup:cust_ACME \ -H "Authorization: Bearer $INVOIA_API_KEY" # Patch it. curl -X PATCH https://invoia.io/api/v1/customers/ext:clickup:cust_ACME \ -H "Authorization: Bearer $INVOIA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "email": "new-billing@acme.example" }' ``` ## Where external\_id is required [#where-external_id-is-required] Required on the **money-moving creates**: * `POST /invoices` — a one-time invoice. * `POST /recurring-series` — a retainer. * `POST /credit-notes` — a reversal. Optional (but recommended) everywhere else: * Customers, products, users (tag an existing user with `PATCH /users/{ref}` to attach `external_id`). * Work registrations (label-only; the row's identity is still the `(line, user, entry-month)` composite key). ## external\_id is NOT external\_reference [#external_id-is-not-external_reference] Customers and products have a **separate** `external_reference` field that passes through to Dinero — used for the accounting-side lookup. * `external_id` — Invoia's correlation id for YOUR world. Never sent to Dinero. * `external_reference` — Dinero's identifier as shown in Dinero. Keep them separate so a change to your ClickUp id doesn't accidentally overwrite the Dinero reference (and vice versa). ## Uniqueness [#uniqueness] `external_id` is unique within `(integration, resource_type)`. Two integrations can safely use the same string; two resource types on the same integration can too (a customer and a product may share `clickup:demo` without conflict). --- # Work registration > Absolute-set hours and completion into the recognition engine. Source: https://invoia.io/developers/api/work-registration Registering work is how you tell Invoia's recognition engine "here is how far along a given line of a given invoice is." Hours + completion feed the same earned / billed / deferred / WIP figures the UI shows. ## The endpoint [#the-endpoint] ``` PUT /api/v1/work-registrations ``` There is exactly one work-registration endpoint. It is a single-item PUT (no batch), and it is **absolute** — you send the totals for a `(line, user)` pair, not deltas. ## Absolute-set semantics [#absolute-set-semantics] Send the **total hours** the user has logged against the line, and their **completion rate** (0–1), and Invoia computes the internal delta for you. ```bash curl -X PUT https://invoia.io/api/v1/work-registrations \ -H "Authorization: Bearer $INVOIA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "series": "ext:clickup:acme-retainer", "occurrence": "2026-07-01", "line": "5f8e…", "user": "email:jane@acme.example", "hours": 12, "completion_rate": 0.6 }' ``` Meaning: "Jane has 12 hours total on this line; her part is 60% done." Repeat calls are idempotent by construction — you never compute deltas, which means a retried sync is safe with no separate idempotency key. Both `hours` and `completion_rate` are optional; you can send one, the other, or both. ## Naming the occurrence (Option B) [#naming-the-occurrence-option-b] You always name the exact occurrence: * **Series + period** — `series: , occurrence: `. The `YYYY-MM-DD` is the first day of the billing period (e.g. `2026-07-01` for the July invoice). Virtual and materialized states are hidden from you — Invoia materializes on demand. * **One-time invoice** — `invoice: `. No occurrence needed. Exactly one of `{series, occurrence}` or `{invoice}` must be sent. ## Entry-month bucketing [#entry-month-bucketing] Every registration lands in the **month it was made** (the "entry month"), not the month you sent as the occurrence. This mirrors the UI's behaviour and prevents accidental back-dating of ledger figures. A negative delta — say Jane's total drops from 12 hours to 10 — draws down newest-first across earlier month buckets, so you never break a closed accounting period from a nightly catch-up sync. ## Naming the user [#naming-the-user] The `user` field accepts the same three refs as everywhere else: * `ext:your-id` — after you tag the user with `PATCH /users/{ref}`. * `email:someone@example.com` — the user's login email. * Raw UUID. Users **cannot be created via the API** — invitations stay in the UI. Read the roster with `GET /users` to see what's available. ## Typed 409 when no occurrence exists [#typed-409-when-no-occurrence-exists] If you register work against a series past its horizon — a series that has ended, or one whose end date was set by cancelling it — the API returns `409` with a typed code: ```json { "error": { "type": "conflict", "code": "OCCURRENCE_NOT_FOUND", "message": "This series has no current occurrence — extend or resume.", "request_id": "req_…" } } ``` Codes you can branch on: * `OCCURRENCE_NOT_FOUND` — the period is past the current horizon (the series has ended, or was cancelled as of an earlier date); extend the series, register against a new one, or wait for the cadence to reach it. * `OCCURRENCE_SKIPPED` — the occurrence was explicitly skipped by an admin. --- # Issuance and auto-send > Explicit issuance vs the auto-send cron; booked-but-send-failed. Source: https://invoia.io/developers/api/issuance Issuing an invoice is the act of freezing it, booking it into Dinero, and (optionally) sending it to the customer. Invoia supports two patterns. ## Explicit issuance [#explicit-issuance] Drive it from your integration when your workflow says "this project is done": ```bash # One-time invoice. curl -X POST https://invoia.io/api/v1/invoices/ext:proj_ACME_migration/issue \ -H "Authorization: Bearer $INVOIA_API_KEY" # A series occurrence. curl -X POST https://invoia.io/api/v1/recurring-series/ext:acme-retainer/occurrences/2026-07-01/issue \ -H "Authorization: Bearer $INVOIA_API_KEY" ``` Issuance is **synchronous** and **idempotent** — a timeout-then-retry returns the already-issued result, never double-books. Under the hood Invoia's Dinero GUID guard is the same one the UI's issue button rides. ## auto\_send (cron-driven) [#auto_send-cron-driven] Every invoice carries an `auto_send` boolean. When it's on, the Invoia cron issues the invoice on its issue date without your involvement — the exact same behaviour as the UI. Leave it on for hands-off retainers and rely on the `invoice.issued` webhook to learn about the booking: ```js // Your webhook receiver. app.post('/webhooks/invoia', (req, res) => { const event = req.body if (event.type === 'invoice.issued') { await syncInvoiceToPmTool(event.data) } res.status(200).end() }) ``` Toggle it via the invoice PATCH: ```bash curl -X PATCH https://invoia.io/api/v1/invoices/ext:proj_ACME_migration \ -H "Authorization: Bearer $INVOIA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "auto_send": false }' ``` ## Booked-but-send-failed (200 + `send_error`) [#booked-but-send-failed-200--send_error] Issuance in Invoia is two steps: **book** the invoice into Dinero, then **send** it to the customer (email or EAN). Booking is what makes the invoice real. If booking succeeds but the send fails (e.g. Dinero's email service returns 5xx), you get a **200** with a `send_error`: ```json { "object": "invoice", "id": "…", "status": "issued", "dinero_invoice_guid": "…", "send_error": { "code": "SEND_FAILED", "message": "Dinero email service unavailable." } } ``` **Do not retry issue** — the invoice is already booked. Retry the send instead (`POST /invoices/{ref}/resend`; slice-forthcoming). This prevents the classic "retry the failure and double-book" trap. ## Editing after issue [#editing-after-issue] Once an invoice has a `dinero_invoice_guid`, it is frozen. Attempting to edit it returns `409 INVOICE_ALREADY_ISSUED`. Raise a **credit note** instead — full or partial — via `POST /credit-notes`. ## Bulk issuance [#bulk-issuance] Bulk issuance stays **cron-driven**: `auto_send=true` on a set of invoices lets Invoia issue them together on the issue date. The API is single-item only. If you need to issue N invoices, iterate — each call is idempotent so re-runs are safe. --- # Webhooks > Signed at-least-once events for changes you did not trigger. Source: https://invoia.io/developers/api/webhooks Webhooks are how your integration learns about **changes it did not trigger** — an auto-issued invoice, a month-end accrual booking, a Dinero reconcile write. Poll-free, signed, at-least-once. ## Registering an endpoint [#registering-an-endpoint] Register a webhook endpoint in **Settings → Developers → Webhooks**. The signing secret is shown ONCE at creation — copy it into your integration's secret store. Per endpoint you choose a subset of event types (or "all events"). ## Event catalog v1 [#event-catalog-v1] * `invoice.issued` — an invoice was booked into Dinero (whether by cron, by an API call, or by the UI). * `invoice.send_failed` — the invoice booked but sending the PDF failed. Retry the send, not the issue. * `credit_note.issued` — a credit note was booked. * `accrual.booked` — a month-end accrual voucher was booked in Dinero. * `customer.updated` — a customer was written (via UI, API, or Dinero reconcile). * `product.updated` — a product was written. Every payload carries the **same wire shape** as the corresponding `GET` response — the same serializer output — so you can act on the event without an extra round-trip. ## Signature verification [#signature-verification] Every delivery carries a `Invoia-Signature` header: ``` Invoia-Signature: t=1717000000,v1=e2b1c3… ``` The signature is `HMAC-SHA256(secret, "{t}.{rawBody}")`, hex-encoded. Verify BEFORE trusting the payload: ```js import { createHmac, timingSafeEqual } from 'node:crypto' function verify(header, rawBody, secret) { const parts = Object.fromEntries( header.split(',').map((p) => p.split('=').map((s) => s.trim())), ) const t = parts.t const v1 = parts.v1 if (!t || !v1) return false // Reject payloads > 5 minutes old. if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex') const a = Buffer.from(expected, 'hex') const b = Buffer.from(v1, 'hex') return a.length === b.length && timingSafeEqual(a, b) } ``` The `t` timestamp prevents replay attacks; reject anything more than a few minutes old. ## Envelope [#envelope] ```json { "id": "evt_01H…", "type": "invoice.issued", "created": "2026-07-06T10:00:00Z", "data": { "object": "invoice", "id": "…", "external_id": "clickup:proj_ACME_migration", "status": "issued", "dinero_invoice_guid": "…", "lines": [ … ] } } ``` * `id` — the event's stable id. Dedupe on this — deliveries are at-least-once (a retry can arrive after the original was already handled). * `type` — one of the catalog above. * `data` — the **fat** payload; the full resource, not a stub. ## Retries [#retries] A failed delivery (non-2xx response or timeout) is retried with exponential backoff for \~24 hours. After that it lands in the delivery log as `failed` and stops retrying. You can inspect and **manually replay** a delivery from Settings → Developers → Webhooks. Best practice: return `200` fast (queue the work internally) and verify the signature in the queue worker if the payload matters — this way a slow database doesn't hold up the receiver. ## Rotating the signing secret [#rotating-the-signing-secret] Rotate the secret from Settings → Developers → Webhooks. The old secret keeps signing for a short overlap window so your receiver can accept both while you deploy the new value. --- # Pagination > Cursor iteration and updated_since for incremental sync. Source: https://invoia.io/developers/api/pagination Every list endpoint (`GET /customers`, `GET /invoices`, …) returns a cursor-paginated envelope: ```json { "object": "list", "data": [ … ], "has_more": true, "next_cursor": "eyJpZCI6…" } ``` ## Reading a full list [#reading-a-full-list] Loop until `has_more` is `false`: ```js let cursor = null const all = [] do { const url = new URL('https://invoia.io/api/v1/customers') if (cursor) url.searchParams.set('cursor', cursor) const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.INVOIA_API_KEY}` }, }) const page = await res.json() all.push(...page.data) cursor = page.has_more ? page.next_cursor : null } while (cursor) ``` Cursors are opaque and stable across mutations — a new item inserted mid-iteration will not throw off your loop or cause duplicates. ## Incremental sync with `updated_since` [#incremental-sync-with-updated_since] Pass `updated_since=` to fetch only what changed after a given point: ```bash curl "https://invoia.io/api/v1/customers?updated_since=2026-07-01T00:00:00Z" \ -H "Authorization: Bearer $INVOIA_API_KEY" ``` Persist the maximum `updated_at` you observed in each page and pass it back on the next poll. Combine with `cursor` if a page-size crossing is possible: `updated_since` narrows the working set, `cursor` walks it. ## Common filters [#common-filters] | Filter | Endpoints | Meaning | | --------------- | ---------------------- | -------------------------- | | `external_id` | most | Exact match on your ext id | | `customer` | invoices, recognition | Filter by customer ref | | `status` | invoices, credit notes | e.g. `issued`, `draft` | | `updated_since` | all lists | ISO 8601 timestamp | Filters and cursor may be combined freely. ## Page size [#page-size] Default page size is 25; the maximum is 100. Pass `limit=`: ```bash curl "https://invoia.io/api/v1/customers?limit=100" \ -H "Authorization: Bearer $INVOIA_API_KEY" ``` --- # Errors > Envelope, typed codes, rate limits, and versioning policy. Source: https://invoia.io/developers/api/errors The API uses HTTP-native status codes so `resp.ok` and standard handling work correctly. Failure bodies always follow one shape: ```json { "error": { "type": "validation_failed", "code": "VALIDATION_FAILED", "message": "Body failed schema validation.", "field_errors": { "name": ["Required"], "email": ["Invalid email address"] }, "request_id": "req_01H…" } } ``` Branch on `code`, not on `message` — `code` is stable; `message` is free-form and may improve over time. ## Status code map [#status-code-map] | Status | Family | Typical code | | ------ | ----------------- | ------------------------------------------ | | `400` | Bad request | Malformed JSON | | `401` | Auth | `UNAUTHENTICATED` | | `403` | Scope | `INSUFFICIENT_SCOPE` | | `404` | Not found | `RESOURCE_NOT_FOUND` / `LINE_NOT_FOUND` | | `409` | Business conflict | see below | | `422` | Validation | `VALIDATION_FAILED` (with `field_errors`) | | `429` | Rate limit | `RATE_LIMITED` (with `Retry-After` header) | | `500` | Internal | `INTERNAL_ERROR` | ## Typed 409 codes [#typed-409-codes] Codes you can branch on to distinguish business-rule conflicts: * `INVOICE_ALREADY_ISSUED` — you tried to edit a frozen invoice; raise a credit note. * `INVOICE_NOT_ISSUED` — you tried to credit-note an unissued invoice. * `SERIES_ENDED` — the series' end date is final (it has passed, or a credit note already reversed part of it) and cannot be moved. * `OCCURRENCE_NOT_FOUND` — no current occurrence (past horizon, paused). * `OCCURRENCE_SKIPPED` — the occurrence was explicitly skipped. * `DELETE_BLOCKED_BY_BOOKINGS` — a customer delete blocked because Dinero has bookings against them. * `DELETE_BLOCKED_BY_REFERENCES` — a customer or product delete blocked because Invoia still holds the record: invoices, recurring series or credit notes for a customer; invoice lines, series lines or one-time lines for a product. The `message` names which. Nothing was deleted on either side — the reference is permanent, so retrying will not help. ## request\_id [#request_id] Every response — success or failure — includes a `request_id`. Log it alongside your integration's own trace id. If you open a support ticket, the `request_id` lets us find your exact call in seconds. ## Rate limiting [#rate-limiting] Every response carries three rate-limit headers: ``` X-RateLimit-Limit: 300 X-RateLimit-Remaining: 287 X-RateLimit-Reset: 1717000000 ``` On `429`, the response adds: ``` Retry-After: 12 ``` Two buckets: * **General** — reads and non-Dinero writes. A generous default. * **Dinero-writes** — a separate, stricter bucket for endpoints that hit Dinero (`issue`, `credit-note`). This protects the expensive operations without throttling your reads. Retry with exponential backoff on `Retry-After`. Do **not** retry on `400/401/403/404/422` — those will not succeed on retry with the same payload. ## Versioning [#versioning] The URL major (`/v1`) is additive-only. Within a major, we may: * Add new endpoints. * Add new optional fields on request bodies. * Add new fields on response bodies. * Add new event types to webhooks. * Add new error codes (branch defensively: an unknown `code` should fall through to a generic handler). We will NOT, within `/v1`: * Remove endpoints. * Remove or rename fields. * Change the meaning of an existing `code`. * Tighten validation on an existing endpoint in a way that would reject payloads that used to succeed. A breaking change ships as `/v2`. Both majors are supported in parallel during a deprecation window announced in the changelog. --- # API reference > Every endpoint, generated from the OpenAPI 3.1 document the API itself serves. The schemas are not repeated here — fetch https://invoia.io/api/v1/openapi.json for the whole document, or any single line below as Markdown for that one operation, request and response schemas included. - `GET /accruals` — Read a customer's accrual state for one month (draft/booked + Dinero GUID) → https://invoia.io/developers/reference/accruals/getAccruals.md - `GET /config` — Read invoicing configuration (base currency + WIP / deferred accounts) → https://invoia.io/developers/reference/config/getConfig.md - `POST /credit-notes` — Raise a credit note (full/partial; external_id required — idempotent; 200+send_error on booked-but-send-failed) → https://invoia.io/developers/reference/credit-notes/postCreditNotes.md - `DELETE /customers/{ref}` — Delete a customer (still-referenced → 409 DELETE_BLOCKED_BY_REFERENCES; Dinero bookings → 409 DELETE_BLOCKED_BY_BOOKINGS) → https://invoia.io/developers/reference/customers/deleteCustomersRef.md - `GET /customers` — List active customers (cursor-paginated; filters: external_id, updated_since) → https://invoia.io/developers/reference/customers/getCustomers.md - `GET /customers/{ref}` — Fetch a customer by ext:/UUID reference → https://invoia.io/developers/reference/customers/getCustomersRef.md - `PATCH /customers/{ref}` — Partial update of a customer (routes non-external_id changes through the Dinero-sync service) → https://invoia.io/developers/reference/customers/patchCustomersRef.md - `POST /customers` — Create a customer or upsert by external_id (syncs to Dinero) → https://invoia.io/developers/reference/customers/postCustomers.md - `DELETE /invoices/{ref}` — Delete a draft invoice (freeze-guarded — 409 on issued) → https://invoia.io/developers/reference/invoices/deleteInvoicesRef.md - `GET /invoices` — List one-time invoices (cursor-paginated; filters: customer, status, external_id, updated_since) → https://invoia.io/developers/reference/invoices/getInvoices.md - `GET /invoices/{ref}` — Fetch a one-time invoice by ext:/UUID reference (fat: lines + assignees) → https://invoia.io/developers/reference/invoices/getInvoicesRef.md - `GET /invoices/{ref}/credit-notes` — List an invoice's credit notes (cursor-paginated; filter: updated_since) → https://invoia.io/developers/reference/invoices/getInvoicesRefCreditNotes.md - `GET /invoices/{ref}/recognition` — Read per-line + per-invoice recognition (earned/billed/deferred/WIP) → https://invoia.io/developers/reference/invoices/getInvoicesRefRecognition.md - `PATCH /invoices/{ref}` — Update a draft invoice (freeze-guarded — 409 on issued) → https://invoia.io/developers/reference/invoices/patchInvoicesRef.md - `POST /invoices` — Create a one-time invoice (external_id required — upserts on repeat) → https://invoia.io/developers/reference/invoices/postInvoices.md - `POST /invoices/{ref}/issue` — Issue a one-time invoice on demand (synchronous, idempotent; 200+send_error on booked-but-send-failed) → https://invoia.io/developers/reference/invoices/postInvoicesRefIssue.md - `DELETE /products/{ref}` — Delete a product (still-in-use → 409 DELETE_BLOCKED_BY_REFERENCES). 204 No Content on success. → https://invoia.io/developers/reference/products/deleteProductsRef.md - `GET /products` — List products for the integration (revenue account, unit, default price, external_id) → https://invoia.io/developers/reference/products/getProducts.md - `GET /products/{ref}` — Fetch a single product by ext:/UUID reference → https://invoia.io/developers/reference/products/getProductsRef.md - `PATCH /products/{ref}` — Update a product (Dinero-synced). external_id can be stamped or cleared. → https://invoia.io/developers/reference/products/patchProductsRef.md - `POST /products` — Create a product (Dinero-synced). If external_id is present, upserts on it. → https://invoia.io/developers/reference/products/postProducts.md - `GET /recognition` — Per-customer recognition roll-up (recognized vs invoiced, base currency) → https://invoia.io/developers/reference/recognition/getRecognition.md - `DELETE /recurring-series/{ref}/lines/{lineRef}` — Delete a series line (this_only / this_and_following) or a one-time line (whole line, scope ignored) → https://invoia.io/developers/reference/recurring-series/deleteRecurringSeriesRefLinesLineRef.md - `GET /recurring-series` — List recurring series (cursor-paginated; filters: customer, status, external_id, updated_since) → https://invoia.io/developers/reference/recurring-series/getRecurringSeries.md - `GET /recurring-series/{ref}` — Fetch a recurring series by ext:/UUID reference (fat: base lines + roster) → https://invoia.io/developers/reference/recurring-series/getRecurringSeriesRef.md - `GET /recurring-series/{ref}/occurrences` — List a series occurrences (past + upcoming, with billing periods + status) → https://invoia.io/developers/reference/recurring-series/getRecurringSeriesRefOccurrences.md - `GET /recurring-series/{ref}/occurrences/{period}` — Read a single occurrence by period (YYYY-MM-DD, matching the occurrence anchor) → https://invoia.io/developers/reference/recurring-series/getRecurringSeriesRefOccurrencesPeriod.md - `PATCH /recurring-series/{ref}` — Update a recurring series header (cadence + horizon + issue-timing + customer + currency + optional external_id) → https://invoia.io/developers/reference/recurring-series/patchRecurringSeriesRef.md - `PATCH /recurring-series/{ref}/lines/{lineRef}` — Edit a series line (this_only / this_and_following) or a one-time line (scope ignored) → https://invoia.io/developers/reference/recurring-series/patchRecurringSeriesRefLinesLineRef.md - `POST /recurring-series` — Create a recurring series with base lines (external_id required — upserts on repeat) → https://invoia.io/developers/reference/recurring-series/postRecurringSeries.md - `POST /recurring-series/{ref}/cancel` — Cancel a recurring series by setting its end date (default: last day of the current billing period; idempotent) — occurrences before it keep issuing, anything sent past it is credited → https://invoia.io/developers/reference/recurring-series/postRecurringSeriesRefCancel.md - `POST /recurring-series/{ref}/lines` — Add a series line (this_and_following) or one-time line (this_only) to an existing recurring series → https://invoia.io/developers/reference/recurring-series/postRecurringSeriesRefLines.md - `POST /recurring-series/{ref}/occurrences/{period}/issue` — Materialise (if needed) then issue a recurring-series occurrence (synchronous, idempotent; 200+send_error on booked-but-send-failed) → https://invoia.io/developers/reference/recurring-series/postRecurringSeriesRefOccurrencesPeriodIssue.md - `GET /users` — List account users (id, name, email, role, default hourly rate, external_id) → https://invoia.io/developers/reference/users/getUsers.md - `GET /users/{ref}` — Fetch a single user by ext:/email:/UUID reference → https://invoia.io/developers/reference/users/getUsersRef.md - `PATCH /users/{ref}` — Tag an existing user with an external_id (external_id is the only patchable field) → https://invoia.io/developers/reference/users/patchUsersRef.md - `PUT /work-registrations` — Register work (absolute-set on hours + completion, occurrence-explicit) on a line + user; single-item; both metrics optional → https://invoia.io/developers/reference/work-registrations/putWorkRegistrations.md