# Propel API — Guide

The Propel API gives read access to your **projects**, their **budgets** and the **records ledger**
(the money that moves on a project). It is the same data the Propel app shows, with the same access rules.

- **Base URL:** `https://sandbox.api.propel-industrial.com` (the address you are reading this from)
- **Reference (every endpoint and field):** [/docs](https://sandbox.api.propel-industrial.com/docs) · machine-readable: [/v0/openapi.json](https://sandbox.api.propel-industrial.com/v0/openapi.json)
- **For scripts and AI agents:** the machine-readable spec [/v0/openapi.json](https://sandbox.api.propel-industrial.com/v0/openapi.json) (complete, no key needed) · this guide as plain text: [/llms.txt](https://sandbox.api.propel-industrial.com/llms.txt), [/docs/guide.md](https://sandbox.api.propel-industrial.com/docs/guide.md)
- **No key yet?** [Request access](https://sandbox.api.propel-industrial.com/access) — every new organisation starts with a free 14-day trial.
- **Something wrong, missing or confusing?** Tell us with `POST /v0/feedback` ([how](#7b-feedback)) — agents
  are welcome to send it on their own. Every API response carries the address in its `Link` header (`rel="feedback"`).

---

## 1. Quickstart

```bash
export PROPEL_KEY="pk_test_…"          # an API key (see §2)

# projects you can access
curl -s https://sandbox.api.propel-industrial.com/v0/projects \
  -H "Authorization: Bearer $PROPEL_KEY"

# a project's budget: structure + approved / invoiced / paid per node
curl -s https://sandbox.api.propel-industrial.com/v0/projects/{projectId}/budget \
  -H "Authorization: Bearer $PROPEL_KEY"

# fully approved records (payment pending or paid) of one project, 200 per page
curl -s "https://sandbox.api.propel-industrial.com/v0/records?projectId={projectId}&status=paymentPending,paid&limit=200" \
  -H "Authorization: Bearer $PROPEL_KEY"
```

---

## 2. Authentication

Every `/v0` request needs `Authorization: Bearer <token>`. Two kinds of token:

| Token | Who | How to get it |
|---|---|---|
| **Session token** | a signed-in Propel user (e.g. the Propel app itself) | the app's sign-in |
| **API key** `pk_<env>_<prefix>_<secret>` | a script, integration or agent | your Propel administrator creates it — shown **once** |

**API keys act on behalf of a user (their owner).** On every request the key gets the owner's
**current** access — if the owner loses access to a project, so does the key, immediately.
On top of that, a key has its own **ceiling**:

- **projects** — optionally limited to a list of projects;
- **field groups** — by default `core, amounts, budget, documents` (no bank, tax, payment or notes
  unless granted explicitly).

Keys are bound to one environment (`test` or `live`) and one tenant; a
`test` key never works on production. Keys can be revoked instantly; only a hash is stored.
Treat keys like passwords — never put them in code, emails or tickets.

---

## 3. Access: projects and field groups

You only ever see projects you are entitled to. **A project or record you can't access returns
`404`**, exactly like one that doesn't exist — the API never confirms that something exists.

Within a project, **field groups** decide which fields you see. Every field in the reference is
labelled with its group (`x-field-group`). Fields you may not see are **omitted**, never nulled.

| Group | Contains (examples) | admin | user | guest |
|---|---|:-:|:-:|:-:|
| `core` | ids, names, status, dates of record, counterparty name | ✔ | ✔ | ✔ |
| `amounts` | net / tax / gross, budget totals | ✔ | ✔ | – |
| `budget` | budget node, structure, codes | ✔ | ✔ | – |
| `payment` | due date, payment date | ✔ | ✔ | – |
| `documents` | document names, the project's intake address | ✔ | ✔ | – |
| `accounting` | accounting codes (e.g. Yardi, DATEV) on budget lines and records | ✔ | ✔ | – |
| `tax` | contractor tax id, registration number | ✔ | ✔ | – |
| `bank` | *(not exposed on the API)* | | | |

Totals follow the same rule: if you can't see `amounts`, budget totals are not returned either,
and **filtering on a field group you can't see is rejected** (`400`) — so nothing can be inferred.

---

## 4. Conventions

**Amounts** — always an `Amount` object, never a bare number, never a decimal:

```json
{ "value": 123456, "currency": "EUR" }     // = 1,234.56 EUR
```

`value` is an integer in the currency's smallest unit (cents for EUR). Amounts in different
currencies are never added together.

**Dates** — calendar dates are `YYYY-MM-DD` in Europe/Vienna (e.g. `recognitionDate`, `cashDate`).
Instants, where they appear, are ISO-8601 in UTC (`2026-09-30T14:03:00Z`).

**IDs** — opaque strings; don't parse them. Budget nodes also carry a readable `code`
(`"07-0007"`) which a company can change — **store the `nodeId`, display the `code`**.
A `nodeId` comes from the company's budget structure, so projects on the same structure share it
(`0 Site` has the same `nodeId` on every development project): when you combine projects, key by
**`projectId` + `nodeId`**.

**Allocations** — `allocations` says where a record's amount is booked: today one entry (the whole net
amount on one budget line). An invoice split across budget lines will carry several entries that add up to
`net`; `budgetNode` is a shortcut for the single-line case and is `null` when a record is split.

**Records** — the ledger. Each record has `systemKind` (`cost` · `income` · `financing` · `tax`),
`direction` (`in` / `out`), `basis` (`actual` / `forecast`) and `source` (`invoice`, …).
v0 returns **cost / actual** records from invoices. `status` is the lifecycle
(`setup → approvalPending → paymentPending → paid`, or `rejected`); `workflowStep` is the
project's own step name (e.g. "Developer Approval").

**Budget structure** — three levels: **budget → sub-budget → cost item**. Each project uses a
structure (a company template) and may add project-only nodes or switch template nodes off.
`/v0/projects/{id}/budget` returns the project's active nodes, **parents before children**, with
`budget` (approved), `invoiced` (all non-rejected invoices, net, *setup included*) and `paid`.
`invoiced` is **not** the payment queue: invoices still in `setup` count too. `invoicedByStatus` splits it
(`setup`, `approvalPending`, `paymentPending`, `paid` — together = `invoiced`); the payment queue is
`paymentPending`. `invoicedGross` and `paidGross` give the same totals gross (what leaves the bank);
`budget`, `invoiced` and `paid` stay net.
**A node's amounts include its own records and all its descendants — total the top level only.**
Every `parentId` points at a node in the same response. `?level=budget` returns just the top lines,
`?level=subBudget` two levels. Invoiced amounts without a budget node are reported as `unallocated`, approved budget without one as
`unallocatedBudget`. All totals are in the project's currency; amounts in any other currency are never
added in — they are listed per currency under `excluded`. Budget figures are net; gross is on the record.

---

## 4a. Sending invoices

Every project has an **intake address** (`inboundEmail` on the project, needs the `documents` field group).
Email invoices there as **PDF attachments** — one email may carry several PDFs:

1. Each PDF becomes **one record** with `status: setup` and `intake.channel: email`.
2. The document is read automatically: `intake.status` goes `queued → processing → completed`
   (usually within a minute) or `failed`. Until it is matched, `counterparty.name` may be `null`.
3. The record then follows the project's approval workflow like any other invoice.

To follow up on what you sent:

```bash
curl -s "https://sandbox.api.propel-industrial.com/v0/records?projectId={projectId}&channel=email&status=setup&limit=200" \
  -H "Authorization: Bearer $PROPEL_KEY"
```

Emails without a PDF are ignored. `intake.receivedAt` is when the record was created.

- **Senders:** emails are processed from the project's approved senders. Mail from any other sender is held
  for review by the project team and only becomes records once released, so a PDF may appear later or not at all.
- **Duplicates:** the same PDF sent to the same project again is processed once (unless the first was rejected).
- **Wrong address:** mail to an address that isn't a project's intake address is rejected.

---

## 5. Pagination

Lists return:

```json
{ "data": [ … ], "page": { "nextCursor": "eyJ2Ijox…", "limit": 50 } }
```

**To read a whole list: use `limit=200` and follow `nextCursor` until it is `null`.**
`limit` is 1–200 (default 50). A page may hold fewer items than `limit` even when more follow —
always rely on `nextCursor`. Records are ordered by `id`, **not by date** — filter by date instead
of assuming an order. `GET /v0/projects` always returns all your projects in one page. Cursors are opaque, bound to your credentials and to the same
filters — reuse them only with the same query. Pagination is not a frozen snapshot: records
added while you page may or may not appear.

## 5a. Filtering records

| Parameter | Meaning |
|---|---|
| `projectId` | one project (`404` if you can't access it, like the project itself). Without it, a call spans all your projects — up to 30; above that, pass `projectId` and query project by project. |
| `status` | one or more of `setup, approvalPending, paymentPending, paid, rejected`, comma-separated. The fully approved slice is `status=paymentPending,paid`. |
| `invoiceNumber` | exact invoice number |
| `contractorId` | one counterparty |
| `recognitionDateFrom`, `recognitionDateTo` | invoice date range, inclusive, `YYYY-MM-DD` — e.g. a month or a quarter |
| `budgetNodeId` | records on **exactly** that node; add `includeDescendants=true` for the node and everything below it |
| `channel` | how the record came in: `email`, `app`, `chat`, `api`, `manual` |
| `cashDateFrom`, `cashDateTo` | payment date range, inclusive, `YYYY-MM-DD` — records without a payment date are left out. Needs the `payment` field group. |
| `hasCashDate` | `true` = only records with a payment date, `false` = only without one (e.g. `status=paid&hasCashDate=false`). Needs `payment`. |
| `source` | `invoice` = supplier invoices only, `accrual` = accrual entries only |
| `accountingCode` | records whose code in an accounting system equals the value, written `system:code` — e.g. `accountingCode=yardi:1100-0010`. Needs the `accounting` field group. |
| `fields` | return only these top-level fields, comma-separated; `id` is always included (e.g. `fields=status,net,cashDate`). Field groups still apply. |
| `count` | `true` = also return `page.total`, the number of matching records across all pages |

Example — what was paid in 2026, light rows and a total:

```bash
curl -s "https://sandbox.api.propel-industrial.com/v0/records?status=paid&cashDateFrom=2026-01-01&cashDateTo=2026-12-31&fields=projectId,net,gross,cashDate&count=true&limit=200" \
  -H "Authorization: Bearer $PROPEL_KEY"
```

**Dates on records — what to expect:**
- `cashDate` is the recorded payment date. It can be empty on a `paid` record (the payment was marked
  without a date), and it is not guaranteed to fall on or after `recognitionDate`.
- `recognitionDate` can be empty on a few records (mostly entries still in `setup`).
- For "paid in a given year", decide which date you mean and handle empty dates explicitly.

**Accounting codes.** Every budget line carries its codes per accounting system (`yardi`, `datev`, `sap`, `bmd`,
`other`) in `accountingCodes`; a line without its own code uses its parent's (`inherited: true`). A record carries the
codes it was **approved** with; before approval, the codes of its budget line. Codes are strings — leading zeros stay.

**Accruals** are records too: `source: "accrual"` (and `workflowStep: "Accrual"`), usually without a
document. `source=invoice` returns supplier invoices only.

**Document names are labels, not workflow state.** A file named "Approved Invoice" (`kind: "approved"`)
doesn't prove the record is approved; use `status`.

**Unknown or empty parameters are rejected with `400 invalid-query`** — a typo never silently
returns unfiltered data. The API is read-only: any method other than `GET` returns `405`.

---

## 6. Errors

Errors are an `Error` object, sent as `application/problem+json` ([RFC 9457](https://www.rfc-editor.org/rfc/rfc9457)):

```json
{ "type": "/problems/not-found",
  "title": "Not found", "status": 404, "requestId": "8f2c…" }
```

| Status | `type` (suffix) | Meaning |
|---|---|---|
| 400 | `invalid-query`, `invalid-body`, `invalid-cursor`, `filter-not-allowed`, `too-many-projects` | fix the request (unknown/empty parameters included) |
| 405 | `method-not-allowed` | data endpoints are read-only; `POST /v0/feedback` is the one write |
| 401 | `authentication-required`, `invalid-credentials` | missing, wrong, revoked or expired token |
| 404 | `not-found` | doesn't exist **or** you can't access it |
| 429 | `too-many-requests` | slow down: at most 300 requests per minute per key or user; wait `Retry-After` seconds |
| 500 | `internal-error` | our fault — quote the `requestId` |

`type` is a stable, relative URI — branch your code on it; `GET https://sandbox.api.propel-industrial.com/problems/<type>` explains each one.
Every response carries an `x-request-id` header; quote it when reporting a problem.

---

## 7. Endpoints (v0, read-only)

| Method | Path | Returns |
|---|---|---|
| GET | `/v0/projects` | projects you can access |
| GET | `/v0/projects/{projectId}` | one project |
| GET | `/v0/projects/{projectId}/budget` | budget structure with approved / invoiced / paid per node |
| GET | `/v0/records` | records (all filters in [5a](#5a-filtering-records); paginated) |
| GET | `/v0/records/{recordId}` | one record |
| GET | `/v0/contractors` | contractors linked to your projects (core + tax; **no bank data**) |
| GET | `/v0/contracts` | contracts with committed, invoiced and open amounts (`?projectId=`; paginated) |
| GET | `/v0/contracts/{contractId}` | one contract with its budget lines and amendments |
| GET | `/v0/projects/{projectId}/cashflow` | cash out per budget line and month (actual, forecast) |
| GET | `/v0/projects/{projectId}/forecast/snapshots` | approved month-end forecasts (`…/{month}` for one) |
| GET | `/v0/projects/{projectId}/units` | lettable units |
| GET | `/v0/projects/{projectId}/rent-roll` | leases in force, passing rent, vacancy, WAULT |
| GET | `/v0/projects/{projectId}/facilities` | loan facilities (`/v0/facilities/{id}/schedule`, `…/drawdowns`) |

Full request/response schemas: [/docs](https://sandbox.api.propel-industrial.com/docs).

### Contracts and commitments

A contract commits money to one or more budget lines. Its figures, all net and in integer minor units:

- `committed` = the contract amount + **approved** amendments (change orders). Pending increases are reported
  separately as `pendingChanges` and are not committed yet; rejected amendments count nowhere.
- `openCommitment` = committed − invoiced, never negative. If invoices exceed the contract, the excess is
  `overInvoiced` — it is never netted against another contract.
- Only `signed` and `completed` contracts count on the budget. A contract without budget lines yet returns
  `committed: null`.
- `locked: true` means the base amount and budget lines are final (after the first approved amendment or linked
  invoice); later changes appear as amendments.

On the budget (`/v0/projects/{projectId}/budget`), every line carries the same figures: `committed`,
`openCommitment`, `overInvoiced`, `pendingChanges`, rolled up like the other amounts. An amendment counts on the
line it is booked on, so totals match the contracts endpoint. Guests see a contract's name, status and dates only.

### Forecast and month-end snapshots

Every budget line carries a forecast, net and rolled up like the other amounts:

- `uncommitted` — still to be contracted. By default the rest of the budget, max(0, budget − committed); a
  project team can enter a different value (always with a reason). `uncommittedDefaulted` tells you which.
- `eac` (estimate at completion) = committed + pendingChanges + uncommitted. A line nobody has touched equals its
  budget — there are no accidental savings.
- `variance` = budget − eac (negative = overrun); `costToComplete` = eac − invoiced.

At month end the forecast is frozen into a snapshot and approved by the project's forecast approver.
`GET /v0/projects/{projectId}/budget?asOf=YYYY-MM` returns the approved month exactly as approved (404 when that
month has no approved snapshot); `GET /v0/projects/{projectId}/forecast/snapshots` lists the months, and
`…/snapshots/{month}` returns the totals and the change per line against the previous approved month, with the
reason given.

### Cash flow

`GET /v0/projects/{projectId}/cashflow?from=YYYY-MM&to=YYYY-MM` returns, per budget line and month, `actual`
(paid, by payment month, past months) and `forecast` (the cost still to come, spread from the current month:
contract payment plans first, then the line's own phasing — linear, S-curve, lump sum or custom months).
`undated` is money that can't be placed in time yet because the line has no dates.

### Units and rent roll

`GET /v0/projects/{projectId}/units` lists the lettable units with type and size (area in m², parking in spaces).
`GET /v0/projects/{projectId}/rent-roll?asOf=YYYY-MM` returns the signed and active leases in force that month with
their passing rent (12 × the rent payable that month, after rent-free months, steps and indexation), plus vacancy
and WAULT to expiry and to first break. Rents need the `amounts` field group; guests see units and sizes only.

### Financing

`GET /v0/projects/{projectId}/facilities` lists the loan facilities with commitment, drawn, requested and available
amounts, pricing and the next interest payment date. `GET /v0/facilities/{facilityId}/schedule` returns the
interest periods (rate, interest, commitment fee, balance; a period whose rate isn't fixed yet is marked
`projected`) and `…/drawdowns` the drawdowns and repayments. Financing needs the `financial` field group, which
keys don't have unless it was granted, and access to every project the facility finances.

---

## 7a. Client libraries

There are no official client libraries yet. The OpenAPI document is complete and resolvable, so you
can generate a typed client in a minute, e.g. for TypeScript:

```bash
npx openapi-typescript https://sandbox.api.propel-industrial.com/v0/openapi.json -o propel.d.ts
```

(Python/others: `openapi-generator-cli generate -i https://sandbox.api.propel-industrial.com/v0/openapi.json -g python`.)
The reference at [/docs](https://sandbox.api.propel-industrial.com/docs) shows copy-paste examples per endpoint (curl, JavaScript, Node, Python).

## 7b. Feedback

Something wrong, missing or unclear? Tell us directly — it reaches the Propel team immediately:

```bash
curl -s -X POST "https://sandbox.api.propel-industrial.com/v0/feedback" \
  -H "Authorization: Bearer $PROPEL_KEY" -H "Content-Type: application/json" \
  -d '{ "category": "missing", "message": "I need to filter records by contractor name.",
        "endpoint": "GET /v0/records", "agent": { "name": "my-reporting-agent", "model": "…" } }'
```

`category` is one of `bug`, `missing`, `confusing`, `idea`, `praise`. Add the `requestId` of a call that
went wrong. **AI agents:** please send feedback when a field or filter you need is missing, a response
surprised you, or these docs were unclear. Never include keys or secrets. Up to 20 messages per day.

## 8. Stability and versioning

- The version is in the path (`/v0`). v0 is the first version of the API.
- We avoid breaking changes: new endpoints, new optional fields and new enum values are added without notice —
  treat unknown fields and enum values gracefully. If a breaking change is unavoidable in v0, it is announced
  in advance in the changelog.
- From v1 on, changes within a version are additive only; anything breaking goes to the next version.
- Not available in v0: write endpoints (except feedback), webhooks, PDF downloads, bank data.

## Privacy

The API returns your organisation's project data to callers you authorise. How Propel handles personal
data: [propel-industrial.com/privacy-policy](https://www.propel-industrial.com/privacy-policy).

## Changelog

| Date | Version | Change |
|---|---|---|
| 2026-10-01 | 0.1.0 | First release: projects, budgets (structure with rolled-up amounts), records, contractors. |
| 2026-10-02 | 0.1.0 | Guide only: dates on records, accruals, document names, `invoiced` vs. payment queue, `nodeId` across projects, full filter list in section 7. |
| 2026-10-05 | 0.1.4 | Forecast on budget lines (`uncommitted`, `eac`, `variance`, `costToComplete`), `?asOf=` approved months, forecast snapshots, cash flow, units, rent roll, loan facilities (schedule, drawdowns; `financial` field group). All additions. |
| 2026-10-03 | 0.1.3 | Contracts: `/v0/contracts` and `/v0/contracts/{contractId}` (committed, invoiced, paid, open, over-invoiced, pending changes, budget lines, amendments). Budget nodes: `committed`, `openCommitment`, `overInvoiced`, `pendingChanges`. All additions. |
| 2026-10-03 | 0.1.2 | Accounting codes: `accountingCodes` on budget lines and records (new `accounting` field group), filter `accountingCode=system:code`. All additions. |
| 2026-10-02 | 0.1.1 | Records: filters `cashDateFrom`, `cashDateTo`, `hasCashDate`, `source`; `fields` (projection) and `count` (`page.total`); `source: "accrual"`; `documents[].kind`. Budget nodes: `invoicedByStatus`, `invoicedGross`, `paidGross`. `Link: rel="feedback"` header on every response. [Request access](/access) page. All additions — nothing existing changed. |
