Every state change, pushed to you.
When a CO is approved, an invoice is coded, or an agent asks for permission, we POST a signed event to your endpoint. No polling, no nightly diff.
One approval, fanned out
Approving CO #14 is one write. The record updates budget, commitments and Pay App #9, and each change becomes an event for whoever subscribed.
Event catalog
Planned event types, grouped by object. Use the sidebar filter to jump to one. Events marked with a payload have a full example below.
- project.created
- project.updated
- cost_code.created
- cost_code.updated
- commitment.created
- commitment.updated
- change_order.created
- change_order.submitted
- change_order.approved
- change_order.rejected
- invoice.received
- invoice.coded
- invoice.exception
- invoice.approved
- pay_app.created
- pay_app.updated
- pay_app.submitted
- pay_app.approved
- pay_app.paid
- lien_waiver.requested
- lien_waiver.received
- rfi.created
- rfi.answered
- rfi.overdue
- daily_log.created
- agent.action.pending_approval
- agent.action.approved
- agent.action.rejected
30 event types in this draft.
The event object
Every delivery is a POST with one event. data.object is the full object after the change, the same shape the REST API returns. previous_attributes holds only the fields that changed.
- idstringUnique per event. Dedupe on it; delivery is at-least-once.
- typestringEvent type, e.g. change_order.approved.
- api_versionstringPinned on the endpoint at create.
- projectstringProject ID, for routing.
- data.objectobjectObject after the change.
- data.previous_attributesobjectChanged fields, before values.
{
"id": "evt_change_ord31",
"object": "event",
"type": "change_order.approved",
"api_version": "2026-10-preview",
"created_at": "2026-09-30T21:18:42Z",
"project": "prj_24118",
"data": {
"object": {
"id": "co_14",
"object": "change_order",
"project": "prj_24118",
"number": 14,
"title": "Add fire dampers — Level 3 corridor",
"status": "approved",
"reason": "hidden_condition",
"source_rfi": "rfi_212",
"amount": 8640000,
"currency": "usd",
"schedule_impact_days": 3,
"lines": [
{
"cost_code": "cc_233300",
"description": "Fire dampers, material",
"amount": 4860000
},
{
"cost_code": "cc_233300",
"description": "Install labor",
"amount": 3120000
},
{
"cost_code": "cc_233300",
"description": "Markup",
"amount": 660000
}
],
"approvals": [
{
"role": "project_manager",
"user": "usr_dana_pm",
"decision": "approved",
"at": "2026-09-30T21:18:42Z"
}
],
"pay_app": "pa_9",
"created_at": "2026-09-24T16:40:09Z",
"updated_at": "2026-09-30T21:18:42Z",
"audit_trail": "/v1/change-orders/co_14/audit"
},
"previous_attributes": {
"status": "pending_approval"
}
}
}change_order.approved
A CO was approved. Budget, commitments and the next pay app have already been updated when you receive it.
data.object is a change_order. Changed: status.
{
"id": "evt_change_ord31",
"object": "event",
"type": "change_order.approved",
"api_version": "2026-10-preview",
"created_at": "2026-09-30T21:18:42Z",
"project": "prj_24118",
"data": {
"object": {
"id": "co_14",
"object": "change_order",
"project": "prj_24118",
"number": 14,
"title": "Add fire dampers — Level 3 corridor",
"status": "approved",
"reason": "hidden_condition",
"source_rfi": "rfi_212",
"amount": 8640000,
"currency": "usd",
"schedule_impact_days": 3,
"lines": [
{
"cost_code": "cc_233300",
"description": "Fire dampers, material",
"amount": 4860000
},
{
"cost_code": "cc_233300",
"description": "Install labor",
"amount": 3120000
},
{
"cost_code": "cc_233300",
"description": "Markup",
"amount": 660000
}
],
"approvals": [
{
"role": "project_manager",
"user": "usr_dana_pm",
"decision": "approved",
"at": "2026-09-30T21:18:42Z"
}
],
"pay_app": "pa_9",
"created_at": "2026-09-24T16:40:09Z",
"updated_at": "2026-09-30T21:18:42Z",
"audit_trail": "/v1/change-orders/co_14/audit"
},
"previous_attributes": {
"status": "pending_approval"
}
}
}change_order.submitted
A draft CO was sent for approval.
data.object is a change_order. Changed: status.
{
"id": "evt_change_ord31",
"object": "event",
"type": "change_order.submitted",
"api_version": "2026-10-preview",
"created_at": "2026-09-30T21:18:42Z",
"project": "prj_24118",
"data": {
"object": {
"id": "co_14",
"object": "change_order",
"project": "prj_24118",
"number": 14,
"title": "Add fire dampers — Level 3 corridor",
"status": "pending_approval",
"reason": "hidden_condition",
"source_rfi": "rfi_212",
"amount": 8640000,
"currency": "usd",
"schedule_impact_days": 3,
"lines": [
{
"cost_code": "cc_233300",
"description": "Fire dampers, material",
"amount": 4860000
},
{
"cost_code": "cc_233300",
"description": "Install labor",
"amount": 3120000
},
{
"cost_code": "cc_233300",
"description": "Markup",
"amount": 660000
}
],
"approvals": [],
"pay_app": "pa_9",
"created_at": "2026-09-24T16:40:09Z",
"updated_at": "2026-09-30T21:18:42Z",
"audit_trail": "/v1/change-orders/co_14/audit"
},
"previous_attributes": {
"status": "draft"
}
}
}budget.revised
A budget line changed: approved change, re-forecast, or transfer.
data.object is a budget_line. Changed: approved_changes, revised.
{
"id": "evt_budgetrevi31",
"object": "event",
"type": "budget.revised",
"api_version": "2026-10-preview",
"created_at": "2026-09-30T21:18:42Z",
"project": "prj_24118",
"data": {
"object": {
"id": "bud_24118_233300",
"object": "budget_line",
"project": "prj_24118",
"cost_code": "cc_233300",
"original": 21400000,
"approved_changes": 8640000,
"revised": 30040000,
"committed": 28720000,
"cost_to_date": 19310000,
"cost_to_complete": 10730000,
"projected_final": 30040000,
"variance": 0,
"created_at": "2026-01-12T15:10:00Z",
"updated_at": "2026-09-30T21:18:42Z",
"audit_trail": "/v1/budgets/bud_24118_233300/audit"
},
"previous_attributes": {
"approved_changes": 0,
"revised": 21400000
}
}
}budget.variance_detected
Projected final cost crossed revised budget on a line. Early fade warning.
data.object is a budget_line. No previous attributes.
{
"id": "evt_budgetvari31",
"object": "event",
"type": "budget.variance_detected",
"api_version": "2026-10-preview",
"created_at": "2026-09-30T21:18:42Z",
"project": "prj_24118",
"data": {
"object": {
"id": "bud_24118_260500",
"object": "budget_line",
"project": "prj_24118",
"cost_code": "cc_260500",
"original": 21400000,
"approved_changes": 8640000,
"revised": 30040000,
"committed": 28720000,
"cost_to_date": 19310000,
"cost_to_complete": 10730000,
"projected_final": 30040000,
"variance": -2380000,
"created_at": "2026-01-12T15:10:00Z",
"updated_at": "2026-09-30T21:18:42Z",
"audit_trail": "/v1/budgets/bud_24118_233300/audit"
}
}
}invoice.coded
An invoice got a cost code and commitment, by a person or an approved agent action.
data.object is a invoice. Changed: status, cost_code.
{
"id": "evt_invoicecod31",
"object": "event",
"type": "invoice.coded",
"api_version": "2026-10-preview",
"created_at": "2026-09-29T13:12:04Z",
"project": "prj_24118",
"data": {
"object": {
"id": "inv_4471",
"object": "invoice",
"project": "prj_24118",
"number": "INV-4471",
"vendor": "vnd_volt_electric",
"vendor_name": "Volt Electric",
"commitment": "cmt_po118",
"amount": 4820000,
"retainage_held": 482000,
"status": "needs_review",
"cost_code": "cc_260500",
"exceptions": [
{
"code": "exceeds_commitment",
"message": "Exceeds PO-118 remaining by $6,150.00",
"amount": 615000
}
],
"coded_by": "act_8812",
"due_date": "2026-10-25",
"created_at": "2026-09-29T13:11:50Z",
"updated_at": "2026-09-29T13:12:04Z",
"audit_trail": "/v1/invoices/inv_4471/audit"
},
"previous_attributes": {
"status": "received",
"cost_code": null
}
}
}invoice.exception
A check failed: over commitment, duplicate number, missing waiver.
data.object is a invoice. No previous attributes.
{
"id": "evt_invoiceexc31",
"object": "event",
"type": "invoice.exception",
"api_version": "2026-10-preview",
"created_at": "2026-09-29T13:12:04Z",
"project": "prj_24118",
"data": {
"object": {
"id": "inv_4471",
"object": "invoice",
"project": "prj_24118",
"number": "INV-4471",
"vendor": "vnd_volt_electric",
"vendor_name": "Volt Electric",
"commitment": "cmt_po118",
"amount": 4820000,
"retainage_held": 482000,
"status": "needs_review",
"cost_code": "cc_260500",
"exceptions": [
{
"code": "exceeds_commitment",
"message": "Exceeds PO-118 remaining by $6,150.00",
"amount": 615000
}
],
"coded_by": "act_8812",
"due_date": "2026-10-25",
"created_at": "2026-09-29T13:11:50Z",
"updated_at": "2026-09-29T13:12:04Z",
"audit_trail": "/v1/invoices/inv_4471/audit"
}
}
}pay_app.submitted
A pay app was locked and sent to the owner.
data.object is a pay_app. Changed: status.
{
"id": "evt_pay_appsub31",
"object": "event",
"type": "pay_app.submitted",
"api_version": "2026-10-preview",
"created_at": "2026-10-01T15:30:00Z",
"project": "prj_24118",
"data": {
"object": {
"id": "pa_9",
"object": "pay_app",
"project": "prj_24118",
"number": 9,
"status": "submitted",
"period_start": "2026-09-01",
"period_end": "2026-09-30",
"format": "aia_g702_g703",
"work_this_period": 231040000,
"retainage_pct": 10,
"retainage_this_period": 23104000,
"net_due_this_period": 207936000,
"change_orders": [
"co_14"
],
"lien_waivers_missing": [
"vnd_apex_steel",
"vnd_coredry"
],
"created_at": "2026-09-30T22:00:00Z",
"updated_at": "2026-10-01T15:30:00Z",
"audit_trail": "/v1/pay-apps/pa_9/audit"
},
"previous_attributes": {
"status": "draft"
}
}
}lien_waiver.received
A signed waiver was attached for a vendor and pay app.
data.object is a lien_waiver. Changed: status.
{
"id": "evt_lien_waive31",
"object": "event",
"type": "lien_waiver.received",
"api_version": "2026-10-preview",
"created_at": "2026-10-02T17:45:00Z",
"project": "prj_24118",
"data": {
"object": {
"id": "lw_9_volt",
"object": "lien_waiver",
"project": "prj_24118",
"pay_app": "pa_9",
"vendor": "vnd_volt_electric",
"vendor_name": "Volt Electric",
"type": "conditional_progress",
"amount": 4338000,
"status": "received",
"file": "file_lw9volt",
"received_at": "2026-10-02T17:45:00Z",
"created_at": "2026-09-30T22:00:00Z",
"updated_at": "2026-10-02T17:45:00Z",
"audit_trail": "/v1/lien-waivers/lw_9_volt/audit"
},
"previous_attributes": {
"status": "requested"
}
}
}rfi.answered
An RFI got its official response. cost_impact tells you if a CO is likely.
data.object is a rfi. Changed: status, answer.
{
"id": "evt_rfianswere31",
"object": "event",
"type": "rfi.answered",
"api_version": "2026-10-preview",
"created_at": "2026-09-22T19:02:00Z",
"project": "prj_24118",
"data": {
"object": {
"id": "rfi_212",
"object": "rfi",
"project": "prj_24118",
"number": 212,
"subject": "Missing fire dampers at L3 corridor rated wall",
"status": "answered",
"ball_in_court": "gc",
"question": "Drawings show a 1-hr rated corridor wall at L3 with no fire dampers at duct penetrations. Confirm dampers required and spec.",
"answer": "Provide UL 555 fire dampers at all rated penetrations per M-301. Price as change.",
"due_date": "2026-09-23",
"answered_at": "2026-09-22T19:02:00Z",
"cost_impact": true,
"change_orders": [
"co_14"
],
"daily_log": "dl_20260918",
"created_at": "2026-09-18T20:14:00Z",
"updated_at": "2026-09-22T19:02:00Z",
"audit_trail": "/v1/rfis/rfi_212/audit"
},
"previous_attributes": {
"status": "open",
"answer": null
}
}
}rfi.overdue
An RFI passed its due date without an answer.
data.object is a rfi. No previous attributes.
{
"id": "evt_rfioverdue31",
"object": "event",
"type": "rfi.overdue",
"api_version": "2026-10-preview",
"created_at": "2026-09-22T19:02:00Z",
"project": "prj_24118",
"data": {
"object": {
"id": "rfi_219",
"object": "rfi",
"project": "prj_24118",
"number": 219,
"subject": "Missing fire dampers at L3 corridor rated wall",
"status": "open",
"ball_in_court": "gc",
"question": "Drawings show a 1-hr rated corridor wall at L3 with no fire dampers at duct penetrations. Confirm dampers required and spec.",
"answer": null,
"due_date": "2026-09-23",
"answered_at": null,
"cost_impact": true,
"change_orders": [],
"daily_log": "dl_20260918",
"created_at": "2026-09-18T20:14:00Z",
"updated_at": "2026-09-22T19:02:00Z",
"audit_trail": "/v1/rfis/rfi_212/audit"
}
}
}agent.action.pending_approval
An agent proposed a write that needs a human. Route it to Slack, email or your own queue.
data.object is a agent_action. No previous attributes.
{
"id": "evt_agentactio31",
"object": "event",
"type": "agent.action.pending_approval",
"api_version": "2026-10-preview",
"created_at": "2026-09-29T13:12:04Z",
"project": "prj_24118",
"data": {
"object": {
"id": "act_8812",
"object": "agent_action",
"project": "prj_24118",
"agent": "agt_ap_coding",
"kind": "invoice.code",
"status": "pending_approval",
"target": "inv_4471",
"proposal": {
"cost_code": "cc_260500",
"commitment": "cmt_po118",
"amount": 4820000
},
"reasoning": "Vendor, PO number and line descriptions match PO-118. Amount exceeds remaining commitment by $6,150.00, so a human must approve.",
"confidence": 0.93,
"requires_role": "project_accountant",
"decided_by": null,
"decided_at": null,
"created_at": "2026-09-29T13:12:04Z",
"updated_at": "2026-09-29T13:12:04Z",
"audit_trail": "/v1/agents/actions/act_8812/audit"
}
}
}agent.action.approved
A person approved an agent proposal and it was applied.
data.object is a agent_action. Changed: status.
{
"id": "evt_agentactio31",
"object": "event",
"type": "agent.action.approved",
"api_version": "2026-10-preview",
"created_at": "2026-09-29T13:12:04Z",
"project": "prj_24118",
"data": {
"object": {
"id": "act_8812",
"object": "agent_action",
"project": "prj_24118",
"agent": "agt_ap_coding",
"kind": "invoice.code",
"status": "applied",
"target": "inv_4471",
"proposal": {
"cost_code": "cc_260500",
"commitment": "cmt_po118",
"amount": 4820000
},
"reasoning": "Vendor, PO number and line descriptions match PO-118. Amount exceeds remaining commitment by $6,150.00, so a human must approve.",
"confidence": 0.93,
"requires_role": "project_accountant",
"decided_by": "usr_pat_pa",
"decided_at": "2026-09-29T15:40:00Z",
"created_at": "2026-09-29T13:12:04Z",
"updated_at": "2026-09-29T13:12:04Z",
"audit_trail": "/v1/agents/actions/act_8812/audit"
},
"previous_attributes": {
"status": "pending_approval"
}
}
}Verify signatures
Each request carries OS-Signature: t=…,v1=…: an HMAC-SHA256 of timestamp.raw_body with your endpoint secret. Verify against the raw bytes before parsing, reject anything older than five minutes, and compare in constant time.
Rotating a secret keeps the old one valid for 24 hours, and both signatures are sent during the overlap.
Handle it
Return any 2xx within 10 seconds, then do slow work async. Store event.id to ignore duplicates. Order is not guaranteed; compare data.object.updated_at if it matters.
import crypto from "node:crypto";
// Header: OS-Signature: t=1759267123,v1=5f2b…
export function verify(rawBody, header, secret) {
const { t, v1 } = Object.fromEntries(header.split(",").map(p => p.split("=")));
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; // replay window
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}import hmac, hashlib, time
# Header: OS-Signature: t=1759267123,v1=5f2b…
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
if abs(time.time() - int(parts["t"])) > 300: # replay window
return False
signed = f"{parts['t']}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])app.post("/hooks/os", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.body.toString(), req.get("OS-Signature"), process.env.OS_WHSEC)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body);
if (seen(event.id)) return res.sendStatus(200); // deliveries are at-least-once
switch (event.type) {
case "change_order.approved":
erp.postChangeOrder(event.data.object); // amount is cents
break;
case "agent.action.pending_approval":
slack.askForApproval(event.data.object); // human gate
break;
}
res.sendStatus(200); // ack fast, do slow work async
});Retry policy planned
Anything other than a 2xx, or no answer in 10 seconds, is a failure. We retry with backoff for about a day and a half, then mark the delivery failed. You can replay any event from the last 30 days via the API.
If an endpoint fails every delivery for three days we disable it and email the account owner. Re-enable it and replay what you missed.
Proposed schedule. Tell us if it breaks your integration.
- ImmediatelyFirst attempt, 10 s timeout
- +1 minRetry 1
- +5 minRetry 2
- +30 minRetry 3
- +2 hRetry 4
- +8 hRetry 5
- +24 hFinal retry, then marked failed
Which events would you subscribe to first?
Founding contractors decide what ships in v1. Tell us what your ERP, owner portal or warehouse needs to hear.
Shape the API — get early access