{
  "openapi": "3.1.0",
  "info": {
    "title": "Ledgerwren API",
    "version": "2.3.0",
    "summary": "Cost centres, budgets, expense claims and customer invoices for one Ledgerwren company account.",
    "description": "The Ledgerwren API gives programmatic access to the finance records of one company account: cost centres and their\nowners, the yearly budget of each cost centre, expense claims from submission to approval, and invoices to customers.\nAll requests and responses are JSON. Amounts are in euros (`EUR`) with at most two decimals; invoice amounts are net. Dates are ISO 8601\ncalendar dates (`YYYY-MM-DD`); timestamps are UTC. People are named by their work email address.\n\n**Reads and writes.** Every `GET` only reads and changes nothing. The `POST` calls write: they create an expense\ndraft, submit it, approve or reject it, create an invoice draft, send an invoice, or reset the sandbox.\n`POST /v1/invoices/{id}/send` is the only call that reaches outside Ledgerwren: with the production key the invoice\nis emailed to the customer's billing address. With the sandbox key nothing is emailed; the send is only recorded.\n\n**Authentication.** Every `/v1` request carries an API key as `Authorization: Bearer <key>`. Each company account has\ntwo keys: a *production* key, which works on the live records, and a *sandbox* key, which reads the same records but\nkeeps everything it writes apart from production. Every response says which one served it in the\n`X-Ledgerwren-Environment` header (`production` or `sandbox`). API keys see the whole company account; restrict who\nmay use a key in your own integration.\n\n**Rate limits.** 120 requests per minute per API key, counted in fixed one-minute windows. Every response carries\n`X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (Unix time in seconds when the window resets).\nOver the limit the API answers `429` with a `Retry-After` header in seconds.\n\n**Pagination.** Expenses and invoices are paginated: `page` (from 1) and `per_page` (1 to 100, default 25), with\n`meta` holding `page`, `per_page`, `total` and `total_pages`, plus `links.next` and `links.prev` (relative URLs, or\nnull). Cost centres, budgets and customers are short lists returned complete.\n\n**Errors.** Errors are `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}` with a stable machine-readable `code` and a\nhuman-readable `message`.\n\n**Approvals.** Every expense has one approver: the submitter's manager, or for the chief executive, who has no\nmanager, the owner of the expense's cost centre. Only that person may approve or reject it, and nobody decides their\nown expense.",
    "contact": {
      "name": "Ledgerwren Developer Support",
      "url": "https://finance-demo.vortner.dev/docs"
    }
  },
  "servers": [
    {
      "url": "https://finance-demo.vortner.dev",
      "description": "Ledgerwren API"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "tags": [
    {
      "name": "Cost centres",
      "description": "The cost centres of the company, one per department, and who owns each one."
    },
    {
      "name": "Budgets",
      "description": "The planned and spent amount of each cost centre per year."
    },
    {
      "name": "Customers",
      "description": "The companies invoiced, with their billing address and deals."
    },
    {
      "name": "Expenses",
      "description": "Expense claims by people of the company, from draft to approval."
    },
    {
      "name": "Invoices",
      "description": "Invoices to customers, from draft to sent and paid."
    },
    {
      "name": "Sandbox",
      "description": "Calls that only the sandbox key may make."
    },
    {
      "name": "Meta",
      "description": "This API description."
    }
  ],
  "paths": {
    "/openapi.json": {
      "get": {
        "operationId": "getOpenApi",
        "tags": [
          "Meta"
        ],
        "summary": "Get this API description",
        "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.",
        "security": [],
        "responses": {
          "200": {
            "description": "The OpenAPI document.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      }
    },
    "/v1/cost-centres": {
      "get": {
        "operationId": "listCostCentres",
        "tags": [
          "Cost centres"
        ],
        "summary": "List cost centres",
        "description": "Returns every cost centre with its owner, the head of its department. The list is complete and not paginated. Only reads; changes nothing.",
        "responses": {
          "200": {
            "description": "The cost centres.",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              },
              "X-Ledgerwren-Environment": {
                "$ref": "#/components/headers/X-Ledgerwren-Environment"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/CostCentre"
                      }
                    }
                  }
                },
                "example": {
                  "data": [
                    {
                      "id": "cc_1200",
                      "code": "1200",
                      "name": "Sales",
                      "department": "Sales",
                      "owner_email": "clara.benedetti@alderfinch.example",
                      "owner_name": "Clara Benedetti"
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/budgets": {
      "get": {
        "operationId": "listBudgets",
        "tags": [
          "Budgets"
        ],
        "summary": "List budgets",
        "description": "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.",
        "parameters": [
          {
            "name": "year",
            "in": "query",
            "required": false,
            "description": "Only budgets of this calendar year.",
            "schema": {
              "type": "integer",
              "minimum": 2000,
              "maximum": 2100
            },
            "example": 2026
          },
          {
            "name": "cost_centre_id",
            "in": "query",
            "required": false,
            "description": "Only the budget of this cost centre.",
            "schema": {
              "type": "string"
            },
            "example": "cc_1200"
          }
        ],
        "responses": {
          "200": {
            "description": "The budgets.",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              },
              "X-Ledgerwren-Environment": {
                "$ref": "#/components/headers/X-Ledgerwren-Environment"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Budget"
                      }
                    }
                  }
                },
                "example": {
                  "data": [
                    {
                      "id": "bud_2026_1200",
                      "cost_centre_id": "cc_1200",
                      "cost_centre_name": "Sales",
                      "year": 2026,
                      "currency": "EUR",
                      "planned": 420000,
                      "booked_costs": 418200,
                      "approved_expenses": 2773.5,
                      "spent": 420973.5,
                      "remaining": -973.5
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/customers": {
      "get": {
        "operationId": "listCustomers",
        "tags": [
          "Customers"
        ],
        "summary": "List customers",
        "description": "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.",
        "responses": {
          "200": {
            "description": "The customers.",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              },
              "X-Ledgerwren-Environment": {
                "$ref": "#/components/headers/X-Ledgerwren-Environment"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Customer"
                      }
                    }
                  }
                },
                "example": {
                  "data": [
                    {
                      "id": "cmp_3001",
                      "name": "Merrow Valley Foods",
                      "city": "Hamburg",
                      "country": "DE",
                      "billing_email": "invoices@merrowvalley.example",
                      "deal_ids": [
                        "deal_5001"
                      ]
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/expenses": {
      "get": {
        "operationId": "listExpenses",
        "tags": [
          "Expenses"
        ],
        "summary": "List expenses",
        "description": "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.",
        "parameters": [
          {
            "$ref": "#/components/parameters/page"
          },
          {
            "$ref": "#/components/parameters/per_page"
          },
          {
            "name": "submitter_email",
            "in": "query",
            "required": false,
            "description": "Only expenses submitted by this person (exact address, case-insensitive).",
            "schema": {
              "type": "string",
              "format": "email"
            },
            "example": "malik.haddad@alderfinch.example"
          },
          {
            "name": "approver_email",
            "in": "query",
            "required": false,
            "description": "Only expenses this person approves (exact address, case-insensitive).",
            "schema": {
              "type": "string",
              "format": "email"
            },
            "example": "clara.benedetti@alderfinch.example"
          },
          {
            "name": "cost_centre_id",
            "in": "query",
            "required": false,
            "description": "Only expenses booked to this cost centre.",
            "schema": {
              "type": "string"
            },
            "example": "cc_1200"
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Only expenses with this status.",
            "schema": {
              "$ref": "#/components/schemas/ExpenseStatus"
            }
          },
          {
            "name": "category",
            "in": "query",
            "required": false,
            "description": "Only expenses of this category.",
            "schema": {
              "$ref": "#/components/schemas/ExpenseCategory"
            }
          },
          {
            "name": "from",
            "in": "query",
            "required": false,
            "description": "Only expenses dated on or after this day (YYYY-MM-DD).",
            "schema": {
              "type": "string",
              "format": "date"
            },
            "example": "2026-10-01"
          },
          {
            "name": "to",
            "in": "query",
            "required": false,
            "description": "Only expenses dated on or before this day (YYYY-MM-DD).",
            "schema": {
              "type": "string",
              "format": "date"
            },
            "example": "2026-11-30"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of expenses.",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              },
              "X-Ledgerwren-Environment": {
                "$ref": "#/components/headers/X-Ledgerwren-Environment"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "meta",
                    "links"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Expense"
                      }
                    },
                    "meta": {
                      "$ref": "#/components/schemas/PageMeta"
                    },
                    "links": {
                      "$ref": "#/components/schemas/PageLinks"
                    }
                  }
                },
                "example": {
                  "data": [
                    {
                      "id": "exp_3009",
                      "submitter_email": "malik.haddad@alderfinch.example",
                      "submitter_name": "Malik Haddad",
                      "cost_centre_id": "cc_1200",
                      "category": "client_entertainment",
                      "description": "Dinner with Quarry & Fern Architects, three people",
                      "amount": 186,
                      "currency": "EUR",
                      "expense_date": "2026-11-05",
                      "status": "submitted",
                      "approver_email": "clara.benedetti@alderfinch.example",
                      "approver_name": "Clara Benedetti",
                      "created_at": "2026-11-05T17:30:00Z",
                      "submitted_at": "2026-11-06T09:15:00Z",
                      "decided_at": null,
                      "decision_comment": null
                    }
                  ],
                  "meta": {
                    "page": 1,
                    "per_page": 25,
                    "total": 60,
                    "total_pages": 3
                  },
                  "links": {
                    "next": "/v1/expenses?page=2&per_page=25",
                    "prev": null
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      },
      "post": {
        "operationId": "createExpense",
        "tags": [
          "Expenses"
        ],
        "summary": "Create an expense draft",
        "description": "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.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ExpenseCreate"
              },
              "example": {
                "submitter_email": "malik.haddad@alderfinch.example",
                "category": "travel",
                "amount": 84.5,
                "expense_date": "2026-11-10",
                "description": "Train Munich to Augsburg, customer visit"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The draft was created.",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              },
              "X-Ledgerwren-Environment": {
                "$ref": "#/components/headers/X-Ledgerwren-Environment"
              },
              "Location": {
                "description": "Path of the new expense.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Expense"
                    }
                  }
                },
                "example": {
                  "data": {
                    "id": "exp_5b0e2f9c41d7",
                    "submitter_email": "malik.haddad@alderfinch.example",
                    "submitter_name": "Malik Haddad",
                    "cost_centre_id": "cc_1200",
                    "category": "travel",
                    "description": "Train Munich to Augsburg, customer visit",
                    "amount": 84.5,
                    "currency": "EUR",
                    "expense_date": "2026-11-10",
                    "status": "draft",
                    "approver_email": "clara.benedetti@alderfinch.example",
                    "approver_name": "Clara Benedetti",
                    "created_at": "2026-11-11T08:02:10Z",
                    "submitted_at": null,
                    "decided_at": null,
                    "decision_comment": null
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "description": "The claim is well-formed but cannot be made: `unknown_person` (the submitter is not in the company), `person_inactive` (has left) or `invalid_dates` (in the future or more than 90 days back).",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              },
              "X-Ledgerwren-Environment": {
                "$ref": "#/components/headers/X-Ledgerwren-Environment"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": {
                    "code": "invalid_dates",
                    "message": "expense_date may not be in the future."
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/expenses/{id}": {
      "get": {
        "operationId": "getExpense",
        "tags": [
          "Expenses"
        ],
        "summary": "Get an expense",
        "description": "Returns one expense claim by id. Only reads; changes nothing.",
        "parameters": [
          {
            "$ref": "#/components/parameters/expenseId"
          }
        ],
        "responses": {
          "200": {
            "description": "The expense.",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              },
              "X-Ledgerwren-Environment": {
                "$ref": "#/components/headers/X-Ledgerwren-Environment"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Expense"
                    }
                  }
                },
                "example": {
                  "data": {
                    "id": "exp_3009",
                    "submitter_email": "malik.haddad@alderfinch.example",
                    "submitter_name": "Malik Haddad",
                    "cost_centre_id": "cc_1200",
                    "category": "client_entertainment",
                    "description": "Dinner with Quarry & Fern Architects, three people",
                    "amount": 186,
                    "currency": "EUR",
                    "expense_date": "2026-11-05",
                    "status": "submitted",
                    "approver_email": "clara.benedetti@alderfinch.example",
                    "approver_name": "Clara Benedetti",
                    "created_at": "2026-11-05T17:30:00Z",
                    "submitted_at": "2026-11-06T09:15:00Z",
                    "decided_at": null,
                    "decision_comment": null
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/expenses/{id}/submit": {
      "post": {
        "operationId": "submitExpense",
        "tags": [
          "Expenses"
        ],
        "summary": "Submit an expense for approval",
        "description": "Writes: moves a `draft` to `submitted`, so it waits for its approver. No request body. Anything but a draft answers `409 invalid_status`.",
        "parameters": [
          {
            "$ref": "#/components/parameters/expenseId"
          }
        ],
        "responses": {
          "200": {
            "description": "The submitted expense.",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              },
              "X-Ledgerwren-Environment": {
                "$ref": "#/components/headers/X-Ledgerwren-Environment"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Expense"
                    }
                  }
                },
                "example": {
                  "data": {
                    "id": "exp_3009",
                    "submitter_email": "malik.haddad@alderfinch.example",
                    "submitter_name": "Malik Haddad",
                    "cost_centre_id": "cc_1200",
                    "category": "client_entertainment",
                    "description": "Dinner with Quarry & Fern Architects, three people",
                    "amount": 186,
                    "currency": "EUR",
                    "expense_date": "2026-11-05",
                    "status": "submitted",
                    "approver_email": "clara.benedetti@alderfinch.example",
                    "approver_name": "Clara Benedetti",
                    "created_at": "2026-11-05T17:30:00Z",
                    "submitted_at": "2026-11-06T09:15:00Z",
                    "decided_at": null,
                    "decision_comment": null
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "The expense is not a draft.",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              },
              "X-Ledgerwren-Environment": {
                "$ref": "#/components/headers/X-Ledgerwren-Environment"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": {
                    "code": "invalid_status",
                    "message": "Expense exp_3009 is submitted; only a draft can be submitted."
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/expenses/{id}/approve": {
      "post": {
        "operationId": "approveExpense",
        "tags": [
          "Expenses"
        ],
        "summary": "Approve an expense",
        "description": "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.",
        "parameters": [
          {
            "$ref": "#/components/parameters/expenseId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ExpenseApproval"
              },
              "example": {
                "approver_email": "clara.benedetti@alderfinch.example",
                "comment": "Fine, thanks."
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The approved expense.",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              },
              "X-Ledgerwren-Environment": {
                "$ref": "#/components/headers/X-Ledgerwren-Environment"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Expense"
                    }
                  }
                },
                "example": {
                  "data": {
                    "id": "exp_3009",
                    "submitter_email": "malik.haddad@alderfinch.example",
                    "submitter_name": "Malik Haddad",
                    "cost_centre_id": "cc_1200",
                    "category": "client_entertainment",
                    "description": "Dinner with Quarry & Fern Architects, three people",
                    "amount": 186,
                    "currency": "EUR",
                    "expense_date": "2026-11-05",
                    "status": "approved",
                    "approver_email": "clara.benedetti@alderfinch.example",
                    "approver_name": "Clara Benedetti",
                    "created_at": "2026-11-05T17:30:00Z",
                    "submitted_at": "2026-11-06T09:15:00Z",
                    "decided_at": "2026-11-11T09:30:00Z",
                    "decision_comment": "Fine, thanks."
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/NotApprover"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "The expense is not waiting for approval.",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              },
              "X-Ledgerwren-Environment": {
                "$ref": "#/components/headers/X-Ledgerwren-Environment"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": {
                    "code": "invalid_status",
                    "message": "Expense exp_3003 is approved; only a submitted expense can be approved."
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/expenses/{id}/reject": {
      "post": {
        "operationId": "rejectExpense",
        "tags": [
          "Expenses"
        ],
        "summary": "Reject an expense",
        "description": "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`.",
        "parameters": [
          {
            "$ref": "#/components/parameters/expenseId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ExpenseRejection"
              },
              "example": {
                "approver_email": "clara.benedetti@alderfinch.example",
                "comment": "Please split the bill per person and submit again."
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The rejected expense.",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              },
              "X-Ledgerwren-Environment": {
                "$ref": "#/components/headers/X-Ledgerwren-Environment"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Expense"
                    }
                  }
                },
                "example": {
                  "data": {
                    "id": "exp_3009",
                    "submitter_email": "malik.haddad@alderfinch.example",
                    "submitter_name": "Malik Haddad",
                    "cost_centre_id": "cc_1200",
                    "category": "client_entertainment",
                    "description": "Dinner with Quarry & Fern Architects, three people",
                    "amount": 186,
                    "currency": "EUR",
                    "expense_date": "2026-11-05",
                    "status": "rejected",
                    "approver_email": "clara.benedetti@alderfinch.example",
                    "approver_name": "Clara Benedetti",
                    "created_at": "2026-11-05T17:30:00Z",
                    "submitted_at": "2026-11-06T09:15:00Z",
                    "decided_at": "2026-11-11T09:30:00Z",
                    "decision_comment": "Please split the bill per person and submit again."
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/NotApprover"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "The expense is not waiting for approval.",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              },
              "X-Ledgerwren-Environment": {
                "$ref": "#/components/headers/X-Ledgerwren-Environment"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": {
                    "code": "invalid_status",
                    "message": "Expense exp_3003 is approved; only a submitted expense can be rejected."
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/invoices": {
      "get": {
        "operationId": "listInvoices",
        "tags": [
          "Invoices"
        ],
        "summary": "List invoices",
        "description": "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.",
        "parameters": [
          {
            "$ref": "#/components/parameters/page"
          },
          {
            "$ref": "#/components/parameters/per_page"
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Only invoices with this status.",
            "schema": {
              "$ref": "#/components/schemas/InvoiceStatus"
            }
          },
          {
            "name": "customer_id",
            "in": "query",
            "required": false,
            "description": "Only invoices to this customer.",
            "schema": {
              "type": "string"
            },
            "example": "cmp_3004"
          },
          {
            "name": "deal_id",
            "in": "query",
            "required": false,
            "description": "Only invoices of this CRM deal.",
            "schema": {
              "type": "string"
            },
            "example": "deal_5017"
          },
          {
            "name": "sales_owner_email",
            "in": "query",
            "required": false,
            "description": "Only invoices whose sales owner is this person (exact address, case-insensitive).",
            "schema": {
              "type": "string",
              "format": "email"
            },
            "example": "sofia.petrova@alderfinch.example"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of invoices.",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              },
              "X-Ledgerwren-Environment": {
                "$ref": "#/components/headers/X-Ledgerwren-Environment"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "meta",
                    "links"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Invoice"
                      }
                    },
                    "meta": {
                      "$ref": "#/components/schemas/PageMeta"
                    },
                    "links": {
                      "$ref": "#/components/schemas/PageLinks"
                    }
                  }
                },
                "example": {
                  "data": [
                    {
                      "id": "inv_4014",
                      "number": "AF-2026-0014",
                      "customer_id": "cmp_3004",
                      "customer_name": "Greyfeather Logistics",
                      "deal_id": "deal_5017",
                      "description": "Enterprise agreement, 400 seats",
                      "amount": 168000,
                      "currency": "EUR",
                      "status": "overdue",
                      "payment_terms_days": 30,
                      "issue_date": "2026-09-28",
                      "due_date": "2026-10-28",
                      "sent_at": "2026-09-28T09:00:00Z",
                      "sent_to": "invoices@greyfeather.example",
                      "delivery": "email",
                      "paid_at": null,
                      "sales_owner_email": "clara.benedetti@alderfinch.example",
                      "sales_owner_name": "Clara Benedetti",
                      "created_at": "2026-09-28T08:30:00Z"
                    }
                  ],
                  "meta": {
                    "page": 1,
                    "per_page": 25,
                    "total": 26,
                    "total_pages": 2
                  },
                  "links": {
                    "next": "/v1/invoices?page=2&per_page=25",
                    "prev": null
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      },
      "post": {
        "operationId": "createInvoice",
        "tags": [
          "Invoices"
        ],
        "summary": "Create an invoice draft",
        "description": "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.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/InvoiceCreate"
              },
              "example": {
                "customer_id": "cmp_3007",
                "description": "Additional 10 seats, pro rata",
                "amount": 1600,
                "sales_owner_email": "sofia.petrova@alderfinch.example",
                "payment_terms_days": 30
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The draft was created.",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              },
              "X-Ledgerwren-Environment": {
                "$ref": "#/components/headers/X-Ledgerwren-Environment"
              },
              "Location": {
                "description": "Path of the new invoice.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Invoice"
                    }
                  }
                },
                "example": {
                  "data": {
                    "id": "inv_7c1d93b0e4aa",
                    "number": null,
                    "customer_id": "cmp_3007",
                    "customer_name": "Fernhollow Dairy",
                    "deal_id": null,
                    "description": "Additional 10 seats, pro rata",
                    "amount": 1600,
                    "currency": "EUR",
                    "status": "draft",
                    "payment_terms_days": 30,
                    "issue_date": null,
                    "due_date": null,
                    "sent_at": null,
                    "sent_to": null,
                    "delivery": null,
                    "paid_at": null,
                    "sales_owner_email": "sofia.petrova@alderfinch.example",
                    "sales_owner_name": "Sofia Petrova",
                    "created_at": "2026-11-12T10:04:51Z"
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "description": "The draft is well-formed but cannot be made: `deal_mismatch` (the deal belongs to another customer) or `unknown_person` (the sales owner is not in the company).",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              },
              "X-Ledgerwren-Environment": {
                "$ref": "#/components/headers/X-Ledgerwren-Environment"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": {
                    "code": "deal_mismatch",
                    "message": "Deal deal_5009 belongs to customer cmp_3003, not cmp_3001."
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/invoices/{id}": {
      "get": {
        "operationId": "getInvoice",
        "tags": [
          "Invoices"
        ],
        "summary": "Get an invoice",
        "description": "Returns one invoice by id. Only reads; changes nothing.",
        "parameters": [
          {
            "$ref": "#/components/parameters/invoiceId"
          }
        ],
        "responses": {
          "200": {
            "description": "The invoice.",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              },
              "X-Ledgerwren-Environment": {
                "$ref": "#/components/headers/X-Ledgerwren-Environment"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Invoice"
                    }
                  }
                },
                "example": {
                  "data": {
                    "id": "inv_4014",
                    "number": "AF-2026-0014",
                    "customer_id": "cmp_3004",
                    "customer_name": "Greyfeather Logistics",
                    "deal_id": "deal_5017",
                    "description": "Enterprise agreement, 400 seats",
                    "amount": 168000,
                    "currency": "EUR",
                    "status": "overdue",
                    "payment_terms_days": 30,
                    "issue_date": "2026-09-28",
                    "due_date": "2026-10-28",
                    "sent_at": "2026-09-28T09:00:00Z",
                    "sent_to": "invoices@greyfeather.example",
                    "delivery": "email",
                    "paid_at": null,
                    "sales_owner_email": "clara.benedetti@alderfinch.example",
                    "sales_owner_name": "Clara Benedetti",
                    "created_at": "2026-09-28T08:30:00Z"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/invoices/{id}/send": {
      "post": {
        "operationId": "sendInvoice",
        "tags": [
          "Invoices"
        ],
        "summary": "Send an invoice to the customer",
        "description": "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`).",
        "parameters": [
          {
            "$ref": "#/components/parameters/invoiceId"
          }
        ],
        "responses": {
          "200": {
            "description": "The sent invoice.",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              },
              "X-Ledgerwren-Environment": {
                "$ref": "#/components/headers/X-Ledgerwren-Environment"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Invoice"
                    }
                  }
                },
                "example": {
                  "data": {
                    "id": "inv_7c1d93b0e4aa",
                    "number": "AF-2026-0023",
                    "customer_id": "cmp_3007",
                    "customer_name": "Fernhollow Dairy",
                    "deal_id": null,
                    "description": "Additional 10 seats, pro rata",
                    "amount": 1600,
                    "currency": "EUR",
                    "status": "sent",
                    "payment_terms_days": 30,
                    "issue_date": "2026-11-12",
                    "due_date": "2026-12-12",
                    "sent_at": "2026-11-12T10:06:12Z",
                    "sent_to": "invoices@fernhollow.example",
                    "delivery": "sandbox_recorded",
                    "paid_at": null,
                    "sales_owner_email": "sofia.petrova@alderfinch.example",
                    "sales_owner_name": "Sofia Petrova",
                    "created_at": "2026-11-12T10:04:51Z"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "The invoice is not a draft.",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              },
              "X-Ledgerwren-Environment": {
                "$ref": "#/components/headers/X-Ledgerwren-Environment"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": {
                    "code": "invalid_status",
                    "message": "Invoice inv_4014 is overdue; only a draft can be sent."
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/reset": {
      "post": {
        "operationId": "resetSandbox",
        "tags": [
          "Sandbox"
        ],
        "summary": "Reset the sandbox",
        "description": "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.",
        "responses": {
          "200": {
            "description": "The sandbox is back to the seed records.",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              },
              "X-Ledgerwren-Environment": {
                "$ref": "#/components/headers/X-Ledgerwren-Environment"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/ResetResult"
                    }
                  }
                },
                "example": {
                  "data": {
                    "environment": "sandbox",
                    "reset": true,
                    "at": "2026-11-12T10:10:00Z"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Called with the production key (`sandbox_only`).",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              },
              "X-Ledgerwren-Environment": {
                "$ref": "#/components/headers/X-Ledgerwren-Environment"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": {
                    "code": "sandbox_only",
                    "message": "Reset works only with the sandbox key; production records are never reset."
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "An API key sent as `Authorization: Bearer <key>`. Each company account has a production key (live records) and a sandbox key (same records; writes kept apart from production). The `X-Ledgerwren-Environment` response header says which one served the request."
      }
    },
    "parameters": {
      "page": {
        "name": "page",
        "in": "query",
        "required": false,
        "description": "Page number, starting at 1.",
        "schema": {
          "type": "integer",
          "minimum": 1,
          "default": 1
        }
      },
      "per_page": {
        "name": "per_page",
        "in": "query",
        "required": false,
        "description": "Items per page, at most 100.",
        "schema": {
          "type": "integer",
          "minimum": 1,
          "maximum": 100,
          "default": 25
        }
      },
      "expenseId": {
        "name": "id",
        "in": "path",
        "required": true,
        "description": "Expense id.",
        "schema": {
          "type": "string"
        },
        "example": "exp_3009"
      },
      "invoiceId": {
        "name": "id",
        "in": "path",
        "required": true,
        "description": "Invoice id.",
        "schema": {
          "type": "string"
        },
        "example": "inv_4018"
      }
    },
    "headers": {
      "X-RateLimit-Limit": {
        "description": "Requests allowed per one-minute window for this API key (120).",
        "schema": {
          "type": "integer"
        }
      },
      "X-RateLimit-Remaining": {
        "description": "Requests left in the current window.",
        "schema": {
          "type": "integer"
        }
      },
      "X-RateLimit-Reset": {
        "description": "Unix time in seconds when the current window ends.",
        "schema": {
          "type": "integer"
        }
      },
      "X-Ledgerwren-Environment": {
        "description": "Which key served the request: `production` or `sandbox`.",
        "schema": {
          "type": "string",
          "enum": [
            "production",
            "sandbox"
          ]
        }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "A parameter or the request body is missing or malformed (`invalid_parameter`, `invalid_body`).",
        "headers": {
          "X-RateLimit-Limit": {
            "$ref": "#/components/headers/X-RateLimit-Limit"
          },
          "X-RateLimit-Remaining": {
            "$ref": "#/components/headers/X-RateLimit-Remaining"
          },
          "X-RateLimit-Reset": {
            "$ref": "#/components/headers/X-RateLimit-Reset"
          },
          "X-Ledgerwren-Environment": {
            "$ref": "#/components/headers/X-Ledgerwren-Environment"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": {
                "code": "invalid_body",
                "message": "amount must be between 0.01 and 10000."
              }
            }
          }
        }
      },
      "Unauthorized": {
        "description": "The API key is missing or not valid (`unauthorized`).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": {
                "code": "unauthorized",
                "message": "Missing or invalid API key. Send Authorization: Bearer <key>."
              }
            }
          }
        }
      },
      "NotApprover": {
        "description": "The person named is not the approver of this expense (`not_approver`).",
        "headers": {
          "X-RateLimit-Limit": {
            "$ref": "#/components/headers/X-RateLimit-Limit"
          },
          "X-RateLimit-Remaining": {
            "$ref": "#/components/headers/X-RateLimit-Remaining"
          },
          "X-RateLimit-Reset": {
            "$ref": "#/components/headers/X-RateLimit-Reset"
          },
          "X-Ledgerwren-Environment": {
            "$ref": "#/components/headers/X-Ledgerwren-Environment"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": {
                "code": "not_approver",
                "message": "Only the approver of exp_3009 (clara.benedetti@alderfinch.example) may approve it."
              }
            }
          }
        }
      },
      "NotFound": {
        "description": "The expense, invoice, cost centre, customer or deal does not exist (`not_found`).",
        "headers": {
          "X-RateLimit-Limit": {
            "$ref": "#/components/headers/X-RateLimit-Limit"
          },
          "X-RateLimit-Remaining": {
            "$ref": "#/components/headers/X-RateLimit-Remaining"
          },
          "X-RateLimit-Reset": {
            "$ref": "#/components/headers/X-RateLimit-Reset"
          },
          "X-Ledgerwren-Environment": {
            "$ref": "#/components/headers/X-Ledgerwren-Environment"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": {
                "code": "not_found",
                "message": "No expense with id exp_9999."
              }
            }
          }
        }
      },
      "RateLimited": {
        "description": "More than 120 requests in the current minute for this API key (`rate_limited`).",
        "headers": {
          "X-RateLimit-Limit": {
            "$ref": "#/components/headers/X-RateLimit-Limit"
          },
          "X-RateLimit-Remaining": {
            "$ref": "#/components/headers/X-RateLimit-Remaining"
          },
          "X-RateLimit-Reset": {
            "$ref": "#/components/headers/X-RateLimit-Reset"
          },
          "X-Ledgerwren-Environment": {
            "$ref": "#/components/headers/X-Ledgerwren-Environment"
          },
          "Retry-After": {
            "description": "Seconds until the window resets.",
            "schema": {
              "type": "integer"
            }
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": {
                "code": "rate_limited",
                "message": "Rate limit of 120 requests per minute exceeded. Retry after 17 seconds."
              }
            }
          }
        }
      },
      "InternalError": {
        "description": "An unexpected error on our side (`internal_error`). Retry later.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": {
                "code": "internal_error",
                "message": "An unexpected error occurred."
              }
            }
          }
        }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "object",
            "required": [
              "code",
              "message"
            ],
            "properties": {
              "code": {
                "type": "string",
                "description": "Stable machine-readable code.",
                "examples": [
                  "not_found"
                ]
              },
              "message": {
                "type": "string",
                "description": "Human-readable explanation."
              }
            }
          }
        }
      },
      "PageMeta": {
        "type": "object",
        "required": [
          "page",
          "per_page",
          "total",
          "total_pages"
        ],
        "properties": {
          "page": {
            "type": "integer",
            "description": "The page returned."
          },
          "per_page": {
            "type": "integer",
            "description": "Items per page."
          },
          "total": {
            "type": "integer",
            "description": "Items across all pages."
          },
          "total_pages": {
            "type": "integer",
            "description": "Number of pages (at least 1)."
          }
        }
      },
      "PageLinks": {
        "type": "object",
        "required": [
          "next",
          "prev"
        ],
        "properties": {
          "next": {
            "type": [
              "string",
              "null"
            ],
            "description": "Relative URL of the next page, or null on the last page."
          },
          "prev": {
            "type": [
              "string",
              "null"
            ],
            "description": "Relative URL of the previous page, or null on the first page."
          }
        }
      },
      "CostCentre": {
        "type": "object",
        "required": [
          "id",
          "code",
          "name",
          "department",
          "owner_email",
          "owner_name"
        ],
        "properties": {
          "id": {
            "type": "string",
            "examples": [
              "cc_1200"
            ]
          },
          "code": {
            "type": "string",
            "description": "The four-digit code used in bookkeeping.",
            "examples": [
              "1200"
            ]
          },
          "name": {
            "type": "string",
            "examples": [
              "Sales"
            ]
          },
          "department": {
            "type": "string",
            "description": "The department the cost centre belongs to."
          },
          "owner_email": {
            "type": "string",
            "format": "email",
            "description": "Work email of the owner, the head of the department."
          },
          "owner_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Full name of the owner."
          }
        }
      },
      "Budget": {
        "type": "object",
        "required": [
          "id",
          "cost_centre_id",
          "cost_centre_name",
          "year",
          "currency",
          "planned",
          "booked_costs",
          "approved_expenses",
          "spent",
          "remaining"
        ],
        "properties": {
          "id": {
            "type": "string",
            "examples": [
              "bud_2026_1200"
            ]
          },
          "cost_centre_id": {
            "type": "string"
          },
          "cost_centre_name": {
            "type": "string"
          },
          "year": {
            "type": "integer",
            "description": "Calendar year the budget covers."
          },
          "currency": {
            "type": "string",
            "enum": [
              "EUR"
            ]
          },
          "planned": {
            "type": "number",
            "description": "The amount planned for the year."
          },
          "booked_costs": {
            "type": "number",
            "description": "Costs booked outside expense claims: suppliers, licences, events."
          },
          "approved_expenses": {
            "type": "number",
            "description": "Approved expense claims of the cost centre dated in the year."
          },
          "spent": {
            "type": "number",
            "description": "`booked_costs + approved_expenses`."
          },
          "remaining": {
            "type": "number",
            "description": "`planned - spent`; negative when the cost centre is over budget."
          }
        }
      },
      "Customer": {
        "type": "object",
        "required": [
          "id",
          "name",
          "city",
          "country",
          "billing_email",
          "deal_ids"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "Customer id, the same as the company id in the CRM.",
            "examples": [
              "cmp_3001"
            ]
          },
          "name": {
            "type": "string",
            "examples": [
              "Merrow Valley Foods"
            ]
          },
          "city": {
            "type": "string"
          },
          "country": {
            "type": "string",
            "description": "ISO 3166-1 alpha-2 country code.",
            "examples": [
              "DE"
            ]
          },
          "billing_email": {
            "type": "string",
            "format": "email",
            "description": "Where sent invoices go."
          },
          "deal_ids": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "The won CRM deals invoiced to this customer."
          }
        }
      },
      "ExpenseCategory": {
        "type": "string",
        "enum": [
          "travel",
          "accommodation",
          "meals",
          "client_entertainment",
          "software",
          "equipment",
          "training",
          "office_supplies",
          "other"
        ],
        "description": "Kind of expense."
      },
      "ExpenseStatus": {
        "type": "string",
        "enum": [
          "draft",
          "submitted",
          "approved",
          "rejected"
        ],
        "description": "`draft`: not yet sent for approval; `submitted`: waiting for the approver; `approved` or `rejected`: decided."
      },
      "Expense": {
        "type": "object",
        "required": [
          "id",
          "submitter_email",
          "submitter_name",
          "cost_centre_id",
          "category",
          "description",
          "amount",
          "currency",
          "expense_date",
          "status",
          "approver_email",
          "approver_name",
          "created_at",
          "submitted_at",
          "decided_at",
          "decision_comment"
        ],
        "properties": {
          "id": {
            "type": "string",
            "examples": [
              "exp_3009"
            ]
          },
          "submitter_email": {
            "type": "string",
            "format": "email",
            "description": "Work email of the person who claims the expense."
          },
          "submitter_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Full name of the submitter."
          },
          "cost_centre_id": {
            "type": "string",
            "description": "The cost centre the expense is booked to."
          },
          "category": {
            "$ref": "#/components/schemas/ExpenseCategory"
          },
          "description": {
            "type": "string",
            "description": "What the expense was for."
          },
          "amount": {
            "type": "number",
            "description": "Amount in `currency`, at most two decimals."
          },
          "currency": {
            "type": "string",
            "enum": [
              "EUR"
            ]
          },
          "expense_date": {
            "type": "string",
            "format": "date",
            "description": "The day the money was spent."
          },
          "status": {
            "$ref": "#/components/schemas/ExpenseStatus"
          },
          "approver_email": {
            "type": "string",
            "format": "email",
            "description": "Work email of the approver: the submitter's manager, or for the chief executive the cost centre's owner."
          },
          "approver_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Full name of the approver."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the claim was created (UTC)."
          },
          "submitted_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When it was submitted for approval; null for a draft."
          },
          "decided_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When it was approved or rejected; null until then."
          },
          "decision_comment": {
            "type": [
              "string",
              "null"
            ],
            "description": "The approver's comment; always set on a rejection."
          }
        }
      },
      "ExpenseCreate": {
        "type": "object",
        "required": [
          "submitter_email",
          "category",
          "amount",
          "expense_date",
          "description"
        ],
        "additionalProperties": false,
        "properties": {
          "submitter_email": {
            "type": "string",
            "format": "email",
            "description": "The person claiming the expense."
          },
          "category": {
            "$ref": "#/components/schemas/ExpenseCategory"
          },
          "amount": {
            "type": "number",
            "minimum": 0.01,
            "maximum": 10000,
            "description": "Amount in EUR, at most two decimals."
          },
          "expense_date": {
            "type": "string",
            "format": "date",
            "description": "The day the money was spent: not in the future, at most 90 days back."
          },
          "description": {
            "type": "string",
            "maxLength": 200,
            "description": "What the expense was for."
          },
          "cost_centre_id": {
            "type": "string",
            "description": "The cost centre to book to; defaults to the submitter's department's."
          }
        }
      },
      "ExpenseApproval": {
        "type": "object",
        "required": [
          "approver_email"
        ],
        "additionalProperties": false,
        "properties": {
          "approver_email": {
            "type": "string",
            "format": "email",
            "description": "The person approving; must be the expense's approver."
          },
          "comment": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 500,
            "description": "Optional comment for the submitter."
          }
        }
      },
      "ExpenseRejection": {
        "type": "object",
        "required": [
          "approver_email",
          "comment"
        ],
        "additionalProperties": false,
        "properties": {
          "approver_email": {
            "type": "string",
            "format": "email",
            "description": "The person rejecting; must be the expense's approver."
          },
          "comment": {
            "type": "string",
            "maxLength": 500,
            "description": "Why the claim is rejected, for the submitter."
          }
        }
      },
      "InvoiceStatus": {
        "type": "string",
        "enum": [
          "draft",
          "sent",
          "paid",
          "overdue"
        ],
        "description": "`draft`: not sent, no number yet; `sent`: with the customer, not yet due; `paid`; `overdue`: sent and past its due date."
      },
      "Invoice": {
        "type": "object",
        "required": [
          "id",
          "number",
          "customer_id",
          "customer_name",
          "deal_id",
          "description",
          "amount",
          "currency",
          "status",
          "payment_terms_days",
          "issue_date",
          "due_date",
          "sent_at",
          "sent_to",
          "delivery",
          "paid_at",
          "sales_owner_email",
          "sales_owner_name",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "examples": [
              "inv_4018"
            ]
          },
          "number": {
            "type": [
              "string",
              "null"
            ],
            "description": "Invoice number `AF-<year>-<nnnn>`, given when the invoice is sent; null for a draft.",
            "examples": [
              "AF-2026-0018"
            ]
          },
          "customer_id": {
            "type": "string"
          },
          "customer_name": {
            "type": [
              "string",
              "null"
            ]
          },
          "deal_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "The CRM deal the invoice belongs to."
          },
          "description": {
            "type": "string",
            "description": "What the invoice is for."
          },
          "amount": {
            "type": "number",
            "description": "Net amount in `currency`, before VAT."
          },
          "currency": {
            "type": "string",
            "enum": [
              "EUR"
            ]
          },
          "status": {
            "$ref": "#/components/schemas/InvoiceStatus"
          },
          "payment_terms_days": {
            "type": "integer",
            "description": "Days from the issue date to the due date."
          },
          "issue_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "The day the invoice was sent; null for a draft."
          },
          "due_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "The day payment is due; null for a draft."
          },
          "sent_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the invoice was sent (UTC)."
          },
          "sent_to": {
            "type": [
              "string",
              "null"
            ],
            "format": "email",
            "description": "The customer's billing address it was sent to."
          },
          "delivery": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "email",
              "sandbox_recorded",
              null
            ],
            "description": "`email`: emailed to `sent_to`; `sandbox_recorded`: sent with the sandbox key, recorded only and never emailed; null for a draft."
          },
          "paid_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "The day payment arrived; null until paid."
          },
          "sales_owner_email": {
            "type": "string",
            "format": "email",
            "description": "Work email of the person who owns the deal."
          },
          "sales_owner_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Full name of the sales owner."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the invoice was created (UTC)."
          }
        }
      },
      "InvoiceCreate": {
        "type": "object",
        "required": [
          "customer_id",
          "description",
          "amount"
        ],
        "additionalProperties": false,
        "properties": {
          "customer_id": {
            "type": "string",
            "description": "The customer to invoice."
          },
          "deal_id": {
            "type": "string",
            "description": "The CRM deal; must belong to the customer."
          },
          "description": {
            "type": "string",
            "maxLength": 200,
            "description": "What the invoice is for."
          },
          "amount": {
            "type": "number",
            "minimum": 0.01,
            "maximum": 1000000,
            "description": "Net amount in EUR, before VAT, at most two decimals."
          },
          "sales_owner_email": {
            "type": "string",
            "format": "email",
            "description": "The sales owner; defaults to the deal's owner, required without a deal."
          },
          "payment_terms_days": {
            "type": "integer",
            "minimum": 7,
            "maximum": 90,
            "default": 30,
            "description": "Days from sending to the due date."
          }
        }
      },
      "ResetResult": {
        "type": "object",
        "required": [
          "environment",
          "reset",
          "at"
        ],
        "properties": {
          "environment": {
            "type": "string",
            "enum": [
              "sandbox"
            ]
          },
          "reset": {
            "type": "boolean",
            "description": "Always true."
          },
          "at": {
            "type": "string",
            "format": "date-time",
            "description": "When the sandbox was reset (UTC)."
          }
        }
      }
    }
  }
}