Fieldstub › Docs › Change events API
Creating a Procore change event over the API
Everything below was learned by probing a Procore sandbox rather than by reading the reference, because several of the things the API tells you are not true. Validation errors name fields that do not exist. Paths are not consistent between resources. One request shape returns 200 and throws your data away.
This is the short path from nothing to a change event with typed line items and a photo attached. For the human side of the tool this writes to, Procore's Change Events guide is the reference.
Auth: a service account, not per-user OAuth#
A Data Management Service Account (DMSA) with client_credentials works for reads and writes, which means an integration can run server side on a schedule with nobody logged in.
POST https://login.procore.com/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&client_id=...&client_secret=...
Two things to know. The body must be form encoded, because JSON is rejected here even though it is accepted elsewhere. And tokens last 5400 seconds, so refresh a little early rather than letting a long request straddle expiry.
Most endpoints also want a Procore-Company-Id header. GET /rest/v1.0/companies is the useful exception: it answers without one, which is how a multi-company integration finds out which companies it has been installed on in the first place.
Paths are not consistent, and this is the biggest time sink#
Financial resources hang off a ?project_id= query parameter. Directory-shaped ones nest under /projects/{id}/. There is no rule you can infer, so check each one.
| Resource | Path |
|---|---|
| Change events | /rest/v1.0/change_events?project_id= |
| Cost codes | /rest/v1.0/cost_codes?project_id= |
| Budget views | /rest/v1.0/budget_views?project_id= |
| Change event statuses | /rest/v1.0/change_event/statuses?project_id= |
| Direct costs | /rest/v1.0/projects/{id}/direct_costs |
| Directory, crews, timecards | /rest/v1.0/projects/{id}/... |
Creating the change event#
This body works:
POST /rest/v1.0/change_events?project_id={project_id}
{ "change_event": {
"title": "T&M 2026-08-09 — Removed unmarked footing at grid C-4",
"description": "...",
"event_scope": "out_of_scope",
"status": "open",
"origin_id": "your-own-id",
"origin_data": "{\"captured\":\"...\"}"
} }
change_event_status: required. Send change_event_status in any form and it keeps saying required. The field it accepts is status. The same pattern shows up on line items, where the wrapper key is line_item and not change_event_line_item.event_scope takes in_scope, out_of_scope or tbd. status takes the lowercase mapped_to_status string, not the status id. Statuses are configured per company and carry their own ids, so read the list rather than hardcoding it.
The empty line item you did not ask for#
Creating a change event auto-creates one line item with no content. It cannot be deleted (every delete path we tried returns 404) but it can be patched into a real one.
PATCH /rest/v1.0/change_events/{id}/line_items/{line_item_id}?project_id={project_id}
So the first real line should reuse it and the rest should be posted normally. Otherwise every record you write carries a stray $0.00 row, which is exactly the kind of detail that makes a project manager stop trusting an integration.
Line items, and amounts that must be strings#
A line item needs a budget_code. A bare cost_code_id is rejected with budget_code: is missing.
POST /rest/v1.0/change_events/{id}/line_items?project_id={project_id}
{ "line_item": {
"description": "Operator — 6h",
"budget_code": { "id": 1870754 },
"cost_impact": { "estimate": {
"quantity": "6.00",
"unit_cost": "95.00",
"calculation_strategy": "automatic"
} }
} }
null, with no error. calculation_strategy is automatic (Procore multiplies quantity by unit cost) or manual (you supply a flat amount), and nothing else.unit_of_measure validates against a list we never found. Both hr and hour are rejected, so we omit it.
The read shape is not the write shape. A POST takes and returns cost_impact.estimate; a later GET on the change event returns flat estimated_cost_amount, estimated_cost_quantity and estimated_cost_unit_cost on the nested line items. Same numbers, two representations, so parse both.
The budget_code field has a trap of its own that is worth its own page: the segment_items form returns 200 and is silently discarded.
Attachments#
Photos ride on a multipart PATCH of the change event itself, not on a separate attachments endpoint.
PATCH /rest/v1.0/change_events/{id}?project_id={project_id}
Content-Type: multipart/form-data
attachments[]=@photo.jpg
If the provenance matters (when the photo was taken, where, who directed the work) write it into origin_data as well. Do not assume EXIF survives the upload.
Idempotency, and the fact that makes review possible#
Set your own origin_id on create and you can look the record up later instead of writing a second one. That makes retries safe.
More usefully: setting origin_id does not lock the record. Procore locks Direct Costs, Sub Invoices, Payments, and approved Commitments and Change Orders, but a change event stays editable after an integration creates it. A PATCH after creation succeeds.
That is a real argument for change events as a write target beyond anything strategic. A record you can correct supports a review loop. A record that freezes on creation does not, and field data always needs correcting.
Rate limits and a pagination trap#
The spike limit is 25 requests per 10 seconds, and it is easy to hit on a first unthrottled run. Serializing calls roughly 450ms apart stays under it comfortably. The response headers are authoritative, including x-rate-limit-reset as a unix timestamp, so respect that before falling back to your own backoff.
segment_items ignores page and per_page entirely and returns the full set on every request. Looping until an empty page produced 5,168 rows for 304 unique records across 17 identical calls. Stop when a page repeats, dedupe by id, and assume any list endpoint might behave this way.Questions
Why does Procore say change_event_status is required when I sent it?
Because the field it accepts is called status. The validation message names a field that does not exist in the request body. Send status with the lowercase mapped_to_status string.
Why are my line item amounts coming back null?
Amounts under cost_impact.estimate must be decimal strings. Numeric JSON values post as null with no error returned.
Can a service account write change events without a user logging in?
Yes. A DMSA with client_credentials can read and write server side. Tokens last 5400 seconds and most endpoints want a Procore-Company-Id header.
Can I write to Procore T&M Tickets instead?
Not through a public API. Twelve resource name variants all 404 and Procore publishes no developer documentation for that tool, so change events is the addressable target.
Put a code on one job and see what comes back.
Fieldstub turns a phone and a QR code into a signed Change Event in Procore. The field needs no login and no seat.
Start free