Ledgerwren API
REST API for the Ledgerwren finance platform: cost centres, budgets, expense claims and customer invoices. Version 2.3.0.
Reads and writes
Every GET call only reads and changes nothing. Every POST call writes. POST /v1/invoices/{id}/send is the only call that reaches outside Ledgerwren: with the production key it emails the invoice to the customer's billing address; with the sandbox key nothing is emailed and the send is only recorded in the sandbox.
| Method | Path | What it does | Effect |
|---|---|---|---|
| GET | /v1/cost-centres | List cost centres | Only reads |
| GET | /v1/budgets | List budgets | Only reads |
| GET | /v1/customers | List customers | Only reads |
| GET | /v1/expenses | List expenses | Only reads |
| POST | /v1/expenses | Create an expense draft | Writes |
| GET | /v1/expenses/{id} | Get an expense | Only reads |
| POST | /v1/expenses/{id}/submit | Submit an expense for approval | Writes |
| POST | /v1/expenses/{id}/approve | Approve an expense | Writes |
| POST | /v1/expenses/{id}/reject | Reject an expense | Writes |
| GET | /v1/invoices | List invoices | Only reads |
| POST | /v1/invoices | Create an invoice draft | Writes |
| GET | /v1/invoices/{id} | Get an invoice | Only reads |
| POST | /v1/invoices/{id}/send | Send an invoice to the customer | Writes |
| POST | /v1/reset | Reset the sandbox | Writes |
Authentication
Base URL https://finance-demo.vortner.dev. Every /v1 request carries an API key:
curl https://finance-demo.vortner.dev/v1/expenses \
-H "Authorization: Bearer $LEDGERWREN_API_KEY"
Each company account has two keys. The production key works on the live records. The sandbox key reads the same records, but everything it writes (expense drafts and decisions, invoice drafts and sends) is kept apart from production and is not visible to the production key. Only the sandbox key may reset its records with POST /v1/reset. Every authenticated response carries X-Ledgerwren-Environment: production or sandbox. A missing or wrong key gets 401 unauthorized. An API key sees the whole company account, so decide in your integration who may use it.
Rate limits
120 requests per minute per API key, counted in fixed one-minute windows. Every /v1 response carries:
X-RateLimit-Limit | 120 |
X-RateLimit-Remaining | Requests left in the current window |
X-RateLimit-Reset | Unix time (seconds) when the window ends |
Over the limit the API answers 429 rate_limited with Retry-After in seconds. /openapi.json and this page are not rate limited.
Pagination
GET /v1/expenses and GET /v1/invoices are paginated with page (from 1) and per_page (1 to 100, default 25; larger values get 400). Responses look like:
{
"data": [ ... ],
"meta": { "page": 1, "per_page": 25, "total": 60, "total_pages": 3 },
"links": { "next": "/v1/expenses?page=2&per_page=25", "prev": null }
}
Single resources come as {"data": {...}}. Cost centres, budgets and customers are short lists returned complete in data.
Errors
{ "error": { "code": "not_found", "message": "No expense with id exp_9999." } }
| Status | Codes |
|---|---|
| 400 | invalid_parameter, invalid_body |
| 401 | unauthorized |
| 403 | not_approver, sandbox_only |
| 404 | not_found |
| 405 | method_not_allowed |
| 409 | invalid_status |
| 422 | unknown_person, person_inactive, invalid_dates, deal_mismatch |
| 429 | rate_limited |
Data model
A cost centre belongs to one department and is owned by its head. Each cost centre has a budget per year; what it has spent is its booked costs plus its approved expenses. An expense is claimed by one person, booked to one cost centre and decided by one approver: the submitter's manager, or for the chief executive the cost centre's owner. It moves from draft to submitted to approved or rejected. An invoice goes to one customer, usually for one CRM deal, and has a sales owner, the deal's owner. It moves from draft to sent to paid; a sent invoice past its due date reads as overdue.
CostCentre
| Field | Type | Description |
|---|---|---|
id | string | |
code | string | The four-digit code used in bookkeeping. |
name | string | |
department | string | The department the cost centre belongs to. |
owner_email | string (email) | Work email of the owner, the head of the department. |
owner_name | string | null | Full name of the owner. |
Budget
| Field | Type | Description |
|---|---|---|
id | string | |
cost_centre_id | string | |
cost_centre_name | string | |
year | integer | Calendar year the budget covers. |
currency | string | |
planned | number | The amount planned for the year. |
booked_costs | number | Costs booked outside expense claims: suppliers, licences, events. |
approved_expenses | number | Approved expense claims of the cost centre dated in the year. |
spent | number | booked_costs + approved_expenses. |
remaining | number | planned - spent; negative when the cost centre is over budget. |
Customer
| Field | Type | Description |
|---|---|---|
id | string | Customer id, the same as the company id in the CRM. |
name | string | |
city | string | |
country | string | ISO 3166-1 alpha-2 country code. |
billing_email | string (email) | Where sent invoices go. |
deal_ids | array | The won CRM deals invoiced to this customer. |
Expense
| Field | Type | Description |
|---|---|---|
id | string | |
submitter_email | string (email) | Work email of the person who claims the expense. |
submitter_name | string | null | Full name of the submitter. |
cost_centre_id | string | The cost centre the expense is booked to. |
category | travel | accommodation | meals | client_entertainment | software | equipment | training | office_supplies | other | |
description | string | What the expense was for. |
amount | number | Amount in currency, at most two decimals. |
currency | string | |
expense_date | string (date) | The day the money was spent. |
status | draft | submitted | approved | rejected | |
approver_email | string (email) | Work email of the approver: the submitter's manager, or for the chief executive the cost centre's owner. |
approver_name | string | null | Full name of the approver. |
created_at | string (date-time) | When the claim was created (UTC). |
submitted_at | string | null (date-time) | When it was submitted for approval; null for a draft. |
decided_at | string | null (date-time) | When it was approved or rejected; null until then. |
decision_comment | string | null | The approver's comment; always set on a rejection. |
Invoice
| Field | Type | Description |
|---|---|---|
id | string | |
number | string | null | Invoice number AF-<year>-<nnnn>, given when the invoice is sent; null for a draft. |
customer_id | string | |
customer_name | string | null | |
deal_id | string | null | The CRM deal the invoice belongs to. |
description | string | What the invoice is for. |
amount | number | Net amount in currency, before VAT. |
currency | string | |
status | draft | sent | paid | overdue | |
payment_terms_days | integer | Days from the issue date to the due date. |
issue_date | string | null (date) | The day the invoice was sent; null for a draft. |
due_date | string | null (date) | The day payment is due; null for a draft. |
sent_at | string | null (date-time) | When the invoice was sent (UTC). |
sent_to | string | null (email) | The customer's billing address it was sent to. |
delivery | string | null | email: emailed to sent_to; sandbox_recorded: sent with the sandbox key, recorded only and never emailed; null for a draft. |
paid_at | string | null (date) | The day payment arrived; null until paid. |
sales_owner_email | string (email) | Work email of the person who owns the deal. |
sales_owner_name | string | null | Full name of the sales owner. |
created_at | string (date-time) | When the invoice was created (UTC). |
Endpoints
GET /openapi.json Only reads
Get this API description
Returns this OpenAPI 3.1 document. No API key is needed and the request does not count toward the rate limit. Only reads; changes nothing.
200The OpenAPI document.
GET /v1/cost-centres Only reads
List cost centres
Returns every cost centre with its owner, the head of its department. The list is complete and not paginated. Only reads; changes nothing.
200The cost centres.401The API key is missing or not valid (unauthorized).429More than 120 requests in the current minute for this API key (rate_limited).500An unexpected error on our side (internal_error). Retry later.
GET /v1/budgets Only reads
List budgets
Returns the budget of each cost centre per year: planned, spent and remaining. spent is booked_costs (supplier invoices, licences and other costs booked in Ledgerwren) plus approved_expenses (approved expense claims of the cost centre dated in that year), so approving an expense raises it. remaining is negative when a cost centre is over its budget. Budgets exist for 2026. The list is complete and not paginated. Only reads; changes nothing.
| Parameter | In | Required | Type | Description |
|---|---|---|---|---|
year | query | no | integer | Only budgets of this calendar year. |
cost_centre_id | query | no | string | Only the budget of this cost centre. |
200The budgets.400A parameter or the request body is missing or malformed (invalid_parameter,invalid_body).401The API key is missing or not valid (unauthorized).404The expense, invoice, cost centre, customer or deal does not exist (not_found).429More than 120 requests in the current minute for this API key (rate_limited).500An unexpected error on our side (internal_error). Retry later.
GET /v1/customers Only reads
List customers
Returns every customer company with its billing address and the won CRM deals invoiced to it. The list is complete and not paginated. Only reads; changes nothing.
200The customers.401The API key is missing or not valid (unauthorized).429More than 120 requests in the current minute for this API key (rate_limited).500An unexpected error on our side (internal_error). Retry later.
GET /v1/expenses Only reads
List expenses
Returns expense claims, newest expense date first. Filters combine with AND. submitter_email gives one person's own claims; approver_email gives the claims a person approves, which are their team's. Drafts are included; filter by status to leave them out. Only reads; changes nothing.
| Parameter | In | Required | Type | Description |
|---|---|---|---|---|
page | query | no | integer | Page number, starting at 1. |
per_page | query | no | integer | Items per page, at most 100. |
submitter_email | query | no | string (email) | Only expenses submitted by this person (exact address, case-insensitive). |
approver_email | query | no | string (email) | Only expenses this person approves (exact address, case-insensitive). |
cost_centre_id | query | no | string | Only expenses booked to this cost centre. |
status | query | no | draft | submitted | approved | rejected | Only expenses with this status. |
category | query | no | travel | accommodation | meals | client_entertainment | software | equipment | training | office_supplies | other | Only expenses of this category. |
from | query | no | string (date) | Only expenses dated on or after this day (YYYY-MM-DD). |
to | query | no | string (date) | Only expenses dated on or before this day (YYYY-MM-DD). |
200A page of expenses.400A parameter or the request body is missing or malformed (invalid_parameter,invalid_body).401The API key is missing or not valid (unauthorized).404The expense, invoice, cost centre, customer or deal does not exist (not_found).429More than 120 requests in the current minute for this API key (rate_limited).500An unexpected error on our side (internal_error). Retry later.
POST /v1/expenses Writes
Create an expense draft
Writes: creates an expense claim as a draft for a person of the company. Ledgerwren sets the approver from the reporting line and books the claim to the submitter's department's cost centre unless cost_centre_id says otherwise. The expense date may not be in the future or more than 90 days back. A draft goes to the approver only when it is submitted.
Body (JSON): submitter_email, category, amount, expense_date, description required; cost_centre_id optional.
201The draft was created.400A parameter or the request body is missing or malformed (invalid_parameter,invalid_body).401The API key is missing or not valid (unauthorized).404The expense, invoice, cost centre, customer or deal does not exist (not_found).422The claim is well-formed but cannot be made:unknown_person(the submitter is not in the company),person_inactive(has left) orinvalid_dates(in the future or more than 90 days back).429More than 120 requests in the current minute for this API key (rate_limited).500An unexpected error on our side (internal_error). Retry later.
GET /v1/expenses/{id} Only reads
Get an expense
Returns one expense claim by id. Only reads; changes nothing.
| Parameter | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string | Expense id. |
200The expense.401The API key is missing or not valid (unauthorized).404The expense, invoice, cost centre, customer or deal does not exist (not_found).429More than 120 requests in the current minute for this API key (rate_limited).500An unexpected error on our side (internal_error). Retry later.
POST /v1/expenses/{id}/submit Writes
Submit an expense for approval
Writes: moves a draft to submitted, so it waits for its approver. No request body. Anything but a draft answers 409 invalid_status.
| Parameter | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string | Expense id. |
200The submitted expense.401The API key is missing or not valid (unauthorized).404The expense, invoice, cost centre, customer or deal does not exist (not_found).409The expense is not a draft.429More than 120 requests in the current minute for this API key (rate_limited).500An unexpected error on our side (internal_error). Retry later.
POST /v1/expenses/{id}/approve Writes
Approve an expense
Writes: approves a submitted expense. approver_email must be the expense's approver; anyone else, and the submitter above all, gets 403 not_approver. An approved expense counts toward its cost centre's budget.
| Parameter | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string | Expense id. |
Body (JSON): approver_email required; comment optional.
200The approved expense.400A parameter or the request body is missing or malformed (invalid_parameter,invalid_body).401The API key is missing or not valid (unauthorized).403The person named is not the approver of this expense (not_approver).404The expense, invoice, cost centre, customer or deal does not exist (not_found).409The expense is not waiting for approval.429More than 120 requests in the current minute for this API key (rate_limited).500An unexpected error on our side (internal_error). Retry later.
POST /v1/expenses/{id}/reject Writes
Reject an expense
Writes: rejects a submitted expense with a comment for the submitter. approver_email must be the expense's approver; anyone else gets 403 not_approver.
| Parameter | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string | Expense id. |
Body (JSON): approver_email, comment required.
200The rejected expense.400A parameter or the request body is missing or malformed (invalid_parameter,invalid_body).401The API key is missing or not valid (unauthorized).403The person named is not the approver of this expense (not_approver).404The expense, invoice, cost centre, customer or deal does not exist (not_found).409The expense is not waiting for approval.429More than 120 requests in the current minute for this API key (rate_limited).500An unexpected error on our side (internal_error). Retry later.
GET /v1/invoices Only reads
List invoices
Returns invoices to customers, newest first. Filters combine with AND. sales_owner_email gives the invoices of one person's deals. status overdue is a sent invoice whose due date has passed; Ledgerwren works it out when the invoice is read. Only reads; changes nothing.
| Parameter | In | Required | Type | Description |
|---|---|---|---|---|
page | query | no | integer | Page number, starting at 1. |
per_page | query | no | integer | Items per page, at most 100. |
status | query | no | draft | sent | paid | overdue | Only invoices with this status. |
customer_id | query | no | string | Only invoices to this customer. |
deal_id | query | no | string | Only invoices of this CRM deal. |
sales_owner_email | query | no | string (email) | Only invoices whose sales owner is this person (exact address, case-insensitive). |
200A page of invoices.400A parameter or the request body is missing or malformed (invalid_parameter,invalid_body).401The API key is missing or not valid (unauthorized).429More than 120 requests in the current minute for this API key (rate_limited).500An unexpected error on our side (internal_error). Retry later.
POST /v1/invoices Writes
Create an invoice draft
Writes: creates an invoice to a customer as a draft. Nothing is sent and the draft has no number yet; it gets its number, issue date and due date when it is sent. With a deal_id the sales owner defaults to the deal's owner; without one, sales_owner_email is required. The deal must belong to the customer.
Body (JSON): customer_id, description, amount required; deal_id, sales_owner_email, payment_terms_days optional.
201The draft was created.400A parameter or the request body is missing or malformed (invalid_parameter,invalid_body).401The API key is missing or not valid (unauthorized).404The expense, invoice, cost centre, customer or deal does not exist (not_found).422The draft is well-formed but cannot be made:deal_mismatch(the deal belongs to another customer) orunknown_person(the sales owner is not in the company).429More than 120 requests in the current minute for this API key (rate_limited).500An unexpected error on our side (internal_error). Retry later.
GET /v1/invoices/{id} Only reads
Get an invoice
Returns one invoice by id. Only reads; changes nothing.
| Parameter | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string | Invoice id. |
200The invoice.401The API key is missing or not valid (unauthorized).404The expense, invoice, cost centre, customer or deal does not exist (not_found).429More than 120 requests in the current minute for this API key (rate_limited).500An unexpected error on our side (internal_error). Retry later.
POST /v1/invoices/{id}/send Writes
Send an invoice to the customer
Writes, and reaches outside Ledgerwren: issues a draft invoice and sends it to the customer. The invoice gets the next number of the year, today as its issue date and a due date payment_terms_days later, and its status becomes sent. With the production key the invoice is emailed to the customer's billing address (delivery email). With the sandbox key nothing is emailed: the send is only recorded in the sandbox (delivery sandbox_recorded) and the production records do not change. No request body. A sent invoice cannot be sent again (409 invalid_status).
| Parameter | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string | Invoice id. |
200The sent invoice.401The API key is missing or not valid (unauthorized).404The expense, invoice, cost centre, customer or deal does not exist (not_found).409The invoice is not a draft.429More than 120 requests in the current minute for this API key (rate_limited).500An unexpected error on our side (internal_error). Retry later.
POST /v1/reset Writes
Reset the sandbox
Writes: puts the sandbox back to the account's seed records by deleting everything the sandbox key has written (expense drafts and decisions, invoice drafts and sends). Only the sandbox key may call it; the production key gets 403 sandbox_only and production records are never reset. No request body.
200The sandbox is back to the seed records.401The API key is missing or not valid (unauthorized).403Called with the production key (sandbox_only).429More than 120 requests in the current minute for this API key (rate_limited).500An unexpected error on our side (internal_error). Retry later.