The "auth routes" open question in GOALS.md conflated four unrelated things once the upstream routes and controllers were checked: logout and the passkey settings routes sit behind the same token_auth middleware as everything else this crate wraps, login needs a credential exchange since there's no token yet, refresh/refresh_react turned out to be the bulk sync-dump endpoint already parked as a separate open question, and connected_account is Gmail OAuth linking that belongs with Integrations. Adds logout, list_passkeys, delete_passkey (passkey registration is left out — it needs a WebAuthn ceremony a browser has to drive), and InvoiceNinjaClient::login as an associated function returning a lean LoginResponse that types only is_owner/is_admin/token and leaves the server's much larger nested payload in extra. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
34 KiB
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 puts them in rough
priority order.
Checked against routes/api.php on the v5-stable branch of the
invoiceninja repo, 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.yamlis reliable for schemas but incomplete for routes — it documents/{id}and action paths only for clients, locations, and tags. Useroutes/api.phpand 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[<entity_type>] — 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). 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 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<Value> |
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_einvoiceanswers422when validation finds problems, so "this invoice is not Peppol-ready" — an ordinary result — arrives asError::Api, and the per-entity detail in the body is not reachable through the typed error. AnOkmeans 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'smessagethen falls back to an excerpt anderrorsis empty. peppol_setuphas two success shapes. Acorppass_urlwhen the connection needs a further browser step (Singapore's CorpPass), or a bare204when it completed. HenceOption<Value>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.
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
POSTcarrying_method=PUT. The route is registered asPUT, but PHP does not parse a multipart body on aPUT, so the files would never arrive. Laravel's kernel turns the spoofed method back into aPUTbefore routing. The OpenAPI spec documentsPOSTwith_method=POST, which would 405 against these routes. is_publicis sent as1/0. Laravel'sbooleanrule accepts onlytrue,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/<id>", &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<Activity> (observed) |
GET /activities?reactv2=1 |
activity_feed(rows) |
Vec<ActivityFeedEntry> (observed) |
POST /activities/entity |
entity_activity(entity, id, rows) |
Vec<ActivityFeedEntry> (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<serde_json::Value> |
POST /emails/entityHistory |
entity_email_history |
Vec<serde_json::Value> |
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_activitytakesinvoice;add_activity_notetakesinvoices. rows, notper_page, caps the activity log (default 75). Pass it throughListParams::param.entity_activityleavesrowsout when not given rather than sendingnull, because the server tests for the key and a null would count as given and remove the cap.- The two activity shapes differ.
Activitycarries hashed ids and a stringactivity_type_id; the feed'sActivityFeedEntrycarries 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), andlive_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
POSTs 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 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 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<SubscriptionStep> (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<BankTransaction> |
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<Document> (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 and 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
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.phpfamily:/client/invoice/{key}/download_pdf, thedownload_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 abase_urlthat ends there. Note these are not the invitation-key downloads documented above, which are authenticated/api/v1routes 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 theapi/v1group 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 for the methods that operate a connection once it exists. POST /quickbooks/settings— the route is registered, butQuickbooksControllerhas nosettingsmethod, so it raisesBadMethodCallExceptionand 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 and integration 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_v2returns per-currency maps,cashflow_forecasta date series,calculated_fieldsscalars), 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. Thedataenvelope is unwrapped, soTis 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 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.