Overview
This is the single-page reference for the DemandFlow REST API. It covers every endpoint, how records are addressed, how streaming responses behave, and the mistakes that most often catch out a new integration. Each endpoint also has its own article if you want the detail on one call in isolation.
Base URL and tokens
Every call goes to the same base URL:
https://rest.demandflow.com
Authentication is a Personal Access Token (PAT) sent as a bearer token on every request:
Authorization: Bearer <your-pat>
Create one from your avatar, then user profile, then the PAT Tokens tab. The value is shown once, so copy it immediately. A token inherits the permissions of the user who created it, is bound to that user's subscription, and can be revoked or deleted at any time from the same panel.
Treat a token like a password. Load it from an environment variable or a secrets manager rather than source control, and use a separate token per integration so that one leak can be revoked without breaking everything else.
First request
curl -H "Authorization: Bearer $TOKEN" \
"https://rest.demandflow.com/v1/entities/PPL/SUB"
That streams back every person in your tenant.
Endpoints at a glance
| Method | Path | Purpose |
|---|---|---|
| GET | /v1/objects/{id} | Read one record by id |
| POST | /v1/objects | Create a record |
| PATCH | /v1/objects/{id} | Update selected fields |
| DELETE | /v1/objects/{id} | Delete a record |
| GET | /v1/entities/{entity}/{comboKey} | Stream a list by entity and comboKey prefix |
| POST | /v1/query | Run several list queries in parallel in one round trip |
| POST | /v1/files/public | Presigned upload for public assets |
comboKeys, how records are addressed
Every record carries a comboKey that encodes its full ancestry as pipe-separated ABBREV:id segments, starting at your subscription:
"comboKey": "SUB:13c16ba0-f7ab-...|ENT:9f3...|BU:41c...|PJ:7ab...|ID:c92..."
List queries match on a prefix of that key, anchored at the left and matched whole segments at a time. The prefix is the filter. "Every application under patent xyz789" is the entity PATENTA plus a prefix ending |PAT:xyz789. There is no "contains" match, and no way to query on a middle segment alone.
- The
SUB:value is your subscription id. It appears on thecomboKeyof every record you read back. It is never a token or a key, and no credential of any kind belongs inside a query. SUBon its own is a valid prefix, meaning every record of that entity in your tenant.- Some entities carry secondary hierarchies on
comboKey2andcomboKey3.POST /v1/querylets you choose which one to match on. - When creating a record, the comboKey you send must end with
|ENT:. The new id is appended directly onto that suffix. - In a URL path, encode
|as%7Cif your client does not do it for you.
Errors
Single-record endpoints return a JSON body with error and message fields, and a standard HTTP status:
| Status | Meaning |
|---|---|
| 400 | Malformed request, such as a bad JSON body or a missing id in the path |
| 401 | No Authorization header |
| 403 | Invalid or expired token, or the record belongs to another tenant |
| 404 | Record not found |
| 405 | Method not allowed on that path |
| 5xx | Server-side failure. Retry after a short delay |
Streaming endpoints behave differently. Once a list response has started, the status has already been sent, so failures arrive inside the body as a line with _type: "error" and a message. A failure in one query line of a /v1/query batch does not stop the others. Check every line, not just the status.
Working with single records
GET /v1/objects/{id}
Reads one record by id, scoped to your subscription. The optional entity query parameter (uppercase entity code) lets the API route the read directly without an extra lookup, and is safe to omit in almost all cases.
Returns 200 with every field on the record, or 404 if the id does not exist or belongs to another tenant.
curl -H "Authorization: Bearer $TOKEN" \
"https://rest.demandflow.com/v1/objects/a1b2c3d4-e5f6-4890-abcd-ef1234567890"
{
"id": "a1b2c3d4-e5f6-4890-abcd-ef1234567890",
"entity": "PPL",
"level": 220,
"comboKey": "SUB:...|ENT:a8da263a-...",
"name": "Ada Lovelace",
"_df_version": 4,
"created": 1776336000000,
"updated": 1776338516765
}
POST /v1/objects
Creates a record and returns it with its new id and version. Three system fields are required on every create:
| Field | Type | Notes |
|---|---|---|
| entity | string | Required. Uppercase entity code, for example PPL or PATENT |
| level | number | Required. The numeric level from that entity's definition |
| comboKey | string | Required. Must end with |ENT: |
Everything else is entity-specific. Returns 201 with the created record, including the server-assigned id and _df_version: 0, or 400 if the body is missing, is not a JSON object, or is invalid JSON.
curl -X POST "https://rest.demandflow.com/v1/objects" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"entity": "PATENTA",
"level": 430,
"comboKey": "SUB:<your-sub-id>|PAT:<parent-patent-id>|ENT:",
"name": "Application Title"
}'
Do not drop the trailing |ENT:. Without it, the new id is appended to whatever segment came last and the record ends up with a malformed comboKey. It will then not be found by any prefix query, including its own parent's.
PATCH /v1/objects/{id}
Updates selected fields and returns the full updated record. Send only the fields you want to change, since anything you omit is left untouched. The system fields id, ownerId and created are ignored if present.
curl -X PATCH "https://rest.demandflow.com/v1/objects/a1b2c3d4-..." \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Ada Lovelace (Countess)", "title": "Countess"}'
A patch is not a bare write. On the way through, the platform stamps updated and updatedId, increments _df_version, pushes the change live to anyone with the record open, fires any automations configured on that entity, and writes an audit entry.
Because each call runs that whole pipeline, batch your changes. One patch carrying twenty fields costs a fraction of twenty single-field patches to the same record. Use the returned record rather than assuming your submitted values landed verbatim.
DELETE /v1/objects/{id}
Deletes a record and returns 204 with an empty body. The record is removed, connected clients are updated live, and the deletion is written to the audit log. Returns 403 if the record belongs to another tenant, or 404 if it does not exist.
Deletion is immediate and cannot be reversed through the API. Deleting a parent does not delete its children, which keep their comboKeys and are left orphaned. For anything high-risk, prefer archiving, by setting a status field, over deleting.
Querying lists
There are two shapes. One entity and one prefix goes in the URL. Anything more, such as several entities at once, counts, text filters or time windows, goes to /v1/query.
GET /v1/entities/{entity}/{comboKey}
| Parameter | In | Notes |
|---|---|---|
| entity | path | Required. Uppercase entity code |
| comboKey | path | Required. Prefix to match. SUB alone lists every record of that entity in your tenant |
| fields | query | Optional. Comma-separated field names to return |
The response is a JSON array, streamed as records are read, and each item carries a _type: "data" marker. Small results can be read whole and parsed normally.
# every person in the tenant
curl -H "Authorization: Bearer $TOKEN" \
"https://rest.demandflow.com/v1/entities/PPL/SUB"
# applications under one patent, three fields only
curl -H "Authorization: Bearer $TOKEN" \
"https://rest.demandflow.com/v1/entities/PATENTA/SUB:abc123%7CPAT:xyz789?fields=id,name,status"
POST /v1/query
Runs several list queries in parallel and streams the combined result as NDJSON. The body is a raw JSON array of query lines, with no wrapper object. Each line runs independently and writes its records into one shared stream, which is how you load everything behind a dashboard in a single round trip.
| Query line field | Type | Notes |
|---|---|---|
| entity | string | Required. Uppercase entity code |
| comboKey | string | Required. Name of the key attribute to match on. For standard queries this is literally "comboKey". Use "comboKey2" or "comboKey3" for an entity's secondary hierarchies |
| query | string | Required. The prefix to match on that key, usually starting SUB:{your-sub-id} |
| load | string | Comma-separated fields to project. Omit for the whole record |
| limit | number | Hard cap on items returned for this line |
| countOnly | boolean | Emits a single count record instead of the data |
| filter | object | Case-insensitive substring filter, { field, term }. Use field: "*" to match anywhere in the record |
| tsStart, tsEnd | number | LOG entity only. Epoch-ms range, used with a timestamp-prefixed comboKey |
| tag | any | Echoed back on this line's meta records, so counts and scan stats can be matched to the line that produced them |
Parallel load:
curl -X POST "https://rest.demandflow.com/v1/query" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '[
{"entity":"PPL", "comboKey":"comboKey", "query":"SUB", "load":"id,name"},
{"entity":"ACTION", "comboKey":"comboKey", "query":"SUB", "load":"id,name,status"}
]'
Text search across a log window:
[
{
"entity": "LOG",
"comboKey": "comboKey",
"query": "SUB:<sub>|LOGSOURCE:<source>|TS:",
"tsStart": 1776000000000,
"tsEnd": 1776600000000,
"filter": { "field": "*", "term": "timeout" },
"limit": 500,
"tag": "timeouts-last-week"
}
]
What comes back
| Line | How to tell | What it means |
|---|---|---|
| Record | Has an id and no _type | Your data. Records from different query lines are interleaved, so route on each record's entity |
| Count | { _meta: true, _type: "count", entity, count, tag? } | The result of a countOnly line |
| Scan | { _meta: true, _type: "scan", entity, scanned, matched, tag? } | Emitted when a text filter ran, so you can see how much was read against how much matched |
| Error | { _type: "error", message, entity, source } | A failure on one query line. The rest of the batch keeps streaming |
Consuming a stream
Two rules cover every client. Split on newlines with a carry-over buffer, and check _type before you trust a line.
const res = await fetch('https://rest.demandflow.com/v1/query', {
method: 'POST',
headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify(queries)
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop(); // last line may be incomplete
for (const line of lines) {
if (!line.trim()) continue;
const row = JSON.parse(line);
if (row._type === 'error') { handleError(row.message); continue; }
if (row._meta) { handleMeta(row); continue; } // count / scan totals
handleRecord(row);
}
}
if (buffer.trim()) handleRecord(JSON.parse(buffer)); // no trailing newline
Never store a trimmed record as if it were whole. Anything returned under load or fields is a partial. Keep partials out of any cache that your full-record reads share, or a later lookup will return a record with most of its fields missing.
Public file uploads
POST /v1/files/public
A two-step presigned upload for assets that must be reachable without a login. The API never sees the file bytes. Post { "filename": "network-diagram.png" } and you get back a form to upload with:
| Response field | Use |
|---|---|
| url | Where to POST the file bytes in step 2 |
| fields | Opaque form fields from S3. Submit every one of them, before the file itself |
| fileKey | The object's key within the bucket |
| fileUrl | The final public URL. Store this on any record that references the file, never the presigned url |
Maximum 10 MB per upload, and the presigned form is valid for 10 minutes. The filename field is optional. Characters other than letters, digits, dot, underscore and hyphen become underscores, and the name is truncated to 120 characters.
Public means public. Files land in your tenant's public bucket and are readable by anyone with the URL, with no authentication. Use it for knowledge base images, portal diagrams and logos. Never use it for contracts, customer data, invoices, or anything else you would not print on a billboard. Private uploads have their own endpoints inside the app.
Limits and gotchas
Reading the stream
- Split on newlines and keep the tail in a buffer, because chunks cut lines in half.
- Check
_typebeforeidon every line. - An error line does not end the stream. Keep reading.
- Ordering across a
/v1/querybatch is not defined. Route onentity,comboKeyortag.
Cost and volume
loadandfieldssave bandwidth, not query cost. The record is read in full, then trimmed.filterruns after the read, not as part of it. The scan record tells you what it cost.filter: { field: "*" }inspects the whole record. That is fine for a few thousand rows, not for a whole-entity sweep.- A bare
SUBprefix on a large entity really will stream everything. Passlimitunless you mean it.
Tenancy
- Your tenant is resolved from the token, never from the request. There is no cross-tenant read, and no parameter that could make one.
- A token can do exactly what its owner can do, and no more.
- Start prefixes at
SUB, or atSUB:<your-sub-id>when drilling into a hierarchy.
Sharp edges
- A create comboKey must end with
|ENT:. This is the most common integration bug. /v1/entities/...returns a JSON array, while/v1/queryreturns NDJSON. They need different parsers.- Encode
|as%7Cin URL paths. - Deleting a parent orphans its children. Enumerate before you delete.
See also
- DemandFlow REST API Overview
- Getting an API Token (PAT)
- The per-endpoint articles in this category, for detail on a single call