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.

MethodPathWhat it doesEffect
GET/v1/cost-centresList cost centresOnly reads
GET/v1/budgetsList budgetsOnly reads
GET/v1/customersList customersOnly reads
GET/v1/expensesList expensesOnly reads
POST/v1/expensesCreate an expense draftWrites
GET/v1/expenses/{id}Get an expenseOnly reads
POST/v1/expenses/{id}/submitSubmit an expense for approvalWrites
POST/v1/expenses/{id}/approveApprove an expenseWrites
POST/v1/expenses/{id}/rejectReject an expenseWrites
GET/v1/invoicesList invoicesOnly reads
POST/v1/invoicesCreate an invoice draftWrites
GET/v1/invoices/{id}Get an invoiceOnly reads
POST/v1/invoices/{id}/sendSend an invoice to the customerWrites
POST/v1/resetReset the sandboxWrites

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-Limit120
X-RateLimit-RemainingRequests left in the current window
X-RateLimit-ResetUnix 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." } }
StatusCodes
400invalid_parameter, invalid_body
401unauthorized
403not_approver, sandbox_only
404not_found
405method_not_allowed
409invalid_status
422unknown_person, person_inactive, invalid_dates, deal_mismatch
429rate_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

FieldTypeDescription
idstring
codestringThe four-digit code used in bookkeeping.
namestring
departmentstringThe department the cost centre belongs to.
owner_emailstring (email)Work email of the owner, the head of the department.
owner_namestring | nullFull name of the owner.

Budget

FieldTypeDescription
idstring
cost_centre_idstring
cost_centre_namestring
yearintegerCalendar year the budget covers.
currencystring
plannednumberThe amount planned for the year.
booked_costsnumberCosts booked outside expense claims: suppliers, licences, events.
approved_expensesnumberApproved expense claims of the cost centre dated in the year.
spentnumberbooked_costs + approved_expenses.
remainingnumberplanned - spent; negative when the cost centre is over budget.

Customer

FieldTypeDescription
idstringCustomer id, the same as the company id in the CRM.
namestring
citystring
countrystringISO 3166-1 alpha-2 country code.
billing_emailstring (email)Where sent invoices go.
deal_idsarrayThe won CRM deals invoiced to this customer.

Expense

FieldTypeDescription
idstring
submitter_emailstring (email)Work email of the person who claims the expense.
submitter_namestring | nullFull name of the submitter.
cost_centre_idstringThe cost centre the expense is booked to.
categorytravel | accommodation | meals | client_entertainment | software | equipment | training | office_supplies | other
descriptionstringWhat the expense was for.
amountnumberAmount in currency, at most two decimals.
currencystring
expense_datestring (date)The day the money was spent.
statusdraft | submitted | approved | rejected
approver_emailstring (email)Work email of the approver: the submitter's manager, or for the chief executive the cost centre's owner.
approver_namestring | nullFull name of the approver.
created_atstring (date-time)When the claim was created (UTC).
submitted_atstring | null (date-time)When it was submitted for approval; null for a draft.
decided_atstring | null (date-time)When it was approved or rejected; null until then.
decision_commentstring | nullThe approver's comment; always set on a rejection.

Invoice

FieldTypeDescription
idstring
numberstring | nullInvoice number AF-<year>-<nnnn>, given when the invoice is sent; null for a draft.
customer_idstring
customer_namestring | null
deal_idstring | nullThe CRM deal the invoice belongs to.
descriptionstringWhat the invoice is for.
amountnumberNet amount in currency, before VAT.
currencystring
statusdraft | sent | paid | overdue
payment_terms_daysintegerDays from the issue date to the due date.
issue_datestring | null (date)The day the invoice was sent; null for a draft.
due_datestring | null (date)The day payment is due; null for a draft.
sent_atstring | null (date-time)When the invoice was sent (UTC).
sent_tostring | null (email)The customer's billing address it was sent to.
deliverystring | nullemail: emailed to sent_to; sandbox_recorded: sent with the sandbox key, recorded only and never emailed; null for a draft.
paid_atstring | null (date)The day payment arrived; null until paid.
sales_owner_emailstring (email)Work email of the person who owns the deal.
sales_owner_namestring | nullFull name of the sales owner.
created_atstring (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.

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.

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.

ParameterInRequiredTypeDescription
yearquerynointegerOnly budgets of this calendar year.
cost_centre_idquerynostringOnly the budget of this cost centre.

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.

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.

ParameterInRequiredTypeDescription
pagequerynointegerPage number, starting at 1.
per_pagequerynointegerItems per page, at most 100.
submitter_emailquerynostring (email)Only expenses submitted by this person (exact address, case-insensitive).
approver_emailquerynostring (email)Only expenses this person approves (exact address, case-insensitive).
cost_centre_idquerynostringOnly expenses booked to this cost centre.
statusquerynodraft | submitted | approved | rejectedOnly expenses with this status.
categoryquerynotravel | accommodation | meals | client_entertainment | software | equipment | training | office_supplies | otherOnly expenses of this category.
fromquerynostring (date)Only expenses dated on or after this day (YYYY-MM-DD).
toquerynostring (date)Only expenses dated on or before this day (YYYY-MM-DD).

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.

GET /v1/expenses/{id} Only reads

Get an expense

Returns one expense claim by id. Only reads; changes nothing.

ParameterInRequiredTypeDescription
idpathyesstringExpense id.

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.

ParameterInRequiredTypeDescription
idpathyesstringExpense id.

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.

ParameterInRequiredTypeDescription
idpathyesstringExpense id.

Body (JSON): approver_email required; comment optional.

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.

ParameterInRequiredTypeDescription
idpathyesstringExpense id.

Body (JSON): approver_email, comment required.

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.

ParameterInRequiredTypeDescription
pagequerynointegerPage number, starting at 1.
per_pagequerynointegerItems per page, at most 100.
statusquerynodraft | sent | paid | overdueOnly invoices with this status.
customer_idquerynostringOnly invoices to this customer.
deal_idquerynostringOnly invoices of this CRM deal.
sales_owner_emailquerynostring (email)Only invoices whose sales owner is this person (exact address, case-insensitive).

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.

GET /v1/invoices/{id} Only reads

Get an invoice

Returns one invoice by id. Only reads; changes nothing.

ParameterInRequiredTypeDescription
idpathyesstringInvoice id.

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).

ParameterInRequiredTypeDescription
idpathyesstringInvoice id.

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.