FieldstubDocs › API

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.

Last updated 2026-08-30 · Read as markdown

Two things to understand before you build#

Creating a ticket is safe. Approving one is not. A ticket is a record of a claim, and nothing about it touches anyone's financials. Approving writes a change event into a customer's Procore and is not reversible from here.

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#

EndpointWhat it does
GET /v1/meWhich company this key belongs to
GET /v1/projectsProcore projects on that company
GET /v1/ticketsList, filter by status or project
POST /v1/ticketsCreate a ticket
GET /v1/tickets/{id}One ticket, including routing
POST /v1/tickets/{id}/approveWrites a change event to Procore
POST /v1/tickets/{id}/rejectClose it without writing
POST /v1/tickets/{id}/photosAttach photos before it is approved
GET /v1/links, POST /v1/linksCapture links for the field
GET /v1/projects/{id}/cost-codesSearch the project cost codes
POST /v1/projects/{id}/budget-codesCreate 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_unrouted automatically. 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" }
The extra round trip is the point. A change event is money on a budget code; a daily log is a dated record that bills nothing and cannot carry the photos, because Procore silently discards attachments on one. Falling back automatically would make 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" } }
StatusWhen
400The ticket is incomplete. details lists every problem at once, not just the first
401Missing, malformed or revoked key
404No such ticket, link or project on this company. Cross-tenant reads look identical to things that do not exist
409Already written, already rejected, or no_change_events: the project has no Project Financials, so name a destination
429More than 120 requests in a minute
502Procore 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