# API coverage What this crate wraps of the Invoice Ninja v5 API, and what it doesn't. Full coverage is a stated goal, so the gaps below are a to-do list rather than a permanent boundary — [`GOALS.md`](GOALS.md) puts them in rough priority order. Checked against `routes/api.php` on the `v5-stable` branch of the [invoiceninja repo](https://github.com/invoiceninja/invoiceninja), which declares 34 `Route::resource` groups and 274 explicit route declarations (272 single-verb, plus two `Route::match` pairs registering two verbs each). > Note on sources: Invoice Ninja's published `openapi/api-docs.yaml` is > reliable for **schemas** but incomplete for **routes** — it documents > `/{id}` and action paths only for clients, locations, and tags. Use > `routes/api.php` and the controllers when checking whether an endpoint > exists or what it returns. **All 34 `Route::resource` groups are now wrapped**, plus `users` (which is not a resource group). 375 methods in total. ## Resources with full CRUD + bulk 30 resources, each with `list_*`, `list_all_*`, `get_*`, `create_*`, `update_*`, `delete_*`, and `bulk_*`: | Resource | Path | Model | | --- | --- | --- | | Clients | `/clients` | `Client` (+ `ClientContact`) | | Invoices | `/invoices` | `Invoice` (+ `InvoiceItem`) | | Quotes | `/quotes` | `Quote` (alias of `Invoice`) | | Credits | `/credits` | `Credit` (alias of `Invoice`) | | Payments | `/payments` | `Payment` (+ `PaymentInvoice`) | | Products | `/products` | `Product` | | Purchase Orders | `/purchase_orders` | `PurchaseOrder` | | Recurring Invoices | `/recurring_invoices` | `RecurringInvoice` | | Recurring Quotes | `/recurring_quotes` | `RecurringQuote` (alias of `RecurringInvoice`) | | Recurring Expenses | `/recurring_expenses` | `RecurringExpense` | | Subscriptions | `/subscriptions` | `Subscription` | | Projects | `/projects` | `Project` | | Tasks | `/tasks` | `Task` | | Task Statuses | `/task_statuses` | `TaskStatus` | | Expenses | `/expenses` | `Expense` | | Expense Categories | `/expense_categories` | `ExpenseCategory` | | Vendors | `/vendors` | `Vendor` (+ `VendorContact`) | | Bank Transactions | `/bank_transactions` | `BankTransaction` | | Tax Rates | `/tax_rates` | `TaxRate` | | Payment Terms | `/payment_terms` | `PaymentTerm` | | Webhooks | `/webhooks` | `Webhook` | | Bank Integrations | `/bank_integrations` | `BankIntegration` | | Bank Transaction Rules | `/bank_transaction_rules` | `BankTransactionRule` | | Company Gateways | `/company_gateways` | `CompanyGateway` | | Designs | `/designs` | `Design` | | Group Settings | `/group_settings` | `GroupSetting` | | Locations | `/locations` | `Location` | | Tags | `/tags` | `Tag` | | Task Schedulers | `/task_schedulers` | `TaskScheduler` | | Tokens | `/tokens` | `CompanyToken` | `create_recurring_quote` currently 500s against Invoice Ninja 5.13.50: the server's own insert names a `design_id` column its `recurring_quotes` table does not have. The request is well-formed; nothing here can work around it. `bulk_*` accepts any action the endpoint supports; `archive`, `restore`, and `delete` work on all of them. An `ids` list matching nothing returns an empty `Vec`, not an error. ## Resources without a bulk route Three `Route::resource` groups have no `/bulk` route, so they get the six CRUD methods but no `bulk_*` (which would just 405). These use `crud_methods!` rather than `resource_methods!`: | Resource | Path | Model | | --- | --- | --- | | Client Gateway Tokens | `/client_gateway_tokens` | `ClientGatewayToken` | | Companies | `/companies` | `Company` | | System Logs | `/system_logs` | `SystemLog` | `Company` and `GroupSetting` keep their `settings` blob as `serde_json::Value`: `CompanySettings` is the largest object in the API, and `Company` itself returns over a hundred fields, so only the structural ones are modeled and `extra` carries the feature toggles. ## Users `/users` is not a `Route::resource` and has no `DELETE /users/{id}`, so it is hand-written: | Endpoint | Method | Needs password? | | --- | --- | --- | | `GET /users` | `list_users`, `list_all_users` | no | | `GET /users/{id}` | `get_user` | **yes** | | `POST /users` | `create_user` | **yes** | | `PUT /users/{id}` | `update_user` | **yes** | | `DELETE /users/{id}/detach_from_company` | `detach_user_from_company` | **yes** | | `POST /users/bulk` | `bulk_users` | **yes** | "Needs password" means the route sits behind the API's `password_protected` middleware and requires an `X-API-PASSWORD` header on top of a valid token. Supply it with `InvoiceNinjaClient::with_api_password`, which also unlocks client and vendor merge, and client/company/user purge. Note that even *reading* a single user is gated this way. ## Entity actions `GET /{resource}/{id}/{action}` exists for **only four** resources — invoices, quotes, credits, and purchase orders. Each gets `{singular}_action(id, action)` and `download_{singular}(id)`. Actions returning the entity: `auto_bill`, `clone_to_invoice`, `clone_to_quote`, `mark_paid`, `mark_sent`, `archive`, `restore`, `delete`. Actions that do **not** return the entity, and so are not usable through `*_action`: | Action | Returns | Use instead | | --- | --- | --- | | `download` | `application/pdf` stream | `download_invoice(id)` etc. | | `convert_to_purchase_order` | a purchase order | `convert_invoice_to_purchase_order(id)` | | `cancel`, `history`, `delivery_note` | empty body (upstream quirk) | — not wrapped | Each of the four also gets `download_{plural}(&ids)` — the bulk `download` action, which streams one merged PDF. It can't go through `bulk_*` for the same reason: the response isn't JSON. Convenience wrappers: `mark_invoice_sent` and `mark_invoice_paid`. ## Invitation-key downloads The same four resources also answer two downloads keyed by **invitation key** rather than record id — the per-contact token that appears in a client-portal link. Eight routes, all wrapped: | Route | Method | Returns | | --- | --- | --- | | `GET /invoice/{key}/download` | `download_invoice_by_invitation_key` | `application/pdf` | | `GET /invoice/{key}/download_e_invoice` | `download_e_invoice_by_invitation_key` | `application/xml` | | `GET /quote/{key}/download` | `download_quote_by_invitation_key` | `application/pdf` | | `GET /quote/{key}/download_e_quote` | `download_e_quote_by_invitation_key` | `application/xml` | | `GET /credit/{key}/download` | `download_credit_by_invitation_key` | `application/pdf` | | `GET /credit/{key}/download_e_credit` | `download_e_credit_by_invitation_key` | `application/xml` | | `GET /purchase_order/{key}/download` | `download_purchase_order_by_invitation_key` | `application/pdf` | | `GET /purchase_order/{key}/download_e_purchase_order` | `download_e_purchase_order_by_invitation_key` | `application/xml` | Two things about these are easy to get wrong, and both are pinned by tests. The entity segment is **singular** — `invoice/…`, not `invoices/…` — which is the only place in the API that departs from the plural CRUD path. And despite being the download a *client* follows, they are **authenticated**: the routes sit inside `routes/api.php`'s `api/v1` group under its `token_auth` middleware, so they need the API token like any other call. This file previously listed them as unwrapped on the grounds that they take no auth token. That was wrong — it conflated them with the client-portal routes in `routes/client.php` (`/client/invoice/{key}/download_pdf` and friends), which are a separate route family, are not under `/api/v1`, and remain unwrapped for that reason. The smoke test's read tier exercises the invoice PDF, taking a key from the `invitations` array the list route already returns. The `download_e_*` four are not exercised: they render from the company's e-invoice settings, which a test instance need not have configured. An unknown or deleted key is a `400` carrying `no record found`. The controllers return that directly rather than raising `ModelNotFoundException`, so the message differs from the `No query results for model` text a missing record produces elsewhere, but the status is the same 400. ## Import and export | Route | Method | Returns | | --- | --- | --- | | `POST /export` | `export` | `ExportJob` | | `POST /preimport` | `preimport` | `ImportPreview` | | `POST /import` | `import` | `MessageResponse` | | `POST /import_json` | `import_json` | `MessageResponse` | | `PUT /edocument/upload` | `upload_edocument` | `MessageResponse` | A CSV import is two calls. `preimport` uploads the files — one per entity type, as `files[]` — and answers with a `hash` plus the column mapping the server suggests; `import` then starts the ingest from that hash. The server holds the files against the hash for 20 minutes. The route also accepts the files and the mapping together in one multipart request. That form is deliberately not wrapped: a mapping is a nested structure, and bracket-encoding nested structures into form fields is exactly what this crate avoids by sending JSON (see [the PHP SDK comparison](#compared-with-the-official-php-sdk)). With a `hash` present the controller never looks at uploaded files, so `import` is plain JSON. `upload_edocument` is *not* a document attachment, which is why it is not one of the `upload_*` methods: `ExpenseController::edocument` parses each file and creates **expenses**. Like the `/{id}/upload` routes it is registered as `PUT` and so goes out as a `POST` with `_method=PUT`; unlike them the reply has no `data` envelope. All five queue background work, so a 200 means accepted, not finished. Rows that fail to import are reported by email. `import_json` restores a company from an `export` backup and **overwrites the target company**. ## E-invoicing Seventeen routes, all wrapped. Mostly pass-throughs to Storecove, Invoice Ninja's Peppol access point, so they return `serde_json::Value` — see [Typed response gaps](#typed-response-gaps) for why. | Route | Method | Returns | | --- | --- | --- | | `POST /einvoice/validateEntity` | `validate_einvoice` | `Value` | | `POST /einvoice/configurations` | `einvoice_configurations` | `Value` | | `GET /einvoice/quota` | `einvoice_quota` | `Value` | | `GET /einvoice/health_check` | `einvoice_health_check` | `()` | | `GET /einvoice/delivery_map` | `einvoice_delivery_map` | `Value` | | `POST /einvoice/token/update` | `update_einvoice_token` | `()` (204) | | `POST /einvoice/peppol/discovery` | `peppol_discovery` | `Value` | | `POST /einvoice/peppol/legal_entity` | `peppol_legal_entity` | `Value` | | `POST /einvoice/peppol/setup` | `peppol_setup` | `Option` | | `POST /einvoice/peppol/disconnect` | `peppol_disconnect` | `Value` | | `PUT /einvoice/peppol/update` | `peppol_update_legal_entity` | `Value` | | `POST /einvoice/peppol/add_additional_legal_identifier` | `peppol_add_legal_identifier` | `MessageResponse` | | `DELETE /einvoice/peppol/remove_additional_legal_identifier` | `peppol_remove_legal_identifier` | `Value` | | `POST /einvoice/peppol/send` | `peppol_retry_send` | `MessageResponse` | | `POST /einvoice/peppol/sg/c5/activate` | `peppol_sg_c5_activate` | `MessageResponse` | | `POST /einvoice/peppol/sg/c5/deactivate` | `peppol_sg_c5_deactivate` | `MessageResponse` | | `POST /einvoice/peppol/sg/c5/cancel` | `peppol_sg_c5_cancel` | `MessageResponse` | Three things here behave unlike the rest of the API: - **A failing check is a non-2xx.** `validate_einvoice` answers `422` when validation finds problems, so "this invoice is not Peppol-ready" — an ordinary result — arrives as `Error::Api`, and the per-entity detail in the body is not reachable through the typed error. An `Ok` means it passed. - **Error bodies are not the usual shape.** The Peppol handlers return `data_get($response, 'message')` as the entire body, which can be a bare string. `Error::Api`'s `message` then falls back to an excerpt and `errors` is empty. - **`peppol_setup` has two success shapes.** A `corppass_url` when the connection needs a further browser step (Singapore's CorpPass), or a bare `204` when it completed. Hence `Option` rather than treating the empty body as a decode failure. `peppol_remove_legal_identifier` is the only `DELETE` in the crate that carries a request body. ## Integrations | Route | Method | Returns | | --- | --- | --- | | `POST /quickbooks/sync` | `quickbooks_sync` | `()` (204) | | `POST /quickbooks/action` | `quickbooks_action` | `()` (204) | | `POST /quickbooks/disconnect` | `quickbooks_disconnect` | `()` (204) | | `POST /quickbooks/reconnect_url` | `quickbooks_reconnect_url` | `Value` | | `POST /stripe/update_payment_methods` | `stripe_update_payment_methods` | `MessageResponse` | | `POST /stripe/import_customers` | `stripe_import_customers` | `MessageResponse` | | `POST /stripe/verify` | `stripe_verify` | `Value` | | `POST /stripe/disconnect/{id}` | `stripe_disconnect` | `Value` | | `GET /calendar_connection` | `calendar_connection` | `Value` | | `POST /calendar_connection/{provider}/complete` | `complete_calendar_connection` | `Value` | | `GET /calendar_connection/calendars` | `calendar_connection_calendars` | `Value` | | `GET /calendar_connection/events` | `calendar_connection_events` | `Value` | | `PUT /calendar_connection/calendars` | `update_calendar_connection_calendars` | `Value` | | `DELETE /calendar_connection` | `delete_calendar_connection` | `()` | | `POST /yodlee/status/{account_number}` | `yodlee_account_status` | `Value` | | `GET /nordigen/institutions` | `nordigen_institutions` | `Value` | All four Stripe routes are **password-protected** — supply `with_api_password` — and answer `403` for a non-admin. These operate a connection that already exists. **Establishing one is a browser flow and is not wrappable**: the OAuth authorize and callback legs live outside the `api/v1` group, take no API token, and answer with redirects. See [the gap list](#individual-endpoints-not-wrapped). `yodlee_account_status` takes Yodlee's own `account_id`, as stored on the matching `BankIntegration` — not an Invoice Ninja id. Its upstream route carries a `@todo` questioning its own path, so treat it as liable to move. `nordigen_institutions` covers GoCardless, which was Nordigen when the route was named; an instance without GoCardless credentials answers `400` rather than an empty list. ## Document uploads `PUT /{resource}/{id}/upload` exists for 16 resources, each getting `upload_{singular}(id, files, is_public)` that returns the refreshed record: | Resource | Method | | --- | --- | | Clients | `upload_client` | | Companies | `upload_company` | | Credits | `upload_credit` | | Expenses | `upload_expense` | | Group Settings | `upload_group_setting` | | Invoices | `upload_invoice` | | Payments | `upload_payment` | | Products | `upload_product` | | Projects | `upload_project` | | Purchase Orders | `upload_purchase_order` | | Quotes | `upload_quote` | | Recurring Expenses | `upload_recurring_expense` | | Recurring Invoices | `upload_recurring_invoice` | | Recurring Quotes | `upload_recurring_quote` | | Tasks | `upload_task` | | Vendors | `upload_vendor` | This is the *only* way the API creates a document: `DocumentController::store` is an empty stub upstream, so `Document` has `list`, `get`, `update`, `delete`, `download`, and `bulk`, but no `create_document`, and there will not be one. Three details that are easy to get wrong, all verified against the upstream source rather than the published spec: - **The request is a `POST` carrying `_method=PUT`.** The route is registered as `PUT`, but PHP does not parse a multipart body on a `PUT`, so the files would never arrive. Laravel's kernel turns the spoofed method back into a `PUT` before routing. The OpenAPI spec documents `POST` with `_method=POST`, which would 405 against these routes. - **`is_public` is sent as `1`/`0`.** Laravel's `boolean` rule accepts only `true`, `false`, `0`, `1`, `'0'`, and `'1'` under strict comparison, so the string `"true"` fails validation. - **Uploads are never retried**, even on a 429. An upload adds a document, so it is not idempotent whatever its verb, and a replay after a lost response would attach the file twice. The server enforces file type and size limits; a refusal comes back as a 422 `Error::Api` with the per-field `errors` map. Files are held in memory while uploading. ## Other wrapped endpoints | Endpoint | Method | | --- | --- | | `GET /documents`, `/documents/{id}` | `list_documents`, `list_all_documents`, `get_document` | | `PUT/DELETE /documents/{id}` | `update_document`, `delete_document` | | `GET /documents/{id}/download` | `download_document` → bytes | | `POST /documents/bulk` | `bulk_documents` | | `POST /reports/{name}` | `report(name, body)` → raw bytes (CSV for most) | | `POST /charts/{name}` | `chart(name, body)` → caller-chosen `T` | | `POST /search` | `search(body)` | | `GET /statics` | `statics()` | | `POST /emails` | `send_email(body)` | | `POST /payments/refund` | `refund_payment(body)` | | `GET /ping` | `ping()` | | `GET /health_check` | `health_check()` | `report` and `chart` take the name as a string, so all 32 report and 11 chart endpoints are reachable without a per-endpoint method: - **Reports** — `activities`, `ar_detail_report`, `ar_summary_report`, `client_balance_report`, `client_contacts`, `client_locations`, `clients`, `client_sales_report`, `contacts`, `credits`, `documents`, `expenses`, `invoice_items`, `invoices`, `locations`, `payments`, `preview`, `products`, `product_sales`, `profitloss`, `projects`, `purchase_order_items`, `purchase_orders`, `quote_items`, `quotes`, `recurring_invoice_items`, `recurring_invoices`, `tasks`, `tax_period_report`, `tax_summary_report`, `user_sales_report`, `vendors` - **Charts** — `totals`, `totals_v2`, `chart_summary`, `chart_summary_v2`, `calculated_fields`, `analytics_summary`, `analytics_totals`, `cashflow_forecast`, `client_payment_analytics`, `project_analytics`, `project_burnup` Three of these take a further path segment rather than carrying the id in the body: `charts/project_analytics/{project}`, `charts/project_burnup/{project}`, and `reports/preview/{hash}`. The name argument is interpolated as-is, so pass the whole tail — `chart("project_burnup/", &body)`. Large report exports are queued and emailed rather than returned inline; in that case the body is a short JSON message instead of CSV. ## Activities, email history and previews Each has a response shape of its own. Marked *observed* where confirmed against a 5.13.50 server. | Endpoint | Method | Returns | | --- | --- | --- | | `GET /activities` | `list_activities`, `list_all_activities` | `Vec` (*observed*) | | `GET /activities?reactv2=1` | `activity_feed(rows)` | `Vec` (*observed*) | | `POST /activities/entity` | `entity_activity(entity, id, rows)` | `Vec` (*observed*) | | `POST /activities/notes` | `add_activity_note(entity, id, notes)` | `Activity` (*observed*) | | `GET /activities/download_entity/{id}` | `download_activity_entity` | PDF bytes | | `POST /emails/clientHistory/{client}` | `client_email_history` | `Vec` | | `POST /emails/entityHistory` | `entity_email_history` | `Vec` | | `POST /preview` | `preview`, `preview_html` | PDF bytes / HTML | | `POST /live_preview` | `live_preview` | PDF bytes (*observed*) | | `POST /live_design` | `live_design` | PDF bytes | | `POST /preview/purchase_order` | `preview_purchase_order` | PDF bytes | | `POST /live_preview/purchase_order` | `live_preview_purchase_order` | PDF bytes | Things that are easy to get wrong: - **The entity name is singular in one route and plural in the next.** `entity_activity` takes `invoice`; `add_activity_note` takes `invoices`. - **`rows`, not `per_page`, caps the activity log** (default 75). Pass it through `ListParams::param`. `entity_activity` leaves `rows` out when not given rather than sending `null`, because the server tests for the key and a null would count as given and remove the cap. - **The two activity shapes differ.** `Activity` carries hashed ids and a string `activity_type_id`; the feed's `ActivityFeedEntry` carries a numeric one and labelled references (`invoice`, `client`, … each `{label, hashed_id}`). - **Email history is raw JSON.** The objects mirror what the mail provider reported, and the instance available had never sent mail, so there was no shape to take. - **Not yet run against a real server:** `preview`, `preview_html`, and both purchase-order previews (they need a design object or a purchase order and a PDF backend), and `live_design`, which answered a bare minimal request with a 500 from the server on 5.13.50. ## Password-protected actions Behind the `password_protected` middleware, so each needs `with_api_password` (the server also lets a recent password-authenticated session through without the header). Most are **irreversible**, and all are `POST`s except one, so none is retried on a 5xx. | Endpoint | Method | Returns | | --- | --- | --- | | `POST /clients/{id}/purge` | `purge_client` | `MessageResponse` | | `POST /clients/{id}/{other}/merge` | `merge_clients(id, other)` | the surviving `Client` | | `POST /vendors/{id}/{other}/merge` | `merge_vendors(id, other)` | the surviving `Vendor` | | `POST /companies/purge/{id}` | `purge_company` | `MessageResponse` | | `POST /companies/purge_save_settings/{id}` | `purge_company_save_settings` | `MessageResponse` | | `POST /users/{id}/invite` | `invite_user` | `MessageResponse` | | `POST /users/{id}/purge` | `purge_user` | `()` (the server answers 204) | | `GET /users/create` | `new_user_template` | a blank `User`, nothing stored | A merge keeps the first id and folds the second into it. The smoke test's optional password tier (merge two throwaway clients, purge the survivor) was run against a disposable 5.13.50 instance on 2026-10-24 and passes; it also established that a purged record reads back as a **400**, not a 404. A password containing a control character cannot be a header value and goes out as `X-API-PASSWORD-BASE64` instead. ## Logout, passkeys, and login `/login`, `/logout`, and the passkey routes used to sit together under one "Auth" gap. They don't share a shape: `/logout` and the passkey *settings* routes are plain `token_auth` endpoints like everything else this crate wraps, while `/login` sits in a different, unauthenticated middleware group because the caller has no token yet to prove who they are. | Endpoint | Method | Needs an existing client? | | --- | --- | --- | | `POST /logout` | `logout` | yes | | `GET /settings/passkeys` | `list_passkeys` | yes | | `DELETE /settings/passkeys/{id}` | `delete_passkey` | yes | | `POST /login` | `InvoiceNinjaClient::login` (associated fn, not a method) | **no** | **`logout` has a bigger blast radius than its name suggests.** The controller doesn't revoke the caller's token — it regenerates *every* `is_system` token belonging to the whole company, logging out every user and device that shares it, including the one making the call. **Passkey registration is not wrapped.** `POST /settings/passkeys/options` and `POST /settings/passkeys` exist to drive a WebAuthn ceremony that only a browser talking to a platform authenticator can complete — there is no attestation object for this crate to produce standalone. Listing and deleting an existing credential need no such ceremony, so those are the two operations offered. **`login` returns the server's full "first load" payload** — nested company, clients, invoices, designs, and more under each `CompanyUser` entry — because it reuses the same transformer as a browser's initial page load. This crate does not model that nested tree; [`LoginCompanyUser`] types only `is_owner`, `is_admin`, and the `token` it exists to produce, and leaves the rest in `extra`, losslessly. [`LoginCompanyUser::token`] is what a caller actually needs — feed it to [`InvoiceNinjaClient::new`] or [`InvoiceNinjaClient::hosted`]. `login` optionally takes a one-time password for accounts with two-factor auth enabled. It does not support passkey login, SSO (`oauth_login`, `oidc/*`), `/login/precheck`, or a self-hosted instance's optional `X-API-SECRET` header — each needs a ceremony, a redirect, or a header this single email/password call does not send. `/refresh` and `/refresh_react` were in the old "Auth" bucket too, but they're not an authentication concept: `BaseController::refreshResponse` is the same bulk, `updated_at`-filtered account dump as `/login`'s first load. That's the sync-engine shape [`GOALS.md`](GOALS.md) already parks as a separate open question, not something to bolt onto auth — not implemented, for the same reason. `/connected_account` (linking a Gmail OAuth account) was also filed under "Auth". It's an integration-linking endpoint, not login/logout, so it belongs with Stripe/QuickBooks under [Integrations](#integrations) above — also not implemented. ## Per-resource extras Routes that sit beside a resource's CRUD set, each with its own response shape (read from the upstream controllers; the ones marked *observed* were also confirmed against a 5.13.50 server). All are `POST` unless noted, so none is retried on a 5xx or a timeout. | Endpoint | Method | Returns | | --- | --- | --- | | `POST /webhooks/{id}/retry` | `retry_webhook` | `Webhook` | | `POST /tasks/sort` | `sort_tasks` | `MessageResponse` | | `POST /designs/set/default` | `set_default_design` | `MessageResponse` | | `POST /client_gateway_tokens/{id}/setAsDefault` | `set_default_client_gateway_token` | `ClientGatewayToken` | | `GET /subscriptions/steps` | `subscription_steps` | `Vec` (*observed*: an object keyed by class name, not the array the source suggests) | | `POST /subscriptions/steps/check` | `check_subscription_steps` | `()` | | `POST /company_gateways/{id}/test` | `test_company_gateway` | `MessageResponse` | | `POST /company_gateways/{id}/clone` | `clone_company_gateway` | `CompanyGateway` | | `POST /company_gateways/{id}/import_customers` | `import_company_gateway_customers` | `MessageResponse` | | `POST /bank_integrations/refresh_accounts` | `refresh_bank_accounts` | `()` (the body is a serialised query builder) | | `POST /bank_integrations/remove_account/{id}` | `remove_bank_account` | `BankIntegration` | | `POST /bank_integrations/get_transactions/{id}` | `fetch_bank_transactions` | `MessageResponse` (the server ignores the id) | | `POST /bank_transactions/match` | `match_bank_transactions` | `Vec` | | `POST /clients/{id}/updateTaxData` | `update_client_tax_data` | `Client` | | `POST /clients/{id}/show_settings` | `client_show_settings` | `serde_json::Value` (*observed*) | | `POST /clients/{id}/peppol_discovery` | `client_peppol_discovery` | `serde_json::Value` | | `POST /clients/{id}/check_vat` | `check_client_vat` | `MessageResponse` | | `GET /clients/{id}/vat_status` | `client_vat_status` | `MessageResponse` (*observed*) | | `POST /clients/{id}/documents` | `client_documents` | `Vec` (*observed*; one page) | `updateTaxData` and `check_vat` call out to external tax services, so the smoke test does not exercise them. ## Not covered ### Nothing is blocked on a dependency Multipart uploads used to be, and are now wrapped — see [Document uploads](#document-uploads) and [Import and export](#import-and-export). ### Resource groups not wrapped None — all 34 `Route::resource` groups are covered. Adding a new one is mechanical: add a model under `src/models/`, re-export it from `src/models/mod.rs`, and add a `resource_methods!` invocation in `src/client.rs` (or `crud_methods!` if the resource has no `/bulk` route — check `routes/api.php`). ### Individual endpoints not wrapped - **Company extras** — `GET /companies/{id}/logo`, `POST /companies/{id}/default`, `updateOriginTaxData`, `POST /companies/current` - **User extras** (not password-protected, unlike the rest of `/users`) — `POST /users/{id}/disconnect_mailer`, `POST /users/{id}/disconnect_oauth`, `POST /user/{id}/reconfirm` - **Auth** — see [Logout, passkeys, and login](#logout-passkeys-and-login) above for what's covered (`login`, `logout`, passkey list/delete) and what isn't: `/login/precheck`, `oauth_login`, `oidc/*`, passkey registration and passkey login, `/refresh`, `/refresh_react`, and `/connected_account` - **Migration** — `/migration/start`. Moves a company between instances and is driven by the v4 migration tool, not by an SDK caller. - **Invoice extras** — `delivery_note`, `payment_schedule`, `update_reminders` - **Client-portal invitation downloads** — the `routes/client.php` family: `/client/invoice/{key}/download_pdf`, the `download_e_*` variants for quotes and credits, `/client/{entity}/{key}/download`, and `/client/documents/{hash}/hashed`. These are not under `/api/v1`, so they don't fit a `base_url` that ends there. Note these are *not* the invitation-key downloads documented above, which are authenticated `/api/v1` routes and are wrapped. - **Integration routes that cannot take an API token** — the OAuth legs of the QuickBooks and calendar flows (`/quickbooks/authorize/{token}`, `/quickbooks/reconnect/{token}`, `/quickbooks/authorized`, `/calendar_connection/{provider}/authorize/{hash}`) and the provider webhooks (`/yodlee/refresh`, `/yodlee/data_updates`, `/yodlee/refresh_updates`, `/yodlee/balance`, `/postmark_webhook`, `/sns_webhook`, `/postmark_inbound_webhook`, `/payment_webhook/…`). The OAuth routes sit outside the `api/v1` group and answer with browser redirects; the webhooks are things a provider calls, not things a client calls. Connecting an account is a browser flow — see [Integrations](#integrations) for the methods that operate a connection once it exists. - **`POST /quickbooks/settings`** — the route is registered, but `QuickbooksController` has no `settings` method, so it raises `BadMethodCallException` and 500s. Nothing to wrap until upstream adds the handler. - **Other integrations** — Apple in-app purchase, PostMark, self-update, templates, hooks (commented out upstream) - **Misc** — `/company_users`, `/company_ledger`, `/client_statement`, `/claim_license`, `/check_license`, `/check_subdomain`, `/one_time_token`, `/last_error`, `/support/messages/send`, `/verify/*`, `/accounts/{id}`, `/feedback`, `/filters/{entity}`, `/scheduler`, `/mailer/check`, `/smtp/check`, `/password_timeout`, `/webcron`, `/token_hash_router`, `/reactivate_email/{bounce_id}`, `/protected_download/{hash}`, `/confirm_purchase`, `/exports/preview/{hash}`, `/templates/preview/{hash}` ### Typed response gaps `statics`, `search`, and `health_check` return concrete types (`Statics`, `SearchResults`, `HealthCheck`). Each keeps the crate's `extra` convention, so fields they don't model stay reachable rather than being dropped, and each has a generic counterpart — `statics_as`, `search_as`, `health_check_as` — for pinning a shape they leave untyped. The [e-invoicing](#e-invoicing) and [integration](#integrations) methods return `serde_json::Value` as a rule, which is the largest untyped surface in the crate and is deliberate. Their replies are pass-throughs from an external service — Storecove, Stripe's gateway driver, Yodlee, GoCardless — or arrays assembled for Invoice Ninja's own React client. None has a documented schema, several differ by provider, and they change on the provider's release schedule rather than Invoice Ninja's. The precedent is `client_peppol_discovery`, which has returned `Value` since it landed. Where a shape *is* stable and is the point of the call it is still typed, as with `ExportJob` and `ImportPreview`. Two remain generic on purpose: - **`chart`** — the eleven chart endpoints share no common response shape (`totals_v2` returns per-currency maps, `cashflow_forecast` a date series, `calculated_fields` scalars), so there is nothing to narrow to. - **`send_email`** — the endpoint serves invoices, quotes, credits, and purchase orders, and answers with whichever entity was emailed. The `data` envelope *is* unwrapped, so `T` is the entity itself. Within the typed models, a few fields are deliberately left as `serde_json::Value`: `Statics::gateways`, `Statics::templates`, `Statics::bulk_updates`, `HealthCheck::extensions`, and `HealthCheck::queue_data`. These are nested configuration blobs rather than uniform lookup rows, and the same reasoning applies as for `Company::settings` — a wrong concrete type is a hard decode error, whereas `Value` is lossless. ## Compared with the official PHP SDK [invoiceninja/sdk-php](https://github.com/invoiceninja/sdk-php) is the first-party wrapper. It's worth checking against, but it is deliberately thinner than this crate: every one of its endpoint classes is just a URI on top of a shared `BaseEntity` offering `all`, `get`, `create`, `update`, `bulk`, `archive`, `delete`, `restore`, and `download`. Only `Companies` and `Statics` add anything of their own. **It covers nothing we don't.** The five resources it used to have on us — `users`, `companies`, `group_settings`, `bank_integrations`, `client_gateway_tokens` — are all wrapped now. **We cover a lot it doesn't:** `payment_terms`, `task_statuses`, `recurring_quotes`, `recurring_expenses`, `bank_transaction_rules`, `company_gateways`, `designs`, `locations`, `tags`, `task_schedulers`, `tokens`, `system_logs`, documents, per-entity actions (`mark_sent`, `mark_paid`, `clone_to_*`, …), per-entity PDF download by id and by invitation key, the XML e-documents, reports, charts, search, `send_email`, `refund_payment`, `ping`, `health_check`, automatic pagination (`list_all_*`), and `X-API-PASSWORD` support for the password-protected routes. Deliberate differences in behaviour: | | PHP SDK | This crate | | --- | --- | --- | | Request bodies | `form_params` (urlencoded) | JSON | | Base URL | host only; appends `/api/v1` itself | full API root, or `hosted()` | | Single-entity delete | none — `delete()` is bulk | `delete_*(id)` via HTTP `DELETE`, plus `bulk_*` | | Validation errors | surfaces only the first message | whole per-field `errors` map on `Error::Api` | | List filters | arbitrary array | typed setters plus `ListParams::param` | The urlencoded-vs-JSON difference is the one that matters: form encoding bracket-encodes nested structures, so a payload like an invoice's `line_items` array of objects is far cleaner as JSON. Both are accepted by the API. Two things in the PHP SDK are *not* worth copying: it sends `stream: false` in download payloads, but no controller reads a `stream` input, and its `BaseEntity::download` is available on every resource even though only PDF-rendering entities support the action.