{
  "openapi": "3.1.0",
  "info": {
    "title": "Fieldstub API",
    "version": "1.0.0",
    "summary": "Capture extra work in the field and file it into Procore as a Change Event.",
    "description": "Fieldstub captures out-of-scope construction work from people who do not have a Procore seat, and writes it into Procore as a Change Event with typed line items on real budget codes.\n\nTwo things to understand before you build against this:\n\n1. **Creating a ticket is safe. Approving one is not.** A ticket is a record of a claim. Approving writes money into a customer's financials, and it is not reversible from here. Do not approve unattended.\n2. **Check `routing` before you approve.** Procore accepts a line item with a blank budget code without warning, which is how costs go missing. Every line with `needs_review: true` should be seen by a human, and `allow_unrouted` exists so that choice has to be made explicitly.",
    "contact": {
      "name": "Fieldstub",
      "email": "hello@fieldstub.com",
      "url": "https://fieldstub.com"
    }
  },
  "servers": [
    {
      "url": "https://app.fieldstub.com/v1"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "tags": [
    {
      "name": "Tickets",
      "description": "Extra work records, before and after they reach Procore."
    },
    {
      "name": "Projects",
      "description": "Procore projects and their budget codes."
    },
    {
      "name": "Links",
      "description": "Capture links. The link is the credential for the field."
    }
  ],
  "paths": {
    "/me": {
      "get": {
        "operationId": "getMe",
        "summary": "Which company this key belongs to",
        "tags": [
          "Projects"
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      }
    },
    "/projects": {
      "get": {
        "operationId": "listProjects",
        "summary": "List Procore projects for this company",
        "tags": [
          "Projects"
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      }
    },
    "/projects/{project_id}/cost-codes": {
      "get": {
        "operationId": "searchCostCodes",
        "summary": "Search the project cost codes",
        "tags": [
          "Projects"
        ],
        "parameters": [
          {
            "name": "project_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "q",
            "in": "query",
            "required": false,
            "description": "Case-insensitive substring match.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "404": {
            "description": "Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/projects/{project_id}/budget-codes": {
      "post": {
        "operationId": "createBudgetCode",
        "summary": "Create a budget code the project does not have yet",
        "description": "Extra work is unplanned by definition, so the code a ticket needs is routinely not in the budget. This creates the WBS code so a line can be routed to it.",
        "tags": [
          "Projects"
        ],
        "parameters": [
          {
            "name": "project_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "cost_code_item_id"
                ],
                "properties": {
                  "cost_code_item_id": {
                    "type": "integer"
                  },
                  "cost_type": {
                    "type": "string",
                    "enum": [
                      "labor",
                      "materials",
                      "equipment",
                      "other"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "404": {
            "description": "Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/tickets": {
      "get": {
        "operationId": "listTickets",
        "summary": "List tickets",
        "tags": [
          "Tickets"
        ],
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "submitted, written or rejected.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "project_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Default 50, maximum 200.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createTicket",
        "summary": "Create a ticket",
        "description": "The same validation the capture form uses. A ticket created here is exactly as trusted as one submitted from a jobsite, which is to say not at all until someone approves it.",
        "tags": [
          "Tickets"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TicketInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Ticket"
                }
              }
            }
          },
          "400": {
            "description": "Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/tickets/{id}": {
      "get": {
        "operationId": "getTicket",
        "summary": "Get one ticket, including where each line will land",
        "tags": [
          "Tickets"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Ticket"
                }
              }
            }
          },
          "404": {
            "description": "Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/projects/{project_id}/capability": {
      "get": {
        "operationId": "projectCapability",
        "summary": "Where a ticket on this project can be filed",
        "description": "Whether the project can take a Change Event, which is really whether the company has Project Financials on it. Read from Procore rather than assumed: a project without the tool answers 403 or 404 while its other tools answer normally. Cached briefly, because licensing does not change per request.",
        "tags": [
          "Projects"
        ],
        "parameters": [
          {
            "name": "project_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "change_events": {
                      "type": "boolean",
                      "description": "The project has the Change Events tool, so a change event can be written."
                    },
                    "daily_log": {
                      "type": "boolean",
                      "description": "The project can take a daily log entry. True everywhere so far."
                    },
                    "destination": {
                      "type": "string",
                      "enum": [
                        "change_event",
                        "daily_log"
                      ],
                      "description": "What approve will do if you name nothing."
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/tickets/{id}/approve": {
      "post": {
        "operationId": "approveTicket",
        "summary": "Approve, and write a Change Event to Procore",
        "description": "Writes money into the customer's Procore. Check GET /projects/{project_id}/capability first on an unfamiliar project: without Project Financials there is no Change Events tool and this returns 409 until you pass destination daily_log. Idempotent: approving an already-written ticket returns it unchanged rather than creating a second Change Event. A previously rejected ticket can be approved, but only with allow_rejected. A line with no budget code at all is refused unless allow_unrouted is true. Set require_exact if no human is watching: it also refuses lines that merely matched a plausible code, which is the case a person would catch on screen and a program would not.",
        "tags": [
          "Tickets"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "allow_unrouted": {
                    "type": "boolean",
                    "default": false,
                    "description": "Accept the blank budget code for lines that could not be routed. Says out loud that somebody chose to."
                  },
                  "allow_rejected": {
                    "type": "boolean",
                    "default": false,
                    "description": "Approve a ticket somebody already rejected. Without it, approving a rejected ticket returns 409: rejecting is not a write and is reversible, but overriding the person who did it should be deliberate. The MCP tool does not expose this."
                  },
                  "require_exact": {
                    "type": "boolean",
                    "default": false,
                    "description": "Refuse any line that is not on a confirmed budget code, including one that only matched loosely. Use it whenever no person is reviewing the write; the MCP server always sets it."
                  },
                  "routes": {
                    "type": "object",
                    "description": "Per-line budget code override, keyed by the line index from routing. Value is a WBS code id.",
                    "additionalProperties": {
                      "type": "integer"
                    }
                  },
                  "destination": {
                    "type": "string",
                    "enum": [
                      "change_event",
                      "daily_log"
                    ],
                    "description": "Where to file it. Omitted on a project with Project Financials, it writes a Change Event. On a project without it, approve returns 409 no_change_events until daily_log is passed explicitly. That is deliberate: a daily log bills nothing and cannot carry the photos, so it is never chosen for you."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "404": {
            "description": "Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/tickets/{id}/reject": {
      "post": {
        "operationId": "rejectTicket",
        "summary": "Reject a ticket",
        "description": "Close a ticket without writing anything to Procore. A rejected ticket can be sent back to Needs review with restore, or approved later.",
        "tags": [
          "Tickets"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Ticket"
                }
              }
            }
          },
          "404": {
            "description": "Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/tickets/{id}/restore": {
      "post": {
        "operationId": "restoreTicket",
        "summary": "Send a rejected ticket back to Needs review",
        "description": "Sets status to submitted. Does not write to Procore. Idempotent if the ticket is already in Needs review. Refused if it is already in Procore.",
        "tags": [
          "Tickets"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Ticket"
                }
              }
            }
          },
          "404": {
            "description": "Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/tickets/{id}/photos": {
      "post": {
        "operationId": "attachPhotos",
        "summary": "Attach photos to a ticket that is not yet in Procore",
        "description": "The office half of capture. A ticket that arrived without a photo can still take one before it is approved, which is the case where the field texts the shot in afterwards. Refused once the ticket is written: there is no second attach into Procore from here. A ticket holds at most 12 photos.",
        "tags": [
          "Tickets"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "photos"
                ],
                "properties": {
                  "photos": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 12,
                    "items": {
                      "type": "object",
                      "properties": {
                        "name": {
                          "type": "string"
                        },
                        "data_url": {
                          "type": "string",
                          "description": "data:image/jpeg;base64,… or bare base64."
                        },
                        "taken_at": {
                          "type": "string"
                        },
                        "coords": {
                          "type": "object"
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Ticket"
                }
              }
            }
          },
          "400": {
            "description": "Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/links": {
      "get": {
        "operationId": "listLinks",
        "summary": "List capture links",
        "tags": [
          "Links"
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createLink",
        "summary": "Create a capture link",
        "description": "Returns a URL anyone can open with no account. Print it as a QR code and tape it in the gang box. Treat the token as a credential.",
        "tags": [
          "Links"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "project_id"
                ],
                "properties": {
                  "project_id": {
                    "type": "string"
                  },
                  "label": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "404": {
            "description": "Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/links/{token}/revoke": {
      "post": {
        "operationId": "revokeLink",
        "summary": "Revoke a capture link",
        "description": "A taped-up QR code outlives the job. This is how it stops working.",
        "tags": [
          "Links"
        ],
        "parameters": [
          {
            "name": "token",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "404": {
            "description": "Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "An API key from the review queue at /keys. Send as Authorization: Bearer fs_live_…"
      }
    },
    "schemas": {
      "Ticket": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "status": {
            "type": "string",
            "enum": [
              "submitted",
              "written",
              "rejected"
            ]
          },
          "project_id": {
            "type": "string"
          },
          "description": {
            "type": "string"
          },
          "reason": {
            "type": "string",
            "description": "Why the work is out of scope."
          },
          "directed_by": {
            "type": "string",
            "description": "Who on the GC side authorized it."
          },
          "captured_at": {
            "type": "string",
            "format": "date-time"
          },
          "captured_via": {
            "type": "string",
            "description": "qr, sms, link or api."
          },
          "captured_by": {
            "type": [
              "string",
              "null"
            ]
          },
          "total": {
            "type": "number"
          },
          "labor": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/LaborLine"
            }
          },
          "equipment": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/EquipmentLine"
            }
          },
          "materials": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/MaterialLine"
            }
          },
          "signature": {
            "type": [
              "object",
              "null"
            ]
          },
          "photos": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Photo"
            }
          },
          "photo_count": {
            "type": "integer"
          },
          "link_token": {
            "type": [
              "string",
              "null"
            ]
          },
          "approved_by": {
            "type": [
              "string",
              "null"
            ],
            "description": "Who released the ticket into Procore: a reviewer's email address, or \"key:<name> (<prefix>)\" when an API key or an agent approved it. Null when the approval predates this field or no actor could be established. Procore's own audit shows the service account that wrote the change event, so this is the only record of the person."
          },
          "approved_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "decisions": {
            "type": "array",
            "description": "Every state change a caller made, oldest first, appended and never rewritten. A ticket that was rejected and then approved anyway keeps both entries, which is the sequence an audit asks about.",
            "items": {
              "type": "object",
              "properties": {
                "action": {
                  "type": "string",
                  "enum": [
                    "approved",
                    "rejected",
                    "restored"
                  ]
                },
                "by": {
                  "type": [
                    "string",
                    "null"
                  ]
                },
                "at": {
                  "type": "string",
                  "format": "date-time"
                }
              }
            }
          },
          "procore": {
            "type": [
              "object",
              "null"
            ],
            "description": "Populated once the ticket has been written to Procore.",
            "properties": {
              "destination": {
                "type": "string",
                "enum": [
                  "change_event",
                  "daily_log"
                ],
                "description": "Where it went. A change event is money on a budget code. A daily log is a dated record that bills nothing, used when the project has no Project Financials. Always present on a written ticket, so nothing has to be inferred from which id is null."
              },
              "change_event_id": {
                "type": [
                  "integer",
                  "null"
                ]
              },
              "manpower_log_id": {
                "type": [
                  "integer",
                  "null"
                ],
                "description": "Set instead of change_event_id when destination is daily_log."
              },
              "number": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "written_at": {
                "type": "string",
                "format": "date-time"
              },
              "url": {
                "type": "string"
              },
              "note": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Present on a daily log write, spelling out what that record is and is not."
              }
            }
          },
          "routing": {
            "type": "array",
            "description": "Where each line of money will land. Present on single-ticket reads and on approve. The most important field in the payload.",
            "items": {
              "$ref": "#/components/schemas/RoutingLine"
            }
          },
          "warnings": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        }
      },
      "LaborLine": {
        "type": "object",
        "properties": {
          "trade": {
            "type": "string"
          },
          "workers": {
            "type": "number",
            "default": 1
          },
          "hours": {
            "type": "number"
          },
          "rate": {
            "type": "number"
          },
          "costCode": {
            "type": "string",
            "description": "Optional. Helps route the line automatically."
          }
        },
        "required": [
          "trade",
          "hours"
        ]
      },
      "EquipmentLine": {
        "type": "object",
        "properties": {
          "description": {
            "type": "string"
          },
          "hours": {
            "type": "number"
          },
          "rate": {
            "type": "number"
          },
          "costCode": {
            "type": "string"
          }
        }
      },
      "MaterialLine": {
        "type": "object",
        "properties": {
          "description": {
            "type": "string"
          },
          "quantity": {
            "type": "number"
          },
          "unitCost": {
            "type": "number"
          },
          "costCode": {
            "type": "string"
          }
        }
      },
      "Photo": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string"
          },
          "taken_at": {
            "type": [
              "string",
              "null"
            ]
          },
          "coords": {
            "type": [
              "object",
              "null"
            ]
          }
        }
      },
      "RoutingLine": {
        "type": "object",
        "properties": {
          "line": {
            "type": "integer",
            "description": "Index. Use it as the key in routes on approve."
          },
          "description": {
            "type": "string"
          },
          "cost_type": {
            "type": [
              "string",
              "null"
            ]
          },
          "quantity": {
            "type": [
              "number",
              "null"
            ]
          },
          "unit_cost": {
            "type": [
              "number",
              "null"
            ]
          },
          "amount": {
            "type": "number"
          },
          "budget_code": {
            "type": [
              "string",
              "null"
            ],
            "description": "Flat code, e.g. 01-000.E."
          },
          "budget_code_id": {
            "type": [
              "integer",
              "null"
            ]
          },
          "routed": {
            "type": "boolean"
          },
          "exact": {
            "type": "boolean",
            "description": "False means a fallback code was used."
          },
          "needs_review": {
            "type": "boolean"
          }
        }
      },
      "TicketInput": {
        "type": "object",
        "required": [
          "project_id",
          "description",
          "reason",
          "directed_by"
        ],
        "properties": {
          "project_id": {
            "type": "string"
          },
          "description": {
            "type": "string",
            "description": "What was done."
          },
          "reason": {
            "type": "string",
            "description": "Why it is out of scope."
          },
          "directed_by": {
            "type": "string",
            "description": "Who authorized it."
          },
          "captured_by": {
            "type": "string"
          },
          "labor": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/LaborLine"
            }
          },
          "equipment": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/EquipmentLine"
            }
          },
          "materials": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/MaterialLine"
            }
          },
          "photos": {
            "type": "array",
            "maxItems": 12,
            "items": {
              "type": "object",
              "properties": {
                "name": {
                  "type": "string"
                },
                "data_url": {
                  "type": "string",
                  "description": "data:image/jpeg;base64,… or bare base64."
                },
                "taken_at": {
                  "type": "string"
                },
                "coords": {
                  "type": "object"
                }
              }
            }
          },
          "signature": {
            "type": "object",
            "properties": {
              "name": {
                "type": "string"
              },
              "role": {
                "type": "string"
              },
              "signed_at": {
                "type": "string"
              },
              "image": {
                "type": "string",
                "description": "data URL. Required for the signature to count."
              }
            }
          }
        }
      },
      "Error": {
        "type": "object",
        "properties": {
          "error": {
            "type": "object",
            "properties": {
              "code": {
                "type": "string",
                "description": "Stable machine-readable code."
              },
              "message": {
                "type": "string"
              },
              "details": {}
            }
          }
        }
      }
    }
  }
}
