API reference Jobs
Jobs Service API Endpoints
The production API base URL is https://api.tradesmentechnologies.com/.
Authentication
Most endpoints require a Bearer token in the Authorization header:
Authorization: Bearer <your_token>
Mutating requests may carry an Idempotency-Key header so a retry never acts twice — see Idempotent Retries in the Accounts API documentation.
Base Path
/prod/jobs
Enums
TradeType (used for jobType)
APPLIANCE_REPAIRAUTO_REPAIRCARPENTRYCARPET_CLEANINGCONCRETEDRYWALLELECTRICALESTHETICIANFENCINGFLOORINGGARAGE_DOORGENERAL_CONTRACTINGGLASS_AND_GLAZINGGUTTERSHANDYMANHVACINSULATIONJUNK_REMOVALLANDSCAPINGLOCKSMITHMASONRYMOVINGPAINTINGPEST_CONTROLPET_GROOMINGPLASTERINGPLUMBINGPOOL_AND_SPAPOWER_WASHINGROOFINGSEPTICSIDINGSOLARTREE_SERVICEWELDINGWINDOW_AND_DOORWINDOW_CLEANINGOTHER
JobStatus
NEWACCEPTEDSCHEDULEDIN_PROGRESSCOMPLETEDPAIDCANCELLED
CustomFieldType
STRINGNUMBERDATEURL
DocumentType
ESTIMATEINVOICECUSTOM_DOCUMENT
DocumentStatus
NEWSENTACCEPTEDDECLINEDPAIDEXPIRED
ManualPaymentMethod (used for paymentMethod when marking a document paid)
How a payment recorded by hand (Update Document with status: "PAID") was actually made, outside the app:
CASHCHECKCARD— a card run on another terminal or processorOTHER
RecurrenceFrequency
DAILYWEEKLYMONTHLYYEARLY
WeekDay
SUNDAYMONDAYTUESDAYWEDNESDAYTHURSDAYFRIDAYSATURDAY
MonthlyRecurrenceType
DAY_OF_MONTH— repeats on a specific calendar day (e.g., the 16th of each month)DAY_OF_WEEK— repeats on the Nth weekday of the month (e.g., the third Thursday)
RecurrenceEndType
NEVER— the recurrence has no end dateON_DATE— the recurrence ends on a specific dateAFTER_OCCURRENCES— the recurrence ends after a fixed number of occurrences
RecurrenceEditScope
THIS_INSTANCE— the edit applies to a single occurrence of a repeating jobTHIS_AND_FUTURE— the edit applies to an occurrence and every occurrence after it
Recurrence
Jobs can optionally repeat on a schedule. The recurrence field is null (the default) for one-off jobs and any job created before this feature existed. When set, it has the shape:
{
"frequency": "DAILY | WEEKLY | MONTHLY | YEARLY",
"interval": 1,
"daysOfWeek": ["MONDAY", "THURSDAY"],
"monthly": {
"type": "DAY_OF_MONTH | DAY_OF_WEEK",
"dayOfMonth": 16,
"weekOfMonth": 3,
"dayOfWeek": "THURSDAY"
},
"end": {
"type": "NEVER | ON_DATE | AFTER_OCCURRENCES",
"date": "2026-12-31T00:00:00.000Z",
"occurrences": 10
}
}
Field rules:
frequency(required): one ofRecurrenceFrequency. Controls the base cadence.interval(required): positive integer. Use1for "every day/week/month/year",2for "every other", etc.daysOfWeek(required whenfrequencyisWEEKLY, forbidden otherwise): a non-empty array ofWeekDayvalues. Duplicates are ignored. For example,["MONDAY", "WEDNESDAY"]means the job recurs twice a week.monthly(required whenfrequencyisMONTHLY, forbidden otherwise): describes how the monthly recurrence is anchored.type: "DAY_OF_MONTH"— setdayOfMonthto an integer 1-31 (e.g., "monthly on day 16").type: "DAY_OF_WEEK"— setweekOfMonth(1-5 for first through fifth, or-1for the last occurrence in the month) anddayOfWeek(aWeekDay). For April 16 (the third Thursday), use{ "type": "DAY_OF_WEEK", "weekOfMonth": 3, "dayOfWeek": "THURSDAY" }.
end(required): terminates the recurrence.type: "NEVER"— no end; additional fields onendare ignored.type: "ON_DATE"— setdateto an ISO-8601 date string; the recurrence stops on or before that date.type: "AFTER_OCCURRENCES"— setoccurrencesto a positive integer; the recurrence stops after that many occurrences (including the first one).
Yearly recurrences repeat on the job's startDateTime month and day; no extra fields are required. Sending recurrence: null on update clears a previously-set recurrence.
The job's startDateTime is always the series' first occurrence, even when it does not match the pattern (e.g. a Tuesday start with a weekly ["MONDAY"] rule): clients must render it and count it toward an AFTER_OCCURRENCES end (iCal RRULE/DTSTART semantics — the server's occurrence counting works the same way).
Instance-level edits (recurrenceExceptions, parentJobId)
Individual occurrences of a repeating job can be edited via Edit Job Instance (below). Three server-managed fields support this; clients must never write them directly:
recurrenceExceptions— an array of ISO-8601 datetimes, present on a repeating job whose occurrences have been detached by aTHIS_INSTANCEedit. When expanding a job'srecurrenceinto calendar instances, skip any occurrence whose start datetime is in this list — the detached occurrence exists as its own standalone job row instead. Absent (ornull) when no instance has been edited. Cleared automatically whenrecurrenceis cleared.parentJobId— present on a job that was created by an instance edit (either a detached single occurrence or the "this and future" segment of a split series); holds thejobIdof the original series job.parentInstanceDateTime— present only on a detached single occurrence; the original occurrence start datetime it replaced.
Google Calendar sync
When a job has a recurrence, the connected employees' Google Calendar events are created/updated as recurring events — the server converts the recurrence into an iCal RRULE and sets it on the event. Examples of the resulting RRULE strings:
- Weekly on Monday and Thursday, ending after 4 occurrences:
RRULE:FREQ=WEEKLY;BYDAY=MO,TH;COUNT=4 - Every 2 weeks on Friday:
RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=FR - Monthly on day 16:
RRULE:FREQ=MONTHLY;BYMONTHDAY=16 - Monthly on the third Thursday, ending on a date:
RRULE:FREQ=MONTHLY;BYDAY=3TH;UNTIL=20261231T235959Z - Monthly on the last Friday:
RRULE:FREQ=MONTHLY;BYDAY=-1FR
UNTIL values are emitted in UTC basic format (YYYYMMDDTHHMMSSZ). Setting recurrence: null on update removes the recurrence property from the next Google Calendar sync.
Detached occurrences (recurrenceExceptions) are emitted as iCal EXDATE lines alongside the RRULE, so the corresponding instances disappear from the recurring Google Calendar event (the detached job syncs as its own standalone event). Exception datetimes carrying an explicit offset are converted to UTC basic format (EXDATE:20260805T140000Z); offset-less wall-clock datetimes are emitted with the company timezone (EXDATE;TZID=America/Denver:20260805T090000).
Endpoints
Create Job
Create a new job entry.
- Method: POST
- Path:
/prod/jobs/jobs - Headers:
Authorization: Bearer <token> - Request Body:
{ "customerId": "uuid", "jobType": "MOVING", "description": "Description of the job", "employeeIds": ["uuid1", "uuid2"], "status": "SCHEDULED", "startDateTime": "ISO-8601", "endDateTime": "ISO-8601", "salesEmployeeId": "uuid", "address": { "streetAddress": "Street address", "city": "City", "state": "State", "zipCode": "12345" }, "destinationAddress": { "streetAddress": "Destination street address", "city": "Destination city", "state": "State", "zipCode": "12345" }, "pipelineId": "uuid", "color": "#FF0000", "customFields": { "fieldId-1": "string value", "fieldId-2": 42, "fieldId-3": { "value": "2026-06-15", "colorApplied": false } }, "recurrence": { "frequency": "WEEKLY", "interval": 1, "daysOfWeek": ["MONDAY", "THURSDAY"], "end": { "type": "AFTER_OCCURRENCES", "occurrences": 10 } }, "programId": "uuid" } - Note:
customerId,description,startDateTime,endDateTime,salesEmployeeId,address,destinationAddress,pipelineId,color,customFields,recurrence, andprogramIdare optional.customerIdmay be omitted ornullto create a job with no client; the stored job then carriescustomerId: null(the attribute is always present). A client-less job gets no job/document reminders, cannot be texted through Send SMS (400), and its documents can only be sent to an explicitemail(Send Document returns404without one).colordefaults tonulland accepts any string (e.g., a hex color like"#FF0000"). Theaddressobject is optional; when provided, it must containstreetAddress,city,state, andzipCode. Anaddress(ordestinationAddress) sent asnullor as an object whose subfields are all empty strings is treated as not provided. ThedestinationAddressobject is optional and follows the same structure asaddress— useful for moving companies or any job that involves a destination. ThecustomFieldsobject maps custom field IDs to their values. ForSTRINGandURLfields the value is a string, forNUMBERfields the value is a number, and forDATEfields the value is an object withvalue(a date string inYYYY-MM-DDformat) andcolorApplied(a boolean indicating whether the expired color has been applied to the job). AURLfield stores and validates exactly like aSTRING; the front end renders it as a link that opens the value in a browser. WhenpipelineIdis provided, thestatusis validated against both the defaultJobStatusenum values and the pipeline's custom statuses. Therecurrencefield may be omitted or set tonullto indicate a non-recurring job (the default); see Recurrence below for the full shape. - Success Response:
201 Created{ "message": "Job created successfully", "jobId": "uuid", "displayId": 1 } - Note:
displayIdis a human-friendly, company-scoped sequential job number (an integer starting at1). It is allocated automatically at creation time, is unique within the company, and increases monotonically. Deleting a job does not free itsdisplayIdfor reuse, so numbers are never recycled (gaps are expected after deletions).jobIdremains the canonical UUID identifier used in all other endpoint paths;displayIdis intended for display only. Jobs created before this feature was added do not have adisplayId. - One-time program visit (
programId): whenprogramIdnames one of the company's maintenance programs (seeprograms-endpoints.md), the job is created as a one-time program visit: it carries aprogramVisitchecklist built from EVERY line of the program (calendar frequencies don't apply to a one-off) with each line's one-time price as its effectiveprogramPrice,oneTime: true, and novisitNumber. The checklist is then driven through the normal visit endpoints (PATCH /programs/visits/{jobId},POST /programs/visits/{jobId}/report); completing the visit creates its invoice from the performed work (see Visit invoices inprograms-endpoints.md) — nothing is invoiced up front. The createdprogramVisitis echoed in the201response (alongsidejobId/displayId) so the client can render it without a refetch.400when the program does not exist, when acustomerIdis given that is not the program's client (an omitted/nullcustomerIdinherits the program's client), when the program has no service lines, or when arecurrenceis also sent. The checklist is a snapshot — later program edits never touch it.
Get All Jobs
Retrieve all jobs for the authenticated company.
Method: GET
Path:
/prod/jobs/jobsHeaders:
Authorization: Bearer <token>Note: For non-
ADMINemployees (LEAD_TECHNICIAN,TECHNICIAN,CREW_TECHNICIAN), only jobs assigned to them (where theiremployeeIdis in the job'semployeeIds) are returned.ADMINemployees receive all jobs for the company.Success Response:
200 OK[ { "companyId": "uuid", "jobId": "uuid", "displayId": 1, "customerId": "uuid", "jobType": "MOVING", "description": "Description of the job", "employeeIds": ["uuid1", "uuid2"], "status": "SCHEDULED", "startDateTime": "ISO-8601", "endDateTime": "ISO-8601", "salesEmployeeId": "uuid", "address": { "streetAddress": "Street address", "city": "City", "state": "State", "zipCode": "12345" }, "destinationAddress": { "streetAddress": "Destination street address", "city": "Destination city", "state": "State", "zipCode": "12345" }, "color": "#FF0000", "paidAmount": 25000, "createdAt": "ISO-8601", "customFields": { "fieldId-1": "string value", "fieldId-2": 42, "fieldId-3": { "value": "2026-06-15", "colorApplied": false } }, "recurrence": { "frequency": "WEEKLY", "interval": 1, "daysOfWeek": ["MONDAY", "THURSDAY"], "end": { "type": "AFTER_OCCURRENCES", "occurrences": 10 } } } ]Note:
displayIdis the company-scoped sequential job number (see Create Job above); it is absent on jobs created before the feature was added.customFieldsis only present on jobs that have custom field values set.addressis only present on jobs that have an address set;destinationAddressis only present on jobs that have a destination set. ForDATEtype custom fields, the value is an object withvalue(the date) andcolorApplied(whether the expired color was applied). Therecurrencefield isnullfor non-recurring jobs and jobs created before this feature was added. Repeating jobs may additionally carryrecurrenceExceptions, and jobs created by an instance edit carryparentJobId(and, for detached single occurrences,parentInstanceDateTime) — see Instance-level edits under Recurrence.paidAmountis the job's net amount collected, in cents (e.g.25000= $250.00). It is a denormalized counter maintained at write time: the sum of approved payment amounts for the job, minus refunds, with voided payments removed. Invoices marked paid off-system (by setting an invoice'sstatustoPAIDvia Update Document — e.g. for a cash or check payment) also contribute their full document total; moving such an invoice back out ofPAIDbacks the amount out again. It defaults to0for jobs with no payments (or jobs created before the counter existed). A negative value is possible transiently (e.g. a refund recorded before its sale) but self-corrects.
Update Job
Update an existing job entry.
- Method: PATCH
- Path:
/prod/jobs/jobs/{jobId} - Headers:
Authorization: Bearer <token> - Request Body: (All fields optional)
{ "customerId": "uuid", "jobType": "MOVING", "description": "Updated description", "employeeIds": ["uuid1", "uuid2"], "status": "COMPLETED", "startDateTime": "ISO-8601", "endDateTime": "ISO-8601", "salesEmployeeId": "uuid", "address": { "streetAddress": "Street address", "city": "City", "state": "State", "zipCode": "12345" }, "destinationAddress": { "streetAddress": "Destination street address", "city": "Destination city", "state": "State", "zipCode": "12345" }, "pipelineId": "uuid", "color": "#00FF00", "customFields": { "fieldId-1": "updated value", "fieldId-2": 99, "fieldId-3": { "value": "2026-06-15", "colorApplied": true } }, "recurrence": null } - Note: All fields in the update request are optional. For
DATEtype custom fields, setcolorAppliedtotrueafter applying the expired color to the job to prevent re-application. IfstartDateTimeis provided andstatusis NOT provided in the request, the job's status will be automatically updated toSCHEDULEDif its current status isNEWorACCEPTED— this auto-status transition only applies when the job has nopipelineId. When a job has apipelineId(existing or being set), thestatusis validated against both the defaultJobStatusenum values and the pipeline's custom statuses. TheaddressanddestinationAddressfields can each be sent asnull(or as an object whose subfields are all empty strings) to clear the existing value, omitted to leave it unchanged, or set to a complete address object containingstreetAddress,city,state, andzipCode. Therecurrencefield can be sent asnullto clear an existing recurrence, omitted to leave it unchanged, or set to a valid recurrence object (see Recurrence below). Clearing the recurrence also clears the job'srecurrenceExceptions(previously detached occurrences remain as standalone jobs). This endpoint edits the whole series; to edit a single occurrence or "this and future" occurrences of a repeating job, use Edit Job Instance below. - Service Pricing:
- For
services,priceis per unit (Total = price * quantity, or price * effectiveQuantity when dimension fields are present — see Line-Item Dimensions under Create Document). - For
oneDPricesandtwoDPrices,priceis the total price for the specified quantity or dimensions (non-linear).
- For
- Inventory usage (
inventoryUsage): the request may also carry the technician-confirmed parts usage (see Inventory usage & deduction below);nullclears it.{ "inventoryUsage": { "items": [{ "itemId": "item-uuid", "name": "16x25x1 filter", "quantity": 2 }], "truckId": "truck-uuid", "edited": true, "sourceDocumentId": "document-uuid" } } - Success Response:
200 OK{ "message": "Job updated successfully", "job": { "companyId": "uuid", "jobId": "uuid", "status": "COMPLETED", "inventoryUsage": { "items": [{ "itemId": "item-uuid", "name": "16x25x1 filter", "quantity": 2 }], "truckId": "truck-uuid", "edited": true, "sourceDocumentId": "document-uuid", "updatedAt": "2026-09-04T10:00:00.000Z", "updatedBy": "employee-uuid" }, "inventoryDeduction": { "appliedAt": "2026-09-04T10:00:00.000Z", "appliedBy": "employee-uuid", "truckId": "truck-uuid", "lines": [{ "itemId": "item-uuid", "name": "16x25x1 filter", "quantity": 2, "fromTruck": 2, "fromWarehouse": 0 }] } } } - Note:
jobis the full job row as stored after the update and its side effects (only a few fields are shown above) — in particular it carriesinventoryDeductionwhen the update completed/paid the job and stock was deducted, and no longer carries it when the update reversed one. Older clients ignore the field.
Inventory usage & deduction
Two job fields back the inventory module (contract: inventory-endpoints.md):
inventoryUsage— client-writable via Update Job (nullclears). The parts the technician confirmed were used on the job. Validation:itemsis an array of at most 100{itemId, name, quantity}entries (itemIdnon-empty string, unique;nameoptional string ≤ 200 chars;quantityfinite number > 0) — it may be empty, which means "nothing was used";truckIdstring ornull;editedboolean (defaultfalse);sourceDocumentIdstring ornull. The server stampsupdatedAtandupdatedBy(the caller'semployeeId) into the stored object. Absent on jobs where the technician never confirmed usage — the deduction then derives usage from the job's documents.inventoryDeduction— server-managed, never client-writable (a PATCH carrying it is ignored). Present while the job isCOMPLETED/PAIDand stock has been deducted:{ appliedAt, appliedBy (employeeId or null on public/webhook paths), truckId (or null), lines: [{ itemId, name, quantity, fromTruck, fromWarehouse }] }. An emptylinesarray still counts as "deducted" so the rule stays idempotent. Removed when the job leaves the terminal statuses and the stock is returned.
The deduction runs inside every endpoint that writes a job's status (Update Job, Edit Job Instance, Create Job, Send Document / Send Multiple Documents for invoices, invoice payments, program visit report send, and the public estimate acceptance which moves a job to ACCEPTED). It is best-effort — a stock failure never fails the status change. Rules, truck resolution and the movement records are described under Job deductions in inventory-endpoints.md.
In-Progress Time
The server records how long each job spends in the IN_PROGRESS status. Two server-managed job fields carry it (a PATCH to Update Job carrying either is ignored); both appear on the job rows returned by Get All Jobs, Update Job and the session bootstrap, and are absent on jobs that were never tracked:
inProgressPeriods— the periods the job was in progress, oldest first:[{ periodId, startedAt, endedAt, startedBy, endedBy, source, editedAt, editedBy }].startedAt/endedAtare ISO-8601 UTC instants;endedAtisnullon the one open period of a job that isIN_PROGRESSright now (measure it up to the current time).startedBy/endedByare theemployeeIdwhose action changed the status (nullon public and webhook paths, andendedByisnullwhile open).sourceisSTATUS(recorded by a status change) orMANUAL(added by an admin).editedAt/editedByare present only on a period an admin corrected.inProgressRevision— an integer that increases with every write of the list. Send it back asbaseRevisionwhen correcting the list (below).
Recording runs inside every endpoint that writes a job's status (the same list as the inventory deduction above, plus the incoming webhook): a job that becomes IN_PROGRESS gets a new open period starting at the server's current time, and a job that leaves IN_PROGRESS — by any path, including an invoice being sent or paid — has its open period closed at the current time. It is best-effort and never fails the status change. A job created directly as IN_PROGRESS starts its period at creation. A job that was already IN_PROGRESS before tracking existed has no period until it leaves and re-enters the status (or an admin adds one): the server never invents a start time. Nothing is gated on a feature flag server-side — the app's allowInProgressTracking company flag only decides whether the data is shown.
Update Job In-Progress Periods
Correct a job's in-progress periods: replaces the whole list, so adding, editing and deleting a period are all "send the list you want".
- Method: PUT
- Path:
/prod/jobs/jobs/{jobId}/in-progress-periods - Headers:
Authorization: Bearer <token>(must beADMIN) - Request Body:
{ "baseRevision": 2, "periods": [ { "periodId": "period-uuid", "startedAt": "2026-10-01T14:00:00.000Z", "endedAt": "2026-10-01T16:30:00.000Z" }, { "startedAt": "2026-10-02T09:00:00.000Z", "endedAt": null } ] }periods— required; an array of at most 200 entries (an empty array clears the job's periods).periods[].periodId— optional. An entry naming a stored period edits it: the period keeps itsstartedBy/source, and gainseditedAt/editedBywhen its times changed. An entry without aperiodIdis a new period (source: "MANUAL", the server assigns the id). A stored period that is not listed is deleted.periods[].startedAt— required; valid ISO timestamp.periods[].endedAt— valid ISO timestamp at or afterstartedAt, ornull/omitted for an open period. An open period is allowed only while the job'sstatusisIN_PROGRESS, at most one may be open, and it must be the latest period.- Periods must not overlap (one may start exactly when the previous one ends).
baseRevision— optional but recommended; the job'sinProgressRevisionas the client last read it (0for a job without the field). A mismatch means the list changed in the meantime (for example the job was completed) and the request is refused with409so the correction is never applied to a stale list.
- Success Response:
200 OK{ "message": "In-progress periods updated successfully", "jobId": "uuid", "inProgressPeriods": [ { "periodId": "period-uuid", "startedAt": "2026-10-01T14:00:00.000Z", "endedAt": "2026-10-01T16:30:00.000Z", "startedBy": "employee-uuid", "endedBy": "employee-uuid", "source": "STATUS", "editedAt": "2026-10-05T12:00:00.000Z", "editedBy": "admin-employee-uuid" } ], "inProgressRevision": 3 }inProgressPeriodsis the stored list, sorted oldest first. - Error Responses:
400for a missing body, aperiodsvalue that is not an array or has more than 200 entries, an invalid entry (the message is prefixed withperiods[<index>]:— invalid or missingstartedAt, invalidendedAt,endedAtbeforestartedAt, an unknown, blank or repeatedperiodId), more than one open period, an open period that is not the latest or on a job that is notIN_PROGRESS, overlapping periods, or abaseRevisionthat is not a non-negative integer;403if the caller is not anADMIN;404if the job does not exist;409when the stored list no longer matchesbaseRevision(or changed during the request) — the body is{ "message": "...", "code": "STALE_REVISION", "jobId": "uuid", "inProgressPeriods": [], "inProgressRevision": 3 }carrying the list and revision as stored now. - Note: Changing a job's periods never changes its
status. A job that isIN_PROGRESSwith no open period is simply not being timed until it leaves and re-enters the status.
Edit Job Instance
Edit one occurrence of a repeating job, or an occurrence and all occurrences after it. The {jobId} in the path is the repeating (series) job.
- Method: PATCH
- Path:
/prod/jobs/jobs/{jobId}/instance - Headers:
Authorization: Bearer <token> - Request Body:
{ "editScope": "THIS_INSTANCE", "instanceStartDateTime": "ISO-8601", "description": "Updated description", "employeeIds": ["uuid1", "uuid2"], "startDateTime": "ISO-8601", "endDateTime": "ISO-8601" } - Note:
customerIdmay benull(a job with no client) on Update Job and Edit Job Instance alike; sendingnulldetaches the client. - Note:
editScope(aRecurrenceEditScope) andinstanceStartDateTime(the start datetime of the occurrence being edited, exactly as produced by expanding the job'srecurrence) are required. All other fields are optional and accept the same values as Update Job (customerId,jobType,description,employeeIds,status,startDateTime,endDateTime,salesEmployeeId,address,destinationAddress,pipelineId,customFields,color,recurrence); at least one of them must be provided. Fields that are omitted are inherited from the series. IfstartDateTime/endDateTimeare omitted, the edited occurrence keeps its original time slot (with the series' duration).editScope: "THIS_INSTANCE"detaches the occurrence: the occurrence's datetime is appended to the series job'srecurrenceExceptions, and a new standalone one-off job is created carrying the edits, with its ownjobIdanddisplayId, plusparentJobIdandparentInstanceDateTimepointing back at the series. Therecurrencefield is not allowed with this scope. Editing the same occurrence again returns409 Conflict— update the detached job with Update Job instead.editScope: "THIS_AND_FUTURE"splits the series: the original job's recurrence is truncated to end just before the edited occurrence, and a new repeating job (withparentJobId) is created starting at the occurrence, carrying the edits and the remainder of the recurrence (for anAFTER_OCCURRENCESend, the remaining occurrence count;NEVER/ON_DATEends carry over unchanged). Arecurrencefield in the request replaces the new segment's recurrence (nullmakes the new segment a one-off job). When therecurrenceis omitted (inherited) andstartDateTimemoves the occurrence to a different calendar day, the inherited rule follows the move: a weekly rule replaces the moved occurrence's weekday with the new start's weekday (other listed days are kept), a monthly day-of-month rule follows the new day of month, and a monthly nth-weekday rule follows the new weekday and week-of-month (a "last" rule stays "last"). This matches standard calendar behavior — moving a weekly-Monday occurrence to Tuesday with this scope makes the following events Tuesdays. The same day-following adjustment applies to the in-place first-occurrence edit described below. An explicitly sent recurrenceis always taken verbatim. Entries in the series'recurrenceExceptionson or after the split move to the new job. IfinstanceStartDateTimeequals the series'startDateTime, nothing precedes the split, so the series job is updated in place (no new job) and the response is200 OKwith the originaljobIdand nodisplayId/parentJobId.
- Error Responses:
400for a non-repeating job, aninstanceStartDateTimeoutside the series (before its start or past its end), arecurrencefield withTHIS_INSTANCEscope, or no updatable fields;404if the job does not exist;409for aTHIS_INSTANCEedit of an already-detached occurrence. - Success Response:
201 Created{ "message": "Job instance updated successfully", "jobId": "uuid", "displayId": 12, "parentJobId": "uuid" } - Note:
jobIdis the newly created job (the detached occurrence or the "this and future" segment);parentJobIdis the original series job. For aTHIS_AND_FUTUREsplit themessageis"Job series split successfully". Google Calendar sync runs for both jobs: the series event gains anEXDATE(or a truncatedRRULE), and the new job syncs as its own event.
Delete Job
Delete a job, its associated documents, and its stored media: every photo, video and video poster on the job is removed from S3 in the same request (best-effort — a storage failure is logged and does not fail the delete; the response body is unchanged). Deleted media is unrecoverable after 30 days in production (the bucket keeps a noncurrent version for that long).
- Method: DELETE
- Path:
/prod/jobs/jobs/{jobId} - Headers:
Authorization: Bearer <token> - Success Response:
200 OK{ "message": "Job and associated documents deleted successfully" } - Error Response:
409 Conflict— returned when the job has anESTIMATEorINVOICEdocument whose status isSENT,ACCEPTED, orPAID. Such a document has been seen or acted on by the customer, so the job (and its financial record) cannot be deleted. Documents in other statuses (NEW,DECLINED,EXPIRED) andCUSTOM_DOCUMENTdocuments of any status do not block deletion. Nothing is deleted when this error is returned.{ "message": "This job cannot be deleted because it has an estimate or invoice that has been sent, approved, or paid.", "code": "JOB_HAS_ACTIVE_DOCUMENTS" }
Standalone Documents
A document normally belongs to a job (jobId). A standalone document is an estimate, invoice or custom document created without a job (the app's Documents tab, "without a job"). It is an ordinary document row with these differences:
- No
jobId. The attribute is absent from the stored row and from every response (nevernull). Standalone documents are therefore never returned by Get Job Documents; they are listed by Get All Documents and by the login response'sdocumentsarray. customerId(optional string) names the client directly, since there is no job to take it from. It is what Send Document / Send Multiple Documents use as the default recipient, what the public viewer renders as the customer (name, address, phone), what document reminders and the "customer accepted / paid" company emails use, and the customer QuickBooks sync books the document under. A standalone document without acustomerId(or whose client was deleted) can only be sent to an explicitemail, renders on the public viewer without a customer name, gets no reminders, and is skipped by the QuickBooks sync. A job's document never carriescustomerId— its client is always the job's.displayIdis"S-<n>"(e.g."S-12"): a per-company, per-type counter, monotonically increasing and never reused after a delete. TheS-prefix keeps it distinct from job-derived numbers ("12","12-2").- No job side effects. Sending a standalone invoice completes no job; accepting or paying one changes no job status, adds nothing to any job's
paidAmount, and deducts no inventory. The document's own status transitions, payments, tips, signatures, notes and the high-value alert behave exactly as for a job's document. - No attachments.
includedPhotoIds/includedVideoIds(and the send endpoints'photoIds/videoIds) refer to a job's media, so a non-empty list on a standalone document is rejected with400; empty lists are accepted. - Attaching to a job. A standalone document can be filed under a job afterwards with Update Document:
PATCH /jobs/documents/{documentId}with{ "jobId": "<jobId>" }. From then on it is an ordinary document of that job — returned by Get Job Documents, its client is the job's client (the document's owncustomerIdis removed, even when the job has a different client or none), its photos/videos may be attached, and every later send / accept / payment has the usual job side effects. The job must exist (404 "Job not found"otherwise). The document keeps itsdisplayId("S-<n>"— the number the client may already have seen never changes), its status, name, notes and line items; attaching itself changes nothing on the job (aSENTinvoice does not complete it, anACCEPTEDestimate does not accept it). A document whose status isPAIDcannot be attached (400): its payment was recorded without a job, so the job'spaidAmountwould not include it.customerIdmay not be sent in the same request (400). There is no detach —jobIdcan never be cleared.
Point of Sale
A point-of-sale sale (the app's Billing tab, "Point of Sale") is an ordinary standalone INVOICE the app creates from a typed-in amount and then pays on the spot — by card through the usual payment endpoints, or by marking it PAID with the payment method used outside the app. It is distinguished only by the request field pointOfSale: true on Create Document, stored verbatim on the row (absent on every other invoice — never false). Nothing server-side reads the flag: the sale is numbered S-<n> from the invoice counter, listed, reported, synced to QuickBooks, refunded and emailed exactly like any other standalone invoice, and older app versions show it as a plain invoice. Its line items are whatever the app sent (one services item carrying the amount), and the optional customerId names the client when the sale was made to one. When the sale is marked paid by hand, manualPaymentMethod (+ optional manualPaymentNote) record how — see Update Document, "Marking an invoice paid".
Two fields exist on every document (job-attached or standalone):
name— optional internal label (max 120 characters, trimmed; blank ornullclears it): how the company refers to the document (e.g."Kitchen remodel — option B"). It is the document's display name in the app and is never shown to the client — the public viewer endpoint strips it.createdBy— theemployeeIdof the employee who created the document. Server-managed, set at creation (absent on documents created before 2026-10-02); also stripped from the public viewer endpoint.
Get All Documents
Retrieve every document of the company — estimates, invoices and custom documents, attached to a job or standalone. The same list the login response carries as documents.
- Method: GET
- Path:
/prod/jobs/documents - Headers:
Authorization: Bearer <token> - Visibility:
ADMINemployees receive every document. Other employees receive the documents of the jobs assigned to them (the same rule as Get All Jobs) plus the standalone documents they created themselves (createdBy). - Success Response:
200 OK— an array of documents in the same item shape as Get Job Documents (includingsignatureUrl/cardholderSignatureUrlwhen signed). A standalone document has nojobIdand may carrycustomerId:[ { "companyId": "uuid", "documentId": "uuid", "documentCode": "secure-code", "displayId": "S-12", "customerId": "uuid", "name": "Kitchen remodel — option B", "createdBy": "employee-uuid", "type": "ESTIMATE", "status": "NEW", "hours": 0, "depositPrice": 0, "requireSignature": true, "services": [], "oneDPrices": [], "twoDPrices": [], "createdAt": "2026-10-02T15:00:00.000Z" } ] - Error Responses:
401 Unauthorized,500 Internal Server Error.
Get Job Documents
Retrieve all documents (estimates/invoices) for a specific job.
- Method: GET
- Path:
/prod/jobs/jobs/{jobId}/documents - Headers:
Authorization: Bearer <token> - Service Pricing:
- For
services,priceis per unit (Total = price * quantity, or price * effectiveQuantity when dimension fields are present — see Line-Item Dimensions under Create Document). - For
oneDPricesandtwoDPrices,priceis the total price for the specified quantity or dimensions (non-linear).
- For
- Success Response:
200 OK
Note:[ { "companyId": "uuid", "documentId": "uuid", "documentCode": "secure-code", "displayId": "1", "jobId": "uuid", "type": "ESTIMATE", "status": "NEW", "hours": 5.5, "depositPrice": 100.0, "requireSignature": true, "categories": [ { "categoryId": "uuid", "name": "Labor", "parentCategoryId": null }, { "categoryId": "uuid", "name": "Prep Work", "parentCategoryId": "parent-uuid" } ], "services": [ { "serviceId": "uuid", "name": "Standard Labor", "description": "Optional description", "unit": "hour", "quantity": 5, "price": 50.0, "cost": 30.0, "width": null, "length": null, "diameter": null, "allowInitials": true, "initials": "JD", "categoryId": "uuid" }, { "serviceId": "uuid", "name": "Deck staining", "description": null, "unit": "sqft", "quantity": 1, "price": 2.5, "cost": null, "width": 10, "length": 12, "diameter": null, "allowInitials": false, "initials": null, "category": null } ], "oneDPrices": [ { "serviceId": "uuid", "name": "Packing Tape", "description": null, "unit": "roll", "quantity": 1, "price": 5.0, "cost": 2.0, "allowInitials": false, "initials": null, "category": null } ], "twoDPrices": [ { "serviceId": "uuid", "name": "Custom Crate", "description": null, "unit": "sqft", "price": 2.5, "cost": 1.0, "allowInitials": false, "initials": null, "category": null, "dimXName": "Width", "dimYName": "Height", "dimXQuantity": 4, "dimYQuantity": 6 } ], "createdAt": "ISO-8601", "customText": "Thank you for your business!", "signatureUrl": "https://s3.amazonaws.com/bucket/signature.png" } ]displayIdis the per-job, per-type document number (see Create Document below); it is a string and is absent on documents created before the feature was added. Thecategoriesarray is only present on documents that have categories defined. Categories are document-level groupings — they do not need to match any company-level service categories.categoryon service items is null when not assigned to a category. ThecustomTextfield is only present on documents that have it set.
Get Document
Retrieve a single document by ID.
- Method: GET
- Path:
/prod/jobs/documents/{documentId} - Headers:
Authorization: Bearer <token>
Response
- Success:
200 OK— Returns the full document object (same shape as items in the Get Job Documents response). IncludessignatureUrlif a signature has been captured. - Error:
400 Bad Request,401 Unauthorized,404 Not Found, or500 Internal Server Error
Create Document
Create an estimate, invoice, or custom document.
Method: POST
Path:
/prod/jobs/documentsHeaders:
Authorization: Bearer <token>Job or standalone:
jobIdis optional. With ajobIdthe document belongs to that job (everything below is unchanged). Omitted,nullor""creates a standalone document — see Standalone Documents above — which additionally accepts an optionalcustomerId(the client; ignored when ajobIdis sent). Every document accepts an optionalname(internal label, max 120 characters).Request Body:
{ "jobId": "uuid", "type": "ESTIMATE", "requireSignature": true, "hours": 5.5, "depositPrice": 100.0, "expiresAt": "ISO-8601", "categories": [ {"categoryId": "uuid", "name": "Labor", "parentCategoryId": null}, {"categoryId": "uuid", "name": "Prep Work", "parentCategoryId": "parent-uuid"} ], "services": [ {"serviceId": "uuid", "name": "Service", "description": "Optional description", "unit": "Unit", "quantity": 5, "price": 50, "cost": 30, "allowInitials": true, "initials": "JD", "category": "uuid"}, {"serviceId": "uuid", "name": "Deck staining", "unit": "sqft", "price": 2.5, "width": 10, "length": 12}, {"serviceId": "uuid", "name": "Patio sealing", "unit": "sqft", "price": 2, "diameter": 10, "quantity": 2} ], "oneDPrices": [{"serviceId": "uuid", "name": "Service", "description": "Optional description", "unit": "Unit", "quantity": 1, "price": 60, "cost": 25, "prices": {"1": 60, "2": 100}, "costs": {"1": 25, "2": 45}, "allowInitials": false, "category": "uuid"}], "twoDPrices": [{ "serviceId": "uuid", "name": "Service", "description": "Optional description", "unit": "Unit", "price": 70, "cost": 40, "prices": {"1": {"1": 70, "2": 120}, "2": {"1": 110, "2": 180}}, "allowInitials": false, "categoryId": "uuid", "dimXName": "Width", "dimYName": "Height", "dimXQuantity": 10, "dimYQuantity": 20 }], "percent": 50, "customText": "Thank you for your business! Payment is due within 30 days.", "additionalCharges": [ {"type": "TAX", "label": "Sales Tax", "percentage": 8.25}, {"type": "CONVENIENCE", "label": "Card Processing Fee", "percentage": 3} ], "pointOfSale": false }Note:
expiresAt,categories,percent,customText,additionalCharges,notes,programId,programSummary, andpointOfSaleare optional.programIdlinks the document to a maintenance program andprogramSummaryis a client-computed object (typicallyannualTotal,quarterlyTotal,diyTotalCost,diyTotalTimeMinutes,savingsPercent) that earlier app versions sent on a program-level estimate/invoice (a retired flow — current apps don't create program-level documents; visits are invoiced at completion, seeprograms-endpoints.md); both are stored verbatim and no server-side behavior reads them.serviceId,description,unit,cost,allowInitials,initials,category,prices, andcostsin the price arrays (services,oneDPrices,twoDPrices) are optional.width,length, anddiameterare optional dimension fields onservicesitems — see Line-Item Dimensions below; when aservicesitem has dimensions,quantitybecomes optional too (it acts as a multiplier and defaults to 1).unitis a free-form label such assqftorhour; when omitted it is stored as null and the doc viewer hides the Unit column if no line items have a unit.costtracks the business's cost for accounting purposes.pricesstores the full pricing table from the service definition (quantity-to-price map foroneDPrices, nested x-to-y-to-price map fortwoDPrices).costsstores the per-quantity cost map foroneDPrices.allowInitialsdefaults tofalse.initialsholds the customer's initials when captured.categoryreferences a company-level category ID — it is used for grouping services within the document and does not need to match any company-level service category. Each category requirescategoryIdandname;parentCategoryIdis optional (null for top-level categories, or thecategoryIdof a parent category for nesting). Categories can be nested to arbitrary depth.Service Pricing:
- For
services,priceis per unit (Total = price * quantity, or price * effectiveQuantity when dimension fields are present — see Line-Item Dimensions under Create Document). - For
oneDPricesandtwoDPrices,priceis the total price for the specified quantity or dimensions (non-linear).
- For
Line-Item Dimensions (
servicesitems only): Aservicesitem may carry optional measured dimensions from which square footage is calculated:width+length(rectangle) ordiameter(circle). The computed square footage is never stored — every consumer (the mobile app, the customer document viewer, QuickBooks sync, reports) derives it from the stored fields using this exact formula:sqft = round2(width * length) when width and length are set = round2((diameter / 2)^2 * PI) when diameter is set (PI = IEEE-754 double Math.PI) round2(x) = Math.round(x * 100) / 100 (JavaScript semantics, applied ONCE to the sqft) effectiveQuantity = quantity * sqft (quantity is optional with dimensions; defaults to 1) lineTotal = price * effectiveQuantityRules:
widthandlengthmust be provided together;diameteris mutually exclusive with them. All dimensions must be positive finite numbers.- With dimensions present,
quantitybecomes an optional multiplier (e.g. 3 identical 10×12 rooms) and defaults to1; without dimensions the existing rule stands (quantityrequired). priceis per square foot for a dimensioned item.unitremains a free-form display label (typically"sqft").- Because
sqftis rounded once (to 2 decimals) before multiplying, the square footage displayed on the document times the unit price always equals the line total exactly. Example:diameter: 10→ sqft78.54; atprice: 2the line total is157.08. - Absent dimension fields are stored as
nullon the document record, so readers must treat both missing andnullas "no dimensions". - The document viewer shows the measurement in the Quantity column (e.g.
10 × 12 = 120,π (10/2)² = 78.54, or2 × (π (10/2)²) = 157.08with a quantity multiplier). Circles spell out the area formula (π and the diameter) rather than the ⌀ symbol.
Partial Billing (Invoices Only):
percentis an optional number in(0, 100]indicating the share of(lineItemsTotal - depositPrice)this invoice bills for. Example: line items total $100,depositPrice$20,percent50 → the invoice charges 50% of $80 = $40. Only valid whentypeisINVOICE; setting it on an estimate returns400. When omitted, behavior is equivalent topercent: 100.Custom Text:
customTextis an optional free-form string (max 5000 characters) shown to the customer in the document viewer. Useful for terms, notes, payment instructions, or thank-you messages. Empty strings are ignored. Set on both estimates and invoices.Point of Sale (Invoices Only):
pointOfSaleis an optional boolean marking the invoice as a point-of-sale sale (see Point of Sale under Standalone Documents). Must be a boolean when provided;trueon any type other thanINVOICEreturns400. Onlytrueis stored (false/ omitted leaves the attribute off the row). It is returned as stored by every document read, including the public viewer.Additional Charges (Invoices Only):
additionalChargesis an optional array of charges added to the invoice balance, each a percentage of the full line-items total (before deposit). Each entry requirestype(one ofTAX,CONVENIENCE,CUSTOM), a non-emptylabel(max 100 chars), and apercentagein(0, 100]. Stored as a snapshot on the document (anyidis dropped). Only valid whentypeisINVOICE; setting it on an estimate or custom document returns400. Companies typically populate it from the configured charge catalog returned in the login response (additionalCharges) — but only itsappliesTo: "INVOICE"entries: a company charge withappliesTo: "PAYMENT"is a payment-time fee that never belongs on the invoice (see Update Company inaccounts-endpoints.md). The server enforces this: a snapshot entry sent withappliesTo: "PAYMENT"is dropped, and so is any entry matching one of the company's currentPAYMENTcharges ontype,labelandpercentage(the stored snapshot never carriesappliesTo). Partial-billing interaction: when the invoice also has apercent< 100, each charge is scaled by that share (charge =lineItemsTotal × percentage × percent/100) so that across a set of partial invoices the customer is charged the full tax/fee exactly once — never double-taxed.Notes (all document types):
notesis an optional array that seeds the document's notes at creation time — used by the app's copy-document flow to carry the source document's notes onto the copy (see Document Notes below for the note shape and per-note endpoints). Each item requires a non-emptytext;employeeIdandcreatedAt(ISO-8601) are optional and are preserved when provided so copied notes keep their original author and timestamp — when omitted they default to the authenticated employee and the current time.noteIdvalues are always regenerated server-side, so notes on the new document are independent of the source's. An empty array is treated as not provided. The stored notes (with their generatednoteIds) are echoed back in the success response undernotes.Custom Documents: When
typeisCUSTOM_DOCUMENT, the document is a free-form attachment to a job (e.g. a liability waiver) with no services or pricing. The required fields aretype,title,text, andrequireSignature(jobIdis optional, as for every document type); other fields (services,oneDPrices,twoDPrices,hours,depositPrice,categories,percent,customText) must be omitted and are ignored if sent.texthas a max length of 20000 characters. The customer-facing flow is the same as an estimate without a deposit — the document can be signed via the public accept endpoint, which setsstatustoACCEPTED(the parent job's status is not changed).{ "jobId": "uuid", "type": "CUSTOM_DOCUMENT", "title": "Liability Waiver", "text": "I waive all liability...", "requireSignature": true, "expiresAt": "ISO-8601" }Success Response:
201 Created{ "message": "Document record created successfully", "documentId": "uuid", "documentCode": "secure-code", "displayId": "1-2", "createdAt": "2026-10-02T15:00:00.000Z", "createdBy": "employee-uuid" }When
noteswere provided in the request, the response also includes anotesarray containing the stored notes with their server-generatednoteIds.Display ID:
displayIdis a human-friendly, per-job document number derived from the parent job'sdisplayId. The first document of a giventypeon a job reuses the job's number (e.g. job1→ first invoice"1", first estimate"1"); each subsequent document of the same type appends its ordinal ("1-2","1-3"). Numbers are scoped per job and pertype, so the first invoice and the first estimate on the same job can both be"1", but two invoices on that job cannot. They increase monotonically and are never reused after a document is deleted (gaps are expected after deletions).documentIdremains the canonical UUID used in all endpoint paths;displayIdis for display only. It is a string (note the"1-2"form). Documents created on a job that predates the jobdisplayIdfeature have nodisplayId(the field is absent). A standalone document is numbered"S-<n>"from a per-company, per-type counter instead (see Standalone Documents).Error:
400 Bad Request(validation, including a non-stringjobId/customerId/nameor anameover 120 characters),401 Unauthorized,404 Not Found(the referencedjobIddoes not exist for the company), or500 Internal Server Error.
Update Document
Update an existing document (estimate or invoice).
- Method: PATCH
- Path:
/prod/jobs/documents/{documentId} - Headers:
Authorization: Bearer <token> - Request Body: (All fields optional)
- Service Pricing:
- For
services,priceis per unit (Total = price * quantity, or price * effectiveQuantity when dimension fields are present — see Line-Item Dimensions under Create Document). - For
oneDPricesandtwoDPrices,priceis the total price for the specified quantity or dimensions (non-linear).
{ "jobId": "uuid", "type": "ESTIMATE", "requireSignature": true, "hours": 5.5, "depositPrice": 100.0, "expiresAt": "ISO-8601", "status": "SENT", "categories": [ {"categoryId": "uuid", "name": "Labor", "parentCategoryId": null}, {"categoryId": "uuid", "name": "Prep Work", "parentCategoryId": "parent-uuid"} ], "services": [ {"serviceId": "uuid", "name": "Service", "description": "Optional description", "unit": "Unit", "quantity": 5, "price": 50, "cost": 30, "allowInitials": true, "initials": "JD", "category": "uuid"}, {"serviceId": "uuid", "name": "Deck staining", "unit": "sqft", "price": 2.5, "width": 10, "length": 12} ], "oneDPrices": [{"serviceId": "uuid", "name": "Service", "description": "Optional description", "unit": "Unit", "quantity": 1, "price": 60, "cost": 25, "allowInitials": false, "category": "uuid"}], "twoDPrices": [{ "serviceId": "uuid", "name": "Service", "description": "Optional description", "unit": "Unit", "price": 70, "cost": 40, "allowInitials": false, "categoryId": "uuid", "dimXName": "Width", "dimYName": "Height", "dimXQuantity": 10, "dimYQuantity": 20 }], "overdueDismissed": true, "hidden": true, "percent": 50, "customText": "Thank you for your business! Payment is due within 30 days.", "additionalCharges": [ {"type": "TAX", "label": "Sales Tax", "percentage": 8.25} ], "includedPhotoIds": ["photo-uuid-1", "photo-uuid-2"], "includedVideoIds": ["video-uuid-1"], "programId": "uuid", "programSummary": {"annualTotal": 980, "quarterlyTotal": 245, "diyTotalCost": 1400, "diyTotalTimeMinutes": 720, "savingsPercent": 30}, "paymentMethod": "CASH", "paymentNote": "Paid at the counter" } - For
- Maintenance Program Link:
programIdandprogramSummaryare optional and stored verbatim (no server-side behavior reads them); legacy of the retired program-level estimate/invoice flow — seeprograms-endpoints.md. Server-built visit invoices (created at visit completion) carryprogramId. - Name and client:
name(string, max 120 characters;nullor blank clears it) may be set on any document.customerId(string, ornullto clear) may be set only on a standalone document — on a document that belongs to a job it is rejected with400.jobId, when sent, must be a non-empty string (null/""→400); sent on a standalone document it attaches the document to that job (job must exist → otherwise404; rejected with400when the document isPAID; the document'scustomerIdis removed). See Standalone Documents, "Attaching to a job". - Attached Photos:
includedPhotoIdsis an optional array of job photo IDs to display on the customer-facing document viewer (the same field the Send Document endpoints write). Every ID must exist on the parent job'sphotos, otherwise the request fails with400. Sending[]clears the attached photos. The field is returned as-is by Get Document / Get Job Documents; the public viewer endpoint resolves it to presigned photo URLs. - Attached Videos:
includedVideoIdsmirrorsincludedPhotoIdsfor job videos: an optional array of job video IDs to display on the customer-facing document viewer (the Send Document endpoints write it viavideoIds). Every ID must exist on the parent job'svideos, otherwise400;[]clears the attached videos. Returned as-is by Get Document / Get Job Documents; the public viewer endpoint resolves it to presigned playback (and thumbnail) URLs. Older app versions never send or read the field. - Hiding Documents:
hiddenis an optional boolean the app uses to hide a document (e.g. a stale expired estimate) from the job's document lists. It must be a boolean when provided; sendfalseto unhide. The flag is purely client-facing: it has no effect on the public viewer, sending, reporting, overdue invoices, or any other server-side behavior. Get Document / Get Job Documents return the field as stored (absent on documents that were never hidden), and the client filters hidden documents from display. - Partial Billing (Invoices Only):
percentmust be a number in(0, 100]and is only valid when the document is anINVOICE(either viatypein the same request, or when the existing document is already an invoice). Sendingnullclears the field. See the Create Document section for semantics. - Custom Text:
customTextis an optional string (max 5000 characters). Sendingnullclears the field. See the Create Document section for semantics. - Additional Charges (Invoices Only):
additionalChargesreplaces the document's charge snapshot. Same shape and validation as the Create Document section; only valid when the document is (or is being set to) anINVOICE. - Custom Documents: For
CUSTOM_DOCUMENTdocuments,titleandtextmay also be updated.titlemust be a non-empty string andtextmust be a non-empty string of at most 20000 characters. - Marking an invoice paid: Setting an
INVOICE'sstatustoPAID(for an off-system cash/check payment) adds the invoice's full document total to the associated job'spaidAmountcounter, just as an in-app payment would (a standalone invoice has no job, so only its own status changes). Moving the invoice back out ofPAIDreverses it. This applies only toINVOICEdocuments and only on an actual status change (re-saving an already-PAIDinvoice does not double-count).paidAt: a status change intoPAIDstampspaidAt(ISO-8601, the request time) on the document when it has none yet — the same field an in-app card payment writes — so a manually paid document now carries when it was paid. An existingpaidAtis never overwritten, and leavingPAIDdoes not clear it.paymentMethod/paymentNote(optional, since 2026-10-07): how the payment was made outside the app.paymentMethodis aManualPaymentMethod(CASH,CHECK,CARD,OTHER) and may only be sent together withstatus: "PAID"in the same request — on its own, or with any other status, the request is rejected with400; an unknown value is400.paymentNote(string, max 200 characters, trimmed; blank clears) requirespaymentMethod. They are stored on the document asmanualPaymentMethodandmanualPaymentNote(the request field names are not the attribute names) and returned as stored by every document read; the public viewer returnsmanualPaymentMethodbut stripsmanualPaymentNote(it is internal, e.g. a check number). A later status change out ofPAIDremoves both attributes. Both fields are optional, so a plain{"status": "PAID"}behaves exactly as before.
- Success Response:
200 OK{ "message": "Document updated successfully" }
Delete Document
Delete an existing document.
- Method: DELETE
- Path:
/prod/jobs/documents/{documentId} - Headers:
Authorization: Bearer <token> - Success Response:
200 OK{ "message": "Document deleted successfully" } - Error Responses:
400 Bad Request(missingdocumentId),401 Unauthorized,404 Not Found(document not found),500 Internal Server Error.
Publicly Accept Document / Sign Invoice
Accept an estimate or sign an invoice via the public viewer (no Bearer token required).
Behavior by document type:
Estimate: Accepts the estimate, sets status to
ACCEPTED, updates the job status. Signature is optional unlessrequireSignatureistrue. Does not require an approved merchant account. On success, a best-effort notification email is sent to the company (the document owner) with the customer's contact details (name, email, phone, address) and a link to the estimate — except when the estimate requires a deposit and the company has an approved merchant account, in which case the company is notified when the deposit is paid instead (see thesourcenote under Create Transaction in the payments docs).Invoice: Signs the invoice (optionally with a signature image). Does not change status. Does not require merchant account check. A paid invoice can still be signed.
Method: POST
Path:
/prod/jobs/documents/{documentId}/public/acceptRequest Body:
{ "companyId": "uuid", "documentCode": "secure-code", "signatureBase64": "data:image/png;base64,..." }Note:
signatureBase64is always optional for invoices, and optional for estimates unlessrequireSignatureis true.Success Response (Estimate):
200 OK{ "message": "Document accepted successfully", "signatureKey": "signatures/companyId/documentId.png" }Success Response (Invoice):
200 OK{ "message": "Invoice signed successfully", "signatureKey": "signatures/companyId/documentId.png" }Note:
signatureKeyis only present when a signature was provided.Error Responses:
400 Bad Request: Missing parameters, invoice already signed, document already accepted (an estimate or custom document whose status is alreadyACCEPTEDorPAID), signature required but missing.403 Forbidden: Invalid document code.404 Not Found: Document not found.
Publicly Save Cardholder Signature
Save the cardholder's signature for a document via the public viewer (no Bearer token required). This is a separate signature from the customer's accept/sign signature (see "Publicly Accept Document / Sign Invoice"). It is stored under its own S3 key and document fields, so it never overwrites the document signature. Intended to capture the cardholder's authorization for a card payment.
- Method: POST
- Path:
/prod/jobs/documents/{documentId}/public/cardholder-signature - Request Body:
Note:{ "companyId": "uuid", "documentCode": "secure-code", "signatureBase64": "data:image/png;base64,...", "cardholderName": "Jane Cardholder" }signatureBase64is required.cardholderNameis optional. - Success Response:
200 OK{ "message": "Cardholder signature saved successfully", "cardholderSignatureKey": "signatures/companyId/documentId-cardholder.png" } - Error Responses:
400 Bad Request: Missing parameters, orsignatureBase64missing.403 Forbidden: Invalid document code.404 Not Found: Document not found.
Note: When a document has a cardholder signature, the public/internal document retrieval endpoints return a cardholderSignatureUrl field (alongside signatureUrl for the document signature).
Publicly Decline Document
Decline a document via the public viewer (no Bearer token required).
- Method: POST
- Path:
/prod/jobs/documents/{documentId}/public/decline - Request Body:
{ "companyId": "uuid", "documentCode": "secure-code", "reason": "Price too high" } - Success Response:
200 OK
Note:{ "message": "Document declined successfully" }reasonis optional; when provided it is stored on the document asdeclineReason. Declining setsstatustoDECLINEDand stampsdeclinedAt. - Error Responses:
400 Bad Request: MissingcompanyIdordocumentCode.403 Forbidden: Invalid document code.404 Not Found: Document not found.
Send Document
Send the document to the customer via email.
- Method: POST
- Path:
/prod/jobs/documents/{documentId}/send - Headers:
Authorization: Bearer <token> - Request Body:
{ "email": "alternate-recipient@example.com", "photoIds": ["uuid-photo-1", "uuid-photo-2"], "videoIds": ["uuid-video-1"], "emailBody": "<p>Hi! Your invoice for the kitchen remodel is ready.</p><p>Reply to this email with any questions.</p>", "bccEmail": "sender@company.com", "markAsSent": true } - BCC Copy:
bccEmailis optional. When provided, that address receives a blind-carbon copy of the exact customer-facing email (used by the app's "email me a copy" send option — the sending user's own address). It is not visible to the recipient and is not stored on the document. Blank/whitespace-only values are ignored; a non-blank value that is longer than 254 characters or contains no@is rejected with400. - Self-Only Send:
markAsSentis optional and defaults totrue. When explicitlyfalse(used by the app's "send only to me" option, together withemailset to the sending user's own address), the email is delivered but the document is NOT recorded as sent to the client: theNEW→SENTstatus transition,sentAt/lastSentAt/lastSentEmailbookkeeping, and the invoice job-completion update are all skipped.photoIds/videoIds(when provided) are still persisted as the document's attached photos / videos, since those are an editable document attribute rather than send history. - Note:
emailis optional. If not provided, the document will be sent to the customer's default email address.photoIdsis optional. When provided, it selects which of the associated job's photos to display in the customer-facing document viewer; every ID must exist on the associated job'sphotosarray (or the request is rejected with400). Passing an empty array clears any previously selected photos.videoIdsis optional and behaves identically for the job'svideosarray (stored asincludedVideoIds; the viewer shows the videos below the photos). Sending a document also updates its status fromNEWtoSENT(first send only). When the document is an invoice, the associated job's status is automatically updated toCOMPLETED. A standalone document (no job) is sent to its owncustomerId's email by default, completes no job, and rejects a non-emptyphotoIds/videoIdswith400. An approved merchant account is not required to send documents — when the company has no approved merchant account, the customer-facing viewer simply shows the document without an online payment option. - Custom Email Body:
emailBodyis optional rich text (HTML, max 20000 characters). When provided, it replaces only the default intro line ("{Company} has sent you an estimate/invoice. Please click the button below to view it.") of the email — the greeting, the "View Document" button, the document link, and the footer are always preserved. When omitted,null, or blank/whitespace-only, the email uses the default copy exactly as before. The HTML is sanitized before sending:<script>,<style>,<iframe>,<object>,<embed>, and similar tags (with their content), HTML comments, inline event-handler attributes (e.g.onclick), andjavascript:/vbscript:URIs are stripped. Ordinary formatting (<p>,<br>,<strong>,<em>,<u>,<a href="https://...">,<ul>/<ol>/<li>, headings, etc.) is preserved. A plain-text version is generated automatically for the email's text part. This field is per-send only — it is not stored on the document. - Success Response:
200 OK{ "message": "Document sent successfully" } - Error Responses:
400 Bad Request: Missing required parameters or no recipient email available.404 Not Found: Document, job, or customer not found.
Send Document by Text Message
Text the customer a link to the document (the customer-facing viewer link, the same one the email contains) and record the document as sent.
- Method: POST
- Path:
/prod/jobs/documents/{documentId}/send-sms - Headers:
Authorization: Bearer <token> - Request Body:
{ "phoneNumber": "+15551234567", "photoIds": ["uuid-photo-1", "uuid-photo-2"], "videoIds": ["uuid-video-1"] } - Recipient:
phoneNumberis optional. When provided it must be a valid 10-digit US number that matches the customer's primaryphoneNumberor one of theiradditionalPhoneNumbers(compared after normalizing both sides to E.164, so the formatting does not matter); when omitted, the customer's primaryphoneNumberis used. The text is always sent to exactly one number, and only ever to a number on the customer's record — there is no arbitrary recipient. The customer is the job's customer, or a standalone document's owncustomerId. - Message:
Tradesmen Tech: Hi {firstName}, {company name} sent you an estimate (#107-2): https://client.tmtecs.com/d/AbCdEf123456("an invoice" / "a document" for the other types; the number is omitted when the document has nodisplayId). The first text a customer ever receives also ends withReply STOP to opt out.The message is sent from the platform's registered number, like the job text messages (see the Notifications API). - Always one SMS segment: the whole message — notice included — is kept within 160 characters of the 7-bit SMS alphabet. Names are folded to that alphabet (accents and symbols outside it are dropped:
José→Jose), and when the full wording would not fit, wording is given up in this order, never the link: the document number, then the company name (Hi {firstName}, you have received an estimate: {link}), then the greeting. - Short link: the text carries the document's short link (see Document Short Links below), not the long viewer link the emails contain. It is minted on the document's first text, stored on the document as
shortCode, and is the same on every later text. - Attached photos / videos:
photoIdsandvideoIdsare optional and behave exactly as on Send Document (validated against the job'sphotos/videos, stored asincludedPhotoIds/includedVideoIds, an empty array clears, a standalone document rejects a non-empty list with400). - Status updates: identical to Send Document: the first send moves the document from
NEWtoSENTand setssentAt; every send setslastSentAtandlastSentPhone(the number texted, E.164 — the text counterpart oflastSentEmail, which is left untouched). When the document is an invoice of a job, the job's status is set toCOMPLETED. There is nomarkAsSentoption. - History: a text for a job's document is also recorded in the job's SMS history (
GET /notifications/sms/{jobId}) withmessageTypeDOCUMENTand thedocumentId. A standalone document has no job, so its text is recorded on the document only. - Success Response:
200 OK{ "message": "Document sent successfully", "phoneNumber": "+15551234567" } - Error Responses: a request the caller can fix is a
400whose body carries a machine-readablecodenext tomessage. A refused text is never a401/403(the app signs the user out on those):400 Bad Request,codeNO_CLIENT: the document's job (or the standalone document) has no customer.400 Bad Request,codeNO_PHONE_NUMBER: the customer has no phone number that can receive a text.400 Bad Request,codeINVALID_PHONE_NUMBER:phoneNumberis not a valid US number or is not on the customer's record.400 Bad Request,codeSMS_OPTED_OUT: the number has opted out of text messages (replied STOP; managed by AWS). Nothing is recorded as sent.400 Bad Request(nocode): missingdocumentId, malformed JSON, or a photo / video ID that is not on the job.404 Not Found: Document, job, or customer not found.
Document Short Links
A short, public link that opens a document: https://client.tmtecs.com/d/{code}. It exists for text messages, where the full viewer link (https://client.tradesmentechnologies.com/doc/index.html?code=…&id=…&company=…&env=…, about 190 characters) would not fit. The short link redirects to that full link — the viewer, its URL and the document's documentCode are unchanged.
- Method: GET
- Host and path:
https://client.tmtecs.com/d/{code}— its own host, with no/prodprefix. Not part of the main API. - Headers: none. The endpoint is public:
codeis the credential, exactly like thedocumentCodein the long link. code: 12 characters,0-9A-Za-z, random and unguessable, unique across companies. Server-managed: it is minted on first use — by Send Document by Text Message on a document's first text, or by Get Document Short Link below — and returned on document objects asshortCode(absent until then). A document has one code for life. Clients never write it.- Success Response:
302 FoundwithLocationset to the document's viewer link.Cache-Control: no-store. - Error Responses:
404 Not Found(a small HTML page): the code is malformed or unknown, or its document no longer exists. The three cases are indistinguishable on purpose.429 Too Many Requests: the host is throttled (20 requests per second, burst 40).
Get Document Short Link
Return a document's short link, minting it if the document has none yet (the app's "Copy link"). Asking for the link is not a send: the document's status, sentAt and lastSent* fields are untouched and nothing is delivered to the customer.
- Method: POST
- Path:
/prod/jobs/documents/{documentId}/short-link - Headers:
Authorization: Bearer <token> - Request Body: none.
- Success Response:
200 OK{ "shortCode": "AbCdEf123456", "url": "https://client.tmtecs.com/d/AbCdEf123456" } - Note: idempotent — every call for the same document returns the same
shortCode, which is also the one a text message for the document carries.urlis the full short link. Works for job documents and standalone documents alike, at every status. - Error Responses:
400 Bad Request: MissingdocumentId.404 Not Found: Document not found.
Send Multiple Documents
Send one or more documents to the customer in a single email.
- Method: POST
- Path:
/prod/jobs/documents/send - Headers:
Authorization: Bearer <token> - Request Body:
{ "documentIds": ["uuid-doc-1", "uuid-doc-2"], "email": "alternate-recipient@example.com", "emailBody": "<p>Hi! Here are your documents for the kitchen remodel.</p>", "photoIds": { "uuid-doc-1": ["uuid-photo-1", "uuid-photo-2"], "uuid-doc-2": [] }, "videoIds": { "uuid-doc-1": ["uuid-video-1"] }, "bccEmail": "sender@company.com", "markAsSent": true } - Note:
documentIdsis required and must be a non-empty array of document IDs; duplicate IDs are de-duplicated. All referenced documents must belong to the same customer (resolved through each document's job — a job with no client counts as its own "customer") or the request is rejected with400. When exactly one document is sent, the email is identical to the single Send Document endpoint above. When more than one document is sent, the email lists every document, each with its own "View" button and link. - Recipient:
emailis optional. If not provided, the email is sent to the shared customer's default email address. - BCC Copy:
bccEmailis optional and behaves exactly like the single Send Document endpoint — the address receives a blind-carbon copy of the exact customer-facing email, is not visible to the recipient, and is not stored on any document. Blank/whitespace-only values are ignored; a non-blank value that is longer than 254 characters or contains no@is rejected with400. - Self-Only Send:
markAsSentis optional, defaults totrue, and behaves exactly like the single Send Document endpoint. When explicitlyfalse, the email is delivered but no document is recorded as sent: every document'sNEW→SENTtransition,sentAt/lastSentAt/lastSentEmailbookkeeping, and all invoice job-completion updates are skipped.photoIds/videoIds(when provided) are still persisted per document. - Photo selection:
photoIdsis optional. It is an object keyed bydocumentId; each value is an array of that document's job photo IDs to display in the customer-facing document viewer. Every key must appear indocumentIds, and every photo ID must exist on the associated job'sphotosarray (otherwise the request is rejected with400). Passing an empty array for a document clears any previously selected photos for it. - Video selection:
videoIdsis optional and has the same shape and rules asphotoIds, validated against each job'svideosarray and stored asincludedVideoIds. - Status updates: Sending updates each document's status from
NEWtoSENT(first send only). For every document that is an invoice, the associated job's status is set toCOMPLETED. An approved merchant account is not required to send documents — when the company has no approved merchant account, the customer-facing viewer simply shows the documents without an online payment option. - Standalone documents: job-attached and standalone documents (see Standalone Documents) may be mixed in one send. A standalone document's client is its own
customerId; the "same customer" rule compares that with the other documents' job clients. Standalone documents complete no job and reject a non-emptyphotoIds/videoIdsentry with400. - Custom Email Body:
emailBodyis optional rich text (HTML, max 20000 characters) and behaves exactly like the single Send Document endpoint — it replaces only the default intro line ("{Company} has sent you N documents." for multi-document sends, or the single-document intro when one document is sent); the greeting, the document list/button(s), the links, and the footer are always preserved. When omitted,null, or blank/whitespace-only, the default copy is used. The HTML is sanitized before sending (the same rules as the single endpoint). This field is per-send only — it is not stored on any document. - Success Response:
200 OK{ "message": "Documents sent successfully", "count": 2, "recipient": "customer@example.com" } - Error Responses:
400 Bad Request: Missing/invaliddocumentIds, documents spanning multiple customers, invalidphotoIds/videoIds,emailBodytoo long, or no recipient email available.404 Not Found: One or more documents, jobs, or the customer was not found.
Job Photos
Generate Photo Upload URL
Generate a presigned S3 URL to upload a photo for a job.
- Method: POST
- Path:
/prod/jobs/jobs/{jobId}/photos/upload-url - Headers:
Authorization: Bearer <token> - Query Parameters:
contentType(Optional): The content type of the image (default:image/jpeg)fileName(Optional): The original file name (default:photo.jpg)
- Success Response:
200 OK{ "uploadUrl": "https://...", "photoId": "uuid", "s3Key": "companyId/jobId/uuid.jpg" }
Add Photo Metadata
Add photo metadata to a job after successful upload to S3.
- Method: POST
- Path:
/prod/jobs/jobs/{jobId}/photos - Headers:
Authorization: Bearer <token> - Request Body:
{ "photoId": "uuid", "s3Key": "companyId/jobId/uuid.jpg", "fileName": "my-photo.jpg", "contentType": "image/jpeg", "size": 12345 } - Note:
fileName,contentType, andsizeare optional. - Success Response:
200 OK{ "message": "Photo metadata added successfully", "photo": { "photoId": "uuid", "s3Key": "...", "fileName": "...", "contentType": "...", "size": 12345 } }
Delete Job Photo
Delete a photo from S3 and remove its metadata from the job record.
- Method: DELETE
- Path:
/prod/jobs/jobs/{jobId}/photos/{photoId} - Headers:
Authorization: Bearer <token> - Success Response:
200 OK{ "message": "Photo deleted successfully" }
Get Job Photo Download URL
Retrieve a pre-signed URL to download a job photo.
- Method: GET
- Path:
/prod/jobs/jobs/{jobId}/photos/{photoId} - Headers:
Authorization: Bearer <token> - Success Response:
200 OK{ "downloadUrl": "https://...", "photoId": "uuid", "fileName": "...", "contentType": "..." }
Job Videos
Videos follow the same two-step flow as photos (presigned S3 PUT, then a metadata POST) but live in a separate videos list on the job record — the shipped app only renders photos, so older clients simply ignore them. Cost controls, all server-enforced:
- Content-type allowlist:
video/mp4,video/quicktime,video/webm,video/x-m4v,video/3gpp. The stored file extension is derived from the content type, never fromfileName. - Size cap:
200 MB(maxBytesis echoed in responses). The client must declare the exact bytesizewhen requesting the upload URL; the presigned PUT signs thatContent-Length, so an upload of a different size fails S3's signature check. The metadata step re-verifies withHeadObjectand deletes + rejects an oversized object. - Client-side processing: the app re-encodes to 720p H.264 (capped at 3 minutes) and uploads a JPEG poster thumbnail alongside, so the server never transcodes or generates thumbnails. Thumbnails over
5 MBare discarded. - Storage tiering: every object lives under the
videos/{companyId}/{jobId}/prefix; a bucket lifecycle rule moves that prefix to S3 Intelligent-Tiering immediately (cold videos automatically drop to the cheaper tiers, no retrieval fees) and, in production, expires deleted videos' noncurrent versions after 30 days.
The session bootstrap (POST /accounts/login etc.) and GET /jobs/jobs return each job's videos list with the entry shape below. Videos can be attached to estimates/invoices via includedVideoIds / videoIds (see the document endpoints above) and are then shown on the public document viewer.
Video entry shape (on job.videos[]):
{
"videoId": "uuid",
"s3Key": "videos/companyId/jobId/uuid.mp4",
"thumbnailS3Key": "videos/companyId/jobId/uuid.thumb.jpg",
"fileName": "walkthrough.mp4",
"contentType": "video/mp4",
"size": 8456123,
"durationMs": 42000,
"width": 1280,
"height": 720,
"uploadedAt": "2026-08-20T17:04:11.000Z"
}
thumbnailS3Key, durationMs, width, and height are present only when known.
Generate Video Upload URL
Mints presigned S3 PUT URLs for a video and its poster thumbnail.
- Method: POST
- Path:
/prod/jobs/jobs/{jobId}/videos/upload-url - Headers:
Authorization: Bearer <token> - Request Body:
{ "contentType": "video/mp4", "fileName": "walkthrough.mp4", "size": 8456123 } - Notes:
contentType(from the allowlist above) andsize(exact byte length, positive integer, at mostmaxBytes) are required;fileNameis optional. Upload the video withPUT uploadUrlusing the sameContent-Typeand aContent-Lengthequal tosize, and the JPEG thumbnail withPUT thumbnailUploadUrl(Content-Type: image/jpeg; skip it when no thumbnail is available). Both URLs are valid forexpiresInseconds (3600). - Success Response:
200 OK{ "uploadUrl": "https://...", "thumbnailUploadUrl": "https://...", "videoId": "uuid", "s3Key": "videos/companyId/jobId/uuid.mp4", "thumbnailS3Key": "videos/companyId/jobId/uuid.thumb.jpg", "maxBytes": 209715200, "expiresIn": 3600 } - Error Responses:
400 Bad Request: Missing/unsupportedcontentType, missing or non-positive-integersize, orsizeabovemaxBytes(body carriesmaxBytes).404 Not Found: Job not found.
Add Video Metadata
Records the video on the job after both uploads finished.
- Method: POST
- Path:
/prod/jobs/jobs/{jobId}/videos - Headers:
Authorization: Bearer <token> - Request Body:
{ "videoId": "uuid", "s3Key": "videos/companyId/jobId/uuid.mp4", "thumbnailS3Key": "videos/companyId/jobId/uuid.thumb.jpg", "fileName": "walkthrough.mp4", "contentType": "video/mp4", "durationMs": 42000, "width": 1280, "height": 720 } - Notes:
videoIdands3Keyare required and must be the values returned by the upload-URL call (keys are validated against the caller's company + job + video).thumbnailS3Key,fileName,contentType,durationMs,width, andheightare optional. The storedsizecomes from S3 (HeadObject), not from the request. AthumbnailS3Keywhose object was never uploaded is silently dropped. - Success Response:
200 OK{ "message": "Video metadata added successfully", "video": { "videoId": "uuid", "s3Key": "videos/companyId/jobId/uuid.mp4", "thumbnailS3Key": "videos/companyId/jobId/uuid.thumb.jpg", "fileName": "walkthrough.mp4", "contentType": "video/mp4", "size": 8456123, "durationMs": 42000, "width": 1280, "height": 720, "uploadedAt": "2026-08-20T17:04:11.000Z" } } - Error Responses:
400 Bad Request: MissingvideoId/s3Key, a key that does not belong to this job video, an unsupportedcontentType, a negative numeric field, or an object larger thanmaxBytes(the object is deleted).404 Not Found: Job not found, or the video object was never uploaded to storage.
Get Job Video Download URL
Presigned GET URLs for streaming the video (supports HTTP range requests, so players can seek) and for its poster thumbnail.
- Method: GET
- Path:
/prod/jobs/jobs/{jobId}/videos/{videoId} - Headers:
Authorization: Bearer <token> - Success Response:
200 OK{ "downloadUrl": "https://...", "thumbnailUrl": "https://...", "videoId": "uuid", "fileName": "walkthrough.mp4", "contentType": "video/mp4", "size": 8456123, "durationMs": 42000, "expiresIn": 3600 } - Notes:
thumbnailUrlisnullwhen the video has no thumbnail;durationMsisnullwhen unknown. URLs are valid forexpiresInseconds. - Error Responses:
404 Not Found: Job or video not found.
Delete Job Video
Deletes the video and its thumbnail from storage and removes the entry from the job record.
- Method: DELETE
- Path:
/prod/jobs/jobs/{jobId}/videos/{videoId} - Headers:
Authorization: Bearer <token> - Success Response:
200 OK{ "message": "Video deleted successfully" } - Error Responses:
404 Not Found: Job not found, or video not on the job.
Job Notes
Notes are stored as a list on the job record under the notes field. Each note captures the authenticated employee who created it and the timestamp it was added.
A note has the following shape:
{
"noteId": "uuid",
"text": "Customer requested arrival before 10am",
"employeeId": "uuid",
"createdAt": "2026-05-12T18:30:00.000Z"
}
The full notes array is returned as part of the job record by GET /jobs/jobs.
Add Job Note
Append a note to a job. The employeeId is set automatically from the authenticated user.
- Method: POST
- Path:
/prod/jobs/jobs/{jobId}/notes - Headers:
Authorization: Bearer <token> - Request Body:
{ "text": "Customer requested arrival before 10am" } - Success Response:
201 Created{ "message": "Note added successfully", "note": { "noteId": "uuid", "text": "Customer requested arrival before 10am", "employeeId": "uuid", "createdAt": "2026-05-12T18:30:00.000Z" } }
Delete Job Note
Delete a note from a job by its noteId.
- Method: DELETE
- Path:
/prod/jobs/jobs/{jobId}/notes/{noteId} - Headers:
Authorization: Bearer <token> - Success Response:
200 OK{ "message": "Note deleted successfully" } - Error Responses:
404 Not Foundif the job does not exist, or if no note with the givennoteIdexists on the job.
Publicly View Document
Get document details for public view (no auth required).
- Method: GET
- Path:
/prod/jobs/documents/{documentId}/public?companyId={companyId}&documentCode={documentCode} - Service Pricing:
- For
services,priceis per unit (Total = price * quantity, or price * effectiveQuantity when dimension fields are present — see Line-Item Dimensions under Create Document). - For
oneDPricesandtwoDPrices,priceis the total price for the specified quantity or dimensions (non-linear).
- For
- Success Response:
200 OK
Note: The{ "companyId": "uuid", "documentId": "uuid", "documentCode": "secure-code", "jobId": "uuid", "type": "ESTIMATE", "status": "NEW", "hours": 5.5, "depositPrice": 100.0, "requireSignature": true, "categories": [ { "categoryId": "uuid", "name": "Labor", "parentCategoryId": null }, { "categoryId": "uuid", "name": "Prep Work", "parentCategoryId": "parent-uuid" } ], "services": [ { "serviceId": "uuid", "name": "Standard Labor", "description": "Optional description", "unit": "hour", "quantity": 5, "price": 50.0, "width": null, "length": null, "diameter": null, "allowInitials": true, "initials": "JD", "category": "uuid" }, { "serviceId": "uuid", "name": "Patio sealing", "description": null, "unit": "sqft", "quantity": 1, "price": 2.0, "width": null, "length": null, "diameter": 10, "allowInitials": false, "initials": null, "category": null } ], "oneDPrices": [ { "serviceId": "uuid", "name": "Packing Tape", "description": null, "unit": "roll", "quantity": 1, "price": 5.0, "allowInitials": false, "initials": null, "category": null } ], "twoDPrices": [ { "serviceId": "uuid", "name": "Custom Crate", "description": null, "unit": "sqft", "price": 2.5, "allowInitials": false, "initials": null, "category": null, "dimXName": "Width", "dimYName": "Height", "dimXQuantity": 4, "dimYQuantity": 6 } ], "createdAt": "ISO-8601", "customText": "Thank you for your business!", "signatureUrl": "https://s3.amazonaws.com/bucket/signature.png", "companyName": "Company Name", "companyAddress": "123 Street, City, State 12345", "companyPhone": "1234567890", "companyEmail": "email@company.com", "companyLogo": "https://s3.amazonaws.com/bucket/logo.png", "customerName": "John Doe", "customerAddress": "456 Customer Ave, Customerville, CA 90210", "customerPhone": "+15559876543", "paymentEnabled": true, "talusPayPublicKey": "pub_xxxxxxxxxxxxxxxxxxxxxxxxxxx", "payLaterEnabled": true, "payLaterMethods": ["affirm", "klarna"], "additionalCharges": [ { "type": "TAX", "label": "Sales Tax", "percentage": 8.25 } ], "tipPercentages": [10, 15, 20], "paymentFeeSchedule": [ { "type": "CONVENIENCE", "label": "Card Processing Fee", "percentage": 3, "waivedForAch": true } ], "reviewLinks": { "googleLink": "https://g.page/r/your-business/review", "otherLink": "https://yelp.com/biz/your-business" }, "photos": [ { "photoId": "uuid", "fileName": "before.jpg", "contentType": "image/jpeg", "url": "https://s3.amazonaws.com/bucket/presigned..." } ], "videos": [ { "videoId": "uuid", "fileName": "walkthrough.mp4", "contentType": "video/mp4", "durationMs": 12000, "url": "https://s3.amazonaws.com/bucket/presigned...", "thumbnailUrl": "https://s3.amazonaws.com/bucket/presigned..." } ], "notes": [ { "noteId": "uuid", "text": "Deposit collected in cash on site", "createdAt": "2026-07-14T18:30:00.000Z" } ] }costfield is stripped from all service arrays in public responses. Thenotesfield is only included when notes have been added to the document (see Document Notes); the internalemployeeIdfield is stripped from each note in public responses. ThecustomerName,customerAddress, andcustomerPhonefields are resolved via the document's job (or, for a standalone document, its owncustomerId);customerAddress(a single formatted line) is the job's address (the job site), falling back to the customer's address when the job has none, and is only included when at least one of those is set.customerPhone(E.164) is only included when set on the customer record. Thecategoriesarray contains the company's categories (resolved from the company-level categories table) for name resolution in the viewer.categoryon service items is null when not assigned to a category. ThepaymentEnabledfield istruewhen the company has an approved merchant account with TalusPay. ThepayLaterEnabledfield istrue(andpayLaterMethodslists the active Checkout method types, e.g.affirm,afterpay_clearpay,klarna) when the company's Stripe "Pay over time" account is active with at least one method; both are omitted otherwise. It is independent ofpaymentEnabled: a company may have either, both or neither. The viewer then starts the payment withPOST /payments/stripe/checkout-session(seestripe-endpoints.md). ThereviewLinksfield is only included when the document is a paid invoice (type: "INVOICE",status: "PAID") and the company has configured review links. Thephotosfield is only included when photos were selected via the send endpoint'sphotoIdsparameter; eachurlis a presigned S3 GET URL valid for 1 hour. Likewisevideosis only included when videos were selected viavideoIds; each entry carries a presigned playbackurl(1 hour) and, when the upload included a poster frame, a presignedthumbnailUrl. TheprogramReportfield ({"url": "...", "reportSent": true}) is only included when the document is anINVOICEwhose job is a maintenance-program visit (seeprograms-endpoints.md);urlis the public visit-report page (gated by the visit's stable report code, minted on first use if the visit was never previewed or sent) andreportSentisfalsewhile the report is still a draft — neither the invoice link nor the report page marks a draft as a preview (a client may view a report that is never sent). TheadditionalChargesfield is the snapshot stored on the invoice (percentage-of-total charges applied to the balance); thetipPercentagesfield reflects the company's configured tip options and is only included when the company has configured any. ThepaymentFeeSchedulefield lists the company's payment-time fees (itsappliesTo: "PAYMENT"charges —type,label,percentage,waivedForAch) and is only included when it has any: the viewer adds each fee, as its percentage of the amount being paid (before any tip), to a card payment and to a "Pay over time" payment, and to a bank-account (ACH) payment unlesswaivedForAchistrue, shows the resulting total per option before the customer chooses, and reports the applied fees onPOST /payments/transaction(paymentFees/paymentFeeAmount, seepayments-endpoints.md); a cancelled or declined payment leaves no fee. Once a document has been paid with a fee, it carriespaymentFeeAmount(dollars) andpaymentFees(the applied{ type, label, percentage, amount }list) alongsidetipAmount/tipPercentage, and keeps them. Only the fields that were set will be present. The internalname,createdByandmanualPaymentNotefields are stripped (manualPaymentMethodis returned as stored). - Error Responses:
400 Bad Request: MissingcompanyIdordocumentCodequery parameter.403 Forbidden: Invalid document code, or the document'sexpiresAthas passed (message"<Type> has expired", e.g."Estimate has expired").404 Not Found: Document not found, or the job / customer a job document references no longer exists.
Document Notes
Notes are stored as a list on the document record under the notes field. Each note captures the authenticated employee who created it and the timestamp it was added. Notes can also be seeded at document creation via the optional notes request field on Create Document (used by the app's copy-document flow).
A note has the following shape:
{
"noteId": "uuid",
"text": "Deposit collected in cash on site",
"employeeId": "uuid",
"createdAt": "2026-07-14T18:30:00.000Z"
}
The full notes array is returned as part of the document record by GET /jobs/documents/{documentId} and GET /jobs/jobs/{jobId}/documents. Notes are also included in the public document view (see Publicly View Document) with the employeeId field stripped, and are displayed to the customer on the document webpage.
Add Document Note
Append a note to a document. The employeeId is set automatically from the authenticated user.
- Method: POST
- Path:
/prod/jobs/documents/{documentId}/notes - Headers:
Authorization: Bearer <token> - Request Body:
{ "text": "Deposit collected in cash on site" } - Success Response:
201 Created{ "message": "Note added successfully", "note": { "noteId": "uuid", "text": "Deposit collected in cash on site", "employeeId": "uuid", "createdAt": "2026-07-14T18:30:00.000Z" } } - Error Responses:
404 Not Foundif the document does not exist.
Delete Document Note
Delete a note from a document by its noteId.
- Method: DELETE
- Path:
/prod/jobs/documents/{documentId}/notes/{noteId} - Headers:
Authorization: Bearer <token> - Success Response:
200 OK{ "message": "Note deleted successfully" } - Error Responses:
404 Not Foundif the document does not exist, or if no note with the givennoteIdexists on the document.
Pipelines
Pipelines allow companies to define custom job status workflows. When a job is associated with a pipeline, its status is validated against both the default JobStatus enum values and the pipeline's custom statuses.
Create Pipeline
Create a new custom pipeline. Admin only.
- Method: POST
- Path:
/prod/jobs/pipelines - Headers:
Authorization: Bearer <token> - Request Body:
{ "name": "Sales Pipeline", "statuses": ["LEAD", "CONTACTED", "QUOTED", "WON", "LOST"], "statusOrder": ["NEW", "LEAD", "CONTACTED", "QUOTED", "SCHEDULED", "WON", "LOST", "COMPLETED", "PAID", "CANCELLED"], "statusColors": { "LEAD": "#3B82F6", "CONTACTED": "#F59E0B", "QUOTED": "#8B5CF6", "WON": "#10B981", "LOST": "#EF4444" } } - Note:
statusOrderis optional. It defines the display order of all statuses (both default and custom) for the UI.statusColorsis optional. It maps status names to color strings for front-end display. - Success Response:
201 Created{ "message": "Pipeline created successfully", "pipelineId": "uuid" }
Update Pipeline
Update an existing pipeline. Admin only.
- Method: PUT
- Path:
/prod/jobs/pipelines/{pipelineId} - Headers:
Authorization: Bearer <token> - Request Body: (All fields optional)
{ "name": "Updated Pipeline Name", "statuses": ["LEAD", "QUALIFIED", "PROPOSAL", "CLOSED_WON", "CLOSED_LOST"], "statusOrder": ["NEW", "LEAD", "QUALIFIED", "PROPOSAL", "CLOSED_WON", "CLOSED_LOST", "COMPLETED", "PAID"], "statusColors": { "LEAD": "#3B82F6", "QUALIFIED": "#10B981" } } - Success Response:
200 OK{ "message": "Pipeline updated successfully" }
Delete Pipeline
Delete a pipeline. Admin only.
- Method: DELETE
- Path:
/prod/jobs/pipelines/{pipelineId} - Headers:
Authorization: Bearer <token> - Success Response:
200 OK{ "message": "Pipeline deleted successfully" }
Custom Fields
Custom fields allow companies to define additional fields that can be attached to jobs. Each custom field has a name and a type (STRING, NUMBER, DATE, or URL). Custom field definitions are returned in the login response so all employees can see the available fields. A URL field stores and validates exactly like a STRING; the front end renders the value as a link that opens it in a browser.
DATE type fields support an optional expiredColor property. When set, the front end can automatically change the job's color to this value when the current date passes the field's date value. The colorApplied boolean on the job's custom field value tracks whether this color change has already been applied, so the system does not re-apply it if the user manually changes the color afterward.
Create Custom Field
Create a new custom field definition. Admin only.
- Method: POST
- Path:
/prod/jobs/custom-fields - Headers:
Authorization: Bearer <token> - Request Body:
{ "name": "Warranty Expiration", "type": "DATE", "expiredColor": "#FF0000" } - Note:
typemust be one of:STRING,NUMBER,DATE,URL. TheexpiredColorfield is optional and only valid forDATEtype fields. When set, the front end will automatically change the job's color to this value when the current date is past the date field's value. - Success Response:
201 Created{ "message": "Custom field created successfully", "fieldId": "uuid" }
Update Custom Field
Update an existing custom field definition. Admin only.
- Method: PUT
- Path:
/prod/jobs/custom-fields/{fieldId} - Headers:
Authorization: Bearer <token> - Request Body: (All fields optional)
{ "name": "Updated Field Name", "type": "NUMBER" } - Note:
expiredColorcan be updated onDATEtype fields. SetexpiredColortonullto remove it. When the type is changed away fromDATE,expiredColoris automatically removed. - Success Response:
200 OK{ "message": "Custom field updated successfully" }
Delete Custom Field
Delete a custom field definition. Admin only.
- Method: DELETE
- Path:
/prod/jobs/custom-fields/{fieldId} - Headers:
Authorization: Bearer <token> - Success Response:
200 OK{ "message": "Custom field deleted successfully" }
Custom Texts
Custom texts are reusable free-form text snippets that can be applied to estimates and invoices via the document's customText field. Each saved snippet has a name (label shown in pickers), a text body, and a documentType (a DocumentType; in practice ESTIMATE or INVOICE) indicating which document types it is intended for. The full list of saved custom texts for a company is returned in the login response (customTexts), so the front end can present a picker without making additional API calls. When the user selects one, the front end copies the snippet's text into the document's customText field before submitting.
Create Custom Text
Create a new saved custom text. Admin only.
- Method: POST
- Path:
/prod/jobs/custom-texts - Headers:
Authorization: Bearer <token> - Request Body:
{ "name": "Default Estimate Terms", "text": "This estimate is valid for 30 days. Work will be scheduled within 2 weeks of acceptance.", "documentType": "ESTIMATE" } - Note:
nameis the label shown in the front-end picker.textis the snippet that will be copied into the document'scustomTextfield; max 5000 characters.documentTypemust be aDocumentTypevalue (ESTIMATE,INVOICE, orCUSTOM_DOCUMENT); the app only offersESTIMATEandINVOICEsnippets, sincecustomTextexists on those two types. - Success Response:
201 Created{ "message": "Custom text created successfully", "customTextId": "uuid" }
Update Custom Text
Update an existing saved custom text. Admin only.
- Method: PUT
- Path:
/prod/jobs/custom-texts/{customTextId} - Headers:
Authorization: Bearer <token> - Request Body: (All fields optional)
{ "name": "Updated Label", "text": "Updated text content.", "documentType": "INVOICE" } - Success Response:
200 OK{ "message": "Custom text updated successfully" }
Delete Custom Text
Delete a saved custom text. Admin only.
- Method: DELETE
- Path:
/prod/jobs/custom-texts/{customTextId} - Headers:
Authorization: Bearer <token> - Success Response:
200 OK{ "message": "Custom text deleted successfully" }
Custom Document Templates
Custom document templates are saved templates that the front end clones to create CUSTOM_DOCUMENT documents on a job. Each template has a title, text, and requireSignature flag. The full list of templates for a company is returned in the login response (customDocumentTemplates), so the front end can present a picker without making additional API calls. When the user selects one, the front end copies the template's fields into a new document via the Create Document endpoint with type: "CUSTOM_DOCUMENT".
Create Custom Document Template
Create a new saved template. Admin only.
- Method: POST
- Path:
/prod/jobs/custom-document-templates - Headers:
Authorization: Bearer <token> - Request Body:
{ "title": "Liability Waiver", "text": "I waive all liability...", "requireSignature": true } - Note:
titleis the label shown in the front-end picker and copied as the document title.textis the body content (max 20000 characters).requireSignatureindicates whether the customer must sign before accepting. - Success Response:
201 Created{ "message": "Custom document template created successfully", "templateId": "uuid" }
Update Custom Document Template
Update an existing template. Admin only.
- Method: PUT
- Path:
/prod/jobs/custom-document-templates/{templateId} - Headers:
Authorization: Bearer <token> - Request Body: (All fields optional)
{ "title": "Updated Title", "text": "Updated body content.", "requireSignature": false } - Success Response:
200 OK{ "message": "Custom document template updated successfully" }
Delete Custom Document Template
Delete a template. Admin only.
- Method: DELETE
- Path:
/prod/jobs/custom-document-templates/{templateId} - Headers:
Authorization: Bearer <token> - Success Response:
200 OK{ "message": "Custom document template deleted successfully" }
Reports
Get Job Report
Retrieve aggregated reporting data from jobs, documents, and payments for a given time period. Useful for CRM dashboards, charts, and graphs.
- Method: GET
- Path:
/prod/jobs/reports?startDate={ISO-8601}&endDate={ISO-8601} - Headers:
Authorization: Bearer <token> - Query Parameters:
startDate(Required): Start of the reporting period (ISO-8601 date string).endDate(Required): End of the reporting period (ISO-8601 date string).
- Note: All data is filtered by
createdAtwithin the specified date range. Every metric in the response is wrapped in a{ value, description }object —valueholds the number, map, or list that the client displays, anddescriptionis a human-readable string explaining what the metric represents (safe to render directly in frontend UI, e.g., as tooltip text or card subtitles). Revenue amounts are in dollars (converted from cents stored in the payments table). Therevenue.totalfield reflects only payments processed through the system (authorized/captured on TalusPay), whilerevenue.totalCollectedalso includes the computed totals of invoices marked as paid outside the system (i.e., PAID invoices with no corresponding approved payment record). ThesettledRevenueblock reflects funds actually settled to the merchant's bank account, aggregated fromtransaction_settlementwebhook events received from TalusPay. Settlement events lag the original transaction by 1–3 business days, so the same dollar may appear inrevenuefor one month and insettledRevenuefor the next — this is expected and not double-counting. TheestimateConversionRateis the ratio of accepted estimates to total sent estimates (0 to 1). TheinvoiceConversionRateis the ratio of paid invoices to total sent invoices (0 to 1); invoices with statusNEWare excluded from both the numerator and denominator. ThetotalInvoiceCostis the sum of thecostfields (the company's internal cost, not the customer-facing price) across all invoice line items in the period. TheaverageJobValueis total revenue divided by the number of completed or paid jobs. ThebyEmployeemap uses employee IDs as keys. ThecustomFieldSumsarray contains the sum of each numeric custom field across all jobs in the period (only fields with typeNUMBERare included;STRINGfields are excluded). Thejobs.inProgressTimeobject reports the time the jobs created in the period have spent in theIN_PROGRESSstatus (see In-Progress Time), in whole seconds:totalSecondsacross all of them,jobCount(how many have any recorded time),averageSeconds(totalSeconds / jobCount, rounded;0when no job has time) andlongestSeconds(the largest single job's total). A job that is still in progress is counted up to the moment of the request. - Success Response:
200 OK{ "period": { "startDate": "2025-01-01T00:00:00.000Z", "endDate": "2025-12-31T23:59:59.999Z" }, "jobs": { "total": { "value": 150, "description": "How many jobs were created during this period." }, "byStatus": { "value": { "NEW": 10, "SCHEDULED": 25, "IN_PROGRESS": 15, "COMPLETED": 80, "PAID": 15, "CANCELLED": 5 }, "description": "How many jobs are in each stage, from newly created through to completed." }, "byType": { "value": { "MOVING": 120, "OTHER": 30 }, "description": "How many jobs of each type you worked on." }, "overTime": { "value": { "2025-01": 12, "2025-02": 14, "2025-03": 18 }, "description": "How your job creation activity trended over time." }, "byEmployee": { "value": { "employee-uuid-1": 45, "employee-uuid-2": 38 }, "description": "How many jobs each employee was assigned." }, "customFieldSums": { "value": [ { "fieldId": "uuid", "name": "Budget", "total": 125000.50, "average": 833.34, "min": 200.00, "max": 5000.00 }, { "fieldId": "uuid", "name": "Estimated Hours", "total": 320, "average": 2.13, "min": 0.5, "max": 12 } ], "description": "Totals, averages, and ranges for the number-based custom fields on your jobs." }, "inProgressTime": { "value": { "totalSeconds": 486000, "jobCount": 90, "averageSeconds": 5400, "longestSeconds": 28800 }, "description": "How long the jobs created during this period have spent in the In Progress stage. A job that is still in progress counts up to now, and the average covers only jobs with recorded time." } }, "documents": { "totalEstimates": { "value": 120, "description": "How many estimates were created during this period." }, "totalInvoices": { "value": 80, "description": "How many invoices were created during this period." }, "byStatus": { "value": { "NEW": 10, "SENT": 30, "ACCEPTED": 60, "DECLINED": 10, "PAID": 80, "EXPIRED": 10 }, "description": "How many estimates and invoices are in each stage, from draft through to paid." }, "totalValue": { "value": 125000.50, "description": "The combined dollar value of every estimate and invoice during this period." }, "totalInvoiceCost": { "value": 85000.00, "description": "What the work on your invoices cost you to deliver — your internal costs, not what you billed the customer." } }, "revenue": { "total": { "value": 95000.00, "description": "Money from payments your customers ran through the app (tapping a card or entering ACH on the invoice page). Cash, check, and anything else you collected outside the app is not counted here." }, "totalCollected": { "value": 103500.00, "description": "All the money you actually brought in — both payments run through the app and invoices you marked as paid yourself (for cash, check, or any other off-app method)." }, "approvedPayments": { "value": 85, "description": "How many successful payments you received through the app." }, "overTime": { "value": { "2025-01": 8000.00, "2025-02": 9500.00, "2025-03": 12000.00 }, "description": "How revenue from app payments trended over time." }, "averageJobValue": { "value": 1000.00, "description": "The average amount of money you earned on each finished job." } }, "settledRevenue": { "total": { "value": 92400.00, "description": "App payments that have actually been deposited into your bank account. Deposits usually arrive 1-3 business days after a payment is taken, so this number trails the revenue above. It only covers payments taken through the app — invoices you marked paid outside the app are not included here." }, "settlementEvents": { "value": 83, "description": "How many app payments have been deposited into your bank account." }, "overTime": { "value": { "2025-01": 7800.00, "2025-02": 9300.00, "2025-03": 11600.00 }, "description": "How bank deposits from app payments trended over time." } }, "estimateConversionRate": { "value": 0.6667, "description": "The share of sent estimates that your customers accepted." }, "invoiceConversionRate": { "value": 0.8500, "description": "The share of sent invoices that your customers paid." } }
Get Financial Export
Retrieve detailed, row-level financial data for documents and payments, enriched with job and customer information. Designed for CSV export for accountants.
- Method: GET
- Path:
/prod/jobs/financial-export?startDate={ISO-8601}&endDate={ISO-8601} - Headers:
Authorization: Bearer <token> - Query Parameters:
startDate(Required): Start of the export period (ISO-8601 date string).endDate(Required): End of the export period (ISO-8601 date string).type(Optional): Filter to return only"documents"or"payments". If omitted, both are returned. Passingtypeskips the database query for the excluded data type.
- Note: All data is filtered by
createdAtwithin the specified date range. Monetary values in thedocumentsarray are in dollars (computed from line items). Monetary values in thepaymentsarray are in dollars (converted from cents). Webhook payments (internal system records) are excluded from the payments array. ThepaymentIdfield has theTXN#prefix stripped for cleaner display. If a document's linked job or customer has been deleted, the corresponding fields (jobType,customerName,customerEmail) will benull; a standalone document (see Standalone Documents) hasjobId: nullandjobType: null, with its customer resolved from its owncustomerId. A payment'sjobId,jobTypeand customer fields are resolved through the payment's job, so a payment without one (e.g. a standalone invoice or a customer-subscription charge) carriesnullthere. Theprofitfield is calculated astotal - cost. Documents include all types (estimates, invoices and custom documents) regardless of status. Whentypeis specified, only the matching key (documentsorpayments) is present in the response. - Success Response:
200 OK{ "period": { "startDate": "2025-01-01T00:00:00.000Z", "endDate": "2025-12-31T23:59:59.999Z" }, "documents": [ { "documentId": "uuid", "type": "INVOICE", "status": "PAID", "jobId": "uuid", "jobType": "PLUMBING", "customerName": "John Doe", "customerEmail": "john@example.com", "total": 500.00, "cost": 300.00, "profit": 200.00, "depositPrice": 100.00, "hours": 5.5, "createdAt": "2025-06-01T10:00:00.000Z", "sentAt": "2025-06-02T10:00:00.000Z", "paidAt": "2025-06-05T10:00:00.000Z" } ], "payments": [ { "paymentId": "uuid", "transactionId": "txn-123", "amount": 150.00, "status": "APPROVED", "paymentMethod": "CARD", "documentId": "uuid", "jobId": "uuid", "jobType": "PLUMBING", "customerName": "John Doe", "customerEmail": "john@example.com", "createdAt": "2025-06-05T10:00:00.000Z" } ] }
Get Overdue Invoices
Retrieve all invoices that have been sent but not paid for more than 7 days. Each document includes a jobId field that can be used to flag the associated job in the UI — except a standalone invoice (see Standalone Documents), which has no jobId and carries customerId instead. Documents with overdueDismissed set to true are excluded. To dismiss an overdue invoice, use PATCH /jobs/documents/{documentId} with { "overdueDismissed": true }. To un-dismiss, patch with { "overdueDismissed": false }.
- Method: GET
- Path:
/prod/jobs/documents/overdue - Headers:
Authorization: Bearer <token> - Success Response:
200 OK[ { "companyId": "uuid", "documentId": "uuid", "jobId": "uuid", "type": "INVOICE", "status": "SENT", "sentAt": "2026-03-01T10:00:00.000Z", "lastSentAt": "2026-03-01T10:00:00.000Z", "services": [], "oneDPrices": [], "twoDPrices": [], "hours": 5.5, "depositPrice": 0, "requireSignature": false, "createdAt": "2026-03-01T09:00:00.000Z" } ] - Error Responses:
401 Unauthorized- Missing or invalid token.500 Internal Server Error