The Fieldstub API
Everything the review queue does, your code can do. Create a ticket, read where each line of money is about to land, and approve it into Procore as a change event with typed line items on real budget codes.
Base URL is https://app.fieldstub.com/v1. The full reference is the OpenAPI spec, which needs no key to read.
Two things to understand before you build#
Check routing before you approve. Every ticket read comes back with a routing array saying exactly which budget code each line will hit. Procore accepts a line item with a blank budget code without any warning, which is how costs quietly go missing, so a line we could not confidently route comes back with needs_review: true and approve will refuse it.
You can override that with allow_unrouted: true. It exists so that accepting a blank code is a decision somebody makes on purpose rather than a default nobody noticed.
Keys#
Create one in the review queue at /keys. It is shown once. We store a hash, so nobody at Fieldstub can look it up for you later. If it is lost, revoke it and make another.
A key is scoped to one company and can read and write everything that company has.
curl https://app.fieldstub.com/v1/me \
-H "Authorization: Bearer fs_live_..."
X-API-Key works too, for when you are writing a shell script in a hurry. Rate limit is 120 requests a minute per key.
The shape of the thing#
| Endpoint | What it does |
|---|---|
GET /v1/me | Which company this key belongs to |
GET /v1/projects | Procore projects on that company |
GET /v1/tickets | List, filter by status or project |
POST /v1/tickets | Create a ticket |
GET /v1/tickets/{id} | One ticket, including routing |
POST /v1/tickets/{id}/approve | Writes a change event to Procore |
POST /v1/tickets/{id}/reject | Close it without writing |
POST /v1/tickets/{id}/photos | Attach photos before it is approved |
GET /v1/links, POST /v1/links | Capture links for the field |
GET /v1/projects/{id}/cost-codes | Search the project cost codes |
POST /v1/projects/{id}/budget-codes | Create a budget code the project lacks |
A whole ticket, start to finish#
Create it:
curl -X POST https://app.fieldstub.com/v1/tickets \
-H "Authorization: Bearer $FIELDSTUB_KEY" \
-H "Content-Type: application/json" \
-d '{
"project_id": "360805",
"description": "Removed unmarked footing at grid C-4",
"reason": "Not on the drawings, blocking pile cap",
"directed_by": "Mike Torres, super",
"labor": [{ "trade": "Laborer", "workers": 3, "hours": 6, "rate": 68 }],
"equipment": [{ "description": "Excavator", "hours": 6, "rate": 185 }]
}'
Read back where the money lands:
{
"routing": [
{ "line": 0, "description": "Laborer x3 — 6h", "amount": 1224.00,
"budget_code": null, "routed": false, "needs_review": true },
{ "line": 1, "description": "Excavator — 6h", "amount": 1110.00,
"budget_code": "01-000.E", "routed": true, "needs_review": false }
]
}
Line 0 has nowhere to go, because the project has no budgeted labor code. Approving now would fail, on purpose. Fix it by pointing the line at a code you choose:
curl -X POST https://app.fieldstub.com/v1/tickets/$ID/approve \
-H "Authorization: Bearer $FIELDSTUB_KEY" \
-H "Content-Type: application/json" \
-d '{ "routes": { "0": 1870754 } }'
The response carries the Procore record, including a link to it. Approving the same ticket twice returns the same change event with already: true rather than creating a second one.
If you are pointing an agent at this#
People are going to, so here is the honest guidance rather than a disclaimer.
- Let it create tickets freely. That is the safe half, and it is genuinely useful: an assistant that turns a voice note or an email thread into a structured ticket is doing real work.
- Do not let it approve unattended. Approving is a financial write into someone else's system. A human should see the amount and the budget codes.
- Never pass
allow_unroutedautomatically. It exists specifically to make a person say out loud that a cost is going somewhere nobody reviews. - Read the spec first. openapi.json is public and describes every field, including which ones mean money.
An MCP server is coming, and it will be a thin wrapper over these same endpoints rather than a second implementation with its own opinions.
Where a ticket lands, and why you have to say#
Change Events live inside Procore's Project Financials. A project without that module has no Change Events tool, so there is nothing to write a change event into. Ask before you approve:
GET /v1/projects/{project_id}/capability
{ "change_events": false, "daily_log": true, "destination": "daily_log" }
On a project where change_events is false, approving without naming a destination returns 409:
{ "error": { "code": "no_change_events", "message": "This project has no Change Events tool ..." } }
Pass the destination to go ahead:
POST /v1/tickets/{id}/approve
{ "destination": "daily_log" }
approve mean two different things depending on a license the caller cannot see, so we make you say which one you meant.A written ticket always reports where it went, so nothing has to be inferred from which id happens to be null:
"procore": {
"destination": "daily_log",
"change_event_id": null,
"manpower_log_id": 2213053,
"note": "Filed to the Procore daily log. Not a financial record ..."
}
Every ticket also carries who decided what. Procore's own audit shows the service account that wrote the change event, because that is what wrote it, so this is the only record of the person or credential that released the money. A key approving through this API is recorded as the key, never as whoever created it.
"approved_by": "key:Ops automation (fs_live_8Kd2)",
"approved_at": "2026-09-04T14:22:10.108Z",
"decisions": [
{ "action": "rejected", "by": "pe@acme.com", "at": "2026-09-04T13:40:02.771Z" },
{ "action": "approved", "by": "key:Ops automation (fs_live_8Kd2)", "at": "2026-09-04T14:22:10.108Z" }
]
The array is appended to and never rewritten, so a ticket somebody rejected and somebody else approved anyway keeps both entries. That sequence is the one an audit asks about, and a single approved_by would erase half of it.
Errors#
Every error has the same shape and a stable code you can branch on.
{ "error": { "code": "project_not_found", "message": "No project 111111 on this company" } }
| Status | When |
|---|---|
400 | The ticket is incomplete. details lists every problem at once, not just the first |
401 | Missing, malformed or revoked key |
404 | No such ticket, link or project on this company. Cross-tenant reads look identical to things that do not exist |
409 | Already written, already rejected, or no_change_events: the project has no Project Financials, so name a destination |
429 | More than 120 requests in a minute |
502 | Procore refused the write. The message is theirs and is usually actionable |
Questions
Why does approve fail with no_change_events?
The project has no Change Events tool, which means the company does not have Project Financials on it. Read GET /v1/projects/{project_id}/capability, then approve again with destination daily_log to file a dated record instead. It is never chosen automatically because a daily log bills nothing.
Can I create a ticket without a QR code?
Yes. POST /v1/tickets with a project_id does the same thing the capture form does, with the same validation. The QR link exists for people who have no account; the API exists for systems that do.
Is approving idempotent?
Yes. Approving an already-written ticket returns the existing change event with already: true. Fieldstub also sets its own origin_id on the Procore record, so a retry finds the existing one rather than duplicating it.
What happens if a line cannot be routed to a budget code?
Approve refuses. The line comes back with needs_review true and you either supply a route, create the budget code, or pass allow_unrouted to accept the blank code deliberately.
Is there an MCP server?
Not yet. It is planned as a wrapper over these endpoints, so anything you build against the REST API now keeps working.
Are there webhooks?
Not yet. Poll GET /v1/tickets?status=submitted for now. Webhooks are the next thing on this API.
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