# Creating a Procore change event over the API

> Auth, the create body Procore actually accepts, line items, amounts as strings, attachments, idempotency and rate limits. Probed against a live sandbox.

Source: https://fieldstub.com/docs/procore-change-events-api/  ·  Updated 2026-08-09
Verified against a Procore sandbox, August 2026

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](https://support.procore.com/products/online/user-guide/project-level/change-events) 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\":\"...\"}"
} }
```

> **The validation error lies about the field name.** Omit the status and Procore replies `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"
    } }
} }
```

> **Amounts must be decimal strings.** Send numbers and they post as `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](https://fieldstub.com/docs/procore-budget-codes/).

## 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.

> **Pagination is not universal.** WBS `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.

## Related

- https://fieldstub.com/docs/procore-budget-codes.md
- https://fieldstub.com/docs/procore-tm-tickets.md

---

Fieldstub captures extra construction work from people with no Procore seat and files it into Procore as a Change Event. https://fieldstub.com/
