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.
Three steps from nothing to your first response.
/api/v1/openapi.json to generate a client or import into Postman.curl -s \ -H "Authorization: Bearer $KEY" \ "https://<your-workspace-domain>/api/v1/accounts?limit=5&sort=name"
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.
Send a bearer token on every call.
Authorization: Bearer bpm_live_xxxxxxxx
| Key | How you get it | Access |
|---|---|---|
| Scoped key | Settings, 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 key | The 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. |
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
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.
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.
Every list endpoint returns the same envelope.
{
"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.
| Parameter | What it does |
|---|---|
select | Comma list of fields to return. Dotted for nested, such as account.name. |
filter | field:op:value clauses joined by ; (AND). Operators: eq ne gt gte lt lte like in isnull. URL-encode the value. |
search | Case-insensitive contains across the endpoint's searchable fields. |
sort | Comma list. A leading - is descending, for example -total,ref. |
page, limit | 1-based page. Default limit 50, at most 200. |
aggregate | count, sum:field, avg:field, min:field, max:field over the filtered set. |
group_by | Aggregate per distinct value. A date field is bucketed by group_period (day week month quarter year). |
having, group_sort, group_limit | Filter, order and cap the groups. |
count=only | Return meta without rows. |
curl -s -H "Authorization: Bearer $KEY" \ "$BASE/quotations?filter=status%3Aeq%3Adraft%3Btotal%3Agt%3A10000&sort=-total&limit=3"
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.
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.
| Path | Scope | What it returns |
|---|---|---|
GET/accounts | accounts | Customers, vendors and prospects. The single record adds contact people, assets, open cases, quotations, AMC contracts, work orders and invoices |
GET/cases | cases | Service cases (list only) |
GET/quotations | quotations | Quotations with lines, totals and invoicing position |
GET/standard-quotes | standard_quotes | Standard quotes |
GET/sales-orders | sales_orders | Sales orders |
GET/invoices | invoices | Invoices and balances due |
GET/purchase-orders | purchase-orders | Purchase orders |
GET/opportunities | opportunities | Deals in the pipeline |
GET/products | products | Product catalogue (internal cost is never returned) |
GET/inventory | inventory | Stock items |
GET/projects | projects | Workforce projects, plus /:id/hours and /:id/invoices |
GET/project-hours | projects | Hours attributed to projects |
GET/employees | employees (named) | Employee records |
GET/rfqs | rfqs (named) | Requests for quotation and supplier replies |
GET/estimates | estimates (named) | Estimates |
GET/abm-programs | abm_programs | Account-based marketing programs |
GET/abm-targets | abm_targets (named) | Target accounts, plus /abm-programs/:id/targets |
GET/tenders | tenders (named) | Tender deals. Filters: stage, outcome, decision, portal, closing (days to the bid end date). Needs the GCC tender module |
GET/tenders/:id | tenders (named) | One tender with a summary of its Bill of quantities |
GET/tenders/:id/boq | tenders (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/wbs | tenders (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 needs a key with write access to the object. Quotations are the writable object in v1, plus two actions.
| Path | What it does |
|---|---|
POST/quotations | Create a quotation with its lines (up to 200 lines per request). |
PATCH/quotations/:id | Change a quotation. Send lines to replace them. |
DELETE/quotations/:id | Delete a quotation. |
POST/projects/:id/invoices | Bill a project period from its attributed hours. |
POST/price | Price 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.
| Status | Meaning |
|---|---|
| 400 | The body is not valid JSON, or a value is malformed. |
| 401 | No key, an unknown key, or a revoked or expired key. |
| 403 | The key's scope does not allow this object, or this direction (read or write). |
| 404 | The record is not in your workspace, or your workspace does not have that module. |
| 409 | A business conflict, such as a superseded quotation or an already-billed period. |
| 422 | Validation failed. The body lists each rejected field with a message. |
| 5xx | A server or database problem. Retry later. /ask answers 502 when the AI service is unavailable and 503 when it is not set up. |
{
"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.
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.
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" }'{
"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.
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.
| Parameter | What it does |
|---|---|
since | The 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_type | Only one object type, such as quotes. 403 if the key cannot read it. |
limit | Default 100, at most 500. |
curl -s -H "Authorization: Bearer $KEY" \ "$BASE/changes?object_type=quotes&since=$CURSOR&limit=100"
{
"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.[redacted]. Internal cost prices are never included.actor is the person's email, or api:v1 for an API write.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.
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.
POST with up to 100 events, oldest first. A new webhook sends only changes made after it was created.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.[redacted].Content-Type: application/json X-BPMSquare-Signature: <hex HMAC-SHA256 of the raw body> X-BPMSquare-Delivery: <delivery id>
{
"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" } ]
}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.
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.
| Area | Tools | Key needs |
|---|---|---|
| Accounts, cases | list_accounts get_account list_cases | accounts, cases |
| Selling | list_quotations list_standard_quotes get_standard_quote list_sales_orders get_sales_order list_opportunities get_opportunity | quotations, standard_quotes, sales_orders, opportunities |
| Estimates, RFQs | list_estimates get_estimate list_rfqs get_rfq | estimates, rfqs (named) |
| Money and stock | list_invoices get_invoice list_purchase_orders get_purchase_order list_inventory get_inventory_item list_products get_product | invoices, purchase-orders, inventory, products |
| Projects and hours | list_projects get_project get_project_hours get_project_invoices query_project_hours create_project_invoice | projects (write for the last one) |
| ABM | list_abm_programs get_abm_program list_abm_targets get_abm_target | abm_programs, abm_targets (named) |
| Staff | list_employees get_employee | employees (named) |
| Tenders | list_tenders get_tender get_tender_boq get_tender_wbs | tenders (named) |
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.query_project_hours is the one tool with grouping and sums./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.
| Path | What it is |
|---|---|
GET/openapi.json | The 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/metadata | Field-level metadata. Today it describes quotations. |
GET/metadata/:entity | Every field of one object: type, whether required, allowed values. |
/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.Some jobs are better done another way. The Integration Guide has a chapter on each.
| Need | Use |
|---|---|
| Load or correct thousands of records from a spreadsheet | Data 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 happens | Call 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 ERP | Inbound 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 ePROMIS | Connectors 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. |
Tell us the system and the objects. We will set up a scoped key and walk the first call with you.