API documentation

Your records,
on an API.

A REST API over your own workspace. Read your records, write quotations, query in plain English, and keep other systems in step with signed webhooks. Scoped keys, JSON everywhere, OpenAPI included.

REST + JSON Scoped keys OpenAPI spec Signed webhooks Change feed MCP, 38 tools
This page is the short version. The Integration Guide (PDF, 120 pages) has every endpoint with a full example, the webhook receiver code in Node and Python, connectors and Data Workbench. Looking for how to use the product instead? Read the Solution guide.
Start

Quick start

Three steps from nothing to your first response.

1
Create a keyA workspace admin opens Settings, Integrations, API keys & developer, then New key. Pick read, write and the objects it may touch.
2
Call your domainEvery workspace has its own address. Send the key as a bearer token.
3
Read the specFetch /api/v1/openapi.json to generate a client or import into Postman.
First callcurl
curl -s \
  -H "Authorization: Bearer $KEY" \
  "https://<your-workspace-domain>/api/v1/accounts?limit=5&sort=name"

Base URL

Base URL
https://<your-workspace-domain>/api/v1

Every workspace is served on its own domain, so use yours. The key decides which workspace a call belongs to; there is no workspace id in any path. app.bpmsquare.com is the shared demo sandbox and is not for client integrations.

Responses are JSON. CORS is open and OPTIONS pre-flight is answered on every route.

Authentication

Send a bearer token on every call.

Header
Authorization: Bearer bpm_live_xxxxxxxx
KeyHow you get itAccess
Scoped keySettings, Integrations, API keys & developer, New key. Workspace admin only. The token is shown once and only a one-way hash is kept.Read and/or write, all objects or a named list, optional expiry (a future date).
Workspace keyThe same screen, Generate. One key per workspace. Regenerate replaces it and the old key stops at once.Read and write on every object except those that need an explicit grant.
Treat a key like a password. Give each integration its own scoped key, read-only unless it must write, with an expiry, so you can revoke one without touching the others. An expired or revoked key is answered as an unknown key (401).

Scopes

A scope is { read, write, objects }. A read-only key gets 403 on a write. A key limited to some objects gets 403 on any other object.

Objects a key can name: quotations accounts cases inventory invoices purchase-orders pricing products projects opportunities employees rfqs estimates abm_programs abm_targets standard_quotes sales_orders tenders

Personal and sensitive data is opt-in

A key that allows "all objects" does not reach employees, RFQs, estimates, ABM targets or tenders. A key must name them. A key with access to everything gets 403 on the tender endpoints. Existing keys never gain access to new sensitive endpoints by accident.

Contacts and contract projects have no endpoint at all. Tenders have a read-only one and need a key that names tenders. The cost of a tender line, the markup, the supplier behind a price, decision reasons and the source e-mail are never returned. Contact people do appear inside GET /accounts/:id with their phone and email, under the accounts scope, so give that scope only to integrations that may see them. The cost price of a product is never returned, and estimates and sales orders carry no cost or margin.

A module you did not buy is not there

Each object belongs to a module. A workspace without that module gets 404 {"error":"Not found"} on its endpoints, so the existence of the endpoint is not revealed.

Use the API

Reading data

Every list endpoint returns the same envelope.

List responseJSON
{
  "data": [ { "...": "rows" } ],
  "meta": { "count": 1, "total": 1, "page": 1, "limit": 50, "has_more": false },
  "_links": { "self": "/api/v1/accounts", "next": null, "prev": null }
}

Query parameters work on every list endpoint, are optional and combine freely. An unknown field name is a 422 that lists the accepted fields.

ParameterWhat it does
selectComma list of fields to return. Dotted for nested, such as account.name.
filterfield:op:value clauses joined by ; (AND). Operators: eq ne gt gte lt lte like in isnull. URL-encode the value.
searchCase-insensitive contains across the endpoint's searchable fields.
sortComma list. A leading - is descending, for example -total,ref.
page, limit1-based page. Default limit 50, at most 200.
aggregatecount, sum:field, avg:field, min:field, max:field over the filtered set.
group_byAggregate per distinct value. A date field is bucketed by group_period (day week month quarter year).
having, group_sort, group_limitFilter, order and cap the groups.
count=onlyReturn meta without rows.

Example: draft quotations over 10,000, by value

Requestcurl
curl -s -H "Authorization: Bearer $KEY" \
  "$BASE/quotations?filter=status%3Aeq%3Adraft%3Btotal%3Agt%3A10000&sort=-total&limit=3"

Example: totals per month, no rows

Requestcurl
curl -s -H "Authorization: Bearer $KEY" \
  "$BASE/invoices?aggregate=sum:total&group_by=issued_at&group_period=month&count=only"

Conventions: ids are UUIDs, dates are YYYY-MM-DD, timestamps are ISO 8601, money is a plain number in the workspace currency, and absent values are null.

Store the id next to your own key, never the reference such as QT-2026-0042. A reference is a label for people; the id is permanent. A list call loads the workspace rows for that object and then applies your query, so narrow it with filter and select.

Endpoints

All paths are under /api/v1. Each list also has a single-record form, /:id, except /cases and /project-hours. Most lists take a few shortcut parameters too, such as ?status= or ?account_id=; they combine with filter.

PathScopeWhat it returns
GET/accountsaccountsCustomers, vendors and prospects. The single record adds contact people, assets, open cases, quotations, AMC contracts, work orders and invoices
GET/casescasesService cases (list only)
GET/quotationsquotationsQuotations with lines, totals and invoicing position
GET/standard-quotesstandard_quotesStandard quotes
GET/sales-orderssales_ordersSales orders
GET/invoicesinvoicesInvoices and balances due
GET/purchase-orderspurchase-ordersPurchase orders
GET/opportunitiesopportunitiesDeals in the pipeline
GET/productsproductsProduct catalogue (internal cost is never returned)
GET/inventoryinventoryStock items
GET/projectsprojectsWorkforce projects, plus /:id/hours and /:id/invoices
GET/project-hoursprojectsHours attributed to projects
GET/employeesemployees (named)Employee records
GET/rfqsrfqs (named)Requests for quotation and supplier replies
GET/estimatesestimates (named)Estimates
GET/abm-programsabm_programsAccount-based marketing programs
GET/abm-targetsabm_targets (named)Target accounts, plus /abm-programs/:id/targets
GET/tenderstenders (named)Tender deals. Filters: stage, outcome, decision, portal, closing (days to the bid end date). Needs the GCC tender module
GET/tenders/:idtenders (named)One tender with a summary of its Bill of quantities
GET/tenders/:id/boqtenders (named)The Bill of quantities, one row per line: item number, name, details, the client's own columns, unit, quantity, rate and amount, and where the line sits in the WBS: wbs_code (an item code, or for a split line 2.1:60;2.2:15;2.3:25, code and percent) and wbs_parts (each share with its quantity and amount). No cost or markup
GET/tenders/:id/wbstenders (named)The tender's WBS, one row per item in tree order: code, name, parent, level (up to three), the owner's name, and lines, shared (split) lines, priced lines and client amount rolled up the tree. parts lists each line's share of the item with its quantity and amount. The meta carries the whole-WBS wbs_total and what is still unassigned; meta.total stays the number of items. Client amounts only: no cost or margin. A tender with no WBS gives an empty list. Filters, search and sort work as on any list

Writing

Writing needs a key with write access to the object. Quotations are the writable object in v1, plus two actions.

PathWhat it does
POST/quotationsCreate a quotation with its lines (up to 200 lines per request).
PATCH/quotations/:idChange a quotation. Send lines to replace them.
DELETE/quotations/:idDelete a quotation.
POST/projects/:id/invoicesBill a project period from its attributed hours.
POST/pricePrice a product through the pricing engine. A read-scope key is enough.

Field names, types and rules are described in GET /metadata/quotations. Every API write appears in your change history as api:v1. Totals are always worked out by BPMSquare: a request that sends total or a line amount is rejected, and so is an unknown field.

Nothing else can be created, changed or deleted through the API, apart from drafting a project invoice from hours. To load or correct other records in bulk, use Data Workbench. A PATCH that sends lines updates the header first and then replaces the lines, so if it fails part-way, read the quotation back and repeat the call.

Errors

StatusMeaning
400The body is not valid JSON, or a value is malformed.
401No key, an unknown key, or a revoked or expired key.
403The key's scope does not allow this object, or this direction (read or write).
404The record is not in your workspace, or your workspace does not have that module.
409A business conflict, such as a superseded quotation or an already-billed period.
422Validation failed. The body lists each rejected field with a message.
5xxA server or database problem. Retry later. /ask answers 502 when the AI service is unavailable and 503 when it is not set up.
422 exampleJSON
{
  "error": "Validation failed",
  "message": "2 fields rejected. See \"details\".",
  "details": [ { "field": "lines[0].qty", "message": "must be greater than 0" } ]
}

BPMSquare does not count your calls and returns no 429 of its own, but your hosting path might. Back off and retry on 429, 502 and 503, and do not poll faster than you need.

Ask in plain English

POST /ask turns a question into the same validated query as above. It is never turned into SQL, it runs with your key's read scope, and the exact query is echoed back so you can reuse it. Numbers come from your data, not from the AI.

Requestcurl
curl -s -X POST -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" "$BASE/ask" \
  -d '{ "object": "quotations", "question": "top 3 draft quotes over 10,000 by value" }'
Response (abbreviated)JSON
{
  "question": "top 3 draft quotes over 10,000 by value",
  "object": "quotations",
  "answerable": true,
  "compiled": "/api/v1/quotations?filter=status%3Aeq%3Adraft%3Btotal%3Agt%3A10000&sort=-total&limit=3",
  "data": [ { "ref": "QT-2026-0042", "status": "draft", "total": 41050 } ]
}

Maximum run time is 30 seconds. A question the data cannot answer returns answerable: false instead of a guess. The key needs read access to the object you name, and a module you did not buy answers 404.

You can ask about quotations accounts cases inventory products opportunities rfqs standard_quotes sales_orders abm_programs abm_targets estimates projects project_hours employees invoices purchase-orders, with the scope that object needs. tenders and tender_boq_lines need a key that names tenders. Four more, assets suppliers work-orders and leads, have no endpoint of their own and need an all-objects key.

Stay in sync

Change feed

GET /changes is an ordered log of every create, update and delete in the workspace. Poll it with the cursor from the previous response instead of re-reading whole objects. Nothing is skipped or repeated across pages, even when two changes happen in the same instant.

ParameterWhat it does
sinceThe cursor from a previous response. Leave it out to start at the beginning. A cursor that was not returned by this endpoint is a 422.
object_typeOnly one object type, such as quotes. 403 if the key cannot read it.
limitDefault 100, at most 500.
Requestcurl
curl -s -H "Authorization: Bearer $KEY" \
  "$BASE/changes?object_type=quotes&since=$CURSOR&limit=100"
Response (abbreviated)JSON
{
  "data": [
    {
      "id": "8b2c3d4e-5f60-4b7c-9d8e-0f1a2b3c4d5e",
      "object_type": "quotes",
      "object_id": "b7d1e0c2-5a3f-4e18-8c92-6f4a1d9e2b03",
      "object_label": "QT-2026-0042",
      "action": "update",
      "changes": [ { "field": "status", "from": "draft", "to": "sent" } ],
      "actor": "sara.admin@example-trading.example",
      "at": "2026-10-27T09:41:12.318204+00:00"
    }
  ],
  "meta": { "count": 1, "has_more": false },
  "cursor": "MjAyNi0xMC0yN1QwOTo0MToxMi4zMTgyMDQrMDA6MDB8..."
}
  • action is create, update, delete or reopen. Match on object_id, not on the label.
  • The feed shows only the object types your key may read. Types with no scope of their own, such as assets, suppliers, work orders and leads, are visible only to an all-objects key. Employees, RFQs, estimates, ABM targets and tenders appear only to a key that names them. A tender's cost entries are held back from the feed.
  • Personal fields such as phone, email and tax number appear as [redacted]. Internal cost prices are never included.
  • actor is the person's email, or api:v1 for an API write.
  • Save your cursor only after you have processed the page. A final full page can say has_more: true and the next call return nothing; that is normal. When a page is empty the cursor repeats your since, so keep it.

The feed is a log, not a copy of the data. To get the current record, read it with its endpoint using object_id.

Webhooks

Instead of polling, a workspace admin can register an HTTPS endpoint under Settings, Integrations, Webhooks (or Webhooks & push, depending on the workspace). It receives the same events as the change feed, in signed batches. No API key is involved: the admin chooses the objects.

  • Deliveries run on a schedule, not the moment a record changes. If you need to know within seconds, poll the change feed.
  • Each delivery is a POST with up to 100 events, oldest first. A new webhook sends only changes made after it was created.
  • Any 2xx answer acknowledges the batch. Anything else, or a timeout (10 seconds), and the same events are sent again later with a new delivery_id. Make your handler idempotent on events[].id.
  • After 15 failures in a row the webhook pauses until an admin resumes it. It carries on from where it stopped.
  • Only public HTTPS addresses are accepted, when you save and again at every delivery. Private and internal addresses are refused.
  • All objects sends the everyday change types. It does not send staff records, contacts, RFQs, estimates, ABM targets, tenders or contract projects: those go only to a webhook that names them. Product cost price is never sent, and personal fields are [redacted].
Headers
Content-Type: application/json
X-BPMSquare-Signature: <hex HMAC-SHA256 of the raw body>
X-BPMSquare-Delivery: <delivery id>
Body (abbreviated)JSON
{
  "delivery_id": "0f1e2d3c-4b5a-4968-8777-665544332211",
  "sent_at": "2026-10-28T20:30:04.118Z",
  "events": [ { "id": "8b2c3d4e-...", "object_type": "quotes", "action": "update", "...": "same shape as a change-feed item" } ]
}
Verify the signatureNode
import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.BPMSQUARE_WEBHOOK_SECRET; // the whsec_ value from Settings

app.post("/hooks/bpmsquare", express.raw({ type: "application/json" }), (req, res) => {
  const given = req.get("X-BPMSquare-Signature") || "";
  const expected = crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");
  const ok = given.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
  if (!ok) return res.status(401).send("bad signature");

  const { events } = JSON.parse(req.body.toString("utf8"));
  for (const e of events) { /* de-duplicate on e.id, then handle e */ }
  res.sendStatus(200);
});

Sign over the raw bytes, so check the signature before you parse the JSON. The signing secret (whsec_ and 48 hex characters) is shown once, when you create or regenerate it. The Test button sends a signed message you can accept without treating it as a data change.

More

MCP server

An AI assistant can read your workspace through the Model Context Protocol, with exactly the permissions of the key you give it. The manifest (mcp.json, version 1.21.0) describes 38 tools. Each is a thin wrapper over one REST endpoint and returns that endpoint's JSON; none does anything the REST API cannot.

AreaToolsKey needs
Accounts, caseslist_accounts get_account list_casesaccounts, cases
Sellinglist_quotations list_standard_quotes get_standard_quote list_sales_orders get_sales_order list_opportunities get_opportunityquotations, standard_quotes, sales_orders, opportunities
Estimates, RFQslist_estimates get_estimate list_rfqs get_rfqestimates, rfqs (named)
Money and stocklist_invoices get_invoice list_purchase_orders get_purchase_order list_inventory get_inventory_item list_products get_productinvoices, purchase-orders, inventory, products
Projects and hourslist_projects get_project get_project_hours get_project_invoices query_project_hours create_project_invoiceprojects (write for the last one)
ABMlist_abm_programs get_abm_program list_abm_targets get_abm_targetabm_programs, abm_targets (named)
Stafflist_employees get_employeeemployees (named)
Tenderslist_tenders get_tender get_tender_boq get_tender_wbstenders (named)
  • Use a dedicated read-only scoped key and name only the objects the assistant needs. Sensitive objects stay closed unless you name them in the key.
  • create_project_invoice is the one tool that writes: it drafts a project invoice from hours, or previews it with dry_run. Give an assistant write access only if you accept that.
  • List tools return the first page only, up to 50 rows, with the shortcut filters. For full lists and totals use the REST API. query_project_hours is the one tool with grouping and sums.
  • Not available as tools: creating, changing or deleting quotations, /price, /ask, the change feed, a single quotation or case, and contacts. Use the REST API.

The assistant needs a small bridge between it and BPMSquare, which reads the manifest and makes the REST call with your key. Ask us for the manifest and the bridge for your assistant of choice.

OpenAPI and metadata

PathWhat it is
GET/openapi.jsonThe machine-readable spec (OpenAPI 3). Import it into Postman or generate a client.
GET/A self-describing index: authentication, endpoints, query rules and conventions. It needs a key like every other route.
GET/metadataField-level metadata. Today it describes quotations.
GET/metadata/:entityEvery field of one object: type, whether required, allowed values.
Read the spec with these limits in mind. Only quotations have a full field schema; every other object gets a generic list and detail path with the shared query parameters. The spec does not list everything this page lists (project hours, a project's hours and invoices, a program's targets, metadata), and it shows a single-case path that does not exist. This page and the Integration Guide are the complete list.
Limits worth knowing. /ask and /price run for at most 30 seconds. A quotation request carries at most 200 lines. If you need higher volumes or a field that is not exposed yet, tell us.

Beyond the REST API

Some jobs are better done another way. The Integration Guide has a chapter on each.

NeedUse
Load or correct thousands of records from a spreadsheetData Workbench (Admin). Import, export and bulk update for accounts, contacts, products, quotes, sales orders, cases, invoices and more. An update needs the Record ID column that only an Export fills in, so a file can never hit the wrong record. The tender Bill of quantities is there too, as Tender BOQ lines: import adds lines and updates them by item number but never removes one, export carries the Record ID, and update uses it. The tender's WBS is there as Tender WBS items (import adds items, export carries the Record ID and the roll-ups, update changes name, code, parent, owner and note), and Tender BOQ lines has a WBS code column on export and update that regroups lines or splits them across items.
Send one record to another system when something happensCall webhook, fired when a Standard Quote is awarded or a button is pressed on an account. Or Push to ERP, on demand from a record.
Tell BPMSquare that an order changed in your ERPInbound order call: a signed POST to /api/middleware/webhook/sales-order with the order ref or ERP id. Needs the Sales Orders and Integration middleware modules.
Connect Dynamics 365, SAP S/4HANA or ePROMISConnectors and the Integration middleware: links per object, field mapping, a message monitor and resync. Gmail and a mail server connect from the same screen. Slack and Google Calendar connect and test today; no screen sends to or reads from them yet.

Building an integration?

Tell us the system and the objects. We will set up a scoped key and walk the first call with you.