# Oatmilk > Oatmilk is a company operations platform for finance and accounting, receipts and bank reconciliation, invoices, people and contractors, agreements and e-signatures, compliance deadlines and AI-assisted review. Everything the Oatmilk app does is an action that integrations can call over a REST API or MCP, with the same permissions as the person behind them. - REST API: `POST https://app.getoatmilk.com/api/v1/accounting/` with `Authorization: Bearer `; read actions also accept GET. Responses are `{ "data": … }` or `{ "error": { "code", "message" } }`. - 779 company actions and 43 contractor actions (`/api/v1/contractor/`), 53 webhook events signed with HMAC-SHA256, and an MCP server at https://app.getoatmilk.com/api/mcp. - Amounts are whole numbers of cents written as strings with a currency; writes take an idempotency key, and edits take the record's `expectedRevision`. - API contract version 2026-09-22. ## Oatmilk the product - [Home](https://getoatmilk.com/index.md): What Oatmilk is and every page of the site. - [Classifier](https://getoatmilk.com/classifier.md): Each bank and card line runs through rules, memory, receipts and Jev. Oatmilk sorts it only when it's sure, and asks you one question when it isn't. - [Evidence matching](https://getoatmilk.com/evidence.md): Oatmilk reads receipts from your company's inboxes and matches each one to its charge. It asks you when it isn't sure and keeps the original. - [Bookkeeping](https://getoatmilk.com/bookkeeping.md): Bank, card, Wise and Stripe charges come in and get sorted. Oatmilk asks you only when it isn't sure. - [Compliance](https://getoatmilk.com/stay-compliant.md): Ask any rules question and get an answer with official sources. Guides, deadlines and rule changes for your company, in one place. - [Invoices](https://getoatmilk.com/invoices.md): Make an invoice in a minute, send it, and see when it's opened and paid. - [Agreements](https://getoatmilk.com/agreements.md): Write an agreement, send it to sign on any device, and keep every signed copy with a full record. - [Contractors](https://getoatmilk.com/contractors.md): Contractors send hours from their own portal. You approve with a tap, pay with Wise and get tax slips at year-end. - [Tax](https://getoatmilk.com/tax.md): Year-end for Canadian companies, one plain question at a time. T2, GST/HST and T4A work, ready for your accountant. - [Ask AI](https://getoatmilk.com/ask-ai.md): Ask a question about your company and get a straight answer. Ask AI reads your books and asks before it changes anything. - [Accountants](https://getoatmilk.com/accountants.md): Invite your accountant to see the books and help with year-end. You pick what they can do and when it ends, and every action is on the record. - [Run it locally](https://getoatmilk.com/local.md): Run Oatmilk on your own computer with the CLI and the web portal, or choose where each part runs: this computer, the cloud, or off. - [Local models](https://getoatmilk.com/local-models.md): Run Ask AI, the classifiers and document reading on Ollama, LM Studio or any OpenAI-compatible server. Mix local and cloud by role. - [CLI](https://getoatmilk.com/cli.md): Oatmilk in your terminal: every page one keypress away, Ask AI built in, and themes that work in light and dark terminals. - [AI connectors](https://getoatmilk.com/mcp.md): Connect Claude, ChatGPT, Cursor, Codex and more to Oatmilk. Your AI can do what you can do, and nothing more. ## Get started - [Overview](https://app.getoatmilk.com/docs.md): Build on Oatmilk: the REST API, webhooks, MCP for AI assistants, the command line, self-hosting, guides and tutorials. - [Quickstart](https://app.getoatmilk.com/docs/quickstart.md): Get an API key, read your books and add a receipt in about five minutes. ## Concepts - [Authentication](https://app.getoatmilk.com/docs/authentication.md): API keys, sign-in tokens, permissions, roles and choosing a company. - [Making requests](https://app.getoatmilk.com/docs/requests.md): Addresses, methods, the response envelope, headers and size limits. - [Errors](https://app.getoatmilk.com/docs/errors.md): Error codes, what they mean, and which ones are safe to retry. - [Idempotency and revisions](https://app.getoatmilk.com/docs/idempotency.md): Retry without doing things twice, and never overwrite someone else's change. - [Pagination](https://app.getoatmilk.com/docs/pagination.md): Read long lists a page at a time with offsets or cursors. - [Rate limits](https://app.getoatmilk.com/docs/rate-limits.md): How many requests you can send and what to do when you hit the limit. - [Money, dates and IDs](https://app.getoatmilk.com/docs/money-and-dates.md): How amounts, currencies, dates and record IDs are written. - [Background jobs](https://app.getoatmilk.com/docs/async-work.md): Work that takes longer than a request, and how to know when it's done. - [Versioning and changes](https://app.getoatmilk.com/docs/versioning.md): How the API changes, and how you'll know. - [Usage and the request log](https://app.getoatmilk.com/docs/usage.md): See how your integrations are doing, and find any request by its ID. - [Budgets and reserves](https://app.getoatmilk.com/docs/budgets.md): Plan spending and prepare for tax payments and year-end costs. ## Webhooks - [Webhooks](https://app.getoatmilk.com/docs/webhooks.md): Get a signed HTTPS request the moment something happens in Oatmilk. - [Verify signatures](https://app.getoatmilk.com/docs/webhooks/signatures.md): Check that a delivery came from Oatmilk and wasn't changed on the way. - [Event catalog](https://app.getoatmilk.com/docs/webhooks/events.md): Every event Oatmilk sends, with a sample of each delivery. - [Test your endpoint](https://app.getoatmilk.com/docs/webhooks/testing.md): Send test events, replay deliveries and develop on your own computer. ## AI and MCP - [Connect your AI app](https://app.getoatmilk.com/docs/connect.md): Use Oatmilk from Claude, ChatGPT and other AI apps. No code needed. - [MCP server](https://app.getoatmilk.com/docs/mcp.md): Connect Claude, ChatGPT, Cursor and other MCP clients and agents to a company's books, safely. - [Docs for AI tools](https://app.getoatmilk.com/docs/ai-tools.md): Plain-text versions of these docs for language models, agents and coding assistants. ## Command line - [The Oatmilk CLI](https://app.getoatmilk.com/docs/terminal.md): Oatmilk in a terminal: a full-screen app, Ask AI, scripts, CI and a bridge for AI agents. - [Install the CLI](https://app.getoatmilk.com/docs/terminal/install.md): Install oatmilk with one command, npm, a standalone binary or from source, and keep it up to date. - [Sign in and choose a company](https://app.getoatmilk.com/docs/terminal/sign-in.md): Connect the CLI to the hosted Oatmilk or your own, with your browser, another device or an API key. - [Use the full-screen app](https://app.getoatmilk.com/docs/terminal/app.md): Move around the workspace, act on records and talk to Ask AI without leaving the terminal. - [Log hours as a contractor](https://app.getoatmilk.com/docs/terminal/hours.md): Log time, send your timesheet and check your pay period from the terminal or your AI app. - [Scripts and CI](https://app.getoatmilk.com/docs/terminal/scripts.md): Ask AI, run API actions and search from scripts, cron jobs and CI, with JSON output and exit codes. - [Connect agents through the CLI](https://app.getoatmilk.com/docs/terminal/agents.md): Give Claude Code, Codex, Cursor or an agent on local models Oatmilk's tools with oatmilk mcp. - [Command reference](https://app.getoatmilk.com/docs/terminal/commands.md): Every oatmilk command, option, environment variable, exit code and file. ## Contractors - [Contractor API](https://app.getoatmilk.com/docs/contractors.md): Let contractors log hours, submit timesheets and read their pay from their own tools. ## Tutorials - [Add receipts from your app](https://app.getoatmilk.com/docs/tutorials/add-receipts.md): Upload receipts of any size with a private upload link, then follow them until they're filed. - [Sync invoices to your CRM](https://app.getoatmilk.com/docs/tutorials/sync-invoices-to-a-crm.md): Keep deal stages in step with invoices using signed webhooks. - [Post deadline reminders to Slack](https://app.getoatmilk.com/docs/tutorials/compliance-alerts-in-slack.md): Turn compliance events into a message your team sees. - [Export a month of transactions](https://app.getoatmilk.com/docs/tutorials/export-transactions.md): Page through entries and write a spreadsheet your accountant can open. - [Chase missing receipts](https://app.getoatmilk.com/docs/tutorials/missing-receipts.md): Find card purchases that still need a receipt and attach one in a single call. - [Log contractor hours from a time tracker](https://app.getoatmilk.com/docs/tutorials/contractor-timesheets.md): Send time entries to a contractor's timesheet and submit it for approval. - [Download all of your company's data](https://app.getoatmilk.com/docs/tutorials/export-company-data.md): Take every record and file Oatmilk keeps for your company, and close the company when you're done. - [Record an intercompany transfer](https://app.getoatmilk.com/docs/tutorials/intercompany-transfers.md): Preview and save an advance or repayment against an existing bank transfer, with sources and a readback. ## Self-hosting - [Self-host Oatmilk](https://app.getoatmilk.com/docs/self-hosting.md): Run your own Oatmilk on your computer, a server or a cloud, with your data and AI models staying with you. - [Run it on your computer](https://app.getoatmilk.com/docs/self-hosting/your-computer.md): Install Oatmilk on your own Mac, Linux or Windows computer, step by step, at oatmilk.localhost. - [Use local AI models](https://app.getoatmilk.com/docs/self-hosting/local-models.md): Run every model Oatmilk uses on your own hardware with Ollama, LM Studio, vLLM or llama.cpp. - [Go completely off-grid](https://app.getoatmilk.com/docs/self-hosting/off-grid.md): A 100% local Oatmilk: accounts, books, files and AI models on one machine, even with no internet. - [Run it on a server](https://app.getoatmilk.com/docs/self-hosting/server.md): Host Oatmilk for your team on a VPS or your own server at your domain, with HTTPS from Let's Encrypt. - [Deploy on AWS](https://app.getoatmilk.com/docs/self-hosting/aws.md): Run Oatmilk on EC2 with Amazon RDS for PostgreSQL, ElastiCache and S3, step by step. - [Deploy on Google Cloud](https://app.getoatmilk.com/docs/self-hosting/google-cloud.md): Run Oatmilk on Compute Engine with Cloud SQL, Memorystore and Cloud Storage, step by step. - [Deploy on Azure](https://app.getoatmilk.com/docs/self-hosting/azure.md): Run Oatmilk on an Azure VM with Azure Database for PostgreSQL and Azure Cache for Redis, step by step. - [Bring your own services](https://app.getoatmilk.com/docs/self-hosting/services.md): Use your own PostgreSQL, Redis and S3-compatible storage, such as Neon, Upstash, Cloudflare R2 or MinIO. - [Sign-in and accounts](https://app.getoatmilk.com/docs/self-hosting/accounts.md): Choose how people sign in to your Oatmilk, who may make an account, and how to manage accounts. - [Update, back up and troubleshoot](https://app.getoatmilk.com/docs/self-hosting/operate.md): Keep a self-hosted Oatmilk healthy: updates, backups, restores, logs, security and common fixes. - [Run it without Docker](https://app.getoatmilk.com/docs/self-hosting/without-docker.md): Run Oatmilk's processes directly on a Linux machine, with PostgreSQL, PostgREST, Storage, Redis and Caddy. - [Develop Oatmilk locally](https://app.getoatmilk.com/docs/self-hosting/develop.md): Run Oatmilk from source with hot reload, local data services and, if you like, local AI models. ## API reference - [Transactions](https://app.getoatmilk.com/docs/api/transactions.md): Entries, bank and card lines, categories, tags, accounts, reconciliation and statements. (115 actions) - [Inbox and uploads](https://app.getoatmilk.com/docs/api/inbox.md): Receipts, file uploads, company mail, connected inboxes and processing jobs. (56 actions) - [Matching](https://app.getoatmilk.com/docs/api/matching.md): Match receipts to bank transactions and review the decisions. (11 actions) - [Autopilot](https://app.getoatmilk.com/docs/api/autopilot.md): What needs a person, Autopilot runs, receipt reminders and the investigator. (30 actions) - [Invoicing](https://app.getoatmilk.com/docs/api/invoicing.md): Invoices, customers and vendors, and how they pay. (50 actions) - [Documents and signing](https://app.getoatmilk.com/docs/api/documents.md): Agreements, e-signatures and kept records such as tax returns. (60 actions) - [Contractors](https://app.getoatmilk.com/docs/api/contractors.md): The finance side of contractors: directory, timesheets, payouts and recruiting. (144 actions) - [Contractor portal](https://app.getoatmilk.com/docs/api/contractor.md): A contractor's own hours, pay, agreements and profile, with a contractor sign-in. (43 actions) - [Reports and tax](https://app.getoatmilk.com/docs/api/reports.md): Reports, exports, insights, subscriptions and tax preparation. (75 actions) - [Compliance](https://app.getoatmilk.com/docs/api/compliance.md): The compliance checklist, deadlines and reminders. (10 actions) - [Stripe](https://app.getoatmilk.com/docs/api/stripe.md): Stripe activity, payouts, exceptions and tax confirmation. (14 actions) - [Connectors and webhooks](https://app.getoatmilk.com/docs/api/connectors.md): Webhooks, Wise, Notion, Google Drive and other data sources. (59 actions) - [AI and memory](https://app.getoatmilk.com/docs/api/ai.md): AI settings, classifier guidance, memory, the sandbox and compliance research. (45 actions) - [Administration](https://app.getoatmilk.com/docs/api/admin.md): Company settings, people, API keys, email, runs and suggestions. (95 actions) - [History and rollback](https://app.getoatmilk.com/docs/api/history.md): Every change anyone made to the company, who made it and how, and previewed rollbacks. (7 actions) - [Budgets and reserves](https://app.getoatmilk.com/docs/api/budgets.md): Spending plans, tax and accountant costs, reserve accounts and funding gaps before filing. (8 actions) ## Machine-readable - [/llms.txt](https://app.getoatmilk.com/llms.txt): A short map of Oatmilk and every docs page, with links to their Markdown. - [/llms-full.txt](https://app.getoatmilk.com/llms-full.txt): Every guide and tutorial, the webhook event catalog and a summary of every API action, in one file. - [/docs/api/llms.txt](https://app.getoatmilk.com/docs/api/llms.txt): The whole API reference: every action with its permissions, fields and an example. - [/docs/webhooks/llms.txt](https://app.getoatmilk.com/docs/webhooks/llms.txt): The webhook guides with every event's sample delivery. - [/docs/terminal/llms.txt](https://app.getoatmilk.com/docs/terminal/llms.txt): Every guide to the oatmilk command line, with its command reference. - [/docs/self-hosting/llms.txt](https://app.getoatmilk.com/docs/self-hosting/llms.txt): Every self-hosting guide: your computer, a server, the clouds, local AI models and operations. - [/api/openapi.json](https://app.getoatmilk.com/api/openapi.json): The OpenAPI 3.1 document, including webhooks. - [MCP server](https://app.getoatmilk.com/api/mcp): Streamable HTTP; sign in with OAuth or send an API key. ## Optional - [Oatmilk](https://getoatmilk.com): the product. - [Every error code](https://app.getoatmilk.com/docs/errors.md#every-error-code): generated from the source. --- # Oatmilk developer docs > Build on Oatmilk: the REST API, webhooks, MCP for AI assistants, the command line, self-hosting, guides and tutorials. Oatmilk runs a company's finance and operations: transactions, receipts, bank matching, invoices, contractors, agreements, compliance and AI-assisted review. Everything the Oatmilk app does is an action you can call: 779 company actions and 43 contractor actions, 53 webhook events, and an MCP server for AI assistants. ## Pages - [Quickstart](https://app.getoatmilk.com/docs/quickstart.md): Get an API key, read your books and add a receipt in about five minutes. - [Authentication](https://app.getoatmilk.com/docs/authentication.md): API keys, sign-in tokens, permissions, roles and choosing a company. - [Making requests](https://app.getoatmilk.com/docs/requests.md): Addresses, methods, the response envelope, headers and size limits. - [Errors](https://app.getoatmilk.com/docs/errors.md): Error codes, what they mean, and which ones are safe to retry. - [Idempotency and revisions](https://app.getoatmilk.com/docs/idempotency.md): Retry without doing things twice, and never overwrite someone else's change. - [Pagination](https://app.getoatmilk.com/docs/pagination.md): Read long lists a page at a time with offsets or cursors. - [Rate limits](https://app.getoatmilk.com/docs/rate-limits.md): How many requests you can send and what to do when you hit the limit. - [Money, dates and IDs](https://app.getoatmilk.com/docs/money-and-dates.md): How amounts, currencies, dates and record IDs are written. - [Background jobs](https://app.getoatmilk.com/docs/async-work.md): Work that takes longer than a request, and how to know when it's done. - [Versioning and changes](https://app.getoatmilk.com/docs/versioning.md): How the API changes, and how you'll know. - [Usage and the request log](https://app.getoatmilk.com/docs/usage.md): See how your integrations are doing, and find any request by its ID. - [Budgets and reserves](https://app.getoatmilk.com/docs/budgets.md): Plan spending and prepare for tax payments and year-end costs. - [Webhooks](https://app.getoatmilk.com/docs/webhooks.md): Get a signed HTTPS request the moment something happens in Oatmilk. - [Verify signatures](https://app.getoatmilk.com/docs/webhooks/signatures.md): Check that a delivery came from Oatmilk and wasn't changed on the way. - [Event catalog](https://app.getoatmilk.com/docs/webhooks/events.md): Every event Oatmilk sends, with a sample of each delivery. - [Test your endpoint](https://app.getoatmilk.com/docs/webhooks/testing.md): Send test events, replay deliveries and develop on your own computer. - [Connect your AI app](https://app.getoatmilk.com/docs/connect.md): Use Oatmilk from Claude, ChatGPT and other AI apps. No code needed. - [MCP server](https://app.getoatmilk.com/docs/mcp.md): Connect Claude, ChatGPT, Cursor and other MCP clients and agents to a company's books, safely. - [Docs for AI tools](https://app.getoatmilk.com/docs/ai-tools.md): Plain-text versions of these docs for language models, agents and coding assistants. - [The Oatmilk CLI](https://app.getoatmilk.com/docs/terminal.md): Oatmilk in a terminal: a full-screen app, Ask AI, scripts, CI and a bridge for AI agents. - [Install the CLI](https://app.getoatmilk.com/docs/terminal/install.md): Install oatmilk with one command, npm, a standalone binary or from source, and keep it up to date. - [Sign in and choose a company](https://app.getoatmilk.com/docs/terminal/sign-in.md): Connect the CLI to the hosted Oatmilk or your own, with your browser, another device or an API key. - [Use the full-screen app](https://app.getoatmilk.com/docs/terminal/app.md): Move around the workspace, act on records and talk to Ask AI without leaving the terminal. - [Log hours as a contractor](https://app.getoatmilk.com/docs/terminal/hours.md): Log time, send your timesheet and check your pay period from the terminal or your AI app. - [Scripts and CI](https://app.getoatmilk.com/docs/terminal/scripts.md): Ask AI, run API actions and search from scripts, cron jobs and CI, with JSON output and exit codes. - [Connect agents through the CLI](https://app.getoatmilk.com/docs/terminal/agents.md): Give Claude Code, Codex, Cursor or an agent on local models Oatmilk's tools with oatmilk mcp. - [Command reference](https://app.getoatmilk.com/docs/terminal/commands.md): Every oatmilk command, option, environment variable, exit code and file. - [Contractor API](https://app.getoatmilk.com/docs/contractors.md): Let contractors log hours, submit timesheets and read their pay from their own tools. - [Add receipts from your app](https://app.getoatmilk.com/docs/tutorials/add-receipts.md): Upload receipts of any size with a private upload link, then follow them until they're filed. - [Sync invoices to your CRM](https://app.getoatmilk.com/docs/tutorials/sync-invoices-to-a-crm.md): Keep deal stages in step with invoices using signed webhooks. - [Post deadline reminders to Slack](https://app.getoatmilk.com/docs/tutorials/compliance-alerts-in-slack.md): Turn compliance events into a message your team sees. - [Export a month of transactions](https://app.getoatmilk.com/docs/tutorials/export-transactions.md): Page through entries and write a spreadsheet your accountant can open. - [Chase missing receipts](https://app.getoatmilk.com/docs/tutorials/missing-receipts.md): Find card purchases that still need a receipt and attach one in a single call. - [Log contractor hours from a time tracker](https://app.getoatmilk.com/docs/tutorials/contractor-timesheets.md): Send time entries to a contractor's timesheet and submit it for approval. - [Download all of your company's data](https://app.getoatmilk.com/docs/tutorials/export-company-data.md): Take every record and file Oatmilk keeps for your company, and close the company when you're done. - [Record an intercompany transfer](https://app.getoatmilk.com/docs/tutorials/intercompany-transfers.md): Preview and save an advance or repayment against an existing bank transfer, with sources and a readback. - [Self-host Oatmilk](https://app.getoatmilk.com/docs/self-hosting.md): Run your own Oatmilk on your computer, a server or a cloud, with your data and AI models staying with you. - [Run it on your computer](https://app.getoatmilk.com/docs/self-hosting/your-computer.md): Install Oatmilk on your own Mac, Linux or Windows computer, step by step, at oatmilk.localhost. - [Use local AI models](https://app.getoatmilk.com/docs/self-hosting/local-models.md): Run every model Oatmilk uses on your own hardware with Ollama, LM Studio, vLLM or llama.cpp. - [Go completely off-grid](https://app.getoatmilk.com/docs/self-hosting/off-grid.md): A 100% local Oatmilk: accounts, books, files and AI models on one machine, even with no internet. - [Run it on a server](https://app.getoatmilk.com/docs/self-hosting/server.md): Host Oatmilk for your team on a VPS or your own server at your domain, with HTTPS from Let's Encrypt. - [Deploy on AWS](https://app.getoatmilk.com/docs/self-hosting/aws.md): Run Oatmilk on EC2 with Amazon RDS for PostgreSQL, ElastiCache and S3, step by step. - [Deploy on Google Cloud](https://app.getoatmilk.com/docs/self-hosting/google-cloud.md): Run Oatmilk on Compute Engine with Cloud SQL, Memorystore and Cloud Storage, step by step. - [Deploy on Azure](https://app.getoatmilk.com/docs/self-hosting/azure.md): Run Oatmilk on an Azure VM with Azure Database for PostgreSQL and Azure Cache for Redis, step by step. - [Bring your own services](https://app.getoatmilk.com/docs/self-hosting/services.md): Use your own PostgreSQL, Redis and S3-compatible storage, such as Neon, Upstash, Cloudflare R2 or MinIO. - [Sign-in and accounts](https://app.getoatmilk.com/docs/self-hosting/accounts.md): Choose how people sign in to your Oatmilk, who may make an account, and how to manage accounts. - [Update, back up and troubleshoot](https://app.getoatmilk.com/docs/self-hosting/operate.md): Keep a self-hosted Oatmilk healthy: updates, backups, restores, logs, security and common fixes. - [Run it without Docker](https://app.getoatmilk.com/docs/self-hosting/without-docker.md): Run Oatmilk's processes directly on a Linux machine, with PostgreSQL, PostgREST, Storage, Redis and Caddy. - [Develop Oatmilk locally](https://app.getoatmilk.com/docs/self-hosting/develop.md): Run Oatmilk from source with hot reload, local data services and, if you like, local AI models. ## API reference - [Transactions](https://app.getoatmilk.com/docs/api/transactions.md): Entries, bank and card lines, categories, tags, accounts, reconciliation and statements. (115 actions) - [Inbox and uploads](https://app.getoatmilk.com/docs/api/inbox.md): Receipts, file uploads, company mail, connected inboxes and processing jobs. (56 actions) - [Matching](https://app.getoatmilk.com/docs/api/matching.md): Match receipts to bank transactions and review the decisions. (11 actions) - [Autopilot](https://app.getoatmilk.com/docs/api/autopilot.md): What needs a person, Autopilot runs, receipt reminders and the investigator. (30 actions) - [Invoicing](https://app.getoatmilk.com/docs/api/invoicing.md): Invoices, customers and vendors, and how they pay. (50 actions) - [Documents and signing](https://app.getoatmilk.com/docs/api/documents.md): Agreements, e-signatures and kept records such as tax returns. (60 actions) - [Contractors](https://app.getoatmilk.com/docs/api/contractors.md): The finance side of contractors: directory, timesheets, payouts and recruiting. (144 actions) - [Contractor portal](https://app.getoatmilk.com/docs/api/contractor.md): A contractor's own hours, pay, agreements and profile, with a contractor sign-in. (43 actions) - [Reports and tax](https://app.getoatmilk.com/docs/api/reports.md): Reports, exports, insights, subscriptions and tax preparation. (75 actions) - [Compliance](https://app.getoatmilk.com/docs/api/compliance.md): The compliance checklist, deadlines and reminders. (10 actions) - [Stripe](https://app.getoatmilk.com/docs/api/stripe.md): Stripe activity, payouts, exceptions and tax confirmation. (14 actions) - [Connectors and webhooks](https://app.getoatmilk.com/docs/api/connectors.md): Webhooks, Wise, Notion, Google Drive and other data sources. (59 actions) - [AI and memory](https://app.getoatmilk.com/docs/api/ai.md): AI settings, classifier guidance, memory, the sandbox and compliance research. (45 actions) - [Administration](https://app.getoatmilk.com/docs/api/admin.md): Company settings, people, API keys, email, runs and suggestions. (95 actions) - [History and rollback](https://app.getoatmilk.com/docs/api/history.md): Every change anyone made to the company, who made it and how, and previewed rollbacks. (7 actions) - [Budgets and reserves](https://app.getoatmilk.com/docs/api/budgets.md): Spending plans, tax and accountant costs, reserve accounts and funding gaps before filing. (8 actions) # Quickstart > Get an API key, read your books and add a receipt in about five minutes. Source: https://app.getoatmilk.com/docs/quickstart This guide takes you from nothing to a working integration: you'll read recent entries, add a receipt, and check on it while Oatmilk reads it. You need an Oatmilk account in a company, and a terminal. ## 1. Get an API key Open **Developers › API keys** in Oatmilk and choose **Create key**. Give it a name you'll recognise later, keep **Read** and **Write** for accounting, and copy the key. You only see it once. Keys start with `oat_live_` in production and `oat_test_` in staging. Keep yours on your own server, never in browser code or a public repository, and pass it to your code as an environment variable: ```bash export OATMILK_API_KEY="oat_live_…" ``` > [!TIP] > A key can never do more than the person who made it. If your role changes, or you leave the company, your keys change or stop with you. ## 2. Make your first request Every action has its own address under `/api/v1/accounting/`. Read actions accept GET with query parameters, so this lists your five most recent entries: ```bash curl -G https://app.getoatmilk.com/api/v1/accounting/entries.list \ -H "Authorization: Bearer $OATMILK_API_KEY" \ --data-urlencode 'limit=5' ``` ## 3. Read the response A successful response wraps the result in `data`. Amounts are whole numbers of cents written as strings, so `"4520"` is $45.20. ```json { "data": [ { "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10", "organization_id": "org_synthetic", "submission_id": "4d5e6f7a-8b9c-4d0e-8f1a-2b3c4d5e6f7a", "submitted_by": "user_synthetic", "merchant": "Synthetic Office Supply", "date": "2026-09-18", "currency": "CAD", "amount_minor": "4520", "tax_minor": "520", "category_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "payment_account_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "type": "expense", "status": "reviewed", "notes": "", "revision": 3, "human_corrected": false, "autopilot_state": "done", "tags": [] } ] } ``` Every response also carries an `X-Request-Id` header. Keep it with your logs: it's the fastest way for us to find a request if you need help. ## 4. Add a receipt Changes use POST with a JSON body. `uploads.inline` adds a receipt of up to 2 MB in one call: send its name, type and bytes as base64. The `Idempotency-Key` header makes the request safe to retry: send the same key again and the receipt is only added once. ```bash curl https://app.getoatmilk.com/api/v1/accounting/uploads.inline \ -H "Authorization: Bearer $OATMILK_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "filename": "receipt.jpg", "mimeType": "image/jpeg", "contentBase64": "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAA==" }' ``` Oatmilk keeps the original file and starts reading it in the background. The response tells you which job to follow: ```json { "data": { "submissionId": "4d5e6f7a-8b9c-4d0e-8f1a-2b3c4d5e6f7a", "jobId": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e", "generation": 1, "status": "queued", "sizeBytes": 182734 } } ``` ## 5. Check on the job Reading a receipt takes a few seconds. Ask for the job until its `status` is `completed` or `failed`. A `queued` or `processing` job is still working, not finished. ```bash curl -G https://app.getoatmilk.com/api/v1/accounting/jobs.get \ -H "Authorization: Bearer $OATMILK_API_KEY" \ --data-urlencode 'jobId=2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e' ``` ```json { "data": { "id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e", "submission_id": "4d5e6f7a-8b9c-4d0e-8f1a-2b3c4d5e6f7a", "status": "completed", "stage": "match", "generation": 1, "attempts": 1, "error_code": null, "next_attempt_at": "2026-09-30T14:00:05Z" } } ``` That's it: the receipt is in the books, matched to its bank transaction when Oatmilk finds one. ## Next steps - [Authentication](https://app.getoatmilk.com/docs/authentication.md) explains permissions, roles and choosing a company. - [Add receipts from your app](https://app.getoatmilk.com/docs/tutorials/add-receipts.md) handles large files with a private upload link. - [Webhooks](https://app.getoatmilk.com/docs/webhooks.md) tell your server when something happens, so you don't have to ask. - The [API reference](https://app.getoatmilk.com/docs/api.md) lists all 779 actions with their fields and examples. # Authentication > API keys, sign-in tokens, permissions, roles and choosing a company. Source: https://app.getoatmilk.com/docs/authentication Every request carries a credential in the `Authorization` header: ```http Authorization: Bearer oat_live_… ``` Oatmilk accepts two kinds of credentials. Both work with the REST API and with MCP. | Credential | Best for | How you get it | | --- | --- | --- | | API key | Your own server, scripts and back-office jobs | Developers › API keys | | OAuth token | Apps other people connect, and AI assistants | The person signs in to Oatmilk and approves access | ## API keys An API key belongs to the person who made it and to one company. It starts with `oat_live_` in production or `oat_test_` in staging, and the two never work in each other's environment. - **It's shown once.** Copy it when you create it. Oatmilk only stores a hash, so nobody can show it to you again. If you lose it, rotate the key to get a new secret. - **It expires.** Keys last 90 days unless you choose otherwise, and at most a year. - **It follows its owner.** Every request checks the owner's current role and membership. A key stops working when it's revoked, when it expires, or when its owner leaves the company. - **It's for servers.** Never put a key in browser code, a mobile app or a public repository. ## Permissions A key or token only does what its permissions allow. Each action's page in the [API reference](https://app.getoatmilk.com/docs/api.md) lists the permissions it needs, and a request without them is refused with `INSUFFICIENT_SCOPE`. | Permission | Allows | | --- | --- | | `accounting:read` | **Read accounting.** Read records and receipt evidence allowed by your role. | | `accounting:write` | **Write accounting.** Submit receipts and, with finance access, edit records and reconcile. Select read access too. | | `accounting:admin` | **Accounting administration.** Manage access, bank settings and closed periods. Administrator role remains required. | | `mail:read` | **Read company mail.** Read cleared company email and its original files. Separate from accounting access. | | `mail:write` | **Manage company mail.** Review email and financial drafts. Select mail read too; booking also requires accounting read and write. | | `mail:security` | **Read restricted account-access mail.** Explicitly allow access codes, security messages and unscreened mail. Requires administrator role and mail read. | | `api_keys:manage` | **Manage API keys.** Create or replace your keys within this key’s permissions and expiration; administrators may revoke organization keys. | | `mailboxes:search` | **Search your inbox for receipts.** Start Find in my inbox for purchases that need a receipt, or check your inbox now, in your own connected inbox only. Read and write access don’t include it. | | `contractor:read` | **Read your contractor records.** Read your own hours, timesheets, pay, agreements and profile with one company. | | `contractor:write` | **Change your contractor records.** Log and submit your own hours and update your profile. Select read access too. | > [!NOTE] > Accounting permissions never include company mail or anyone's connected inbox. Those need their own permissions, so an integration that keeps your books can't read your email. ## Roles Permissions say what a credential may try. The owner's role in the company decides what they may actually do, and the stricter of the two always wins. | Role | Can work with | | --- | --- | | `admin` | Everything, including people, settings, connectors, webhooks and closing periods. | | `finance` | The books: transactions, receipts, matching, invoices, contractors and reports. | | `contributor` | Their own receipts and card purchases, and the company's categories. | | `accountant` | An outside accountant: reads and exports what their access allows, through OAuth only. | | `contractor` | The contractor API: only their own hours, pay, agreements and profile. | ## Choosing a company An API key always works in the company it was made in. A person with an OAuth token may belong to several companies: send `X-Accounting-Organization` with the company's ID to choose one. If you send it with an API key, it must name the key's own company. ```http X-Accounting-Organization: org_2abc… ``` Each person can also have a personal workspace for their own money, chosen the same way. `identity.get` returns `workspaceKind` (`company` or `personal`). A personal workspace has every action except the contractor portal, recruiting and team invitations, which fail there with `PERSONAL_WORKSPACE`. ## OAuth for apps and AI assistants Apps that other people connect should use OAuth instead of asking for an API key. The person signs in to Oatmilk, sees what your app asks for, and approves it. Your app never sees their password, and they can disconnect it at any time. Oatmilk publishes its OAuth details at `https://app.getoatmilk.com/.well-known/oauth-authorization-server`, and MCP clients discover them automatically from `https://app.getoatmilk.com/.well-known/oauth-protected-resource/api/mcp`. See [MCP server](https://app.getoatmilk.com/docs/mcp.md). ## Steps only a person can take Some things always need a person in Oatmilk itself: signing an agreement, approving a suggestion, sending money, or entering bank and tax numbers. The API refuses them with an `INTERACTIVE_…` error that includes the link to the right page, so your app can hand it to the person. See [Errors](https://app.getoatmilk.com/docs/errors.md#steps-only-a-person-can-take). # Making requests > Addresses, methods, the response envelope, headers and size limits. Source: https://app.getoatmilk.com/docs/requests The Oatmilk API is a set of named actions, the same ones the Oatmilk app uses. Each action has its own address, takes a JSON object, and returns a JSON object. There are no nested resource paths to learn: if you know an action's name, you know its address. ```http POST https://app.getoatmilk.com/api/v1/accounting/invoices.create ``` ## Methods - **POST** works for every action. Send the input as a JSON body with `Content-Type: application/json`. - **GET** also works for read actions, with the input in the query string. It's handy for quick checks and caching proxies, but values arrive as text, so use POST for anything with lists or nested objects. An action that changes something refuses GET with `METHOD_NOT_ALLOWED`. Each action's page in the [API reference](https://app.getoatmilk.com/docs/api.md) shows which methods it accepts. ## The response envelope A successful response has one field, `data`, holding the action's result: ```json { "data": { "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10", "status": "sent" } } ``` A refused or failed request has one field, `error`, with a stable `code` you can branch on and a `message` you can show a person: ```json { "error": { "code": "INVALID_INPUT", "message": "Amount needs a valid value. Check this field and try again.", "field": "amountMinor", "docUrl": "https://app.getoatmilk.com/docs/errors#invalid-input" } } ``` `field` names the first problem when there is one, and `docUrl` links to what the code means. Some errors also carry `dashboardUrl` or `portalUrl`: the page where a person can finish the step. See [Errors](https://app.getoatmilk.com/docs/errors.md). ## Headers | Header | Meaning | | --- | --- | | `X-Request-Id` | A unique ID for this request. Keep it in your logs and include it when you ask for help. | | `X-Accounting-API-Version` | The date of the API contract this response follows. | | `Retry-After` | On a 429 response: how many seconds to wait before retrying. | | `WWW-Authenticate` | On a 401 response: says a bearer credential is required. | ## Limits | Limit | Value | | --- | --- | | Requests per API key | 120 per minute | | JSON body | 3 MB | | Time to send the body | 15 seconds | | File in `uploads.inline` | 2 MB | | File through an upload link | 50 MB | ## Browser requests The API answers cross-origin requests, so a tool running in a browser can call it. That doesn't make it safe to put an API key in a web page: anyone who opens the page can read the key. Call Oatmilk from your own server, or use OAuth so each person signs in with their own account. ## The OpenAPI document `https://app.getoatmilk.com/api/openapi.json` describes every action in OpenAPI 3.1, generated from the same definitions the API checks requests against. Import it into Postman, Insomnia or a code generator, or point an AI tool at it. Its `webhooks` section describes every event Oatmilk sends. # Errors > Error codes, what they mean, and which ones are safe to retry. Source: https://app.getoatmilk.com/docs/errors Oatmilk uses ordinary HTTP status codes and adds a stable `code` to every error. Branch on `code`, show `message` to people, and keep `X-Request-Id` in your logs: paste it into [Developers › Request log](https://app.getoatmilk.com/docs/usage.md#find-a-request) to see who sent the request and how it ended. ```json { "error": { "code": "CONFLICT", "message": "This record changed. Refresh it before retrying.", "docUrl": "https://app.getoatmilk.com/docs/errors#conflict" } } ``` | Status | Means | | --- | --- | | `200` | It worked. The result is in `data`. | | `400` | The input doesn't match the action. `field` names the first problem. | | `401` | The credential is missing, expired or revoked. | | `403` | The credential or role doesn't allow this, or a person has to do it in Oatmilk. | | `404` | The action or record doesn't exist, or you can't see it. | | `409` | The record changed, or isn't in a state that allows this yet. | | `413`, `415` | The body is too large, or isn't JSON. | | `429` | Too many requests. Wait for `Retry-After`. | | `500`, `503` | Something went wrong on our side, or a feature isn't ready. | ## Common error codes | Code | Status | Means | What to do | | --- | --- | --- | --- | | `UNAUTHORIZED` | 401 | The Authorization header is missing, malformed, or the key or token has expired or been revoked. | Send `Authorization: Bearer `. Check the key in Developers › API keys and create a new one if it was revoked. | | `INSUFFICIENT_SCOPE` | 403 | The key or token works, but it doesn't include a permission this action needs. | Every action's page lists the permissions it needs. Create a key that includes them. | | `FORBIDDEN` | 403 | Your role in the company doesn't allow this, or the step can only happen in the dashboard. | Ask an administrator, or give the person the `dashboardUrl` from the error. | | `INTERACTIVE_ADMIN_REQUIRED` | 403 | An administrator has to do this in Oatmilk itself, such as sending a payout. | Give the `dashboardUrl` from the error to an administrator. | | `INTERACTIVE_APPROVAL_REQUIRED` | 403 | A person approves this suggestion in Oatmilk. Listing and declining still work through the API. | Give the `dashboardUrl` to the person who approves it. | | `INTERACTIVE_SIGNATURE_REQUIRED` | 403 | Only the named signer can sign or decline, in their own session. | Send the signer the `signUrl` or `portalUrl`. Integrations can't sign for a person. | | `INTERACTIVE_PORTAL_REQUIRED` | 403 | The contractor does this in their own portal: bank details, tax numbers, or agreeing to tax slips by email. | Give the contractor the `portalUrl` from the error. | | `COMPANY_PROTECTED` | 403 | This is the company the Oatmilk installation runs for, so it can't be closed. | Nothing to fix: export its data with `company.export` if you need a copy. | | `UNKNOWN_ACTION` | 404 | There is no action with that name. Names are case-sensitive. | Check the spelling against the API reference. The error suggests the closest name when there is one. | | `NOT_FOUND` | 404 | The record doesn't exist in this company, or you can't see it. | Check the ID and the company you're working in (`X-Accounting-Organization`). | | `METHOD_NOT_ALLOWED` | 405 | GET was used for an action that changes something, or the method isn't GET or POST. | Use POST with a JSON body. GET only works for read actions. | | `INVALID_INPUT` | 400 | The input doesn't match the action's fields, or the body isn't JSON (status 415). | `field` names the first problem. Fix it and send the request again. | | `CONFIRMATION_MISMATCH` | 400 | The confirmation you typed doesn't match, such as the company's name when closing it. | Send the name exactly as Oatmilk shows it (case doesn't matter). Nothing was changed. | | `TOO_LARGE` | 413 | The JSON body is larger than 3 MB. | Upload files with `uploads.prepare` and a PUT to the upload address, not inside the JSON body. | | `REQUEST_TIMEOUT` | 408 | The request body took longer than 15 seconds to arrive. | Retry with the same idempotency key. | | `IDEMPOTENCY_CONFLICT` | 409 | The Idempotency-Key header and the idempotencyKey field were both sent, with different values. | Send one of them, or send the same value in both. | | `STALE_REVISION` | 409 | The record changed after you read it, so your `expectedRevision` is out of date. | Read the record again, check what changed, and send the new revision if your change still applies. | | `CONFLICT` | 409 | The record changed since you read it, or the idempotency key was already used for a different request. | Read the record again before deciding what to do. Use a new idempotency key for a new change; reuse one only to retry exactly the same request. | | `DUPLICATE` | 409 | A record with the same details already exists. | Look the existing record up instead of creating it again. | | `INVALID_STATE` | 409 | The record isn't in a state that allows this yet, such as approving something already approved. | Read the record's status and follow the next step it needs. | | `RATE_LIMITED` | 429 | This key sent more than 120 requests in the current minute. | Wait for the number of seconds in `Retry-After`, then retry with the same idempotency key. | | `INTERNAL_ERROR` | 500 | Something went wrong on our side. | Retry after a short wait with the same idempotency key. If it keeps happening, send us the `X-Request-Id`. | | `SCHEMA_NOT_READY` | 503 | The environment is finishing a database update for this feature. | Retry in a few minutes. | | `CLOSE_UNAVAILABLE` | 503 | This installation's database doesn't let Oatmilk delete a company's records in one step, so nothing was deleted. | Whoever runs the installation lets the database owner set `session_replication_role`, then the company can be closed. | | `NOT_CONFIGURED` | 503 | A feature this action needs isn't set up for your company or environment yet. | Finish its setup in Settings. The message says what's missing. | ## Retrying safely Retry only what can succeed later: `429`, `500`, `503`, timeouts and dropped connections. Wait a little longer each time, and always resend the same `Idempotency-Key` so the change happens once. Don't retry other `4xx` errors unchanged: they fail the same way until you fix the request. See [Idempotency and revisions](https://app.getoatmilk.com/docs/idempotency.md). ```js title="retry.js" async function callOatmilk(action, input, idempotencyKey = crypto.randomUUID()) { for (let attempt = 1; ; attempt += 1) { const response = await fetch(`https://app.getoatmilk.com/api/v1/accounting/${action}`, { method: "POST", headers: { Authorization: `Bearer ${process.env.OATMILK_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": idempotencyKey }, body: JSON.stringify(input), }); if (response.ok) return (await response.json()).data; const retryable = response.status === 429 || response.status >= 500; if (!retryable || attempt === 5) throw new Error((await response.json()).error.code); const wait = Number(response.headers.get("Retry-After")) || 2 ** attempt; await new Promise(resolve => setTimeout(resolve, wait * 1000)); } } ``` ## Steps only a person can take Some errors mean "a person does this in Oatmilk". They carry the link to the exact step, so your app can pass it on instead of failing. | Code | What happens next | | --- | --- | | `INTERACTIVE_ADMIN_REQUIRED` | An administrator finishes it in Oatmilk at `dashboardUrl`, such as sending a payout. | | `INTERACTIVE_APPROVAL_REQUIRED` | A person approves the suggestion at `dashboardUrl`. Listing and declining still work through the API. | | `INTERACTIVE_SIGNATURE_REQUIRED` | The signer signs or declines in their own session. Integrations can't sign for anyone. | | `INTERACTIVE_PORTAL_REQUIRED` | The contractor enters bank details or tax numbers in their own portal at `portalUrl`. | ## Every error code The API can return these codes. The list is generated from Oatmilk's source, so it always matches what the API does. | Code | HTTP status | | --- | --- | | `ACCESS_ENDED` | 403 | | `ACCESS_EXPIRED` | 403 | | `ACCESS_REQUIRED` | 403 | | `ACCOUNT_CONFIRMATION_REQUIRED` | 409 | | `ACCOUNT_DETAILS_REQUIRED` | 400 | | `ACCOUNT_EXISTS` | 409 | | `ACCOUNT_NOT_ADDED` | 503 | | `ACCOUNT_REQUIRED` | 400 | | `AGENT_UNAVAILABLE` | 503 | | `AGREEMENT_CHANGED` | 409 | | `AGREEMENT_REQUIRED` | 409 | | `AGREEMENTS_OVERLAP` | 409 | | `AI_CREDITS_REQUIRED` | 403 | | `AI_KEY_REFUSED` | 400 | | `ALREADY_ACCOUNTANT` | 409 | | `ALREADY_INVITED` | 409 | | `ALREADY_MATCHED` | 409 | | `ALREADY_MEMBER` | 409 | | `ALREADY_STARTED` | 409 | | `ALREADY_SUBMITTED` | 409 | | `AMOUNT_MISMATCH` | 409 | | `ARCHIVE_FAILED` | 503 | | `ARCHIVED` | 409 | | `ATTACHMENT_LIMIT` | 400 | | `ATTACHMENT_RETRIEVE` | 503 | | `ATTACHMENT_SCOPE` | 409 | | `ATTACHMENT_UNAVAILABLE` | 503 | | `AUDIO_TOO_LARGE` | 413 | | `AUDIO_TOO_LONG` | 413 | | `AUDIT_FAILED` | 503 | | `AUTH_UNAVAILABLE` | 503 | | `BANK_MATCH_AMBIGUOUS` | 409 | | `BANK_MISMATCH` | 409 | | `BATCH_CLAIM_LOST` | 409 | | `BATCH_SIZE` | 400 | | `BUDGET_SCOPE_TOO_LARGE` | 422 | | `BUDGET_SOURCE_UNAVAILABLE` | 503 | | `CANNOT_REMOVE_SELF` | 409 | | `CARD_IN_USE` | 409 | | `CATEGORY_REQUIRED` | 409 | | `CATEGORY_UNAVAILABLE` | 409 | | `CLASSIFICATION_FAILED` | 503 | | `CLERK_UNAVAILABLE` | 503 | | `CLOSE_UNAVAILABLE` | 503 | | `COMPANY_CHOICE_REQUIRED` | 403 | | `COMPANY_LIMIT` | 409 | | `COMPANY_PROTECTED` | 403 | | `CONFIRMATION_MISMATCH` | 400 | | `CONFLICT` | 409 | | `CONNECTOR_CREDENTIAL_INVALID` | 503 | | `CONNECTOR_KEY_MISSING` | 503 | | `CONTRACTOR_CONFIRMATION_REQUIRED` | 409 | | `CONTRACTOR_EXISTS` | 409 | | `CONTRACTOR_UNAVAILABLE` | 503 | | `CORRECTION_CLOSED` | 409 | | `CORRECTION_OPEN` | 409 | | `CREDENTIAL_EMAIL_REQUIRES_MAILBOX` | 403 | | `DATA_SOURCE_UNAVAILABLE` | 409 | | `DATA_SOURCE_UNSUPPORTED` | 400 | | `DATABASE_ERROR` | 503 | | `DAY_FULL` | 422 | | `DEPOSIT_EXCEEDED` | 409 | | `DISCORD_UNAVAILABLE` | 503 | | `DOCUMENT_CHANGED` | 409 | | `DOCUMENT_STORAGE` | 503 | | `DOWNLOAD_FAILED` | 503 | | `DOWNLOAD_UNAVAILABLE` | 503 | | `DUPLICATE` | 409 | | `DUPLICATE_ENTRY` | 409 | | `DUPLICATE_NUMBER` | 409 | | `EMAIL_CLAIM_CANCELLED` | 409 | | `EMAIL_DISABLED` | 409 | | `EMAIL_DOMAIN_EXISTS` | 409 | | `EMAIL_DOMAIN_NOT_FOUND` | 404 | | `EMAIL_DOMAIN_NOT_VERIFIED` | 409 | | `EMAIL_DOMAIN_REJECTED` | 409 | | `EMAIL_DOMAIN_SETUP_PENDING` | 503 | | `EMAIL_DOMAIN_SETUP_REQUIRED` | 503 | | `EMAIL_DOMAIN_UNAVAILABLE` | 503 | | `EMAIL_INBOUND_SETUP_REQUIRED` | 503 | | `EMAIL_MISMATCH` | 403 | | `EMAIL_NEEDED` | 409 | | `EMAIL_NOT_ENABLED` | 409 | | `EMAIL_UNAVAILABLE` | 503 | | `EMAIL_UNVERIFIED` | 403 | | `EMPTY_IMPORT` | 400 | | `ENCRYPTION_KEY_CHANGED` | 503 | | `ENCRYPTION_MISCONFIGURED` | 503 | | `ENCRYPTION_NOT_CONFIGURED` | 503 | | `ENTRY_EVIDENCE_BINDING` | 409 | | `ENTRY_EVIDENCE_RECEIPT_DENIED` | 409 | | `EVIDENCE_ARCHIVE_FAILED` | 503 | | `EVIDENCE_HASH_MISMATCH` | 409 | | `EVIDENCE_INTEGRITY` | 409 | | `EVIDENCE_MISSING` | 400, 409 | | `EVIDENCE_READ_FAILED` | 503 | | `EVIDENCE_READING_PENDING` | 409, 503 | | `EVIDENCE_REQUIRED` | 409 | | `EVIDENCE_SCOPE` | 403, 409 | | `EXPORT_TOO_LARGE` | 400, 409, 413 | | `EXTRACTION_FAILED` | 502 | | `EXTRACTION_TIMEOUT` | 503 | | `FILE_FORMAT` | 400, 415, 422 | | `FILE_SIZE` | 400, 413 | | `FILE_TOO_LARGE` | 400, 413 | | `FILE_TYPE` | 400, 415 | | `FINANCIAL_SCOPE_TOO_LARGE` | 422 | | `FINGERPRINT_KEY_READABLE` | 409 | | `FINGERPRINT_KEY_UNREADABLE` | 503 | | `FOLIO_REFUSED` | 409 | | `FORBIDDEN` | 403 | | `FORECAST_TOO_LARGE` | 422 | | `FUTURE_HOURS` | 422 | | `FX_RATE_NOT_PUBLISHED` | 404 | | `FX_RATE_UNAVAILABLE` | 503 | | `FX_RATE_UNSUPPORTED` | 422 | | `GIFI_UNAVAILABLE` | 503 | | `GOOGLE_DOCS_NOT_PUBLIC` | 422 | | `GOOGLE_DOCS_TIMEOUT` | 504 | | `GOOGLE_DOCS_UNAVAILABLE` | 503 | | `GOOGLE_DRIVE_FORBIDDEN` | 403 | | `GOOGLE_DRIVE_NOT_CONFIGURED` | 503 | | `GOOGLE_DRIVE_NOT_CONNECTED` | 409 | | `GOOGLE_DRIVE_NOT_FOUND` | 404 | | `GOOGLE_DRIVE_SCOPE_MISSING` | 400 | | `GOOGLE_DRIVE_STATE_INVALID` | 400 | | `GOOGLE_DRIVE_TOKEN_FAILED` | 400, 502 | | `GOOGLE_DRIVE_UNAVAILABLE` | 502 | | `HOURS_BEFORE_START` | 409 | | `HOURS_LOCKED` | 409 | | `HOURS_NOT_PAID` | 409 | | `HOURS_ON_HOLD` | 409 | | `HOURS_PAID` | 409 | | `HUMAN_CORRECTED` | 409 | | `IDEMPOTENCY_CONFLICT` | 409 | | `IDENTITY_UNAVAILABLE` | 503 | | `IMAGE_LIMIT` | 400, 422 | | `IMAGE_TOO_LARGE` | 413 | | `IMPORT_FAILED` | 409, 502, 503 | | `INBOX_SEARCH_BUSY` | 429 | | `INBOX_SEARCH_DAILY_LIMIT` | 429 | | `INBOX_SEARCH_RATE_LIMITED` | 429 | | `INSUFFICIENT_SCOPE` | 403 | | `INTEGRITY_ERROR` | 409 | | `INTERACTIVE_ADMIN_REQUIRED` | 403 | | `INTERACTIVE_APPROVAL_REQUIRED` | 403 | | `INTERACTIVE_PORTAL_REQUIRED` | 403 | | `INTERACTIVE_SIGNATURE_REQUIRED` | 403 | | `INTERNAL_ERROR` | 500, 502 | | `INVALID_ACCOUNT` | 400, 409, 503 | | `INVALID_ADDRESS` | 400 | | `INVALID_AI_RESULT` | 400 | | `INVALID_AMOUNT` | 400 | | `INVALID_ASSIGNEE` | 400 | | `INVALID_AUDIO` | 400 | | `INVALID_BUDGET_CATEGORY` | 400 | | `INVALID_CATEGORY` | 400 | | `INVALID_CSV` | 400 | | `INVALID_CURRENCY` | 400 | | `INVALID_CURSOR` | 400 | | `INVALID_DATE` | 400 | | `INVALID_DATE_RANGE` | 400 | | `INVALID_DOCUMENT` | 400 | | `INVALID_DOMAIN` | 400 | | `INVALID_END_DATE` | 400 | | `INVALID_EVIDENCE` | 400 | | `INVALID_FIELD` | 400 | | `INVALID_FINAL_CHARGE` | 409 | | `INVALID_HEADER` | 400 | | `INVALID_IDENTITY` | 400 | | `INVALID_IMPORT` | 400 | | `INVALID_INPUT` | 400, 415, 422 | | `INVALID_INVITATION` | 404 | | `INVALID_MAPPING` | 400 | | `INVALID_MERCHANT` | 400 | | `INVALID_PARENT` | 400 | | `INVALID_PAYMENT_DETAILS` | 400 | | `INVALID_PERIOD` | 400 | | `INVALID_RECEIPT` | 409 | | `INVALID_RECIPIENT` | 400, 409 | | `INVALID_ROW` | 400 | | `INVALID_SETTINGS` | 409 | | `INVALID_SIGNATURE` | 400 | | `INVALID_SOURCE_REFERENCE` | 400, 409 | | `INVALID_STATE` | 409 | | `INVALID_STRIPE_SIGNATURE` | 400 | | `INVALID_TAX_NUMBER` | 400 | | `INVALID_VERIFICATION_LINK` | 400 | | `INVALID_WEBHOOK` | 400 | | `INVESTIGATION_BUSY` | 409 | | `INVESTIGATION_TOO_LARGE` | 409 | | `INVESTIGATION_UNAVAILABLE` | 503 | | `INVITATION_CLOSED` | 409 | | `INVITATION_EXPIRED` | 409 | | `INVITATION_REVOKED` | 409 | | `INVITATION_USED` | 409 | | `INVITE_INVALID` | 400, 403 | | `JOB_MISSING` | 503 | | `JOB_SUPERSEDED` | 409 | | `KICKOFF_UNAVAILABLE` | 409 | | `KIND_NOT_ALLOWED` | 403 | | `LINK_KEY_CHANGED` | 409 | | `LOCATION_UNAVAILABLE` | 503 | | `LOOKUP_CREDITS_LOW` | 503 | | `LOOKUP_UNAVAILABLE` | 503 | | `MAIL_HELD` | 403 | | `MAIL_ORIGINAL_PENDING` | 503 | | `MAIL_QUOTA` | 429 | | `MAILBOX_ACCESS_REVOKED` | 409 | | `MAILBOX_ADDRESS_MISSING` | 502 | | `MAILBOX_CONNECT_FAILED` | 503 | | `MAILBOX_CONNECT_RATE_LIMITED` | 429 | | `MAILBOX_CREDENTIAL_INVALID` | 409 | | `MAILBOX_GRANT_EXPIRED` | 409 | | `MAILBOX_IMPORT_FAILED` | 503 | | `MAILBOX_OFFLINE_ACCESS_MISSING` | 409 | | `MAILBOX_PROVIDER_ERROR` | 502 | | `MAILBOX_PROVIDER_NOT_CONFIGURED` | 503 | | `MAILBOX_RATE_LIMITED` | 429 | | `MAILBOX_SCOPE_MISSING` | 409 | | `MAILBOX_SCOPE_TOO_BROAD` | 409 | | `MAILBOX_STATE_EXPIRED` | 400 | | `MAILBOX_STATE_INVALID` | 400 | | `MAILBOX_STATE_MISMATCH` | 403 | | `MAILBOX_STATE_USED` | 400 | | `MAILBOX_TOKEN_FAILED` | 502 | | `MATCHING_MODEL_RETRY_PENDING` | 503 | | `MATCHING_SUPERSEDED` | 409 | | `MATCHING_TOO_LARGE` | 400 | | `MATCHING_UNAVAILABLE` | 503 | | `MEMBER_UNAVAILABLE` | 404 | | `METHOD_NOT_ALLOWED` | 405 | | `MISSING_FIELDS` | 400 | | `MULTIPLE_ACCOUNTS` | 400 | | `NESTED_EMAIL_LIMIT` | 400 | | `NO_ADMIN` | 409 | | `NO_CHANGES` | 400, 409 | | `NO_DOCUMENT` | 409 | | `NO_FINGERPRINT_KEY` | 409 | | `NO_HOURS_DATABASE` | 400 | | `NO_MEMBER` | 409 | | `NO_READABLE_SOURCE` | 409 | | `NO_REQUESTS` | 400 | | `NO_SPEECH` | 422 | | `NOT_A_CARD_CHARGE` | 409 | | `NOT_A_COMPANY_RECORD` | 409 | | `NOT_A_HOTEL_CHARGE` | 409 | | `NOT_A_PURCHASE` | 409 | | `NOT_A_STATEMENT` | 409 | | `NOT_AN_AGREEMENT` | 422 | | `NOT_AVAILABLE` | 409 | | `NOT_CONFIGURED` | 409, 503 | | `NOT_FOUND` | 404 | | `NOT_ISSUED` | 409 | | `NOT_NEEDED` | 409 | | `NOT_REGISTERED` | 409 | | `NOT_SIGNED` | 409 | | `NOT_TEAM_MEMBER` | 409 | | `NOTES_LIMIT` | 409 | | `NOTHING_CHANGED` | 400 | | `NOTHING_TO_IMPORT` | 400 | | `NOTION_ACCESS` | 404 | | `NOTION_ARCHIVED` | 409 | | `NOTION_FILE_GONE` | 404 | | `NOTION_INVALID_VALUE` | 400 | | `NOTION_LINK_INVALID` | 400 | | `NOTION_MAPPING_INVALID` | 400 | | `NOTION_MAPPING_REQUIRED` | 409 | | `NOTION_NOT_CONFIGURED` | 503 | | `NOTION_PROPERTY_READ_ONLY` | 400 | | `NOTION_SYNC_FAILED` | 503 | | `NOTION_WRONG_DATABASE` | 409 | | `OLDER_AGREEMENT` | 409 | | `ORGANIZATION_REQUIRED` | 403 | | `ORIGINAL_IN_USE` | 409 | | `ORIGINAL_REMOVED` | 409 | | `PAGE_LIMIT` | 400 | | `PAGE_UNAVAILABLE` | 404 | | `PAID_AMOUNT_REQUIRED` | 400 | | `PARTY_HAS_AUTOMATIC_INVOICES` | 409 | | `PAY_REQUIRED` | 400 | | `PAYMENT_DETAILS_CHANGED` | 409 | | `PAYMENT_TOO_SMALL` | 409 | | `PDF_UNAVAILABLE` | 409 | | `PERIOD_CLOSED` | 409 | | `PERIOD_NOT_ENDED` | 409 | | `PERIOD_PAID` | 409 | | `PERIOD_SKIPPED` | 409 | | `PERIODS_OVERLAP` | 409 | | `PERSONAL_WORKSPACE` | 403, 409 | | `PERSONAL_WORKSPACE_UNAVAILABLE` | 409 | | `POSSIBLE_DUPLICATE` | 409 | | `PREVIEW_READ_ONLY` | 403 | | `PREVIEW_SAMPLE` | 404 | | `PROVIDER_ID` | 400 | | `PROVIDER_RETRIEVE` | 503 | | `PROVIDER_URL` | 503 | | `RATE_LIMITED` | 429 | | `RAW_EMAIL_UNAVAILABLE` | 503 | | `READER_FAILED` | 502 | | `READER_UNAVAILABLE` | 503 | | `RECEIPT_DETAILS_CHANGED` | 409 | | `RECEIPT_DUPLICATE_REVIEW` | 409 | | `RECEIPT_NOT_NEEDED` | 409 | | `RECEIPT_ON_ITS_WAY` | 409 | | `RECEIPT_TARGET_CHANGED` | 409 | | `RECEIPT_TARGET_UNAVAILABLE` | 409 | | `RECENTLY_SENT` | 429 | | `RECIPIENT_IDENTITY_REVIEW` | 409 | | `RECONCILIATION_TOO_LARGE` | 400 | | `REMOVED_BY_PERSON` | 409 | | `REPORT_TOO_LARGE` | 400, 409 | | `REQUEST_CLOSED` | 409 | | `REQUEST_TIMEOUT` | 408 | | `REQUEST_UNAVAILABLE` | 409, 410 | | `RESTRICTED_EVIDENCE` | 403 | | `REVERIFICATION_REQUIRED` | 403 | | `REVIEW_REQUIRED` | 409 | | `REVIEW_UNAVAILABLE` | 503 | | `REVISION_CONFLICT` | 409 | | `ROLE_IN_AGREEMENT` | 409 | | `ROLE_MISMATCH` | 409 | | `ROLE_REQUIRED` | 409 | | `ROLE_UNAVAILABLE` | 409 | | `SAME_AGREEMENT_SIGNED` | 409 | | `SCHEMA_NOT_READY` | 503 | | `SEALED_DATA_UNREADABLE` | 409, 503 | | `SEND_LEASE_LOST` | 409 | | `SEND_STATE` | 409 | | `SETUP_PENDING` | 403 | | `SIGN_IN_REQUIRED` | 401 | | `SIGNATURES_REQUIRED` | 409 | | `SIGNERS_REQUIRED` | 409 | | `SIGNING_KEY_EXISTS` | 409 | | `SIGNUPS_CLOSED` | 403 | | `SOURCE_LIMIT` | 400 | | `SOURCE_UNAVAILABLE` | 400 | | `STALE_FINANCIAL_SOURCE` | 409 | | `STALE_HOURS` | 409 | | `STALE_PROPOSAL` | 409 | | `STALE_REVISION` | 409 | | `STATEMENT_FORMAT` | 422 | | `STATEMENT_IMPORTED` | 409 | | `STATEMENT_PAGE_TOO_LONG` | 422 | | `STATEMENT_SCOPE_TOO_LARGE` | 413 | | `STATEMENT_SOURCE_CHANGED` | 409 | | `STATEMENT_TOO_LARGE` | 413 | | `STATEMENT_UNDECIDED` | 500 | | `STATEMENT_UNDONE` | 409 | | `STATUS_NOT_APPLICABLE` | 409 | | `STORAGE_CONFLICT` | 409 | | `STORAGE_ERROR` | 503 | | `STRIPE_ACCOUNT_CHANGED` | 409 | | `STRIPE_ACCOUNT_MISMATCH` | 403 | | `STRIPE_ARCHIVE_FAILED` | 503 | | `STRIPE_BODY_LIMIT` | 413 | | `STRIPE_EVENT_SCOPE` | 403 | | `STRIPE_HISTORY_REQUIRED` | 409 | | `STRIPE_INVALID_AMOUNT` | 502 | | `STRIPE_INVALID_CURRENCY` | 502 | | `STRIPE_INVALID_CURSOR` | 409 | | `STRIPE_INVALID_DATE` | 502 | | `STRIPE_INVALID_EVENT` | 400 | | `STRIPE_INVALID_EXCHANGE_RATE` | 502 | | `STRIPE_INVALID_PAGE` | 502 | | `STRIPE_INVALID_RESPONSE` | 502 | | `STRIPE_INVALID_TRANSACTION` | 502 | | `STRIPE_LEASE_LOST` | 409 | | `STRIPE_MODE_MISMATCH` | 400, 403 | | `STRIPE_NOT_CONFIGURED` | 503 | | `STRIPE_NUMERIC_RUNTIME` | 503 | | `STRIPE_PATH_DENIED` | 400 | | `STRIPE_REPORT_LIMIT` | 409 | | `STRIPE_RESPONSE_LIMIT` | 502 | | `STRIPE_RESTRICTED_KEY_REQUIRED` | 503 | | `STRIPE_TOTAL_CONFLICT` | 502 | | `STRIPE_UNREPRESENTABLE_AMOUNT` | 409 | | `SUBSCRIPTIONS_TOO_LARGE` | 422 | | `TAG_NOT_LINKED` | 409 | | `TAX_EXCEEDS_TOTAL` | 400 | | `TAX_REVIEW_INCOMPLETE` | 409 | | `TAX_REVIEW_REQUIRED` | 409 | | `TAX_SETTINGS_REQUIRED` | 400 | | `TAX_SOURCE_RECEIPT_DENIED` | 409 | | `TERMS_PENDING` | 409 | | `TEXT_LIMIT` | 400 | | `TIMEOUT` | 504 | | `TITLE_AGREEMENT_REQUIRED` | 409 | | `TITLE_EVIDENCE_REQUIRED` | 409 | | `TOO_LARGE` | 413 | | `TOO_MANY_INVITATIONS` | 409 | | `TOO_MANY_ROWS` | 400 | | `TOO_MANY_TRANSACTIONS` | 409 | | `TOO_SOON` | 409 | | `TOTALS_CHANGED` | 409 | | `TRANSCRIPT_TOO_LONG` | 422 | | `TRANSCRIPTION_FAILED` | 502 | | `UNAUTHORIZED` | 401 | | `UNAVAILABLE` | 503 | | `UNKNOWN_ACTION` | 400, 404 | | `UNKNOWN_TOOL` | 404 | | `UNPAID_WORK` | 409 | | `UNREADABLE` | 422 | | `UNREADABLE_EVIDENCE` | 409 | | `UNSUPPORTED_AUDIO` | 415 | | `UNSUPPORTED_RETRY_MODE` | 409 | | `UPLOAD_BATCH_CHANGED` | 409 | | `UPLOAD_FAILED` | 503 | | `UPLOAD_INCOMPLETE` | 409 | | `UPLOAD_INTEGRITY` | 409 | | `UPLOAD_LIMIT` | 429 | | `UPLOAD_UNAVAILABLE` | 503 | | `UPSTREAM_UNAVAILABLE` | 503 | | `VERIFICATION_EMAIL_UNAVAILABLE` | 503 | | `WAITLIST_NOT_NEEDED` | 409 | | `WAITLISTED` | 403 | | `WEBHOOK_KEY_CHANGED` | 503 | | `WEBHOOK_KEY_MISSING` | 503 | | `WEBHOOK_SECRET_INVALID` | 503 | | `WEBHOOK_URL_INVALID` | 400 | | `WISE_ACCOUNT` | 400 | | `WISE_ATTACHMENT_NOT_FOUND` | 404 | | `WISE_CURRENCY` | 502 | | `WISE_DOCUMENT_NOT_READY` | 404 | | `WISE_DOCUMENT_TOO_LARGE` | 502 | | `WISE_FORMAT` | 502 | | `WISE_FUNDING_PENDING` | 409 | | `WISE_NO_BALANCE` | 409 | | `WISE_NOT_CONFIGURED` | 409, 503 | | `WISE_NOT_READY` | 409 | | `WISE_PATH` | 400 | | `WISE_PERIOD_NOT_STARTED` | 409 | | `WISE_PRECISION` | 503 | | `WISE_PROFILE` | 400 | | `WISE_PROFILE_MISSING` | 409 | | `WISE_PROFILE_OWNER` | 403 | | `WISE_QUOTE_BLOCKED` | 409 | | `WISE_RECIPIENT` | 409 | | `WISE_RESPONSE` | 502 | | `WISE_SCA_REJECTED` | 502 | | `WISE_SETUP_REQUIRED` | 400, 409 | | `WISE_SOURCE_BINDING_PENDING` | 409 | | `WISE_STATUS_PENDING` | 409 | | `WISE_SYNC_CONFIG_CHANGED` | 409 | | `WISE_SYNC_LEASE_LOST` | 409 | | `WISE_TRANSFER` | 409, 503 | | `WISE_TRANSFER_DETAILS_REQUIRED` | 409 | | `WISE_UNAVAILABLE` | 502, 503 | | `WORKSPACE_PAUSED` | 403 | | `WRITE_OFF_TOO_LARGE` | 400 | # Idempotency and revisions > Retry without doing things twice, and never overwrite someone else's change. Source: https://app.getoatmilk.com/docs/idempotency Networks fail. A request can reach Oatmilk and its response can still be lost on the way back. Two tools make it safe to try again: idempotency keys stop a change from happening twice, and revisions stop you from overwriting a change you haven't seen. ## Idempotency keys Generate a unique key once for each change you intend to make, such as a UUID, and send it with the request. If you send the same request again with the same key, Oatmilk returns the first result instead of doing the work again. ```http Idempotency-Key: 2f1c8a52-5d6b-4f0e-9a1d-7c3e2b1a0f9d ``` You can send the key as the `Idempotency-Key` header or as the `idempotencyKey` field in the body. If you send both, they must match. Actions that change something list the key on their reference page, and most require it. - **Make one key per intended change**, not per attempt. Save it before you send the request so a crash can't lose it. - **Reuse it only for exactly the same input.** The same key with a different input is refused with a conflict, because it would be ambiguous which one you meant. - **Keys are per person.** Your key never collides with someone else's. Wise syncs keep the accepted account plan, time cutoff and original source bytes. Retrying the same request and key continues a partial sync; after it finishes, retries return its saved response. That response describes the original sync, even if the books have changed since then. Use a new key for a new sync, including the next history pass using `nextFrom`. Wise receipt work can return `receipts.status: "queued"`, with `imported: 0`, a queued count and job IDs. The receipt worker continues that work separately. A completed transaction sync does not mean those receipt jobs have finished. Exact Wise response replay applies to requests accepted by the resumable sync system. If a known key from an older sync conflicts, the error asks you to start a new sync with a fresh key. Existing originals, bank lines and allocations are preserved; Oatmilk cannot reconstruct an older request's unsaved time cutoff. > [!NOTE] > Creating an API key or a webhook endpoint returns its secret once. If you replay the creation with the same key you get the record again, but without the secret. Rotate it if you lost the first response. ## Revisions Records that people edit carry a `revision` number that goes up with every change. To change one, send the revision you last read as `expectedRevision`: ```bash curl https://app.getoatmilk.com/api/v1/accounting/webhooks.endpoints.update \ -H "Authorization: Bearer $OATMILK_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "id": "5d4c3b2a-1f0e-4d9c-8b7a-6e5f4d3c2b1a", "expectedRevision": 1, "events": [ "invoice.*", "party.*" ] }' ``` If someone changed the record after you read it, Oatmilk refuses with `STALE_REVISION` or `CONFLICT` and changes nothing. Read the record again, check whether your change still makes sense, and send it with the new revision. Never guess a revision or retry with a higher number: that would overwrite a change nobody has looked at. ## Together A safe write keeps its idempotency key across retries and reads the record again when its revision is out of date. `callOatmilk` is the helper from [Retrying safely](https://app.getoatmilk.com/docs/errors.md#retrying-safely), which throws the error code: ```js title="safe-update.js" const key = crypto.randomUUID(); let record = await callOatmilk("webhooks.endpoints.list", {}).then(result => result.items[0]); for (;;) { try { return await callOatmilk("webhooks.endpoints.update", { id: record.id, expectedRevision: record.revision, description: "CRM sync" }, key); } catch (error) { if (error.message !== "STALE_REVISION" && error.message !== "CONFLICT") throw error; record = await callOatmilk("webhooks.endpoints.list", {}).then(result => result.items.find(item => item.id === record.id)); } } ``` # Pagination > Read long lists a page at a time with offsets or cursors. Source: https://app.getoatmilk.com/docs/pagination Actions that return lists return one page at a time. They use one of two styles, and each action's reference page shows which fields it takes. ## Offset pages Most lists take `limit` and `offset`. Ask for the next page by adding the number of records you already have to `offset`, and stop when a page comes back shorter than `limit`. `callOatmilk` is the helper from [Retrying safely](https://app.getoatmilk.com/docs/errors.md#retrying-safely). ```bash curl -G https://app.getoatmilk.com/api/v1/accounting/entries.list \ -H "Authorization: Bearer $OATMILK_API_KEY" \ --data-urlencode 'limit=100' \ --data-urlencode 'offset=0' ``` ```js title="all-entries.js" const entries = []; for (let offset = 0; ; offset += 100) { const page = await callOatmilk("entries.list", { limit: 100, offset, from: "2026-09-01", to: "2026-09-30" }); entries.push(...page); if (page.length < 100) break; } ``` ## Cursor pages Lists that change quickly, such as `attention.mine` and `inbox.list`, return `items` with a `nextCursor`. Pass it back as `cursor` to get the next page. When `nextCursor` is `null`, you have everything. ```bash curl -G https://app.getoatmilk.com/api/v1/accounting/attention.mine \ -H "Authorization: Bearer $OATMILK_API_KEY" \ --data-urlencode 'limit=25' ``` ```json { "data": { "total": 1, "cards": [ "4242" ], "items": [ { "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10", "merchant": "Synthetic Office Supply", "date": "2026-09-18", "currency": "CAD", "amountMinor": "4520", "lastFour": "4242" } ], "nextCursor": null } } ``` Cursors don't skip or repeat records when new ones arrive between pages, which offsets can. ## Keep pages small Ask for what you need. A page of 100 is quick to read and keeps you well inside the [rate limit](https://app.getoatmilk.com/docs/rate-limits.md). Filter on the server with fields like `from`, `to`, `status` and `search` instead of downloading everything and filtering yourself. # Rate limits > How many requests you can send and what to do when you hit the limit. Source: https://app.getoatmilk.com/docs/rate-limits Each API key can send 120 requests per minute. The count is shared between the REST API and MCP, and starts again at the top of every minute (UTC). Contractor sign-ins have their own allowance of 120 requests per minute for each company. When you go over, Oatmilk answers `429` with a `RATE_LIMITED` error and a `Retry-After` header saying how many seconds to wait: ```http HTTP/1.1 429 Too Many Requests Retry-After: 17 Content-Type: application/json { "error": { "code": "RATE_LIMITED", "message": "Too many API requests. Retry after the indicated delay." } } ``` ## Staying under the limit - **Wait for `Retry-After`**, then retry with the same `Idempotency-Key`. Never retry in a tight loop. - **Use webhooks instead of polling.** A [webhook](https://app.getoatmilk.com/docs/webhooks.md) tells you the moment an invoice is paid, so you don't have to ask every few seconds. - **Filter on the server.** One request with `from`, `to` and `status` is cheaper than many unfiltered pages. - **Spread background work.** If a nightly job reads a lot, pace it rather than sending everything at once. If your integration needs more, tell us what it does and we'll look at it with you. # Money, dates and IDs > How amounts, currencies, dates and record IDs are written. Source: https://app.getoatmilk.com/docs/money-and-dates ## Amounts are cents, written as strings Every amount is a whole number of the currency's smallest unit, written as a string: `"1250"` is $12.50 in Canadian dollars. Strings keep large amounts exact in every language, where floating-point numbers can't. Fields that hold amounts end in `Minor`, such as `amountMinor` and `taxMinor`. ```json { "amountMinor": "113000", "currency": "CAD" } ``` Convert at the edges of your app, never in between: ```js const cents = BigInt("113000"); const dollars = new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" }).format(Number(cents) / 100); ``` ## Currencies Amounts always travel with a three-letter currency code, such as `CAD`, `USD` or `EUR`. Oatmilk never adds amounts in different currencies together: reports and totals come back per currency. ## Dates and times - **Dates** are `YYYY-MM-DD`, such as `2026-09-30`. A date range with `from` and `to` includes both days. - **Times** are ISO 8601 in UTC, such as `2026-09-30T14:00:00Z`. - **Webhook timestamps** (`created`) are Unix seconds. ## IDs Record IDs are UUIDs, such as `7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10`. Treat them as opaque text: don't parse them or build them yourself. Company IDs start with `org_` and people's IDs with `user_`. # Background jobs > Work that takes longer than a request, and how to know when it's done. Source: https://app.getoatmilk.com/docs/async-work Reading a receipt, matching a bank line or preparing an export can take longer than one request should. Actions that start this kind of work answer right away with the job's ID and its current status, and the work carries on in the background. ```json { "data": { "submissionId": "4d5e6f7a-8b9c-4d0e-8f1a-2b3c4d5e6f7a", "jobId": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e", "generation": 1, "status": "queued" } } ``` ## Following a job There are two ways to know when a job finishes: 1. **Ask.** Call the job's status action, such as `jobs.get` for receipts, every few seconds until `status` is `completed` or `failed`. Wait a little longer between each check. 2. **Listen.** Subscribe to a [webhook](https://app.getoatmilk.com/docs/webhooks.md), such as `receipt.processed`, and Oatmilk tells you when it's done. ```json { "data": { "id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e", "submission_id": "4d5e6f7a-8b9c-4d0e-8f1a-2b3c4d5e6f7a", "status": "queued", "stage": "preserve", "generation": 1, "attempts": 0, "error_code": null, "next_attempt_at": "2026-09-30T14:00:05Z" } } ``` ```json { "data": { "id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e", "submission_id": "4d5e6f7a-8b9c-4d0e-8f1a-2b3c4d5e6f7a", "status": "processing", "stage": "extract", "generation": 1, "attempts": 1, "error_code": null, "next_attempt_at": "2026-09-30T14:00:05Z" } } ``` ```json { "data": { "id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e", "submission_id": "4d5e6f7a-8b9c-4d0e-8f1a-2b3c4d5e6f7a", "status": "completed", "stage": "match", "generation": 1, "attempts": 1, "error_code": null, "next_attempt_at": "2026-09-30T14:00:05Z" } } ``` ## What the statuses mean | Status | Means | | --- | --- | | `queued` | Accepted and waiting its turn. Nothing is finished yet. | | `processing` | Being worked on now. `stage` says which step. | | `completed` | Done. Read the record to see the result. | | `failed` | Stopped after its retries. `error_code` says why; a person can retry it in Oatmilk. | > [!WARNING] > A queued or processing job hasn't changed your books yet. Don't tell a person their receipt is filed until its job is `completed`. # Versioning and changes > How the API changes, and how you'll know. Source: https://app.getoatmilk.com/docs/versioning Every response carries `X-Accounting-API-Version`, the date of the contract it follows. Today that's `2026-09-22`. ## What we change without notice These changes are backwards compatible, so build your integration to accept them: - new actions, and new optional input fields - new fields in responses - new webhook event types, and new fields in event data - new error codes, and clearer error messages Ignore fields you don't use, branch on error `code` rather than `message`, and subscribe to exact event names (or handle unknown types gracefully) if you use wildcards. ## What we don't change We don't remove or rename actions, fields or events, change their meaning, or make an optional field required without a new version date and notice to everyone with an active key or webhook. ## Staying current The [OpenAPI document](https://app.getoatmilk.com/api/openapi.json), these docs and the [llms.txt files](https://app.getoatmilk.com/docs/ai-tools.md) are generated from the code that runs the API, so they change in the same release as the API itself. # Usage and the request log > See how your integrations are doing, and find any request by its ID. Source: https://app.getoatmilk.com/docs/usage Oatmilk records every request made with an API key or an AI app, so you can see how an integration is doing and find out why a request failed without adding logging of your own. Open **Developers › Usage** for the trends and **Developers › Request log** for each request. ## What is recorded For each request Oatmilk keeps: - the request ID it returned in `X-Request-Id` - when it arrived and how long it took - the action, and whether it came through the REST API (`GET` or `POST`) or an AI app over MCP - the HTTP status and, for a failure, its error code - the API key it used, or the AI app and the person signed in to it Oatmilk never records what a request sent or what it answered, and never the key itself. Requests are kept for 30 days. A request refused before Oatmilk knows which company it's for, such as one with no key or a key that doesn't exist, isn't recorded anywhere. A request with a valid key that lacks a permission is recorded against that key, so you can see it was refused. ## Who sees what Administrators see every request made in the company. Everyone else sees the requests made with their own keys and their own AI app connections. ## Usage **Developers › Usage** shows the last day, 7 days or 30 days: - requests per day, split into those that worked, client errors (`4xx`) and server errors (`5xx`) - the error rate and the median and 95th percentile response times - the keys and apps that sent the most requests, the busiest actions and the most common error codes - for administrators, webhook deliveries: how many were delivered or failed each day, response times, each endpoint's success rate and why deliveries failed ## Find a request Paste an `X-Request-Id` into **Developers › Request log** to find that request. You can also filter the log by result, by key or app, and by whether it came through the REST API or MCP. Open a request to see its error code, with a link to what the code means, and the action's reference page. ## From your code The same figures are actions, so a monitoring job can read them with a key that has the `api_keys:manage` permission: ```bash curl "https://app.getoatmilk.com/api/v1/accounting/developers.usage?days=7" \ -H "Authorization: Bearer $OATMILK_API_KEY" curl "https://app.getoatmilk.com/api/v1/accounting/developers.requests.list?status=server_error&limit=10" \ -H "Authorization: Bearer $OATMILK_API_KEY" ``` Administrators can read webhook delivery figures with `webhooks.deliveries.stats`. See [developers.usage](https://app.getoatmilk.com/docs/api/developers.usage.md), [developers.requests.list](https://app.getoatmilk.com/docs/api/developers.requests.list.md) and [webhooks.deliveries.stats](https://app.getoatmilk.com/docs/api/webhooks.deliveries.stats.md) for every field. # Budgets and reserves > Plan spending and prepare for tax payments and year-end costs. Source: https://app.getoatmilk.com/docs/budgets ## See what you need to set aside Open **Finance › Budgets**, or ask Ask AI, “Do we have enough saved for HST and our accountant?” The `budgets.overview` action returns spending plans, upcoming costs, reserve accounts and funding gaps. Each amount keeps its currency. Filing dates and payment dates are separate. Tax dates come from the company's compliance checklist. Set the company's tax registration, reporting frequency and year-end in Tax info first. A reviewed GST/HST workpaper can supply a planning estimate. Corporate income tax and accountant fees need an estimate or a confirmed amount supplied by the company. Missing amounts remain unknown. ## Give an account a purpose In **Reserves**, choose an existing bank account and set its purpose, such as GST/HST or income tax. You can cap how much of its balance is set aside. With the API or an AI app, read `budgets.overview`, then pass the account's `purposeRevision` to `budgets.accounts.designate` as `expectedRevision`. This is a planning designation. It does not move money or change the bank account. Only available balances in the same currency count. Missing or stale balances need checking. Each balance is allocated once, in payment-date order, so two upcoming bills cannot both claim the same savings. ## Plan a cost Use `budgets.obligations.save` for taxes, accountant fees or another cost. Keep the amount's basis as **Estimate** until confirmed. A negative amount represents an expected refund; it does not fund another payment. An unknown amount is `null`. An automatic compliance plan can be replaced with a saved amount by supplying its `complianceItemId`. Recording `paidMinor` updates the plan; it does not send a payment or file a return. Funding checks add warnings to the existing Checklist within the plan's reminder window. The regular compliance process refreshes those checks, and the company's existing reminder settings decide whether reminders are emailed. Use `budgets.evaluate` to check again now. ## Track spending Use `budgets.save` to set a monthly, quarterly or yearly spending plan for one category or all expenses. Actuals use reviewed bookkeeping entries, deduct refunds and exclude transfers. Unreviewed spending is shown separately so an incomplete review does not look like unused budget. All writes require the current revision and an idempotency key. Ask AI uses the same actions and permissions, and reversible changes appear in History. Budgeting actions are available through the `budgets` MCP toolset to administrators and finance members. ## Plan recurring spending **Spending** also shows future costs from detected subscriptions and reviewed repeating purchases. Saved subscription amounts, renewal dates, paused or ended status, and accepted same-currency merges are reused. Each forecast shows its charge evidence, whether its amount is confirmed or based on the latest charge, and what still needs checking. Future charges are kept separate from booked spending, tax amounts and payment plans. A forecast does not create a bill or reserve money. Totals keep each currency separate; an unknown amount or schedule does not become zero. A newer purchase still awaiting review makes that source uncertain so another charge is not counted again. Complete category splits supply their spending amounts; proven balance-sheet portions stay out. Mixed or missing categories are shown as uncertain and do not suggest a category budget. Incomplete splits need review before another charge is projected. The evidence list contains recent charges and says when older charge IDs were left out; estimates still use the complete bounded history read. **Plan for this** opens a spending plan with the source's category, currency and full charge for its monthly, quarterly or yearly cycle. Review the amount and dates before saving. A yearly charge is not spread across monthly actuals. Ask AI can explain the evidence or help save the same plan. With the API or an AI app, request `budgets.overview` with `sections: ["recurring"]` to read only forecasts. The other choices are `due`, `spending` and `reserves`; no sections returns all of them. Each tab reads its own sources so spending plans can open while forecasts load. Funding checks always read the complete payment and spending sources and leave speculative forecasts out. # Webhooks > Get a signed HTTPS request the moment something happens in Oatmilk. Source: https://app.getoatmilk.com/docs/webhooks Webhooks tell your server when something happens: an invoice is paid, a document is signed, a receipt finishes processing, a deadline comes up. Instead of asking Oatmilk every few minutes, you give it an address and it sends you each event as it happens. ## How it works 1. You add an endpoint, an `https://` address on your server, and choose which events it receives. 2. When an event happens, Oatmilk sends a `POST` with the event as JSON, signed with your endpoint's secret. 3. Your server checks the signature, answers `2xx` quickly, and does the work afterwards. 4. If your server doesn't answer `2xx`, Oatmilk tries again later, waiting longer each time. ## Add an endpoint An administrator adds endpoints in **Developers › Webhooks**, or with the API. Subscribe to exact event names such as `invoice.paid`, to a group such as `invoice.*`, or to `*` for everything. ```bash curl https://app.getoatmilk.com/api/v1/accounting/webhooks.endpoints.create \ -H "Authorization: Bearer $OATMILK_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "url": "https://example.com/webhooks/oatmilk", "description": "CRM sync", "events": [ "invoice.*" ] }' ``` The response includes the endpoint's signing secret. It's shown once, so store it with your other secrets right away. ```json { "data": { "endpoint": { "id": "5d4c3b2a-1f0e-4d9c-8b7a-6e5f4d3c2b1a", "url": "https://example.com/webhooks/oatmilk", "description": "CRM sync", "events": [ "invoice.*" ], "secret_hint": "a1B2", "secret_version": 1, "active": true, "failure_count": 0, "disabled_reason": null, "disabled_at": null, "last_delivery_at": null, "last_success_at": null, "created_by": "user_synthetic", "archived_at": null, "revision": 1, "created_at": "2026-09-30T14:00:00Z", "updated_at": "2026-09-30T14:00:00Z", "status": "active" }, "secret": "whsec_synthetic_shown_once_store_it_now" } } ``` Endpoints must use `https://` on port 443 or 8443 with a public address. Private and local network addresses are refused, except `http://localhost` while you develop. ## What you receive Every delivery is a JSON event with the same envelope. `type` says what happened, `subject` names the record it happened to, and `data` holds the details for that type. ```http POST /webhooks/oatmilk HTTP/1.1 Host: example.com Content-Type: application/json User-Agent: Oatmilk-Webhooks/1.0 Oatmilk-Signature: t=1790000000,v1=054f6210bdad1839a948f5b1baa4c10b12f49d1268668775cd06aae44572ee04 Oatmilk-Event-Id: 5b5965f9-0d1e-4f2a-8b3c-4d5e6f7a8b9c Oatmilk-Event-Type: invoice.paid Oatmilk-Delivery-Id: 3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f Oatmilk-Delivery-Attempt: 1 { "id": "5b5965f9-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "invoice.paid", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "invoice", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "number": "INV-2026-012", "status": "paid", "partyId": "0b7a5d5e-3c34-4b8e-9e6a-5f8d0a1c2b3d", "currency": "CAD", "totalMinor": "113000", "amountPaidMinor": "113000", "balanceMinor": "0", "issueDate": "2026-09-01", "dueDate": "2026-10-01", "scheduledSendDate": null } } ``` | Field | Meaning | | --- | --- | | `id` | The event's unique ID. The same event always has the same ID, so use it to skip duplicates. | | `type` | What happened, such as `invoice.paid`. See the [event catalog](https://app.getoatmilk.com/docs/webhooks/events.md). | | `created` | When it happened, in Unix seconds. | | `organizationId` | The company it happened in. | | `subject` | The record it's about: `type` and `id`, or `null`. | | `data` | Details for this event type. Mail events never include the sender, subject or content. | Each delivery also carries these headers: | Header | Value | | --- | --- | | `Content-Type` | Always `application/json`. | | `User-Agent` | `Oatmilk-Webhooks/1.0`. | | `Oatmilk-Signature` | `t=,v1=`, checked as described in Verify signatures. | | `Oatmilk-Event-Id` | The event's ID, the same on every retry. Use it to skip duplicates. | | `Oatmilk-Event-Type` | The event type, such as `invoice.paid`. | | `Oatmilk-Delivery-Id` | This delivery's ID, as shown in the delivery log. | | `Oatmilk-Delivery-Attempt` | 1 for the first try, then 2, 3 and so on for retries. | ## Answer quickly Oatmilk waits 10 seconds for an answer. Check the signature, store the event, answer `200`, and do slow work, such as calling other services, afterwards in a queue. A slow answer counts as a failure and the event is sent again. ## Retries When your endpoint doesn't answer `2xx`, times out or can't be reached, Oatmilk tries again, up to 8 times in all: | Attempt | Sent | Time since the first try | | --- | --- | --- | | 1 | Right after the event | — | | 2 | 30 seconds after the last try | 30 seconds | | 3 | 1.5 minutes after the last try | 2 minutes | | 4 | 4.5 minutes after the last try | 6.5 minutes | | 5 | 13.5 minutes after the last try | 20 minutes | | 6 | 40.5 minutes after the last try | 1 h 1 min | | 7 | 2 h 2 min after the last try | 3 h 2 min | | 8 | 6 hours after the last try | 9 h 2 min | Redirects aren't followed, and `410 Gone` stops deliveries straight away. After 20 failed attempts in a row with no success in the last day, Oatmilk turns the endpoint off and emails your administrators. Fix it, turn it back on in Developers › Webhooks, and retry failed deliveries from the delivery log. ## Handle duplicates and order Deliveries are at least once: a retry, or a lost response, can bring the same event twice. Store each event `id` you've handled and skip repeats. Events can also arrive out of order, so when order matters, read the record from the API and act on its current state rather than on the order events arrived. ## Next - [Verify signatures](https://app.getoatmilk.com/docs/webhooks/signatures.md) before you trust a delivery. - Browse the [event catalog](https://app.getoatmilk.com/docs/webhooks/events.md) with a sample of every event. - [Test your endpoint](https://app.getoatmilk.com/docs/webhooks/testing.md) without waiting for real events. # Verify signatures > Check that a delivery came from Oatmilk and wasn't changed on the way. Source: https://app.getoatmilk.com/docs/webhooks/signatures Anyone can send a request to your endpoint, so check every delivery before you trust it. Oatmilk signs each one with your endpoint's secret and puts the signature in the `Oatmilk-Signature` header: ```http Oatmilk-Signature: t=1790000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd ``` - `t` is when the delivery was signed, in Unix seconds. - `v1` is an HMAC-SHA256 of the text `{t}.{raw body}`, keyed with your endpoint's secret, as hex. ## Check it 1. Read the **raw body** exactly as it arrived, before any JSON parsing. Parsing and re-serialising changes the bytes, and the signature won't match. 2. Split the header on commas, then each part on the first `=`, to get `t` and `v1`. 3. Refuse the delivery if `t` is more than 300 seconds from now. This stops someone replaying an old delivery. 4. Compute the HMAC of `{t}.{raw body}` with your secret and compare it to `v1` with a constant-time comparison. ```js title="verify.js" import { createHmac, timingSafeEqual } from "node:crypto"; export function verifyOatmilkSignature(rawBody, header, secret, toleranceSeconds = 300) { const parts = Object.fromEntries(header.split(",").map(part => part.trim().split("="))); const timestamp = Number(parts.t); if (!Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false; const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest(); const received = Buffer.from(parts.v1 ?? "", "hex"); return received.length === expected.length && timingSafeEqual(received, expected); } // Use the raw request body exactly as received, before JSON parsing: // verifyOatmilkSignature(body, request.headers.get("Oatmilk-Signature"), process.env.OATMILK_WEBHOOK_SECRET) ``` ```python title="verify.py" import hashlib import hmac import time def verify_oatmilk_signature(raw_body: bytes, header: str, secret: str, tolerance_seconds: int = 300) -> bool: parts = dict(part.strip().split("=", 1) for part in header.split(",") if "=" in part) try: timestamp = int(parts["t"]) except (KeyError, ValueError): return False if abs(time.time() - timestamp) > tolerance_seconds: return False signed = f"{timestamp}.".encode() + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, parts.get("v1", "")) ``` ```ts title="app/webhooks/oatmilk/route.ts" import { verifyOatmilkSignature } from "@/lib/oatmilk-signature"; export async function POST(request: Request) { const rawBody = await request.text(); const signature = request.headers.get("Oatmilk-Signature") ?? ""; if (!verifyOatmilkSignature(rawBody, signature, process.env.OATMILK_WEBHOOK_SECRET!)) { return new Response("Invalid signature", { status: 400 }); } const event = JSON.parse(rawBody); if (await alreadyHandled(event.id)) return new Response(null, { status: 200 }); await enqueue(event); return new Response(null, { status: 200 }); } ``` ## Try it here Paste a delivery's raw body, its `Oatmilk-Signature` header and your endpoint's secret to see whether it verifies, and why not if it doesn't. You can also sign a body with your own secret to send a test delivery to your server. Everything runs in your browser: nothing you type here is sent anywhere. In the HTML version of this page, a playground verifies a delivery or signs a test body with your own secret, entirely in the browser. ## When verification fails | Symptom | Likely cause | | --- | --- | | Every delivery fails | The wrong secret, or a framework parsed the body before you read it. | | Deliveries fail after you rotated the secret | Your server still uses the old secret. Rotation takes effect for the next delivery. | | Only some deliveries fail | A proxy or middleware is changing the body, such as re-encoding characters. | | Failures mention the time | Your server's clock is off by more than 300 seconds. Sync it with NTP. | ## Rotate a secret Rotate an endpoint's secret in Developers › Webhooks or with `webhooks.endpoints.rotateSecret`. The new secret is returned once and signs every delivery from then on, so update your server first, then rotate. # Event catalog > Every event Oatmilk sends, with a sample of each delivery. Source: https://app.getoatmilk.com/docs/webhooks/events These are the 53 events Oatmilk sends today, grouped by area. Pick one to see the exact delivery your endpoint would receive. The samples are built by the same code that sends real deliveries, with synthetic data. Subscribe to an exact name such as `invoice.paid`, to a group with `invoice.*`, or to everything with `*`. New event types may be added: handle types you don't recognise by answering `200` and ignoring them. ### Invoices #### `invoice.created` An invoice was created as a draft. ```json { "id": "bf4e5ff3-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "invoice.created", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "invoice", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "number": null, "status": "draft", "partyId": "0b7a5d5e-3c34-4b8e-9e6a-5f8d0a1c2b3d", "currency": "CAD", "totalMinor": "113000", "amountPaidMinor": "0", "balanceMinor": "113000", "issueDate": null, "dueDate": "2026-10-01", "scheduledSendDate": null } } ``` #### `invoice.updated` Invoice details changed. ```json { "id": "242a3bc2-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "invoice.updated", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "invoice", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "number": null, "status": "draft", "partyId": "0b7a5d5e-3c34-4b8e-9e6a-5f8d0a1c2b3d", "currency": "CAD", "totalMinor": "113000", "amountPaidMinor": "0", "balanceMinor": "113000", "issueDate": null, "dueDate": "2026-10-01", "scheduledSendDate": null } } ``` #### `invoice.review_requested` An automatically prepared invoice is waiting for an administrator's review. ```json { "id": "05df9dfe-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "invoice.review_requested", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "invoice", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "number": "INV-2026-012", "status": "awaiting_approval", "partyId": "0b7a5d5e-3c34-4b8e-9e6a-5f8d0a1c2b3d", "currency": "CAD", "totalMinor": "113000", "amountPaidMinor": "0", "balanceMinor": "113000", "issueDate": "2026-09-01", "dueDate": "2026-10-01", "scheduledSendDate": null } } ``` #### `invoice.approved` An administrator approved a prepared invoice for sending. ```json { "id": "517c257c-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "invoice.approved", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "invoice", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "number": "INV-2026-012", "status": "approved", "partyId": "0b7a5d5e-3c34-4b8e-9e6a-5f8d0a1c2b3d", "currency": "CAD", "totalMinor": "113000", "amountPaidMinor": "0", "balanceMinor": "113000", "issueDate": "2026-09-01", "dueDate": "2026-10-01", "scheduledSendDate": null } } ``` #### `invoice.issued` An invoice received its number and became final. ```json { "id": "88423668-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "invoice.issued", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "invoice", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "number": "INV-2026-012", "status": "approved", "partyId": "0b7a5d5e-3c34-4b8e-9e6a-5f8d0a1c2b3d", "currency": "CAD", "totalMinor": "113000", "amountPaidMinor": "0", "balanceMinor": "113000", "issueDate": "2026-09-01", "dueDate": "2026-10-01", "scheduledSendDate": null } } ``` #### `invoice.sent` An invoice was emailed to the customer or marked as sent. ```json { "id": "5b5b1cd5-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "invoice.sent", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "invoice", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "number": "INV-2026-012", "status": "sent", "partyId": "0b7a5d5e-3c34-4b8e-9e6a-5f8d0a1c2b3d", "currency": "CAD", "totalMinor": "113000", "amountPaidMinor": "0", "balanceMinor": "113000", "issueDate": "2026-09-01", "dueDate": "2026-10-01", "scheduledSendDate": null, "method": "email", "automatic": false } } ``` #### `invoice.payment_recorded` A payment was recorded against an invoice. ```json { "id": "8947d320-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "invoice.payment_recorded", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "invoice", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "number": "INV-2026-012", "status": "partially_paid", "partyId": "0b7a5d5e-3c34-4b8e-9e6a-5f8d0a1c2b3d", "currency": "CAD", "totalMinor": "113000", "amountPaidMinor": "50000", "balanceMinor": "63000", "issueDate": "2026-09-01", "dueDate": "2026-10-01", "scheduledSendDate": null, "paymentId": "3e4f5a6b-7c8d-4e9f-8a0b-1c2d3e4f5a6b", "paymentAmountMinor": "50000" } } ``` #### `invoice.payment_removed` A recorded payment was removed from an invoice. ```json { "id": "4aaf716a-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "invoice.payment_removed", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "invoice", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "number": "INV-2026-012", "status": "sent", "partyId": "0b7a5d5e-3c34-4b8e-9e6a-5f8d0a1c2b3d", "currency": "CAD", "totalMinor": "113000", "amountPaidMinor": "0", "balanceMinor": "113000", "issueDate": "2026-09-01", "dueDate": "2026-10-01", "scheduledSendDate": null, "paymentId": "3e4f5a6b-7c8d-4e9f-8a0b-1c2d3e4f5a6b", "paymentAmountMinor": "50000" } } ``` #### `invoice.partially_paid` An invoice is partly paid and a balance remains. ```json { "id": "89abb6ea-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "invoice.partially_paid", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "invoice", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "number": "INV-2026-012", "status": "partially_paid", "partyId": "0b7a5d5e-3c34-4b8e-9e6a-5f8d0a1c2b3d", "currency": "CAD", "totalMinor": "113000", "amountPaidMinor": "50000", "balanceMinor": "63000", "issueDate": "2026-09-01", "dueDate": "2026-10-01", "scheduledSendDate": null } } ``` #### `invoice.paid` An invoice is paid in full. ```json { "id": "5b5965f9-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "invoice.paid", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "invoice", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "number": "INV-2026-012", "status": "paid", "partyId": "0b7a5d5e-3c34-4b8e-9e6a-5f8d0a1c2b3d", "currency": "CAD", "totalMinor": "113000", "amountPaidMinor": "113000", "balanceMinor": "0", "issueDate": "2026-09-01", "dueDate": "2026-10-01", "scheduledSendDate": null } } ``` #### `invoice.overdue` An invoice passed its due date with a balance remaining. ```json { "id": "6466ebf5-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "invoice.overdue", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "invoice", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "number": "INV-2026-012", "status": "overdue", "partyId": "0b7a5d5e-3c34-4b8e-9e6a-5f8d0a1c2b3d", "currency": "CAD", "totalMinor": "113000", "amountPaidMinor": "0", "balanceMinor": "113000", "issueDate": "2026-09-01", "dueDate": "2026-10-01", "scheduledSendDate": null } } ``` #### `invoice.voided` An invoice was voided. ```json { "id": "a6472076-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "invoice.voided", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "invoice", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "number": "INV-2026-012", "status": "void", "partyId": "0b7a5d5e-3c34-4b8e-9e6a-5f8d0a1c2b3d", "currency": "CAD", "totalMinor": "113000", "amountPaidMinor": "0", "balanceMinor": "113000", "issueDate": "2026-09-01", "dueDate": "2026-10-01", "scheduledSendDate": null } } ``` #### `invoice.status_changed` An invoice moved to a different status. Sent with the more specific event for the new status. ```json { "id": "64e730e8-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "invoice.status_changed", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "invoice", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "number": "INV-2026-012", "status": "paid", "partyId": "0b7a5d5e-3c34-4b8e-9e6a-5f8d0a1c2b3d", "currency": "CAD", "totalMinor": "113000", "amountPaidMinor": "113000", "balanceMinor": "0", "issueDate": "2026-09-01", "dueDate": "2026-10-01", "scheduledSendDate": null } } ``` ### Accounts #### `party.created` A customer or vendor account was added. ```json { "id": "d68f1ad6-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "party.created", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "party", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "name": "Synthetic Ventures Inc.", "kind": "customer", "jurisdiction": "CA-ON", "currency": "CAD", "archived": false } } ``` #### `party.updated` Account details changed. ```json { "id": "3b6af6a5-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "party.updated", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "party", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "name": "Synthetic Ventures Inc.", "kind": "customer", "jurisdiction": "CA-ON", "currency": "CAD", "archived": false } } ``` #### `party.archived` An account was archived. ```json { "id": "cbbd66c4-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "party.archived", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "party", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "name": "Synthetic Ventures Inc.", "kind": "customer", "jurisdiction": "CA-ON", "currency": "CAD", "archived": true } } ``` ### Signatures #### `signing.envelope.sent` A document was sent for signature. The subject is the envelope; subjectType and subjectId name the record it belongs to. ```json { "id": "075b4f43-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "signing.envelope.sent", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "envelope", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "title": "Synthetic services agreement", "subjectType": "contractor", "subjectId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "signers": 2 } } ``` #### `signing.envelope.viewed` A signer opened the document. ```json { "id": "52af32ad-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "signing.envelope.viewed", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "envelope", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "recipientId": "5d4c3b2a-1f0e-4d9c-8b7a-6e5f4d3c2b1a" } } ``` #### `signing.envelope.signed` A signer finished signing. ```json { "id": "4bb0ab43-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "signing.envelope.signed", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "envelope", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "recipientId": "5d4c3b2a-1f0e-4d9c-8b7a-6e5f4d3c2b1a", "completed": false } } ``` #### `signing.envelope.completed` Everyone signed and the completed PDF is ready. ```json { "id": "60666626-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "signing.envelope.completed", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "envelope", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "title": "Synthetic services agreement", "subjectType": "contractor", "subjectId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "finalSha256": "9f2b5c1d7e3a4b6c8d0e2f4a6b8c0d2e4f6a8b0c2d4e6f8a0b2c4d6e8f0a2b4c" } } ``` #### `signing.envelope.declined` A signer declined to sign. ```json { "id": "e54db1c1-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "signing.envelope.declined", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "envelope", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "recipientId": "5d4c3b2a-1f0e-4d9c-8b7a-6e5f4d3c2b1a" } } ``` #### `signing.envelope.changes_requested` A signer suggested changes before signing. Signing waits until the sender sends a revised version or keeps the document as it is. ```json { "id": "4fac8213-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "signing.envelope.changes_requested", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "envelope", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "recipientId": "5d4c3b2a-1f0e-4d9c-8b7a-6e5f4d3c2b1a", "requestId": "6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d", "changes": 2, "comments": 1 } } ``` #### `signing.envelope.revised` The sender answered suggested changes with a revised version. The subject is the old envelope, which is cancelled as replaced; revisedEnvelopeId is the new one sent for signature. ```json { "id": "6c84221b-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "signing.envelope.revised", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "envelope", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "title": "Synthetic services agreement", "subjectType": "contractor", "subjectId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "revisedEnvelopeId": "7b8c9d0e-1f2a-4b3c-8d4e-5f6a7b8c9d0e", "revision": 2, "accepted": 2, "rejected": 1 } } ``` #### `signing.envelope.voided` The sender voided the document. ```json { "id": "531da664-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "signing.envelope.voided", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "envelope", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "title": "Synthetic services agreement", "subjectType": "contractor", "subjectId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" } } ``` #### `signing.envelope.expired` The signing window ended before everyone signed. ```json { "id": "afb478fa-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "signing.envelope.expired", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "envelope", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "title": "Synthetic services agreement", "subjectType": "contractor", "subjectId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" } } ``` ### Compliance #### `compliance.item.created` A checklist item was added. ```json { "id": "e49d1e1e-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "compliance.item.created", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "compliance_item", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "ruleKey": "gst_hst_return", "title": "GST/HST return", "dueDate": "2026-10-31", "status": "upcoming", "category": "sales_tax" } } ``` #### `compliance.item.assigned` A checklist item's owner changed. ```json { "id": "0de97794-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "compliance.item.assigned", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "compliance_item", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "ruleKey": "gst_hst_return", "title": "GST/HST return", "dueDate": "2026-10-31", "status": "upcoming", "assigneeUserId": "user_synthetic" } } ``` #### `compliance.item.due_soon` A checklist item was included in a reminder before its due date. ```json { "id": "4b4cdc02-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "compliance.item.due_soon", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "compliance_item", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "ruleKey": "gst_hst_return", "title": "GST/HST return", "dueDate": "2026-10-31", "daysUntil": 14, "digestKey": "gst_hst_return:2026-10-31" } } ``` #### `compliance.item.overdue` A checklist item passed its due date. ```json { "id": "89b5aa20-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "compliance.item.overdue", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "compliance_item", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "ruleKey": "gst_hst_return", "title": "GST/HST return", "dueDate": "2026-10-31", "daysUntil": -2, "digestKey": "gst_hst_return:2026-10-31" } } ``` #### `compliance.item.completed` A checklist item was marked done. ```json { "id": "45d673e3-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "compliance.item.completed", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "compliance_item", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "ruleKey": "gst_hst_return", "title": "GST/HST return", "dueDate": "2026-10-31", "status": "done" } } ``` #### `compliance.item.skipped` A checklist item was marked not applicable. ```json { "id": "a43d5876-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "compliance.item.skipped", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "compliance_item", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "ruleKey": "gst_hst_return", "title": "GST/HST return", "dueDate": "2026-10-31", "status": "skipped" } } ``` #### `compliance.item.snoozed` A checklist item was snoozed. ```json { "id": "aba90388-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "compliance.item.snoozed", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "compliance_item", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "ruleKey": "gst_hst_return", "title": "GST/HST return", "dueDate": "2026-10-31", "status": "upcoming", "snoozedUntil": "2026-10-15" } } ``` #### `compliance.item.reopened` A completed checklist item was reopened. ```json { "id": "7fc23838-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "compliance.item.reopened", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "compliance_item", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "ruleKey": "gst_hst_return", "title": "GST/HST return", "dueDate": "2026-10-31", "status": "upcoming" } } ``` #### `compliance.reminder.sent` A reminder email was queued for administrators. ```json { "id": "bc71c767-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "compliance.reminder.sent", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "compliance_digest", "id": "gst_hst_return:2026-10-31" }, "data": { "groupKey": "gst_hst_return:2026-10-31", "itemIds": [ "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f" ], "recipientCount": 2, "trigger": "schedule", "emailId": "4b5c6d7e-8f9a-4b0c-8d1e-2f3a4b5c6d7e" } } ``` #### `compliance.kickoff.started` Tax preparation started automatically. ```json { "id": "6031a16b-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "compliance.kickoff.started", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "compliance_item", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "kind": "corporate", "from": "2025-10-01", "to": "2026-09-30", "runId": "3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f", "trigger": "schedule" } } ``` #### `compliance.kickoff.completed` The tax preparation workspace is ready. ```json { "id": "e48f84f1-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "compliance.kickoff.completed", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "compliance_item", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "kind": "corporate", "from": "2025-10-01", "to": "2026-09-30", "runId": "3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f", "followUps": 3, "emailQueued": true } } ``` ### Contractors #### `contractor.profile.submitted` A contractor submitted their profile. ```json { "id": "bf2c169d-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "contractor.profile.submitted", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "contractor", "id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" }, "data": { "contractorId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" } } ``` #### `contractor.payment_profile.updated` A contractor's payment method changed. Account details aren't included. ```json { "id": "dbabbb50-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "contractor.payment_profile.updated", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "contractor", "id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" }, "data": { "contractorId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "method": "wise_email", "currency": "CAD", "source": "contractor" } } ``` #### `contractor.hours.submitted` A contractor submitted hours for approval. ```json { "id": "af7232fd-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "contractor.hours.submitted", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "contractor", "id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" }, "data": { "contractorId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "periodId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "periodStart": "2026-09-01", "periodEnd": "2026-09-15", "submittedMinutes": 2250 } } ``` #### `contractor.hours.approved` Submitted hours were approved. ```json { "id": "abbba00d-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "contractor.hours.approved", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "contractor", "id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" }, "data": { "contractorId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "periodId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "approvedEntries": 5, "status": "approved" } } ``` #### `contractor.payout.prepared` A payout was prepared from approved hours and is waiting for approval. ```json { "id": "1704c2d0-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "contractor.payout.prepared", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "contractor_payout", "id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e" }, "data": { "contractorId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "payoutId": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e", "periodId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "totalMinor": "450000", "currency": "CAD", "status": "pending_approval" } } ``` #### `contractor.payout.approved` An administrator approved a payout. ```json { "id": "bf097e5e-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "contractor.payout.approved", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "contractor_payout", "id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e" }, "data": { "contractorId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "payoutId": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e", "periodId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "totalMinor": "450000", "currency": "CAD", "status": "approved" } } ``` #### `contractor.payout.sent` A payout was sent through the payment provider. ```json { "id": "2dab54b7-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "contractor.payout.sent", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "contractor_payout", "id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e" }, "data": { "contractorId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "payoutId": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e", "periodId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "totalMinor": "450000", "currency": "CAD", "status": "sent", "environment": "production" } } ``` #### `contractor.payout.paid` A payout was marked as paid. ```json { "id": "2da99ddb-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "contractor.payout.paid", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "contractor_payout", "id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e" }, "data": { "contractorId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "payoutId": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e", "periodId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "totalMinor": "450000", "currency": "CAD", "status": "paid_manually" } } ``` #### `contractor.payout.failed` A payout could not be completed. ```json { "id": "273519c2-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "contractor.payout.failed", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "contractor_payout", "id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e" }, "data": { "contractorId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "payoutId": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e", "errorCode": "WISE_TRANSFER_RETURNED", "totalMinor": "450000", "currency": "CAD" } } ``` #### `contractor.payout.cancelled` A payout was cancelled. ```json { "id": "f3aa8578-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "contractor.payout.cancelled", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "contractor_payout", "id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e" }, "data": { "contractorId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "payoutId": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e", "periodId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "totalMinor": "450000", "currency": "CAD", "status": "cancelled" } } ``` #### `contractor.agreement.imported` A signed contractor agreement was uploaded. ```json { "id": "d83680d7-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "contractor.agreement.imported", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "contractor", "id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" }, "data": { "contractorId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "envelopeId": "5d4c3b2a-1f0e-4d9c-8b7a-6e5f4d3c2b1a", "executedOn": "2026-09-01" } } ``` #### `contractor.agreement.sent` A contractor agreement was prepared or sent for signature. ```json { "id": "c719384d-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "contractor.agreement.sent", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "contractor", "id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" }, "data": { "contractorId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "envelopeId": "5d4c3b2a-1f0e-4d9c-8b7a-6e5f4d3c2b1a", "sent": true } } ``` ### Receipts and mail #### `receipt.processed` A receipt finished processing and is ready for review. ```json { "id": "55e6b5c2-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "receipt.processed", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "submission", "id": "4d5e6f7a-8b9c-4d0e-8f1a-2b3c4d5e6f7a" }, "data": { "submissionId": "4d5e6f7a-8b9c-4d0e-8f1a-2b3c4d5e6f7a", "entries": [ { "merchant": "Synthetic Office Supply", "date": "2026-09-18", "currency": "CAD", "amountMinor": "4520", "categoryId": "5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b", "splits": [] } ], "flags": [] } } ``` #### `mail.received` An email to one of your Oatmilk addresses passed screening and reached the company inbox. Sender, subject, and content aren't included. ```json { "id": "2fde0a18-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "mail.received", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "mail", "id": "6f7a8b9c-0d1e-4f2a-8b3c-4d5e6f7a8b9c" }, "data": { "messageId": "6f7a8b9c-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "purpose": "financial", "mailbox": "invoices" } } ``` ### Suggestions #### `proposal.created` A suggested change is waiting for review. ```json { "id": "9008d816-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "proposal.created", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "invoice", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "proposalId": "8b9c0d1e-2f3a-4b4c-8d5e-6f7a8b9c0d1e", "subjectType": "invoice", "subjectId": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10", "field": "status", "proposedValue": { "status": "paid" }, "source": "rule", "confidence": "high" } } ``` #### `proposal.decided` A person approved or declined a suggested change. ```json { "id": "be8ff2a0-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "proposal.decided", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "invoice", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "proposalId": "8b9c0d1e-2f3a-4b4c-8d5e-6f7a8b9c0d1e", "subjectType": "invoice", "subjectId": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10", "field": "status", "decision": "approve", "status": "applied" } } ``` ### Connectors #### `notion.sync.completed` A Notion sync finished. ```json { "id": "2c2c186d-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "notion.sync.completed", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "connector", "id": "notion" }, "data": { "runId": "9c0d1e2f-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "direction": "both", "read": 4, "compared": 3, "pushed": 3, "created": 1, "unchanged": 0, "proposals": 1, "suggestedLinks": 0, "errors": 0, "queued": 0 } } ``` ## The test event `webhook.test` is sent when you press **Send a test event** in Developers › Webhooks or call `webhooks.test` without an event type. It reaches every endpoint whatever its subscriptions, and has `"test": true` at the top level so you can tell it apart. A test of a catalog event has the same shape as the real one, with `"test": true` in its envelope and its `data`. # Test your endpoint > Send test events, replay deliveries and develop on your own computer. Source: https://app.getoatmilk.com/docs/webhooks/testing You don't have to wait for an invoice to be paid to know your endpoint works. ## Send a test event In **Developers › Webhooks**, open an endpoint and choose **Send a test event**. Or ask the API, optionally with a catalog event to get a realistic sample: ```bash curl https://app.getoatmilk.com/api/v1/accounting/webhooks.test \ -H "Authorization: Bearer $OATMILK_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "endpointId": "5d4c3b2a-1f0e-4d9c-8b7a-6e5f4d3c2b1a", "eventType": "invoice.paid" }' ``` Test deliveries are signed like real ones and appear in the delivery log, but they never count towards turning an endpoint off. ## Watch the delivery log Every delivery, with its payload, your server's status code, how long it took and a short excerpt of its answer, is in the delivery log in Developers › Webhooks and in `webhooks.deliveries.list`. Delivery rates and response times over time are in **Developers › Usage** and `webhooks.deliveries.stats`. Retry a failed delivery from there, or with `webhooks.deliveries.retry`, once your server is fixed. ```bash curl -G https://app.getoatmilk.com/api/v1/accounting/webhooks.deliveries.list \ -H "Authorization: Bearer $OATMILK_API_KEY" \ --data-urlencode 'status=failed' \ --data-urlencode 'limit=20' ``` ## Develop on your own computer While you develop, an endpoint can point at `http://localhost`. To receive real deliveries from staging, expose your local server with a tunnel such as `ngrok` or `cloudflared` and add its `https://` address as an endpoint. To test your signature check without Oatmilk at all, sign a sample with your own secret in the [signature playground](https://app.getoatmilk.com/docs/webhooks/signatures.md#try-it-here) and send it with the `curl` command it gives you. The timestamp is current, so your tolerance check passes. ## A checklist before going live - The endpoint answers `2xx` within a few seconds, before slow work. - It verifies `Oatmilk-Signature` against the raw body and refuses anything older than 300 seconds. - It stores each event `id` and skips duplicates. - It reads the record from the API when the order of events matters. - It answers `200` to event types it doesn't recognise. - The signing secret is stored like a password, and rotating it is a documented step. # Connect your AI app > Use Oatmilk from Claude, ChatGPT and other AI apps. No code needed. Source: https://app.getoatmilk.com/docs/connect Connect Oatmilk to the AI app you already use, then ask it about your books in plain words: what needs you this week, which purchases still need a receipt, or what's due this month. It takes about a minute, and the app signs in to Oatmilk as you, so it can only see and do what you can. Choose your app and follow its steps: ### Claude Claude on the web, the desktop app and phones, including Cowork. 1. Claude opens Add custom connector with Oatmilk filled in. Choose Add. 2. Choose Connect and sign in to Oatmilk, then approve the access it asks for. 3. In a chat, ask Claude to add a receipt or show what needs you. Cards open right in the conversation. [Add to Claude](https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=Oatmilk&connectorUrl=https%3A%2F%2Fapp.getoatmilk.com%2Fapi%2Fmcp) [Download the Oatmilk plugin](https://app.getoatmilk.com/api/plugin/oatmilk.zip) ### ChatGPT ChatGPT on the web and in the desktop app. 1. In ChatGPT, turn on Developer mode in Settings › Security and login. 2. Open Plugins, choose +, name it Oatmilk and paste the connection address. 3. Sign in to Oatmilk when ChatGPT asks, then start a chat with Oatmilk. 4. For Oatmilk's guides in desktop Work mode or Codex, download and unzip the plugin. Run codex plugin marketplace add ./oatmilk from its parent folder, then restart the app. In Plugins Directory, choose Oatmilk and install. Connection address: `https://app.getoatmilk.com/api/mcp` [Download the Oatmilk plugin](https://app.getoatmilk.com/api/plugin/oatmilk.zip) ### Claude Code The plugin adds Oatmilk and its guides to Claude Code. 1. Run these two commands in Claude Code. 2. Run /mcp, choose oatmilk and sign in to Oatmilk. ```bash /plugin marketplace add https://app.getoatmilk.com/api/plugin/marketplace.json /plugin install oatmilk@oatmilk ``` ### Codex The Codex app, CLI and IDE extension. 1. Run these two commands in a terminal; the second signs you in to Oatmilk. 2. For Oatmilk's guides too, download and unzip the plugin. From its parent folder, run codex plugin marketplace add ./oatmilk, then codex plugin add oatmilk@oatmilk. Restart Codex and sign in when asked. ```bash codex mcp add oatmilk --url https://app.getoatmilk.com/api/mcp codex mcp login oatmilk ``` [Download the Oatmilk plugin](https://app.getoatmilk.com/api/plugin/oatmilk.zip) ### Cursor Opens Cursor with Oatmilk ready to add. 1. Cursor asks you to confirm adding Oatmilk. 2. Choose Connect next to oatmilk in Settings › MCP and sign in to Oatmilk. [Add to Cursor](cursor://anysphere.cursor-deeplink/mcp/install?name=oatmilk&config=eyJ1cmwiOiJodHRwczovL2FwcC5nZXRvYXRtaWxrLmNvbS9hcGkvbWNwIn0%3D) ### VS Code GitHub Copilot in VS Code. 1. VS Code asks you to confirm adding Oatmilk. 2. Start the server and sign in to Oatmilk when asked. [Add to VS Code](vscode:mcp/install?%7B%22name%22%3A%22oatmilk%22%2C%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Fapp.getoatmilk.com%2Fapi%2Fmcp%22%7D) ### Gemini CLI Google's Gemini CLI in your terminal. 1. Add this to the mcpServers in ~/.gemini/settings.json. 2. Run /mcp auth oatmilk in Gemini CLI and sign in to Oatmilk. ```json { "mcpServers": { "oatmilk": { "httpUrl": "https://app.getoatmilk.com/api/mcp" } } } ``` ### Another app Any app that connects to remote MCP servers. 1. Add a remote MCP server (Streamable HTTP) with the connection address. 2. Sign in with your Oatmilk account, or send an Oatmilk API key as a Bearer token. 3. Apps with a tool limit can add ?toolset=autopilot,inbox (or contractor for contractors) to the address. Connection address: `https://app.getoatmilk.com/api/mcp` ### Then try asking - "What needs my attention in Oatmilk this week?" - "Which of my card purchases still need a receipt? Here are the receipts." - "Are any compliance deadlines coming up this month?" ## What it can do The app works with the same actions as Oatmilk itself: it can find what needs you, add receipts, draft an invoice or check upcoming deadlines. Some steps always come back to you in Oatmilk, such as approving a suggestion, signing an agreement or sending money. When that happens, the app hands you a link to the exact page. > [!TIP] > Signed in to Oatmilk? The same steps are in your account menu, at the top right of the app: **Connect to Claude**, **Connect to ChatGPT** and **Connect other AI apps**. ## For developers To connect an agent or your own client, choose tools with toolsets, or send an API key instead of signing in, see [MCP server](https://app.getoatmilk.com/docs/mcp.md). # MCP server > Connect Claude, ChatGPT, Cursor and other MCP clients and agents to a company's books, safely. Source: https://app.getoatmilk.com/docs/mcp Oatmilk runs a [Model Context Protocol](https://modelcontextprotocol.io) server, so AI assistants can work in Oatmilk with the same actions and the same permissions as the person using them. An assistant can find what needs a receipt, add one, reconcile a transaction or draft an invoice, and it can never do more than that person could in the app. ``` https://app.getoatmilk.com/api/mcp ``` > [!TIP] > Not a developer? [Connect your AI app](https://app.getoatmilk.com/docs/connect.md) walks you through Claude and ChatGPT in about a minute, with no code. ## Connect a client Most clients sign in with OAuth: add the address, sign in to Oatmilk when the client asks, and approve what it may do. Clients that can't sign in can send an API key as a bearer token instead. **Claude.** Add to Claude opens Claude's Add custom connector form with Oatmilk filled in. You can also add a custom connector with this address in Settings › Connectors. Sign in to Oatmilk when asked. Cards open right in the chat on the web, desktop and phone apps. [Add to Claude](https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=Oatmilk&connectorUrl=https%3A%2F%2Fapp.getoatmilk.com%2Fapi%2Fmcp) ```text https://app.getoatmilk.com/api/mcp ``` **ChatGPT.** Turn on Developer mode in Settings › Security and login, open Plugins, choose + and add this address. Sign in to Oatmilk when ChatGPT asks. For the guides in desktop Work mode or Codex, download and unzip the plugin, run codex plugin marketplace add ./oatmilk from its parent folder, then restart the app. In Plugins Directory, choose Oatmilk and install. [Download the Oatmilk plugin](https://app.getoatmilk.com/api/plugin/oatmilk.zip) ```text https://app.getoatmilk.com/api/mcp ``` **Claude Code.** Install the Oatmilk plugin, which adds the server and guides for receipts, bookkeeping, year-end, company details and contractor hours. Then run /mcp in Claude Code to sign in. ```bash claude plugin marketplace add https://app.getoatmilk.com/api/plugin/marketplace.json claude plugin install oatmilk@oatmilk ``` **Codex.** Add the server from your terminal. The second command signs you in to Oatmilk. For the guides too, download and unzip the plugin, then run codex plugin marketplace add ./oatmilk and codex plugin add oatmilk@oatmilk from its parent folder. Restart Codex and sign in when asked. [Download the Oatmilk plugin](https://app.getoatmilk.com/api/plugin/oatmilk.zip) ```bash codex mcp add oatmilk --url https://app.getoatmilk.com/api/mcp codex mcp login oatmilk ``` **Cursor.** Add to Cursor asks you to confirm, or add the server to your project's or your global MCP settings. Cursor asks you to sign in the first time. [Add to Cursor](cursor://anysphere.cursor-deeplink/mcp/install?name=oatmilk&config=eyJ1cmwiOiJodHRwczovL2FwcC5nZXRvYXRtaWxrLmNvbS9hcGkvbWNwIn0%3D) ```json title=".cursor/mcp.json" { "mcpServers": { "oatmilk": { "url": "https://app.getoatmilk.com/api/mcp" } } } ``` **Gemini CLI.** Add the server to your Gemini CLI settings. Gemini CLI asks you to sign in the first time. ```json title="~/.gemini/settings.json" { "mcpServers": { "oatmilk": { "httpUrl": "https://app.getoatmilk.com/api/mcp" } } } ``` **opencode.** Add the server to your project's or your global opencode settings. opencode asks you to sign in the first time it uses a tool, or run opencode mcp auth oatmilk. To use an API key instead, add headers with Authorization set to Bearer {env:OATMILK_API_KEY}. ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", "mcp": { "oatmilk": { "type": "remote", "url": "https://app.getoatmilk.com/api/mcp", "enabled": true } } } ``` **Hermes Agent.** Add the server to your Hermes settings, keep the key in ~/.hermes/.env, then start Hermes or run /reload-mcp. The key's permissions decide which tools work. ```yaml title="~/.hermes/config.yaml" mcp_servers: oatmilk: url: "https://app.getoatmilk.com/api/mcp" headers: Authorization: "Bearer ${OATMILK_API_KEY}" enabled: true ``` **VS Code.** Add to VS Code asks you to confirm, or add the server to your workspace. VS Code asks you to sign in the first time it starts. [Add to VS Code](vscode:mcp/install?%7B%22name%22%3A%22oatmilk%22%2C%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Fapp.getoatmilk.com%2Fapi%2Fmcp%22%7D) ```json title=".vscode/mcp.json" { "servers": { "oatmilk": { "type": "http", "url": "https://app.getoatmilk.com/api/mcp" } } } ``` **Oatmilk CLI.** For an agent that only starts local commands: the Oatmilk command line serves the same tools over stdio, signed in with oatmilk login or OATMILK_API_KEY. ```json title="mcp.json" { "mcpServers": { "oatmilk": { "command": "npx", "args": [ "-y", "@getoatmilk/cli", "mcp" ] } } } ``` **With an API key.** For a client or your own agent that can't sign in, send an API key as a bearer token. The key's permissions decide which tools work. ```json title="mcp.json" { "mcpServers": { "oatmilk": { "url": "https://app.getoatmilk.com/api/mcp", "headers": { "Authorization": "Bearer ${OATMILK_API_KEY}" } } } } ``` ## Interactive cards In apps that show them (Claude, ChatGPT, VS Code and others that support MCP Apps), some tools open a card right in the chat instead of answering in text. Each card does one job, checks the same permissions as the app, and saves through the ordinary tools, so a client without cards can still do everything a card does. | Card | Tool | What it does | | --- | --- | --- | | Needs you | `oatmilk_needs_you` | Everything waiting on the person, with one action each that opens the right card or page. | | Add receipts | `oatmilk_add_receipts` | Take a photo or choose receipts, optionally for a purchase that needs one, and watch Oatmilk read them. | | Questions from Oatmilk | `oatmilk_answer_questions` | Oatmilk's questions about charges, answered one at a time. | | File documents | `oatmilk_file_documents` | Upload any document, see what Oatmilk thinks it is, and file it in the right place. | | Company profile | `oatmilk_company_profile` | See the company's details and where each came from; administrators edit them or fill them from incorporation papers. | | Tax preparation | `oatmilk_tax_prep` | The year-end (T2) or GST/HST checklist for a period, with the next step and official deadlines. | | Compliance deadlines | `oatmilk_compliance_deadlines` | Upcoming filing and remittance deadlines, each with Mark done, Snooze a week or Not applicable. | | Your hours | `contractor_log_hours` | A contractor's own timesheet: log time and send it for review. | Ask for what you want, such as "add these receipts" or "what's due this month?", and the assistant opens the right card. When a step needs a person in Oatmilk, the card links straight to that page. In ChatGPT, files you attach to the chat go straight in: `oatmilk_upload_receipts` adds receipts and `oatmilk_upload_documents` adds anything else Oatmilk files, then the same cards open to show them. Cards there can also pick files from your ChatGPT library. ## Insights Map Use the same world map from an external agent: `accounting_insights_map_get` searches layers, dates, currencies, transaction types, places and visible bounds, and `accounting_insights_map_drilldown` reads the records behind a returned cluster. `accounting_insights_map_view` takes the canonical `{filters,renderer}` state and returns a safe view URL, with an absolute `dashboardUrl` for the connected deployment. Ask AI in Oatmilk knows the current filters, viewport and selected cluster and applies a requested view through its navigation tool. The server chooses the native Map address: `/insights?view=map` for company members and `/accountant/map?view=map` for outside accountants. Accountants see only sources their access permits, and event links open their existing year-end page. Company-member Ask AI does not open accountant portals; outside accountants can use Ask AI in their native Map and the same MCP tools, within their live client grants. `accounting_insights_map_export` returns an actual private PNG asset with a short-lived `downloadUrl` and an MCP resource link. It uses the same filters and viewport as the page. All map tools require `accounting:read` and enforce the person's current source permissions. They do not edit the books. Totals stay separated by currency, and coverage describes missing locations, unavailable layers and bounds or marker limits. These tools are in the `reports` toolset and work in clients without a map card; the Google map opens in Oatmilk. ## Events Clients that support MCP Events (ChatGPT, in Work chats) can be told when something happens: a receipt finishes reading (`receipt.processed`), an email reaches the company's Oatmilk inbox (`mail.received`), a deadline is near or overdue (`compliance.item.due_soon`, `compliance.item.overdue`), an invoice is paid, overdue or waiting for review (`invoice.paid`, `invoice.overdue`, `invoice.review_requested`), a contractor sends hours (`contractor.hours.submitted`) or a document is fully signed (`signing.envelope.completed`). Ask for it in plain words, such as "tell me when the Northwind invoice is paid". Each event is offered only when your role may read what it describes, carries ids and a link rather than content, and is signed with [Standard Webhooks](https://www.standardwebhooks.com/). The assistant reads the details with the usual tools before doing anything. ## The plugin The Oatmilk plugin adds the server along with guides that teach ChatGPT, Codex and Claude how Oatmilk works: getting started, receipts, bookkeeping, year-end, company details, contractor hours and notifications. Claude Code installs it from the [marketplace](https://app.getoatmilk.com/api/plugin/marketplace.json) with the commands above. For Codex, download the [plugin](https://app.getoatmilk.com/api/plugin/oatmilk.zip), unzip it, then run `codex plugin marketplace add ./oatmilk` and `codex plugin add oatmilk@oatmilk`. In the ChatGPT desktop app, list the unzipped folder in your personal marketplace (`~/.agents/plugins/marketplace.json`), restart the app and install Oatmilk from the Plugins Directory. ## Choose what the assistant sees The full server offers one tool for every action you're allowed to run, which is more than some clients can hold. Add `?toolset=` with one or more of these names to see only what you need: | Toolset | Includes | | --- | --- | | `autopilot` | What needs a person, Autopilot status, jobs, explanations, learned rules and settings, receipt reminders to cardholders, and the investigator. | | `transactions` | Entries, bank and card transactions, merchants and their profiles, trips and hotel holds, receipt investigations, reimbursements, categories, project tags, accounts, cards, reconciliation, Bank of Canada exchange rates, statement imports and the original statement files with their checked lines. | | `matching` | Receipt matching decisions, candidates and confirmation. | | `inbox` | Receipts, secure document requests, uploads of any file (intake), company mail, connected inboxes, evidence and processing jobs. | | `stripe` | Stripe activity, payouts, exceptions, tax confirmation and reports. | | `reports` | Reports, insights, a searchable world map with layers, drilldowns and image exports, software subscriptions, exports, tax preparation and the shareholder register. | | `compliance` | The compliance checklist, deadlines and reminders. | | `budgets` | Spending plans, tax and accountant obligations, designated reserve accounts, filing and payment dates, funding gaps and readiness alerts. | | `invoicing` | Invoices, customers and vendors, and payment methods. | | `documents` | Agreements, e-signatures and kept records such as filed tax returns, notices of assessment and company documents, with what company records say about the company for an administrator to check and save. | | `contractors` | For the company's finance team: the contractor directory, timesheets, payouts, and recruiting (job postings, candidates, interviews). | | `contractor` | For a contractor: only your own contractor_* tools (your hours and timesheets, pay periods, payouts and statements, agreements, profile and reminders). No accounting tools. | | `ai` | AI settings, classifier guidance, memory, the sandbox, the agents' live status, regulation updates from compliance research, the compliance portal (its library and search), and the Chief of Staff, the Oatmilk agent in Discord (its rules for each server and channel, people and roles, tools and scheduled posts). | | `connectors` | Wise, data connectors (Notion and Google Drive), webhooks and Notion. | | `history` | Everything anyone changed in the company, people, Ask AI, AI apps, the API and automation, with filters, the values before and after, and rolling changes back. | | `admin` | Organization settings and onboarding, exporting all of the company's data, members and team invitations, senders, API keys and their usage and request log, platform settings, notices about new people, experiments, runs and proposals. | ``` https://app.getoatmilk.com/api/mcp?toolset=inbox,transactions ``` ## Tools and actions Every tool is an API action under another name: `accounting_` followed by the action in snake case, so `uploads.inline` is `accounting_uploads_inline`. Contractor tools start with `contractor_`. Each action's page in the [API reference](https://app.getoatmilk.com/docs/api.md) names its tool. A few actions stay out of MCP on purpose, and their pages say why. Tools that delete, void or revoke something are marked as destructive, and tools that email people or call another service are marked as reaching outside Oatmilk, so a client can ask before running them. Pass `organizationId` to any tool to work in another company you belong to. ## What stays with people An assistant can prepare almost anything, but some steps always need the person in Oatmilk: approving a suggestion, signing an agreement, sending money, and seeing full bank or tax numbers. The tool then answers with the link to the exact page, and a good assistant hands it to you. ## Good prompts to start with - "What needs my attention in Oatmilk this week?" - "Which of my card purchases still need a receipt? Here are the receipts." - "Draft an invoice for Synthetic Ventures for 12 hours at $150, due in 30 days." - "Are any compliance deadlines coming up this month?" # Docs for AI tools > Plain-text versions of these docs for language models, agents and coding assistants. Source: https://app.getoatmilk.com/docs/ai-tools Every page here has a plain Markdown twin, and the whole site is summarised in [llms.txt](https://llmstxt.org) files that AI tools can read. They're generated from the same source as these pages and the API itself, so they're never out of date. | File | Contains | | --- | --- | | [/llms.txt](https://app.getoatmilk.com/llms.txt) | A short map of Oatmilk and every docs page, with links to their Markdown. | | [/llms-full.txt](https://app.getoatmilk.com/llms-full.txt) | Every guide and tutorial, the webhook event catalog and a summary of every API action, in one file. | | [/docs/api/llms.txt](https://app.getoatmilk.com/docs/api/llms.txt) | The whole API reference: every action with its permissions, fields and an example. | | [/docs/webhooks/llms.txt](https://app.getoatmilk.com/docs/webhooks/llms.txt) | The webhook guides with every event's sample delivery. | | [/docs/terminal/llms.txt](https://app.getoatmilk.com/docs/terminal/llms.txt) | Every guide to the oatmilk command line, with its command reference. | | [/docs/self-hosting/llms.txt](https://app.getoatmilk.com/docs/self-hosting/llms.txt) | Every self-hosting guide: your computer, a server, the clouds, local AI models and operations. | | [/api/openapi.json](https://app.getoatmilk.com/api/openapi.json) | The OpenAPI 3.1 document, including webhooks. | ## Use them - **In a chat:** paste `https://app.getoatmilk.com/llms.txt` and ask your question. The assistant follows the links it needs. - **In a coding assistant:** add `https://app.getoatmilk.com/llms-full.txt` as documentation, or point it at the [OpenAPI document](https://app.getoatmilk.com/api/openapi.json). - **On any page:** use **Copy page** at the top to copy its Markdown, or add `.md` to its address. To let an assistant act in Oatmilk rather than read about it, [connect it with MCP](https://app.getoatmilk.com/docs/mcp.md). # The Oatmilk CLI > Oatmilk in a terminal: a full-screen app, Ask AI, scripts, CI and a bridge for AI agents. Source: https://app.getoatmilk.com/docs/terminal The Oatmilk command line, `oatmilk`, opens your workspace in a terminal. It has the same pages, records and actions as the web app, with Ask AI in a message box at the bottom. Scripts, CI jobs and other agents can use it without the full-screen app, and agents that can only start a command can reach Oatmilk's MCP tools through it. It works with the hosted Oatmilk and with [your own installation](https://app.getoatmilk.com/docs/self-hosting.md), and it signs in as you: your role in each company decides what it can see and do. ## Try it in a minute You need Node.js 22 or newer. ```bash title="Start it" npx @getoatmilk/cli ``` ## The first time The first time it starts, the app asks one question: how you want to use Oatmilk. - **Sign in to Oatmilk.** Your account at getoatmilk.com, with nothing to install. If Ollama or LM Studio is running with a model that calls tools, it offers to run Ask AI on it, so your questions stay on your computer while your books stay in Oatmilk. - **Keep everything on this computer.** Your account, books, files and AI models all on your computer, with no sign-up. It needs Docker, [Bun](https://bun.sh) and [Ollama](https://ollama.com) or [LM Studio](https://lmstudio.ai). Setup finds them, picks models that fit, downloads missing ones into Ollama, asks your name and your company's name, starts Oatmilk and signs the terminal in. - **More options.** Choose for each part (sign-in, books, files, Ask AI and the agents, classifiers, reading documents, email and web research, each on your computer, in the cloud or off), use your own cloud services with AI on your computer, or connect to an Oatmilk you or your team already run. Each step shows where you are (`2/5`), and the setup says how local it is in words, like `85% local`. ```bash oatmilk setup # choose again oatmilk setup --local --yes # everything on this computer, without questions oatmilk setup --local --cloud-for agents --yes oatmilk status # where each part runs, the server, the models, Ask AI oatmilk local start # also: stop, restart, logs, open, password, backup, wipe oatmilk models # the models Oatmilk uses; models test, models pull oatmilk ai use ollama # Ask AI in the terminal on a model on this computer ``` `--local` and `--cloud` pick where any command goes, `--offline` refuses an Oatmilk on the internet, `--no-start` leaves a stopped local Oatmilk stopped, and `--no-ai` opens the app without Ask AI. ## Sign in On an Oatmilk that setup made on your computer, the terminal signs itself in. Otherwise the app asks how to sign in: choose the browser, approve, and you land on Home. From a command line, the same steps are: ```bash npm install -g @getoatmilk/cli # or keep using npx oatmilk login # opens your browser; approve, then go back to the terminal oatmilk # the full-screen app ``` On an SSH session or a machine without a browser, run `oatmilk login --device` and paste back the code Oatmilk shows you on any other device. Browser sign-in is the same OAuth flow AI assistants use for [MCP](https://app.getoatmilk.com/docs/mcp.md), and your role in each company still decides what you can do. An [API key](https://app.getoatmilk.com/docs/authentication.md) belongs to one company and keeps exactly the permissions it was given. Sign-ins are saved for your user only, and `oatmilk logout` revokes them. On your own Oatmilk, `oatmilk login --password` signs in with an account's email and password. ## Your data An administrator can take everything out, or close the company for good: ```bash oatmilk export # every record and file, into a folder oatmilk orgs close # offers an export, then asks for the company's name oatmilk wipe # remove every sign-in and setting from this computer oatmilk local backup # the Oatmilk on this computer: its database and files, to restore later ``` The workspace has the same in **Settings › Your data**. ### Moving between your computer and the cloud Books don't move by themselves when you change where Oatmilk runs, and the app says so before you switch. - **To another computer, or your own cloud database and storage:** `oatmilk local backup`, then restore it with the same `self-host/.env` ([Operate](https://app.getoatmilk.com/docs/self-hosting/operate.md#restore)). Nothing is lost. - **Between Oatmilk Cloud and your computer:** `oatmilk export --cloud` or `oatmilk export --local` keeps a full copy of every record and file. Bringing that copy into the other Oatmilk isn't possible yet, so keep using the one that has your books, or start fresh in the new one. Passwords, provider keys and bank or tax numbers are left out of an export, so connections are made again in a new Oatmilk. ## What you can do with it | Use | Start with | Guide | | --- | --- | --- | | Browse and act on the books in a full-screen app | `oatmilk` | [Use the full-screen app](https://app.getoatmilk.com/docs/terminal/app.md) | | Add receipts and invoices from files | `oatmilk add receipt.pdf` | [Add a receipt](https://app.getoatmilk.com/docs/terminal/app.md#add-a-receipt) | | Log hours and send your timesheet, as a contractor | `oatmilk hours add 2h "Design review"` | [Log hours as a contractor](https://app.getoatmilk.com/docs/terminal/hours.md) | | See where your data comes from, and connect more | `oatmilk connect` | [Connect your data](https://app.getoatmilk.com/docs/terminal/app.md#connect-your-data) | | Ask AI on Ollama or LM Studio, with your books in Oatmilk | `oatmilk ai use ollama` | [Ask AI on a model on your computer](https://app.getoatmilk.com/docs/terminal/app.md#ask-ai-on-a-model-on-your-computer) | | Ask AI one question from a script | `oatmilk -p "What needs me today?"` | [Scripts and CI](https://app.getoatmilk.com/docs/terminal/scripts.md) | | Run any API action | `oatmilk api entries.list '{"limit":5}'` | [Scripts and CI](https://app.getoatmilk.com/docs/terminal/scripts.md#run-any-api-action) | | Give an agent Oatmilk's tools | `oatmilk mcp` | [Connect agents through the CLI](https://app.getoatmilk.com/docs/terminal/agents.md) | ## Guides 1. [Install the CLI](https://app.getoatmilk.com/docs/terminal/install.md): npm, other package managers, standalone binaries or from source. 2. [Sign in and choose a company](https://app.getoatmilk.com/docs/terminal/sign-in.md): browser, another device or an API key, against the hosted Oatmilk or your own. 3. [Use the full-screen app](https://app.getoatmilk.com/docs/terminal/app.md): the sidebar, lists, records, the ⌃K palette and Ask AI. 4. [Log hours as a contractor](https://app.getoatmilk.com/docs/terminal/hours.md): log time, send your timesheet and say when you had no hours. 5. [Scripts and CI](https://app.getoatmilk.com/docs/terminal/scripts.md): `exec`, `api`, `actions`, `search`, JSON output and exit codes. 6. [Connect agents through the CLI](https://app.getoatmilk.com/docs/terminal/agents.md): a local MCP server for Claude Code, Codex, Cursor and agents on local models. 7. [Command reference](https://app.getoatmilk.com/docs/terminal/commands.md): every command, option, environment variable and exit code. ## In scripts Set `OATMILK_API_KEY` instead of signing in, and read results as JSON. ```bash title="Ask AI and the API without the app" oatmilk -p "Which receipts am I missing this month?" --output-format json oatmilk exec "Categorize the Uber rides as Travel" --yes oatmilk api entries.list '{"limit":5}' ``` When Ask AI needs an approval or an answer it couldn't get, `exec` exits with code `7`, and the JSON result lists what it is waiting on. Run it again with `--yes` to approve. The [command reference](https://app.getoatmilk.com/docs/terminal/commands.md#exit-codes) lists every exit code. # Install the CLI > Install oatmilk with one command, npm, a standalone binary or from source, and keep it up to date. Source: https://app.getoatmilk.com/docs/terminal/install The CLI is one file with no dependencies to install. Its command is `oatmilk`. ## Before you start - **Node.js 22 or newer**, unless you use a standalone binary. Check with `node --version`. - **A terminal at least 80 columns wide** for the full-screen app. Headless commands work anywhere, including CI. - **An Oatmilk account**, on the hosted Oatmilk or [your own installation](https://app.getoatmilk.com/docs/self-hosting.md). ## Install it with one command On macOS and Linux: ```bash curl -fsSL https://getoatmilk.com/install.sh | sh ``` The script checks for Node.js 22, downloads `oatmilk`, checks it against the SHA-256 that getoatmilk.com publishes beside it, puts it in `~/.oatmilk/bin` and adds that folder to your shell's `PATH`. Open a new terminal and run `oatmilk`. An install made this way [updates itself](#update). | Setting | Does | | --- | --- | | `OATMILK_INSTALL_DIR` | Installs somewhere other than `~/.oatmilk` | | `OATMILK_NO_MODIFY_PATH=1` | Leaves your shell's startup files alone | On Windows, use npm (below). ## Try it without installing `npx` downloads the latest version and runs it. Nothing stays installed apart from npm's cache. ```bash npx @getoatmilk/cli --version npx @getoatmilk/cli ``` Put `npx @getoatmilk/cli` wherever these guides write `oatmilk`. ## Install it with npm ```bash npm install -g @getoatmilk/cli oatmilk --version ``` Other package managers work the same way: ```bash pnpm add -g @getoatmilk/cli yarn global add @getoatmilk/cli bun add -g @getoatmilk/cli ``` > [!TIP] > If your shell says `oatmilk: command not found` after installing, npm's global folder isn't on your `PATH`. `npm prefix -g` prints the folder; add its `bin` folder (on Windows, the folder itself) to `PATH`, then open a new terminal. ## Standalone binaries Each CLI release on GitHub has binaries that don't need Node.js, for people with access to the Oatmilk repository: | Computer | File | | --- | --- | | Mac with Apple silicon | `oatmilk-darwin-arm64` | | Mac with an Intel processor | `oatmilk-darwin-x64` | | Linux on x64 | `oatmilk-linux-x64` | | Linux on ARM | `oatmilk-linux-arm64` | | Windows | `oatmilk-windows-x64.exe` | Download the file for your computer and `SHA256SUMS` from the same release, then check it and put it on your `PATH`: ```bash title="macOS or Linux" sha256sum --check --ignore-missing SHA256SUMS # on macOS: shasum -a 256 --check --ignore-missing SHA256SUMS chmod +x oatmilk-linux-x64 sudo mv oatmilk-linux-x64 /usr/local/bin/oatmilk oatmilk --version ``` On macOS, if the system refuses to open a downloaded binary, allow it in **System Settings › Privacy & Security**, or run `xattr -d com.apple.quarantine /usr/local/bin/oatmilk`. ## From source If you have Oatmilk's source, for example to [self-host it](https://app.getoatmilk.com/docs/self-hosting.md), you can build the CLI from the same checkout. You need [Bun](https://bun.sh) and Node.js 22 or newer. ```bash bun install bun run build:cli # writes packages/cli/dist/oatmilk.js and checks it runs node packages/cli/dist/oatmilk.js --version npm install -g ./packages/cli # optional: makes oatmilk a command ``` Run `bun run build:cli` again after pulling changes. ## Check that it works ```bash oatmilk --version oatmilk --help oatmilk whoami # exits with code 3 and a hint until you sign in ``` Next, [sign in](https://app.getoatmilk.com/docs/terminal/sign-in.md). ## Update ```bash oatmilk update # installs the newest version the way oatmilk was installed oatmilk update --check # only says whether there is one ``` When a new version is out, the app shows it at the top right, like `↑ 0.3.0 ⌃U`. Press `⌃U` (or type `/update`) to install it; restart `oatmilk` to use it. An install made with `install.sh` updates itself in the background instead, and tells you when it has. The app checks at most every 12 hours, never slows its start, and never checks in CI. Choose how it updates, here or under **Settings › Updates** in the app: ```bash oatmilk update auto # update by itself (the default) oatmilk update notify # only tell me oatmilk update off # never check ``` An npm, pnpm, Yarn or Bun install is updated with the same package manager, for example `npm install -g @getoatmilk/cli@latest`. `npx @getoatmilk/cli@latest` always runs the newest version. A standalone binary is updated by downloading the new one. ## Uninstall Sign out first, so the sign-ins it saved are revoked, then remove it and its files: ```bash oatmilk logout --all npm uninstall -g @getoatmilk/cli # or, for install.sh: rm -rf ~/.oatmilk rm -rf ~/.config/oatmilk ~/.cache/oatmilk # macOS also: ~/Library/Caches/oatmilk ``` On Windows, the folders are `%APPDATA%\oatmilk` and `%LOCALAPPDATA%\oatmilk`. The [command reference](https://app.getoatmilk.com/docs/terminal/commands.md#files-it-keeps) lists everything the CLI saves. # Sign in and choose a company > Connect the CLI to the hosted Oatmilk or your own, with your browser, another device or an API key. Source: https://app.getoatmilk.com/docs/terminal/sign-in The CLI signs in the way an AI app does: you approve it in your browser, and it acts as you. Your role in each company still decides what it can see and do. For scripts and CI, an [API key](https://app.getoatmilk.com/docs/authentication.md#api-keys) works instead. ## 1. Choose the Oatmilk to connect to Without `--host`, the CLI talks to the hosted Oatmilk at `https://app.getoatmilk.com`. For any other Oatmilk, pass its address once when you sign in; the CLI remembers it for later commands. | Oatmilk | Address | | --- | --- | | The hosted Oatmilk | Nothing to add | | Your own, on this computer | `--host https://oatmilk.localhost` | | Your own, on a server | `--host https://books.example.com` | | A development server from `bun run dev` | `--host localhost:3000` | The address is read from `--host` first, then the `OATMILK_HOST` environment variable, then the last address you signed in to. Addresses must use `https`; plain `http` is allowed only for `localhost`. ## 2. Sign in with your browser ```bash oatmilk login # or, for your own Oatmilk: oatmilk login --host https://books.example.com ``` 1. Your browser opens Oatmilk's sign-in page. Sign in if you aren't already. 2. Oatmilk shows what the CLI asks for. Choose **Allow**. 3. The page says you're signed in. Go back to the terminal: it shows your email, company and role. The browser sends the sign-in back to a one-time address on your own computer (`http://localhost:/callback`), so nothing to copy. If no browser opens, the CLI prints the address to open; `--no-browser` or `OATMILK_NO_BROWSER=1` always prints it instead. ## Sign in on another device Over SSH, in a container, or on a machine without a browser: ```bash oatmilk login --device ``` 1. The CLI prints an address. Open it on any device: your laptop or your phone. 2. Sign in and choose **Allow**. 3. Oatmilk shows a code with a **Copy code** button. Paste it into the terminal. The code only works for the terminal that asked for it, and only once. ## Sign in with an API key An API key belongs to one company and keeps exactly the permissions it was given, so it suits scripts, CI and shared machines. Create one in **Developers › API keys** in Oatmilk, then: ```bash oatmilk login --api-key # asks for the key without showing it echo "$KEY" | oatmilk login --api-key # or pipe it in ``` For a single command or a CI job, skip `login` and set the key in the environment. `OATMILK_API_KEY` and `OATMILK_TOKEN` (an OAuth access token) win over a saved sign-in. ```bash OATMILK_API_KEY=oat_live_… oatmilk whoami ``` ## 3. Check who you are ```bash oatmilk whoami oatmilk whoami --json ``` It shows your email, the company, your role, the address and how you signed in. With an API key it also lists the key's permissions. ## 4. Choose a company A browser sign-in can open every company you belong to. The CLI works in one at a time, and `oatmilk whoami` shows which. ```bash oatmilk orgs # the companies you can open; ● marks the current one oatmilk orgs use "Contoso" # switch by name or ID oatmilk api entries.list --org org_2abc… # one command in another company ``` `OATMILK_ORG` sets the company for every command. An API key always works in its own company, so `orgs use` asks you to sign in with the browser instead. ## Your own Oatmilk A [self-hosted Oatmilk](https://app.getoatmilk.com/docs/self-hosting.md) signs you in through its own accounts, with the same steps. Two things differ on this computer's `*.localhost` address: - **The certificate.** Oatmilk's local HTTPS certificate comes from its own certificate authority, which Node.js doesn't trust by default. Point `NODE_EXTRA_CA_CERTS` at the file `bun run self-host cert` saves, in every terminal that runs the CLI: ```bash export NODE_EXTRA_CA_CERTS="$HOME/oatmilk/self-host/oatmilk-local-ca.crt" # where your Oatmilk checkout is oatmilk login --host https://oatmilk.localhost ``` - **The name.** `*.localhost` names always mean this computer. The CLI knows that without an `/etc/hosts` entry. A server with a real domain and a Let's Encrypt certificate needs neither. ## Sign out ```bash oatmilk logout # this address oatmilk logout --all # every address you signed in to ``` Signing out revokes the browser sign-in, so the saved token stops working everywhere. To stop an API key, delete it in **Developers › API keys**. ## Where sign-ins are saved Sign-ins live in `credentials.json` in `~/.config/oatmilk` (`$XDG_CONFIG_HOME/oatmilk` when that is set, `%APPDATA%\oatmilk` on Windows). Only your user can read it. `OATMILK_CONFIG_DIR` moves it, for example to keep a separate sign-in per project. Browser sign-ins refresh themselves; run `oatmilk login` again if one stops working. ## When it goes wrong | Message or exit code | What to do | | --- | --- | | Exit code `3`, "You're not signed in" | Run `oatmilk login`, or set `OATMILK_API_KEY`. | | "Oatmilk addresses must use https" | Use `https://…`. Only `localhost` may use `http`. | | "That isn't an Oatmilk API key" | Keys start with `oat_`. Copy the whole key again. | | A certificate error on `*.localhost` | Set `NODE_EXTRA_CA_CERTS` as shown in [Your own Oatmilk](#your-own-oatmilk). | | Exit code `4`, not allowed | Your role, or the key's permissions, don't allow that action. Check with `oatmilk whoami`. | | The browser never comes back | Cancel with Ctrl+C and use `oatmilk login --device`. | # Use the full-screen app > Move around the workspace, act on records and talk to Ask AI without leaving the terminal. Source: https://app.getoatmilk.com/docs/terminal/app Run `oatmilk` to open the app. It shows the same workspace as the browser, for your role, with Ask AI's message box always at the bottom. ```bash oatmilk # opens Home oatmilk "What needs me today?" # opens the app and asks Ask AI straight away oatmilk --page /finance/transactions # opens a page by its address in the web app ``` ## The screen - **Sidebar.** The same pages as the workspace: Home, Inbox, Checklist, Transactions, Reimbursements, Invoices, Reports, Agreements, Contractors, Settings and more, depending on your role. It hides when the terminal is narrower than 80 columns. - **The page.** A list, with the open record beside it: transactions, merchants, trips, statements, invoices, History, webhooks and more. Pages without a terminal view yet, such as Calendar, say what the page is for, suggest questions for Ask AI and link to the browser. - **Ask AI.** The message box at the bottom. Its chat docks on the right when the terminal is at least 120 columns wide. - **The header and status bar.** The company you're in (and the address, when it isn't the hosted Oatmilk), the keys that work right now, and whether Ask AI is working. `Tab` and `⇧Tab` move between the sidebar, the page and the message box. Press `?` for every key. ## Lists and records | Key | Does | | --- | --- | | `↑` `↓`, `j` `k`, `PgUp` `PgDn`, `g` `G` | Move | | `⏎` or `→` | Open the record beside the list | | `Esc` or `←` | Close it, or go back to the sidebar | | `/` | Search this list | | `[` `]` | The previous or next view, such as Needs you or Done | | `i` | Ask AI about the highlighted record | | `o`, `y` | Open it in the browser, copy its link | | `R` | Refresh | | Letters | The record's actions, shown at the bottom: `a` approve, `c` categorize, and so on | Lists load more as you scroll, and every change shows at once. The browser shows the same change, because both use the same API. ## Jump anywhere with ⌃K `⌃K` opens the palette. Type a page name, a merchant, an invoice number or a contractor's name, and press `⏎`. It also runs any API action by name, such as `invoices.void`, starting you off with its required fields. ## Ask AI Type in the message box and press `⏎`. Ask AI is the same as in the browser: the same tools, the page you're on as context, and your chats in both places. - **Approvals.** Before Ask AI deletes, voids, grants access or emails people, the message box asks you. Nothing changes until you approve. - **Questions.** When Ask AI needs an answer, its choices appear in the message box. Choose one with `↑` `↓`. - **Navigation.** "Take me to receipt matching" moves the app there. When Ask AI offers a page, `⌃G` opens it. - **The chat.** `⌃O` shows or hides it, `PgUp` `PgDn` scrolls it, and `Esc` stops an answer. `⌥⏎` or `⌃J` adds a new line. When the app starts, it picks up your latest chat from the last 30 minutes. ## Ask AI on a model on your computer Ask AI can think on [Ollama](https://ollama.com), [LM Studio](https://lmstudio.ai) or any OpenAI-compatible server on your computer, while your books stay where they are, including in Oatmilk Cloud. The model reads them through Oatmilk's tools, signed in as you, so it can do only what you can. The questions, the answers and everything the model reads stay on your computer. ```bash oatmilk ai use ollama # starts Ollama if it's installed, downloads a model if it has none, and checks it oatmilk ai use lmstudio # or a model already loaded in LM Studio oatmilk ai # where Ask AI thinks, and the models found here oatmilk ai use oatmilk # back to Oatmilk's AI ``` In the app, `/model` lists Oatmilk's AI and every chat model it finds, and switches at once. The top bar shows the model while one on your computer is in use. - Pick a model that calls tools, such as Qwen 3.5. `oatmilk ai test` checks it; the list says which models may not. - Every change still asks you first, in the message box, or with `--yes` in scripts. - These chats stay on your computer, so `/chats` and `--chat` are for Oatmilk's AI only. - Background work, such as reading receipts and sorting transactions, keeps running wherever your Oatmilk runs it. To run that on your computer too, [run Oatmilk locally](https://app.getoatmilk.com/docs/self-hosting/your-computer.md). ## Add a receipt **Add a receipt** leads Home, as in the browser, and is in ⌃K. Type the file's path, or drop the file onto the terminal, which types its path for you; several files work too. From a command line: ```bash oatmilk add ~/Downloads/receipt.pdf "~/Desktop/lunch photo.jpg" oatmilk add invoice.pdf --for e0000000-0000-4000-8000-000000000001 # attach it to one transaction ``` Oatmilk reads each file and matches it to its purchase. Adding the same file again does nothing. ## Connect your data **Settings › Connections** lists where the company's data comes from, each with where it stands and one next step on `⏎`. `oatmilk connect` prints the same list. ```bash oatmilk connect # every source and its next step oatmilk connect receipts # the address to forward receipts and invoices to oatmilk connect gmail # opens the page where you connect your inbox oatmilk connect drive # starts Google Drive's sign-in in your browser oatmilk sync # syncs Wise and Stripe now ``` Keys and sign-ins for Stripe, Wise, Notion, Gmail and Google Drive are entered only in Oatmilk in your browser, so they never pass through the terminal. The terminal opens the right page and shows the result. What you see depends on your role: everyone gets their receipts address, inboxes and accounts, finance also gets Wise, and administrators get every source. ## Commands in the message box Type `/` to list them. | Command | Does | | --- | --- | | `/help` | Keys and commands | | `/new` | Start a new chat | | `/chats` | Reopen a recent chat, including one from the browser | | `/model` | Where Ask AI thinks: Oatmilk's AI, or a model on your computer | | `/go` | Jump to a page or record, like ⌃K | | `/search` | Search transactions, email, invoices and more | | `/run` | Run any Oatmilk action | | `/org` | Switch company | | `/theme` | Choose a theme; `/theme matcha` picks one at once | | `/browser` | Open this page in the browser | | `/logout` | Sign out of this machine | | `/quit` | Leave Oatmilk (or press `⌃C` twice) | ## Themes The app has the same themes as Oatmilk in the browser, and fits them to your terminal: on a dark terminal a theme uses its Night colors, on a light one its day colors, and every color is checked to read well on your background. Type `/theme`, or choose **Theme** at the top of **Settings**, and move through the list to see each one on the whole app. `⏎` keeps it on this computer; `Esc` puts back the one you had. ⌃K finds themes by name too. If the colors look wrong for your terminal, set `OATMILK_TERMINAL=light` or `OATMILK_TERMINAL=dark`. ## Tips - Use a terminal at least 120 columns wide to see the list, the record and the chat side by side. - `NO_COLOR=1` turns colours off. - The app needs a sign-in. If you start it signed out, it asks how to sign in first. See [Sign in and choose a company](https://app.getoatmilk.com/docs/terminal/sign-in.md). # Log hours as a contractor > Log time, send your timesheet and check your pay period from the terminal or your AI app. Source: https://app.getoatmilk.com/docs/terminal/hours If a company pays you as a contractor through Oatmilk, you can do your hours without opening the portal. The terminal uses the same timesheet as the portal, so anything you log shows up there too. ## 1. Install it and sign in Accept the invitation email from the company first. Then: ```bash curl -fsSL https://getoatmilk.com/install.sh | sh oatmilk hours ``` The first time, it asks to sign in with your browser. Use the email the invitation went to. On Windows, install with `npm install -g @getoatmilk/cli`. ## 2. Log time ```bash oatmilk hours add 2h "Design review" oatmilk hours add 1h30m "Client call" --date yesterday oatmilk hours add 45m "Fixes" --date mon ``` - **How long:** `2h`, `1h30m`, `90m`, `1.5` or `1:30`, up to 24 hours. - **Which day:** today when you leave it out, or `yesterday`, a weekday like `mon`, or a date like `2026-10-06`. Days still to come aren't allowed. - Logging the same work on the same day twice asks first; `--yes` keeps both. ## 3. Send it for review ```bash oatmilk hours # this pay period: what's logged, what's not sent, when it's due oatmilk hours submit # sends everything not sent yet, after asking oatmilk hours none # tells the company you had no hours this period ``` Entries the company sent back show why, and go out again with the next `oatmilk hours submit`. ## More than one company ```bash oatmilk hours companies oatmilk hours use "Acme" ``` `--org ` picks a company for one command. ## In the app Running `oatmilk` opens your hours. `a` logs time one question at a time, `s` sends the period for review, `[` and `]` move between pay periods, and `o` opens the portal. ## From your AI app Connect Claude, ChatGPT or another AI app to only your own tools, then ask it "Log 2 hours today for the design review": ```bash claude mcp add --transport http oatmilk "https://app.getoatmilk.com/api/mcp?toolset=contractor" ``` In the portal, your account menu has **Log hours faster** with the steps for each app. See [Contractors](https://app.getoatmilk.com/docs/contractors.md). ## What stays in the portal Signing agreements, your payment details, tax numbers and the extra fields some companies ask for on each entry. `oatmilk hours open` opens the portal at your timesheet. # Scripts and CI > Ask AI, run API actions and search from scripts, cron jobs and CI, with JSON output and exit codes. Source: https://app.getoatmilk.com/docs/terminal/scripts Every headless command prints its result to standard output and its progress to standard error, so you can pipe the result into `jq` or a file and still see what happened. None of them opens the full-screen app. ## Sign in for a script On your own machine, `oatmilk login` once is enough. On a server or in CI, use an [API key](https://app.getoatmilk.com/docs/authentication.md#api-keys) from **Developers › API keys**, with only the permissions the job needs: ```bash export OATMILK_API_KEY=oat_live_… export OATMILK_HOST=https://books.example.com # only for your own Oatmilk oatmilk whoami --json ``` ## Ask AI one question `oatmilk -p "…"` and `oatmilk exec "…"` are the same: one Ask AI request, with the answer streamed to standard output and its steps to standard error. ```bash oatmilk -p "Which receipts am I missing this month?" oatmilk exec "Summarize overdue invoices" --output-format json echo "What changed this week?" | oatmilk exec oatmilk exec --continue "And last month?" # your latest chat oatmilk exec --chat 7d0c… "Draft the reminder emails" # a particular chat oatmilk exec --page /finance/transactions "What needs me here?" ``` | `--output-format` | Prints | | --- | --- | | `text` (the default) | The answer, as it arrives | | `json` | One JSON result when the answer is finished | | `stream-json` | One JSON event per line: `chat`, `step`, `text`, `input`, `navigate`, then `result` | The JSON result looks like this: ```json title="oatmilk exec … --output-format json" { "chatId": "7d0c4e1a-…", "status": "done", "response": "Three receipts are missing: …", "steps": [{ "callId": "…", "tool": "…", "label": "…", "status": "done" }], "opened": [] } ``` ### Approvals in scripts Ask AI asks before it deletes, voids, grants access or emails people. In a script nobody can answer, so the command stops: `status` is `needs_input`, `pending` lists what it is waiting on, and the exit code is `7`. ```bash oatmilk exec "Void the duplicate invoice INV-1042" --output-format json > result.json if [ $? -eq 7 ]; then jq '.pending' result.json # what it is waiting on oatmilk exec "Void the duplicate invoice INV-1042" --yes # run it again, approving fi ``` `--yes` approves every step of that request, so use it only for prompts you trust. When Ask AI asked a question instead, answer it in the same chat with `oatmilk exec --chat "…"`. ## Run any API action `oatmilk api` runs any action in the [API reference](https://app.getoatmilk.com/docs/api.md) with your sign-in and prints its JSON `data`. ```bash oatmilk api entries.list '{"limit":5}' oatmilk api entries.list @filters.json # input from a file cat input.json | oatmilk api invoices.create - # input from standard input oatmilk api invoices.void --input id=inv_… --input expectedRevision=2 --input reason="Sent twice" --yes oatmilk api entries.list '{"limit":100}' --compact | jq 'length' ``` - `--input key=value` sets one field on top of the JSON. Values that parse as JSON (numbers, `true`, arrays) are used as such, and `a.b=1` sets a nested field. - Changes get an [idempotency key](https://app.getoatmilk.com/docs/idempotency.md) when the action accepts one, so a retried command doesn't act twice. Pass your own with `--idempotency-key`. - Actions that delete, void, grant access or email people ask first in a terminal, and need `--yes` in a script. - Reads, and changes with an idempotency key, retry up to twice through a brief outage or a rate limit. ## Find an action ```bash oatmilk actions # every action oatmilk actions invoice # actions whose name or summary matches oatmilk actions invoices.void # one action: what it does, its permission and its input oatmilk actions invoice --json ``` The list comes from your Oatmilk's `/api/openapi.json` and is cached for a day. `--refresh` reads it again. ## Search transactions and chats ```bash oatmilk search uber oatmilk search --status needs_you --limit 50 --json oatmilk chats # your recent Ask AI chats, from the terminal and the browser oatmilk chats show 7d0c… # one chat's messages ``` `--status` is one of `needs_you`, `working`, `done` or `review`. ## Exit codes Scripts can branch on the exit code instead of parsing messages. With `--json`, errors are JSON too: `{ "error": { "code", "message", … } }`. | Code | Means | | --- | --- | | 0 | Done | | 1 | Failed | | 2 | Wrong usage | | 3 | Sign-in needed | | 4 | Not allowed | | 5 | Not found | | 6 | Rate limited | | 7 | Ask AI needs your input | | 130 | Cancelled | ## Example: a weekly check in CI Save an API key with the **Read** permission as a masked CI variable named `OATMILK_API_KEY`, then run the CLI on a schedule. In GitLab CI: ```yaml title=".gitlab-ci.yml" missing-receipts: image: node:22 rules: - if: $CI_PIPELINE_SOURCE == "schedule" script: - npx -y @getoatmilk/cli -p "List purchases from the last 7 days that still need a receipt, with merchant and amount." > report.md artifacts: paths: [report.md] ``` Any CI service works the same way: Node.js 22, `OATMILK_API_KEY` from its secret store, and `npx -y @getoatmilk/cli`. ## Example: a nightly export with cron ```bash title="export-entries.sh" #!/usr/bin/env bash set -euo pipefail export OATMILK_API_KEY="$(cat ~/.oatmilk-key)" oatmilk api entries.list '{"limit":200}' > "entries-$(date +%F).json" ``` ```bash crontab -e # 0 2 * * * /home/me/export-entries.sh ``` For a larger export, follow [Export transactions](https://app.getoatmilk.com/docs/tutorials/export-transactions.md), and for changes as they happen, use [webhooks](https://app.getoatmilk.com/docs/webhooks.md) instead of polling. # Connect agents through the CLI > Give Claude Code, Codex, Cursor or an agent on local models Oatmilk's tools with oatmilk mcp. Source: https://app.getoatmilk.com/docs/terminal/agents Oatmilk's [MCP server](https://app.getoatmilk.com/docs/mcp.md) runs inside Oatmilk at `/api/mcp`, and most AI apps connect to it directly. Some agents can only start a local command. For those, `oatmilk mcp` is a small MCP server on standard input and output that forwards every request to your Oatmilk, signed in as the CLI. Use it when: - the agent only supports command (stdio) servers; - you want the agent to use the sign-in you already made with `oatmilk login`, or an API key from the environment, instead of its own; - the agent runs on a machine that can reach your Oatmilk but can't open a browser to sign in. ## 1. Sign in once ```bash oatmilk login # the hosted Oatmilk oatmilk login --host https://books.example.com # or your own ``` For an agent that runs unattended, set `OATMILK_API_KEY` in its environment instead, with only the permissions it needs. A read-only key can't change the books, whatever the agent tries. ## 2. Print the setup for your apps ```bash oatmilk mcp config oatmilk mcp config --host https://books.example.com ``` It prints ready-to-paste setup for Claude Code, Codex, Gemini CLI and Cursor, both for connecting to the server directly and through the CLI. ## 3. Add it to your agent **Claude Code:** ```bash claude mcp add oatmilk -- npx -y @getoatmilk/cli mcp claude mcp add oatmilk -- npx -y @getoatmilk/cli mcp --host https://books.example.com # your own Oatmilk ``` **Any app with an `mcp.json`** (Cursor, Windsurf, Claude Desktop and others): ```json title="mcp.json" { "mcpServers": { "oatmilk": { "command": "npx", "args": ["-y", "@getoatmilk/cli", "mcp", "--host", "https://books.example.com"] } } } ``` Leave out `"--host"` and the address for the hosted Oatmilk. If you installed the CLI, `"command": "oatmilk", "args": ["mcp"]` starts faster. ## Choose which tools it sees Agents work better with fewer tools. `--toolset` limits the bridge to one or more of Oatmilk's [toolsets](https://app.getoatmilk.com/docs/mcp.md#choose-what-the-assistant-sees), separated by commas: ```bash claude mcp add oatmilk-receipts -- npx -y @getoatmilk/cli mcp --toolset inbox,transactions ``` ## Agents on local models The bridge forwards to any Oatmilk, so an agent running on Ollama or LM Studio can use a [self-hosted Oatmilk](https://app.getoatmilk.com/docs/self-hosting.md) without anything leaving your network. Small models call tools less reliably, so use a tool-calling model of 9B parameters or more, and an API key with narrow permissions. [opencode](https://opencode.ai) with Ollama and the bridge, in `opencode.json`: ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", "provider": { "ollama": { "npm": "@ai-sdk/openai-compatible", "name": "Ollama (local)", "options": { "baseURL": "http://localhost:11434/v1" }, "models": { "qwen3.5:9b": { "name": "Qwen 3.5 9B" } } } }, "mcp": { "oatmilk": { "type": "local", "command": ["npx", "-y", "@getoatmilk/cli", "mcp", "--host", "https://oatmilk.localhost"], "environment": { "NODE_EXTRA_CA_CERTS": "/path/to/oatmilk/self-host/oatmilk-local-ca.crt" }, "enabled": true } } } ``` `NODE_EXTRA_CA_CERTS` is only needed for a `*.localhost` installation; see [Sign in and choose a company](https://app.getoatmilk.com/docs/terminal/sign-in.md#your-own-oatmilk). ## Check it Ask the agent "Which Oatmilk company am I in?" It should answer with your company's name. If it can't reach Oatmilk: - Run `oatmilk whoami --host …` with the same address. Exit code `3` means the CLI isn't signed in there. - Add `--debug` to the bridge's arguments to print each request to standard error, which most agents keep in their MCP log. - An agent that starts the bridge from a different user or folder may not see your sign-in. Give it `OATMILK_API_KEY`, or the same `OATMILK_CONFIG_DIR`. # Command reference > Every oatmilk command, option, environment variable, exit code and file. Source: https://app.getoatmilk.com/docs/terminal/commands `oatmilk --help` prints a short version of this page. ## Commands | Command | Does | | --- | --- | | `oatmilk [prompt]` | Opens the full-screen app, and asks Ask AI the prompt if you give one | | `oatmilk -p ""` | Asks Ask AI once without the app; the same as `exec` | | `oatmilk setup` | Chooses how to use Oatmilk: sign in to Oatmilk Cloud, keep everything on this computer, or a mix, saying how local it is (`85% local`); `--local`, `--cloud` or `--host` without questions | | `oatmilk status` | Where each part runs and how local, whether the local Oatmilk answers, the models, where Ask AI thinks, and who's signed in | | `oatmilk local ` | The Oatmilk running on this computer; `backup` saves its database and files (Docker) | | `oatmilk models` | Ollama and LM Studio on this computer and the models Oatmilk uses; `models start`, `models test`, `models pull `, `models use` | | `oatmilk ai` | Where Ask AI thinks in the terminal; `ai use ollama\|lmstudio` runs it on a model on this computer, `ai use oatmilk` goes back, `ai test` checks the model calls tools | | `oatmilk login` | Signs in through your browser | | `oatmilk logout` | Signs out of this machine and revokes the sign-in; a local account's saved password is forgotten too | | `oatmilk whoami` | Who you're signed in as, the company, your role and permissions | | `oatmilk orgs` | The companies you can open; `orgs use ` switches; `orgs close` closes the company for good | | `oatmilk connect` | Where the company's data comes from: receipts by email, Gmail and Outlook, bank and cards, Wise, Stripe, Notion and Google Drive, each with its next step; `connect ` takes it | | `oatmilk sync` | Syncs Wise and Stripe now; `sync wise` or `sync stripe` syncs one | | `oatmilk export` | Downloads every record and file of the company into a folder | | `oatmilk wipe` | Removes every sign-in, setting and history from this computer | | `oatmilk update` | Installs the newest version; `--check` only says whether there is one; `update auto\|notify\|off` chooses whether the app updates by itself | | `oatmilk hours` | A contractor's pay period; `hours add