Change Feed Endpoint
This feature is not yet generally available. It will be available in our next major release.
The Change Feed returns a stream of incremental changes — Add, Edit and Delete — across your Kahua domain, in marker order. It is how an external warehouse stays in sync after its initial load.
It is step three of the Data Feed lifecycle, and the only one you run continuously:
| Step | Endpoint | Purpose |
|---|---|---|
| 1. Schema Discovery | /schemas | Discover what can be loaded. |
| 2. Initial Data Load | /entityfeed | Bulk-load the current state of each entityDef. |
| 3. Change Feed (this page) | /changefeed | Poll for incremental changes, forever. |
If you are new to the Data Feed start with Overview and Getting Started. It covers the Data Feed license, the API User group, obtaining a token, and the marker model — all of which this page assumes.
Use of this API requires a Data Feed license. See the Prerequisites section of Overview and Getting Started for more details.
Do not use Change Feed to build your initial dataset. Run /entityfeed first. Starting Change Feed from lastMarker: 0 gives you only the changes still retained ( approximately 30 days of activity), not the records that have simply existed untouched for longer. Refer to Recovering from 410.
Beyond a valid token, the calling account must:
-
Belong to the API User group — otherwise 403.
-
Hold an assigned Data Feed license (kahua_DataFeed) — a purchased add-on, checked per caller, so every integration account needs its own seat. Otherwise 403.
This endpoint accepts Bearer, Basic, or Session authentication.
POST https://{envName}service.kahua.com/v1/domains/{domainKey}/changefeed
Authorization: Bearer <token>
Content-Type: application/json
Accept-Encoding: gzip, br (optional; honored if present)
Path Parameters
| Parameter | Type | Description |
|---|---|---|
| envName | string | Name of the environment being used. It may be empty if using production or it could be devweekly during testing. |
| domainKey | string | The domain key. Value can be found from within Kahua in Domain Settings -> Authentication |
Query Parameters
None
This endpoint uses POST because it takes a structured JSON query in the request body. It makes no changes to your Kahua data.
| Property | Type | Required | Default | Description |
|---|---|---|---|---|
| lastMarker | long | no | 0 | Your cursor — the last marker you have processed. The feed returns changes strictly after it. 0 starts from the earliest retained marker. |
| pageSize | integer | no | 100 | Entries per page. Clamped to [1, 1000]. |
| projectId | long | no | — | Restrict to a single project / partition. |
| entityDefNames | string[] | no | — | Array to restrict to specific entity types. Also acts as an escape hatch: an entity type you name explicitly bypasses the standard exclusion list. |
| attributes | map<string, string[]> | no | — | Per-entity-type attribute projection. Required if you want any data at all. |
| consolidateChanges | boolean | no | false | Collapse multiple changes to the same record within a page into one net-change entry. |
Header-only by default. If you omit attributes — or omit a key for a particular entity type — entries for that type come back with no data. This keeps payloads small and forces integrations to be explicit about what they consume. Most integrations should supply attributes.
The field names in attributes are the name values from /schemas.
Example request
{
"lastMarker": 10247,
"pageSize": 500,
"projectId": 5001,
"entityDefNames": ["kahua_AEC_RFI.RFI", "kahua_AEC_DailyReport.DailyReport"],
"attributes": {
"kahua_AEC_RFI.RFI": ["Number", "Subject", "Status", "DueDate"],
"kahua_AEC_DailyReport.DailyReport": ["Number", "Date", "Shift", "StatusWorkflow"]
},
"consolidateChanges": true
}
Status 200 OK.
| Property | Type | Description |
|---|---|---|
| marker | long | The highest marker ID in this page. Pass this back as lastMarker on your next call, and persist it. |
| currentMarker | long | The newest marker in the domain at request time. Compare against marker to know how far behind you are. |
| hasMore | boolean | true when more pages follow — poll again immediately rather than waiting for your interval. |
| count | integer | Entries in this page, after exclusion and consolidation. May be 0 while hasMore is true — refer to below. |
| changes | ChangeFeedEntry[] | Array of the change entries, ordered by markerId ascending. |
| recommendedPageSize | int (key absent when not hinted) | A server hint. When present, the server is telling you your request ran long (roughly 20 seconds or more) and you should use this smaller page size on the next call. |
| Property | Type | Description |
|---|---|---|
| action | string | Add, Edit, or Delete. |
| entityDef | string | Fully-qualified entity type. |
| entityId | long | The record's ID — your warehouse key. |
| entityInstanceId | long | The record's version ID at the time of the change. When consolidated, the highest in the group. |
| partitionId | long | The project / partition the record belongs to. |
| markerTime | datetime (UTC) | When the change occurred. When consolidated, the time of the highest-markerId change in the group. |
| markerId | long | The marker ID for this change. When consolidated, the highest in the group. |
| data | object (key absent when there is no data) | Flat {fieldName: value} map, limited to the attributes you requested. |
There is no data payload for an entry when:
-
the action is Delete;
-
the record no longer exists at the time of your request;
-
you supplied no attribute projection for that entity type.
Treat a missing data key and a data value of null as equivalent — check for "absent or null" rather than requiring the key.
Null values are omitted from the JSON, not written as "field": null. This applies to envelope fields such as recommendedPageSize, and to data when there is none.
The exception is inside the data map: a field that exists on the record but has no value is serialized as "Field": null.
Never detect end-of-data by inspecting changes or a null field. Use hasMore.
{
"marker": 10251,
"currentMarker": 10420,
"hasMore": true,
"count": 3,
"changes": [{
"action": "Add",
"entityDef": "kahua_AEC_RFI.RFI",
"entityId": 88012,
"entityInstanceId": 1,
"partitionId": 5001,
"markerTime": "2026-04-20T14:22:03Z",
"markerId": 10249,
"data": {
"Number": "0042",
"Subject": "Foundation waterproofing detail",
"Status": "Open",
"DueDate": "2026-05-15T00:00:00Z"
}
}, {
"action": "Edit",
"entityDef": "kahua_AEC_RFI.RFI",
"entityId": 88010,
"entityInstanceId": 3,
"partitionId": 5001,
"markerTime": "2026-04-20T15:05:11Z",
"markerId": 10250,
"data": {
"Number": "0040",
"Subject": "Structural steel spec clarification",
"Status": "Answered",
"DueDate": null
}
}, {
"action": "Delete",
"entityDef": "kahua_AEC_DailyReport.DailyReport",
"entityId": 77501,
"entityInstanceId": 2,
"partitionId": 5001,
"markerTime": "2026-04-20T16:12:30Z",
"markerId": 10251
}
]
}
The Delete entry carries no data key — that is the expected shape. "DueDate": null on the Edit entry is a real value: the field exists and is empty.
data is the state now, not the state at the time of the change
This is the single most important semantic on this endpoint.
data is read when you make the request, not captured when the change happened. So:
-
An Edit entry's data is the record's current state — which may reflect later edits that you have not yet received as their own entries.
-
If a record was changed and then deleted before you polled, its earlier Add / Edit entries in this page come back with no data.
The Change Feed is therefore an upsert stream, not a historical audit log. It reliably tells you which records changed and in what order; it does not let you reconstruct each intermediate version. If your warehouse needs point-in-time history, derive it from the sequence of markers you receive — do not expect the API to replay old field values.
Paging and the cursor
-
The cursor is exclusive: a request returns markers strictly greater than lastMarker.
-
Marker IDs are domain-wide and always increasing. As long as your cursor advances, you never skip or duplicate entries.
-
Persist marker after every page, not just at the end of a run. If your process dies mid-page-run, you resume from the last persisted marker and lose nothing.
A page can be empty while more data follows
Excluded record types are filtered out after the page is cut, and the cursor always advances to the highest raw marker in the page. In a domain with a lot of internal system activity, a page can therefore return fewer entries than pageSize, or none at all, while hasMore is still true.
Never stop paging because changes is empty. Continue until hasMore is false. You are fully caught up when hasMore is false and marker == currentMarker.
How internal operations map to actions
| Kahua operation | Feed action |
|---|---|
| Record created | Add |
| Record updated, or moved between partitions | Edit |
| Record deleted | Delete |
| Internal invalidation | not delivered |
Note that a move between projects arrives as an Edit — check partitionId on every entry, not just on Add.
Reference fields
Fields holding references to other records are returned as the referenced record's numeric entity ID:
{ "Originator": 48291035, "Vendor": 77203841 }
Collections of references are returned as an array of IDs:
{ "Items": [12345, 12346, 12347] }
Rules:
-
An unset reference is null.
-
A reference to a deleted record still returns the stored ID — the reference is not cleared. Your warehouse may hold a foreign key to a row you have deleted; handle it.
-
Dot-path traversal is not supported. You cannot request Vendor.Company. Projections cover only the direct fields of the changed record. To get referenced records' details, sync their entityDef too and join in your warehouse.
With consolidateChanges: true, multiple changes to the same record within a single page collapse into one net-change entry. This reduces the volume your warehouse processes when records are edited repeatedly.
| First action in page | Last action in page | You receive | data |
|---|---|---|---|
| Add | Add or Edit | Add | current state |
| Edit | Edit | Edit | current state |
| Edit | Delete | Delete | (none) |
| Add | Delete | (nothing — entry omitted) | — |
| Delete | Add or Edit | Edit | current state |
Key behaviors:
-
Add → Delete: the record was created and destroyed inside the page. It is omitted entirely — your warehouse never learns it existed, which is correct.
-
Delete → Add/Edit: the record was restored or re-created. Delivered as Edit.
-
The consolidated entry carries the highest markerId, markerTime and partitionId of the group, and the highest entityInstanceId.
-
Consolidation is page-scoped. A record changed across a page boundary appears in both pages, once each. Your writes must be idempotent regardless.
-
count reflects the post-consolidation entry count; marker is still the highest raw marker ID.
Consolidation is recommended for most warehouse integrations. Leave it off only if you need every individual change event.
Kahua infrastructure record types, which carry no business data, and record types belonging to internal Kahua apps are automatically excluded from unfiltered responses. Refer to /schemas as the authoritative list of what is available to you.
Escape hatch: naming an entity type explicitly in entityDefNames bypasses exclusion for that type. Use this only when you have a specific, confirmed need for an internal record type.
-
Always filter when you can. Pass entityDefNames and/or projectId — unfiltered domain-wide calls are the most expensive shape and should be rare.
-
Rate limit: by default 60 requests per minute, per calling account, per domain — shared with /entityfeed. A background initial load draws on the same budget as your polling.
-
Compression: send Accept-Encoding: gzip, br. On pageSize=1000 responses this typically gives 5–10× size reduction.
-
Honor recommendedPageSize. When the server returns it, your request took long enough that a smaller page will be more reliable.
-
Delete entries are cheap — they require no record lookup.
Response Body
| Property | Type | Description |
|---|---|---|
| errorCode | string | Code that represents the error condition |
| message | string | Additional information about the error |
Error codes 410 and 429 include additional properties described in the error codes table.
Codes
| Status | error | Notes |
|---|---|---|
| 400 | InvalidRequest | Validation failure — for example pageSize out of range. |
| 403 | Forbidden | No access to the domain, not a member of the API User group, or no Data Feed license (kahua_DataFeed). |
| 410 | MarkerExpired | Your lastMarker has aged out of the ~30-day retention window. Body includes currentMarker. Refer to below. |
| 429 | RateLimited | Rate limit exceeded. Sleep retryAfterSeconds included in the body (or honor Retry-After), then retry the same request. |
Recovering from 410 MarkerExpired
Your stored cursor is older than the retention window. You have three options, and only one of them leaves you with correct data:
| Option | Result |
|---|---|
| Re-run /entityfeed, then resume Change Feed from its asOfMarker | Correct. Your warehouse is complete and consistent again. This is the recommended recovery. |
| Restart with lastMarker: 0 | Incomplete. Replays only the ~30 days of retained activity. Records untouched for longer have no surviving marker and will never be delivered. Use this only if you already hold a complete dataset and just need to re-apply recent changes. |
| Jump to lastMarker: currentMarker | Accepts a permanent gap. You resume live but never learn about anything that happened while you were away. Only appropriate if stale data is acceptable to you. |
The way to avoid 410 entirely is to poll on a schedule comfortably inside the retention window and to alert on your integration falling behind.
curl -X POST 'https://service.kahua.com/v1/domains/MagsevenBuilders/changefeed' \
-H 'Authorization: Bearer <token>' \
-H 'Content-Type: application/json' \
-H 'Accept-Encoding: gzip, br' \
--data '{
"lastMarker": 4701,
"pageSize": 1000,
"entityDefNames": ["kahua_AEC_RFI.RFI"],
"attributes": {
"kahua_AEC_RFI.RFI": ["Number", "Subject", "Status", "DueDate"]
},
"consolidateChanges": true
}'
1. Start from your initial load's marker
POST /v1/domains/MagsevenBuilders/changefeed
{ "lastMarker": <asOfMarker from /entityfeed>, "pageSize": 1000,
"entityDefNames": [...], "attributes": {...}, "consolidateChanges": true }
2. Drain
→ Apply changes as upserts / deletes
→ Persist the returned `marker` after EVERY page
→ Keep calling while hasMore == true, even if a page returned zero entries
→ Caught up when hasMore == false AND marker == currentMarker
3. Poll on your interval (e.g. every 24 hours)
→ Same request, with your stored marker as lastMarker
→ Repeat step 2
4. On 410 MarkerExpired
→ Re-run /entityfeed, then resume from its asOfMarker
5. On 429 RateLimited
→ Sleep retryAfterSeconds, then retry the same request
6. If recommendedPageSize is returned
→ Use it as pageSize on the next call
Requirements for a correct consumer:
-
Writes must be idempotent — the same change can legitimately arrive twice.
-
Order matters: apply entries in markerId order, and never process a later page before an earlier one.
-
Persist the marker transactionally with the data you applied, so a crash cannot advance the cursor past unwritten rows.
-
Data Feed — Overview and Getting Started
-
Schema Discovery — /schemas
-
Entity Feed — /entityfeed