The public API reads one company's Forge data with plain web requests, and no AI is involved. This reference is built from Forge's published description of the API, so it lists exactly the routes, limits and rules that description states.
Key or OAuth
- An API key is for software one company runs for itself. An owner or admin of that company creates it on the web and gives it to you. See use an API key instead.
- OAuth is for a product that serves many Forge customers. Each contractor approves your product for their own company on Forge's own page. See register an app.
Both only read, and both read one company. A key works on the public API and the MCP server alike. An OAuth token works on the one it was issued for, so a product that uses both asks for a token for each.
Addresses
This manual was built for https://api.forge.equipment/v1. The published description names these servers, and every path below starts from one of them:
- Production:
https://api.forge.equipment - Staging:
https://forge-backend-staging-production.up.railway.app
Read one company's Forge data: jobs, their photos and areas, scopes, job walks with their transcripts and recordings, and estimates and change orders.
Prices
Estimates and change orders answer the customer view by default: customer prices only, what the homeowner is charged. view=internal adds the company's own figures; never show those to a homeowner. When priceUnavailableReason is set: No customer price exists for this estimate. Do not compute one from cost.
Authentication
Send Authorization: Bearer <token> with either an OAuth access token issued for resource=https://api.forge.equipment/v1 (authorization code with PKCE) or a personal API key (fgo_key_...) created by a company owner or admin on the Forge web app. Every call reads the one company the grant or key belongs to. No route takes a company parameter.
Read only
There are no write routes.
Rate limits
120 requests a minute and 20,000 requests a day per grant or key. Over a limit the answer is 429 with a Retry-After header in seconds.
Paging
List routes return at most 100 items a page (default 20). Pass nextCursor back as cursor to read the next page; it is null on the last page. A cursor is bound to the route, its filters and the company.
Incremental sync
GET /v1/jobs takes updatedSince (ISO 8601): only jobs changed at or after that time. Order: updatedAt descending, then id descending; a cursor resumes exactly after the last item.
Versioning
The version is the /v1 path prefix. Within /v1 Forge only adds: new routes, new optional query parameters and new response fields. Clients must ignore response fields they do not know. Removing or renaming a field, changing its type or meaning, or adding a required parameter happens only under a new prefix (/v2), announced at least 90 days before /v1 changes.
Errors
Every error body is { errorCode, message, requestId }. Unknown query parameters are refused with 400.
Browsers
The API answers no browser origin: keys and tokens are held on a server.
Ways in
- A personal API key, sent as a bearer token. Keys start with
fgo_key_. - OAuth, authorization code with PKCE. Authorize at
https://api.forge.equipment/oauth/authorize, exchange the code athttps://api.forge.equipment/oauth/token, with scopeforge.readand resourcehttps://api.forge.equipment/v1.
Routes
GET /v1/jobs
Find jobs
Parameters
cursorquery, stringThe nextCursor of the previous page, unchanged. Bound to this route, its filters and the company.
limitquery, integerItems per page, 1 to 100. Default 20.
queryquery, stringCase-insensitive match on the job name.
stagequery, string: presale, pre_construction, active, closedThe job's lifecycle phase.
statusquery, string: active, archivedupdatedSincequery, string (date-time)Incremental sync: only jobs changed at or after this time (ISO 8601). Order is updatedAt descending, then id descending.
Answers
200JobPage400A query parameter is unknown or not valid. ApiError401No token or key, or a dead one. ApiError403Refused: role, data access, billing or a browser origin. ApiError404Not found in this company. ApiError429Over a rate limit. Retry-After says when to try again. ApiError (header Retry-After)
GET /v1/jobs/{id}
One job, its area list and Forge's JobTread send record
Parameters
idpath, required, stringA job id.
Answers
200JobDetail400A query parameter is unknown or not valid. ApiError401No token or key, or a dead one. ApiError403Refused: role, data access, billing or a browser origin. ApiError404Not found in this company. ApiError429Over a rate limit. Retry-After says when to try again. ApiError (header Retry-After)
GET /v1/jobs/{id}/photos
A page of one job's photos
Parameters
idpath, required, stringA job id.
cursorquery, stringThe nextCursor of the previous page, unchanged. Bound to this route, its filters and the company.
limitquery, integerItems per page, 1 to 100. Default 20.
areaquery, stringAn area slug or label, any case. An area that matches nothing is an empty page.
tradequery, stringA trade label, any case, combined with area.
includeHiddenquery, booleanHidden photos are left out unless this is true.
Answers
200PhotoPage400A query parameter is unknown or not valid. ApiError401No token or key, or a dead one. ApiError403Refused: role, data access, billing or a browser origin. ApiError404Not found in this company. ApiError429Over a rate limit. Retry-After says when to try again. ApiError (header Retry-After)
GET /v1/photos/{id}
One photo's image
The resized copy, at most 1568 px on its long edge and 1 MB, as JPEG bytes through this API. A photo is never a link.
Parameters
idpath, required, stringA photo id.
includeHiddenquery, booleanA hidden photo is refused (PHOTO_HIDDEN) unless this is true.
Answers
200The JPEG bytes.400A query parameter is unknown or not valid. ApiError401No token or key, or a dead one. ApiError403Refused: role, data access, billing or a browser origin. ApiError404Not found in this company. ApiError422PHOTO_HIDDEN, PHOTO_NO_RESIZED_COPY or PHOTO_OVER_IMAGE_CAP. ApiError429Over a rate limit. Retry-After says when to try again. ApiError (header Retry-After)
GET /v1/scopes/{id}
The current scope
Parameters
idpath, required, stringA scope document id.
viewquery, string: customer, internalcustomer (the default) leaves out the company's internal sections.
cursorquery, stringThe nextCursor of the previous page, unchanged.
Answers
200ScopePage400A query parameter is unknown or not valid. ApiError401No token or key, or a dead one. ApiError403Refused: role, data access, billing or a browser origin. ApiError404Not found in this company. ApiError429Over a rate limit. Retry-After says when to try again. ApiError (header Retry-After)
GET /v1/walks
Walks, sweeps and job-level recordings
Parameters
cursorquery, stringThe nextCursor of the previous page, unchanged. Bound to this route, its filters and the company.
limitquery, integerItems per page, 1 to 100. Default 20.
jobIdquery, stringOnly walks on this job.
fromquery, string (date-time)Recorded at or after this time (ISO 8601).
toquery, string (date-time)Recorded before this time (ISO 8601).
Answers
200WalkPage400A query parameter is unknown or not valid. ApiError401No token or key, or a dead one. ApiError403Refused: role, data access, billing or a browser origin. ApiError404Not found in this company. ApiError429Over a rate limit. Retry-After says when to try again. ApiError (header Retry-After)
GET /v1/walks/{id}/transcript
A page of one walk's transcript
Parameters
idpath, required, stringA walk id from GET /v1/walks.
cursorquery, stringThe nextCursor of the previous page, unchanged.
fromSecondsquery, numberStart of a time range, seconds into the take.
toSecondsquery, numberEnd of a time range, seconds into the take.
textquery, string: shown, originaloriginal returns a translated walk's original-language text.
takeIdquery, stringOnly this take's segments (a chapter's takeId).
Answers
200TranscriptPage400A query parameter is unknown or not valid. ApiError401No token or key, or a dead one. ApiError403Refused: role, data access, billing or a browser origin. ApiError404Not found in this company. ApiError429Over a rate limit. Retry-After says when to try again. ApiError (header Retry-After)
GET /v1/walks/{id}/recordings
One walk's recordings and chapters
Each audio or video take as a link that works for 15 minutes (every issue is logged), and each area's chapter.
Parameters
idpath, required, stringA walk id from GET /v1/walks.
Answers
200WalkRecordings400A query parameter is unknown or not valid. ApiError401No token or key, or a dead one. ApiError403Refused: role, data access, billing or a browser origin. ApiError404Not found in this company. ApiError429Over a rate limit. Retry-After says when to try again. ApiError (header Retry-After)503A link could not be logged, so none was issued. ApiError
GET /v1/estimates
The current estimates
The current version of each estimate, drafts included and labelled. view=customer, the default, carries customer prices only: what the homeowner is charged. view=internal returns the same object with the company's own figures added beside each price, and the markup; never show those to a homeowner. When priceUnavailableReason is set: No customer price exists for this estimate. Do not compute one from cost.
Parameters
cursorquery, stringThe nextCursor of the previous page, unchanged. Bound to this route, its filters and the company.
limitquery, integerItems per page, 1 to 100. Default 20.
statusquery, string: draft, approved, sent, signedOnly estimates with this status label.
updatedSincequery, string (date-time)Incremental sync: only estimates changed at or after this time (ISO 8601). Order is updatedAt descending, then id descending.
viewquery, string: customer, internalcustomer (the default) carries customer prices only. internal adds the company's own figures beside them; never show those to a homeowner.
Answers
200EstimateList400A query parameter is unknown or not valid. ApiError401No token or key, or a dead one. ApiError403Refused: role, data access, billing or a browser origin. ApiError404Not found in this company. ApiError429Over a rate limit. Retry-After says when to try again. ApiError (header Retry-After)
GET /v1/estimates/{id}
One estimate
The header and totals on every page, then one page of lines; nextCursor reads the rest. view=customer, the default, carries customer prices only: what the homeowner is charged. view=internal returns the same object with the company's own figures added beside each price, and the markup; never show those to a homeowner. When priceUnavailableReason is set: No customer price exists for this estimate. Do not compute one from cost.
Parameters
idpath, required, stringAn estimate id.
viewquery, string: customer, internalcustomer (the default) carries customer prices only. internal adds the company's own figures beside them; never show those to a homeowner.
cursorquery, stringThe nextCursor of the previous page, unchanged.
Answers
200EstimatePage400A query parameter is unknown or not valid. ApiError401No token or key, or a dead one. ApiError403Refused: role, data access, billing or a browser origin. ApiError404Not found in this company. ApiError429Over a rate limit. Retry-After says when to try again. ApiError (header Retry-After)
GET /v1/change-orders/{id}
One change order
Its lines marked added, removed or changed, priced from the markup frozen when it was signed. Never the signature. view=customer, the default, carries customer prices only: what the homeowner is charged. view=internal returns the same object with the company's own figures added beside each price, and the markup; never show those to a homeowner. When priceUnavailableReason is set: No customer price exists for this estimate. Do not compute one from cost.
Parameters
idpath, required, stringA change order id, as GET /v1/jobs/{id} names it.
viewquery, string: customer, internalcustomer (the default) carries customer prices only. internal adds the company's own figures beside them; never show those to a homeowner.
cursorquery, stringThe nextCursor of the previous page, unchanged.
Answers
200ChangeOrderPage400A query parameter is unknown or not valid. ApiError401No token or key, or a dead one. ApiError403Refused: role, data access, billing or a browser origin. ApiError404Not found in this company. ApiError429Over a rate limit. Retry-After says when to try again. ApiError (header Retry-After)
Response shapes
JobPage
itemsarray of JobnextCursorstring, may be nulltruncatedbooleanTrue when the page stopped early to stay under the size cap; nextCursor resumes.
Job
externalLinksarray of ExternalJobLinkidstringnamestring, may be nullstagestring: presale, pre_construction, active, closed, may be nullstatusstring: active, archivedcreatedAtstring (date-time)updatedAtstring (date-time)clientNamestring, may be nullclientEmailstring, may be nullclientPhonestring, may be nullsiteAddressSiteAddress, may be nulldescriptionstring, may be nulldescriptionSourcestring: forge, person, may be nulljobTypestring, may be nullpermitRequiredstring, may be nulldocumentsarray of DocumentRefphotoCountintegerwalkCountintegerhasScopebooleanTrue when a person has confirmed the job's scope: an approved scope document, a legacy scope meeting, or a locked Blueprint estimate.
ExternalJobLink
systemstringexternalIdstring
SiteAddress
streetstring, may be nullcitystring, may be nullstatestring, may be nullzipCodestring, may be null
DocumentRef
idstringdocumentTypestring: estimate, scope_meeting, scope_documentversionintegerstatusstring: draft, approved, sent, signed, may be nullupdatedAtstring (date-time)
ApiError
errorCodestringA stable code for the refusal.
messagestringA sentence for a person.
requestIdstring, may be nullQuote this when asking Forge for help.
JobDetail
jobJobareasarray of JobAreajobTreadJobTreadBlock, may be nullForge's own record of what it sent to the JobTread job this job is linked to, read only and never read from JobTread. Check it, and JobTread itself, before creating anything Forge may already have sent. Null when Forge never linked the job to JobTread.
JobArea
slugstringlabelstringphotoCountintegerPhotos filed to this area, hidden ones left out.
tradesarray of string
JobTreadBlock
sourcestring: forge_send_recordWhat Forge sent, not what JobTread holds now.
connectionstring: connected, needs_reconnect, not_connectedThe connection state Forge holds; never read from JobTread.
jobtreadJobIdstringThe JobTread job id Forge linked this job to.
estimatesarray of JobTreadEstimateSendNewest first.
dailyLogsarray of JobTreadDailyLogSendNewest first.
changeOrdersarray of JobTreadChangeOrderSendNewest first.
truncatedbooleanTrue when older sends were left out to keep the block bounded.
JobTreadEstimateSend
estimateIdstringThe Forge estimate id (GET /v1/estimates/{id}).
versionnumber, may be nullThe estimate version Forge sent.
sentAtstring (date-time)When Forge sent it.
JobTreadDailyLogSend
walkIdstringThe GET /v1/walks id of the walk or sweep the daily log was sent from.
sentAtstring (date-time)When Forge sent it.
JobTreadChangeOrderSend
changeOrderIdstringThe Forge change order id (GET /v1/change-orders/{id}).
sentAtstring (date-time)When Forge sent it.
PhotoPage
itemsarray of PhotonextCursorstring, may be nulltruncatedbooleanfilterPhotoFilteromittedarray of PhotoOmissionPhotos left off this page, with the reason.
Photo
idstringjobIdstringsourcestring: meeting, taskcaptionstring, may be nulltakenAtstring (date-time), may be nullphasestring: presale, pre_construction, active, closed, may be nullscopeLineItemIdstring, may be nullwalkIdstring, may be nullThe GET /v1/walks id of the walk it was taken on.
takeIdstring, may be nullThe transcript take it was taken during.
offsetSecondsnumber, may be nullSeconds into the take.
areaPhotoArea, may be nulltradesarray of stringfiledBystring: timeline, person, may be nullconfidencestring: high, medium, low, may be nullfilingStatusstring: auto, confirmed, unfiled, may be nullhiddenbooleanHidden photos appear only with includeHidden=true.
isFramebooleanTrue for a frame pulled from a walk video, not a photo a person took. Frames stay off every customer document until a person promotes one.
PhotoArea
slugstringlabelstring
PhotoFilter
areastring, may be nulltradestring, may be nullincludeHiddenboolean
PhotoOmission
photoIdstringreasonstring: NO_RESIZED_COPY, RESIZED_COPY_CHECK_FAILED
ScopePage
idstringjobIdstringversionintegerstatusstring: draft, approved, sent, signeddocumentTypestring: scope_meeting, scope_documentviewstring: customer, internalsectionsarray of ScopeSectionnextCursorstring, may be nulltruncatedboolean
ScopeSection
keystringtitlestringitemsarray of objectcontinuesbooleanTrue when this section continues on the next page.
WalkPage
itemsarray of WalknextCursorstring, may be nulltruncatedboolean
Walk
idstringwm_ a walk, ws_ a sweep, wj_ a job-level recording.
kindstring: walk, sweep, legacy_job_recordingjobIdsarray of stringrecordedAtstring (date-time)walkTypestring, may be nulljobPhasestring: presale, pre_construction, active, closed, may be nulltakeCountintegerdurationSecondsnumber, may be nulltranscriptStatestring: ready, transcribing, failed, nonehasTranscriptbooleanhasAudiobooleanhasVideobooleanhasAreasbooleanAt least one take has an area timeline (chapters).
has3DbooleanA ready 3D area exists for this walk. False while 3D capture is switched off.
TranscriptPage
walkIdstringkindstring: walk, sweep, legacy_job_recordingstatestring: ready, transcribing, failed, nonelanguagestring, may be nulltranslatedbooleantextstring: shown, originalsegmentsarray of TranscriptSegmentrecordingsarray of RecordingLink, may be nullFirst page only.
chaptersarray of TranscriptChapter, may be nullFirst page only.
lineMomentsarray of LineMoment, may be nullFirst page only. Where each scope line of the job's active scope was said in this walk (seconds into its take, read from the transcript). Empty when no line has a fresh moment: a moment is withheld once the scope changed since it was stamped. A line said in two takes appears once, in the earliest.
areasSourcestring: timeline, none, may be nullFirst page only. 'timeline': chapters came from the walk's area timeline. 'none': the walk has no area chapters (no area named, or no timeline).
threeDarray of ThreeDLink, may be nullFirst page only. The walk's 3D areas with their states; a ready one carries a 15 minute viewer link when this connection's consent names 3D. Empty when the walk has none or 3D is switched off.
nextCursorstring, may be nulltruncatedboolean
TranscriptSegment
startnumber, may be nullendnumber, may be nulltextstringtakeIdstring, may be nulljobIdstring, may be nullcategorystring, may be nulldecisionstring, may be nullproposedTodoProposedTodo, may be nullsegmentIdstring, may be nullA sweep segment's id; null on a walk.
syncedSegmentSync, may be nullThe other system's object this sweep segment was marked synced with.
ProposedTodo
titlestringdescriptionstringtradestringprioritystring, may be null
SegmentSync
systemstringexternalObjectIdstring
RecordingLink
takeIdstringkindstring: audio, videodurationSecondsnumber, may be nullsizeBytesnumber, may be nullcontentTypestringurlstring, may be nullA link that works for 15 minutes. Every issue is logged.
expiresAtstring (date-time), may be nullunavailableReasonstring: NO_FILE, FILE_MISSING_FROM_STORAGE, STORAGE_CHECK_FAILED, LINK_AUDIT_UNAVAILABLE, STORAGE_KEY_OUT_OF_SCOPE, may be null
TranscriptChapter
areaSlugstringareaLabelstringtakeIdstringstartSecondsnumberendSecondsnumber
LineMoment
scopeItemIdstringareaSlugstring, may be nulltakeIdstringatSecondsnumber
ThreeDLink
areaSlugstringareaLabelstringstatestring: ready, processing, failedscalestring, may be nullmetric-arkit, metric-lidar or unscaled once ready.
viewerUrlstring, may be nullA viewer link that works for 15 minutes. Every issue and every open is logged. Null unless the area is ready and this connection's consent names 3D.
expiresAtstring (date-time), may be null
WalkRecordings
walkIdstringrecordingsarray of RecordingLinkchaptersarray of TranscriptChapterEach area's chapter, in seconds into its take.
EstimateList
itemsarray of EstimateSummarynextCursorstring, may be nulltruncatedboolean
EstimateSummary
idstringjobIdstringversionintegerstatusstring: draft, approved, sent, signedupdatedAtstring (date-time)viewstring: customerpriceUnavailableReasonstring: RATE_BASIS_REVIEW_REQUIRED, MARKUP_NOT_SET, LEGACY_SIGNED_PRICE_UNAVAILABLE, COMPANY_PRICING_UNAVAILABLE, may be nullSet when no customer price exists; every price field is then null. No customer price exists for this estimate. Do not compute one from cost.
totalsEstimateTotals
EstimateTotals
customerSubtotalnumber, may be nullThe sum of the line totals in the contract total, before tax.
taxLinesarray of EstimateTaxLinecustomerTotalnumber, may be nullWhat the homeowner is charged, tax included.
EstimateTaxLine
displayLabelstringratePctnumberamountnumber, may be nullNull when prices are refused.
EstimatePage
headerEstimateHeadertotalsEstimateTotalslinesarray of EstimateLinenextCursorstring, may be nulltruncatedboolean
EstimateHeader
idstringkindstring: estimatejobIdstringversionintegerstatusstring: draft, approved, sent, signedDrafts are included and labelled draft.
viewstring: customerupdatedAtstring (date-time)priceUnavailableReasonstring: RATE_BASIS_REVIEW_REQUIRED, MARKUP_NOT_SET, LEGACY_SIGNED_PRICE_UNAVAILABLE, COMPANY_PRICING_UNAVAILABLE, may be nullSet when no customer price exists; every price field is then null. No customer price exists for this estimate. Do not compute one from cost.
EstimateLine
pricingNeedsReviewbooleanTrue when quantity and rate evidence need review. Prices are withheld until resolved.
lineIdstring, may be nulltitlestring, may be nullsectionstring, may be nullsubgroupstring, may be nullbucketstring: standard, conditional, optional, client_suppliedclient_supplied lines are $0; optional lines sit outside the contract total.
rowTypestring, may be nulltradestring, may be nulldescriptionstring, may be nullqtynumber, may be nullunitstring, may be nullcustomerUnitPricenumber, may be nullcustomerLineTotalnumber, may be null
ChangeOrderPage
headerChangeOrderHeadertotalsEstimateTotalslinesarray of ChangeOrderLinenextCursorstring, may be nulltruncatedboolean
ChangeOrderHeader
idstringkindstring: change_orderjobIdstringnumberintegertitlestringstatusstring: draft, pending_signature, signed, rejected, void, may be nullsignedAtstring (date-time), may be nullsignatureMethodstring: esig, verbal_recorded, paper_uploaded, may be nulldescriptionstring, may be nullThe text the client signed.
viewstring: customerpriceUnavailableReasonstring: RATE_BASIS_REVIEW_REQUIRED, MARKUP_NOT_SET, LEGACY_SIGNED_PRICE_UNAVAILABLE, COMPANY_PRICING_UNAVAILABLE, may be nullSet when no customer price exists; every price field is then null. No customer price exists for this estimate. Do not compute one from cost.
ChangeOrderLine
lineIdstring, may be nulltitlestring, may be nulldescriptionstring, may be nulltradestring, may be nullqtynumber, may be nullunitstring, may be nullchangestring: added, removed, changed, may be nullcustomerUnitPricenumber, may be nullcustomerLineTotalnumber, may be nullA removed line is a credit (negative).