API reference

A small, honest REST API

Suppliers read their catalogue and orders; platforms read a builder's projects, schedule and materials. JSON in, JSON out, standard bearer auth. Check GET /v1/routes to see exactly what your key can do before you call anything else.

Base URL and auth

https://www.buildpaperless.com.au/api/v1

Send your key as a bearer token. Keys start with bp_and are created in a builder's developer settings. A developer key acting for a customer also needs an organisation_id parameter or header (X-Organisation-Id). The US spelling is accepted as a permanent alias.

Download OpenAPI spec

OpenAPI 3.1 JSON, generated from the live routes. Import it into Postman, Insomnia or a client generator.

Rate limits

Every response carries the current limit state:

X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 998
X-RateLimit-Reset: 1750000000

Errors

A stable, machine-readable code on every failure, at every status:

{ "error": {
  "code": "forbidden_scope",
  "message": "..."
} }
Incremental sync

Keeping a copy in step, and what happens when a record is removed

Incremental sync and deletions. updated_since returns records changed at or after an instant. The bound is inclusive, so pass the updated_at from your previous successful response rather than your own clock: our clocks differ, and a record written during your last run would be missed.

updated_since cannot report removals. A removed record stops appearing, which is indistinguishable from one that did not change. Reconcile with a full read -- every page of the resource with no updated_since -- once per calendar month, and within a day of any removal you learn about out of band (a builder telling you they re-estimated or cancelled a job). Page it at limit=200, the cap on every list except /v1/orders, which caps at 100. For a mid-size builder -- 300 projects, 20,000 materials lines, 6,000 milestones -- that full read is roughly 150 requests. On a standard key -- 1,000 a month -- that is about 15% of the allowance. A very large organisation spends more: 100,000 materials lines is 500 requests on that resource alone. Read your own rate_limit and rate_limit_period from GET /v1/me rather than assuming the default, work out your own number, and budget for it before you choose your incremental polling cadence, not after.

What that means per resource:

  • Projects. A cancelled project stays in the response with status: "cancelled", so a cancellation needs no full read. An archived project is filtered out of every response and disappears with no signal, and is hard-deleted 90 days after it was archived.
  • Schedule milestones. Every milestone on a project vanishes at once when that project is cancelled or archived, and nothing moves on the milestone rows themselves. An individual milestone can also be archived, which filters it out of every response with no signal, or hard-deleted outright. Treat any project-level change as the trigger to re-read that project’s schedule in full.
  • Materials lines. A deleted line is simply gone. Far more common: superseding an estimate drops every line of the previous estimate out of the response without any line’s updated_at moving. GET /v1/materials carries estimate_id on every line, and GET /v1/projects/{id}/materials reports every estimate currently in play as meta.estimate_ids. That set is the authority: a project can carry more than one active estimate at once, so a NEW estimate_id appearing does not on its own mean an older one is gone. Discard the lines you hold under an estimate_id only once it is absent from meta.estimate_ids for that project, and re-read the project in full when that set changes.
  • Categories. Hard-deleted with no marker, and dropped by the same estimate supersede as materials lines.
  • Leads. Deleted outright. The row is simply gone. Separately, a lead embeds its contacts from our customer records: adding, swapping or removing a contact moves the lead’s updated_at, but editing an existing contact in place (correcting a phone number, say) does not, so that edit is invisible to an incremental poll. Re-read a lead in full when you need its contact detail to be current.
  • Purchase orders. A draft purchase order is hard-deleted. Every other status is cancelled rather than removed, so it surfaces as an ordinary change.
  • Orders, suppliers and products. State lives in status / is_active, so a cancellation or deactivation IS surfaced by updated_since. One exception: re-adding a supplier that was previously deactivated purges the old inactive row and issues a new id, so the same real supplier changes identity.

category_name on the materials lines is a denormalised label for category_id. Renaming a category does not move any materials line’s updated_at -- re-read GET /v1/categories to refresh your labels.

Ordering (changed in 1.2.0). Every paged list that accepts updated_since orders oldest-first while the parameter is present, so you page forward away from your cursor; /v1/orders, /v1/suppliers and the paged mode of /v1/projects/{id}/materials were the last three to do so. GET /v1/projects/{id}/schedule is the one exception: it is not paged, so there is no page boundary for a record to slip across and it keeps its date ordering. Without updated_since each list keeps the order it has always had.

A sync loop, start to finish

Any resource, same shape. The cursor comes out of the response you just read.

const KEY = process.env.PAPERLESS_API_KEY;
const get = async (path) => {
  const res = await fetch(`https://www.buildpaperless.com.au/api/v1${path}`, {
    headers: { Authorization: `Bearer ${KEY}` },
  });
  if (!res.ok) throw new Error((await res.json()).error.code);
  return res.json();
};

// A full read. No updated_since, every page. This is your first run, and it is
// also the monthly reconcile that catches removals.
// 200 everywhere except /v1/orders, which caps at 100 and returns 400 above it.
const PAGE = (resource) => (resource === '/orders' ? 100 : 200);

async function fullRead(resource) {
  const rows = [];
  const limit = PAGE(resource);
  for (let offset = 0; ; offset += limit) {
    const { data, meta } = await get(`${resource}?limit=${limit}&offset=${offset}`);
    rows.push(...data);
    if (!data.length || offset + data.length >= meta.total) break;
  }
  // Anything you hold that did not come back has been removed. Drop it.
  store.replaceAll(resource, rows);
  // null, not '', when the resource is empty: an empty updated_since is a 400,
  // and a new organisation has no rows yet on most resources.
  return rows.reduce((max, r) => (max === null || r.updated_at > max ? r.updated_at : max), null);
}

// Every run after that. Cursor in, new cursor out, and it comes from the rows,
// never from new Date() -- the bound is inclusive and our clock is not yours.
async function incremental(resource, cursor) {
  if (!cursor) return fullRead(resource);   // nothing read yet, so nothing to page from
  let next = cursor;
  const limit = PAGE(resource);
  for (let offset = 0; ; offset += limit) {
    const { data, meta } = await get(
      `${resource}?updated_since=${encodeURIComponent(cursor)}&limit=${limit}&offset=${offset}`
    );
    for (const row of data) {
      store.upsert(resource, row);            // changed or new
      if (row.updated_at > next) next = row.updated_at;
    }
    if (!data.length || offset + data.length >= meta.total) break;  // pages back to back
  }
  return next;                                 // store this, use it next run
}

// Wiring it up. One full read a month, incremental in between.
let cursor = store.getCursor('/projects');
cursor = store.lastFullRead('/projects') < monthAgo()
  ? await fullRead('/projects')
  : await incremental('/projects', cursor);
store.setCursor('/projects', cursor);
GET /v1/health
GET/v1/health

Service health and key liveness

Confirms the API is reachable and the presented key authenticates. Touches no customer data.

Scopeany valid key

Parameters

This endpoint takes no parameters.

Responses

200Success. Payload is wrapped in `{ "data": ... }`, with `meta` on paged lists.
400Malformed or unrecognised parameter. `error.code` is `invalid_request`.
401Missing, malformed, expired or inactive API key.
403the organisation that owns the key may not use the API right now. `error.code` is one of `developer_access_disabled`, `subscription_inactive`, `read_only_mode` (a past-due organisation may read but not write) or `agent_access_required`.
404No such record, or no grant on the organisation named.
429Rate limit exceeded: your key used its `rate_limit` allowance for its rolling window (writes count double), or made more than `burst_limit_per_minute` requests in the last 60 seconds. Honour `Retry-After`. A 429 does not itself consume quota, so retrying while blocked cannot extend the block. See the rate limit note in the document description.
500Something went wrong on our side.
See pricing and plan access
curl https://www.buildpaperless.com.au/api/v1/health \
  -H "Authorization: Bearer bp_live_xxx"
200OK
{
  "data": {
    "organisation_id": "3f9a2b10-...",
    "scopes": [
      { "scope": "catalogue:read", "granted": true, "live": true },
      { "scope": "materials:write", "granted": false, "live": true }
    ],
    "endpoints": [
      { "method": "GET", "path": "/api/v1/products", "required_scopes": ["catalogue:read"], "granted": true }
    ],
    "planned": [
      { "feature": "webhooks", "planned": true, "scope": "webhooks", "granted": false }
    ]
  }
}