API reference Programs
Programs Service API Endpoints (Maintenance Programs)
The production API base URL is https://api.tradesmentechnologies.com/.
Authentication
All endpoints except the three public views (visit report, program, template) 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/programs
Concepts
A maintenance program is a recurring bundle of services sold to a client (e.g. monthly fan cleaning + semi-annual duct cleaning for an airbnb). Companies define reusable program templates; creating a customer program copies a template (client-side) and is then edited freely per client. The backend materializes real jobs for upcoming visits; each visit job carries a programVisit checklist. Completing a visit sends the client an emailed visit report (grand total only — no per-service prices) rendered by a public web page.
Service line shape
Used inside sections on both templates and customer programs:
{
"lineId": "uuid",
"serviceId": "uuid",
"serviceType": "LINEAR",
"name": "Ceiling fan cleaning",
"unit": "ea",
"quantity": 2,
"frequency": { "type": "QUARTERLY", "quarters": [2, 4] },
"programPrice": 45.0,
"oneTimePrice": 60.0,
"diyCost": 25.0,
"diyTimeMinutes": 30,
"variants": [
{ "variantId": "uuid", "label": "Standard" },
{ "variantId": "uuid2", "label": "Deep" }
],
"selectedVariantId": "uuid",
"templateLineId": "template-line-uuid",
"included": false
}
A NON_LINEAR_1D line additionally carries the tier tables it was priced from:
{
"lineId": "uuid",
"serviceId": "uuid",
"serviceType": "NON_LINEAR_1D",
"name": "Duct cleaning",
"unit": "vents",
"quantity": 3,
"frequency": { "type": "MONTHLY" },
"programPrice": 200.0,
"oneTimePrice": 250.0,
"prices": { "1": 100, "2": 180, "3": 250 },
"programPrices": { "1": 80, "2": 150, "3": 200 },
"selectedVariantId": null
}
Field notes:
serviceTypeis one ofLINEAR,NON_LINEAR_1D,CUSTOM.serviceIdis required forLINEAR/NON_LINEAR_1D(a reference into the company's service catalog) and must be absent forCUSTOMlines.frequency— how often the service is performed, in calendar terms (never "every N visits"). An object with atypeof:MONTHLY— due on the first visit of each calendar month (the default when the field is omitted).QUARTERLY— due once inside each selected quarter of the year, on the visit in the program's quarter month: the month whose position in the quarter (1st, 2nd or 3rd) matches the program'sstartDatemonth, so quarterly visits are always three months apart counted from the start (a September start → Sep, Dec, Mar, Jun; Q4 work is done in December). Requiresquarters, a non-empty array of integers 1–4 (any combination; deduplicated and sorted on write). All four quarters = quarterly service, two = twice a year, one = once a year.
On customer programs the line frequencies are what determine the visit schedule itself — there is no client-set program-level frequency (see "Visit schedule derivation" under Customer Programs). A visit's due-ness is decided against the derived candidate series: a
MONTHLYline is due when the visit is the first one of its month, aQUARTERLYline when it is additionally in the program's quarter month of a selected quarter (per the series expansion). The retired every-N-visitscadencefield is accepted on input for backward compatibility but ignored and no longer stored; lines stored withoutfrequencybehave asMONTHLY.programPriceis the discounted price stored on the line;oneTimePriceis a snapshot of the catalog price at build time. Both are required non-negative numbers on every line (quantityis a required positive number;unitis an optional string). Templates follow the catalog: saving a service (PUT /services/services/{id}or the non-linear variant) rewrites every template line that references it — name, unit, both prices, the non-linear tier-table snapshots, frequency, DIY values, and options are copied from the service's program settings (services-endpoints.md, "Program settings on services"), keepinglineIdandquantity. Customer programs are grandfathered: later catalog changes never affect existing programs. ForNON_LINEAR_1Dlines both prices are the TOTAL for the storedquantity; forLINEAR/CUSTOMthey are per-unit and multiply byquantity. Options (variants) never change the price.prices/programPrices(NON_LINEAR_1Dlines only, optional; added 2026-09-23) — snapshots of the catalog service's one-time and program tier tables ({quantity: total}, positive-integer keys, non-negative totals — the same shape as the non-linear service's ownprices/programPrices), taken when the line was added from the price book. They let a client re-price the line from the tiers it was sold at when the estimator changesquantitylater, without a catalog lookup; the server never recomputesprogramPrice/oneTimePricefrom them — the client still sends the resulting totals. Sentnull, omitted, or{}means no snapshot (the line is stored without the keys — the shipped app never sends them, and clients fall back to the live catalog byserviceId). Sending either key on aLINEAR/CUSTOMline is a400. Template sync (below) refreshes both tables from the service.diyCost(non-negative number) anddiyTimeMinutes(non-negative integer) are optional, display-only DIY-comparison values (what the work would cost the customer to do themselves, and how long it would take) — they power the public views' DIY comparison (the only figure those views show) and never affect any computed total. Same unit semantics as the prices (per-unit forLINEAR/CUSTOM× quantity, total forNON_LINEAR_1D). Each is stored only when sent as a number; sendingnullor omitting it clears the value (lines are rebuilt on every save).variantsis optional (omit when the service has no options — an empty array is a400on a line, unlike on a catalog service). Each option is label-only — a reminder of which flavor of the service the property gets (e.g. filter size, cleaning depth); it carries NO price (the line's ownprogramPrice/oneTimePricealways apply). A legacypricesent by older app versions is accepted and dropped.selectedVariantIdis only meaningful on customer programs (chosen by the estimator) and must reference one of the line's variants; it is optional/null on templates.Lines carry no photo requirements. Older app versions still send
requireBeforePhoto/requireAfterPhoto; both are accepted and dropped (never stored).lineId,sectionId, andvariantIdvalues are generated by the server when omitted (creates) and preserved when sent (updates) — line ids correlate visit-checklist state across program edits, so clients must send them back unchanged when updating.templateLineId(customer programs only; optional string) — the template line this line was copied from when the program was created from a template. A line carrying it can never be removed from the program: an update whosesectionsno longer contain itslineIdis rejected with400(code: TEMPLATE_LINE_REMOVED, see Update Customer Program). The client declines the service instead (below), and the visit report keeps listing it as a service the company offers. Lines added to a program after it was created carry notemplateLineIdand may be removed freely. Sent on a template it is dropped.included(customer programs only; optional boolean, defaulttrue) — whether the client signed up for the service.false= the client declined it: the line stays on the program but takes no part in anything — it is left off every visit checklist, it creates no visits (see "Visit schedule derivation"), it does not count towardvisitPrice/ the public summary, and the public program view lists it flaggeddeclined. Each visit's report lists declined lines under their own heading, separate from signed-up services that are merely due on a later visit (programVisit.excludedLines, see Program Visits). Stored only whenfalse(absent = included). Sent on a template it is dropped.Omitted
templateLineId/includedon an update keep the stored values for a line whoselineIdalready exists on the program (a client that does not know the fields re-sends every line without them and must not clear a program's template provenance or the client's declined services). Sendnullexplicitly to clear either one. New lines default to no provenance andincluded: true.
Program Templates
Program templates are managed by ADMIN employees only (403 otherwise); Get Program Templates is open to any authenticated employee. Templates are returned in the session bootstrap payload under programTemplates (see accounts-endpoints.md).
Create Program Template
- Method: POST
- Path:
/prod/programs/templates - Headers:
Authorization: Bearer <token> - Request Body:
Note:{ "name": "Airbnb Monthly Care", "sections": [ { "name": "Kitchen", "lines": [ { "serviceId": "uuid", "serviceType": "LINEAR", "name": "Ceiling fan cleaning", "unit": "ea", "quantity": 2, "frequency": { "type": "MONTHLY" }, "programPrice": 45.0, "oneTimePrice": 60.0 } ] } ] }nameandsectionsare required (sectionsmay be an empty array while drafting). Each section requiresnameandlines. See "Service line shape" above for line fields; ids are generated when omitted. - Success Response:
201 Created{ "message": "Program template created successfully", "templateId": "uuid", "template": { "companyId": "uuid", "templateId": "uuid", "name": "Airbnb Monthly Care", "sections": [], "createdAt": "2026-08-17T12:00:00.000Z", "updatedAt": "2026-08-17T12:00:00.000Z" } }
Get Program Templates
- Method: GET
- Path:
/prod/programs/templates - Headers:
Authorization: Bearer <token> - Success Response:
200 OK{ "templates": [] }
Update Program Template
Whole-record replace of name + sections (send the full template each time; existing ids must be sent back to preserve identity).
- Method: PUT
- Path:
/prod/programs/templates/{templateId} - Headers:
Authorization: Bearer <token> - Request Body: same shape as create.
- Success Response:
200 OK{ "message": "Program template updated successfully", "template": {} } - Error Responses:
404 Not Foundwhen the template does not exist.
Delete Program Template
Deleting a template does not affect customer programs created from it (they copied everything at creation time).
- Method: DELETE
- Path:
/prod/programs/templates/{templateId} - Headers:
Authorization: Bearer <token> - Success Response:
200 OK{ "message": "Program template deleted successfully" }
Create Template Share Link
Mints (or returns the existing) STABLE shareCode for a template and hands back the public preview page URL — the admin's "make sure it looks good before assigning it to a client" view. Re-posting always returns the same link. ADMIN-only, like all template management.
- Method: POST
- Path:
/prod/programs/templates/{templateId}/share-link - Headers:
Authorization: Bearer <token> - Success Response:
200 OK{ "message": "Template share link created successfully", "shareUrl": "https://client.tradesmentechnologies.com/program-report/index.html?template=uuid&company=uuid&code=abc&env=prod", "shareCode": "base62-32-chars" } - Error Responses:
404 Not Foundwhen the template does not exist.
Get Program Template (Public)
No authentication — access is controlled by the opaque shareCode (the document-viewer model). Same payload family as Get Customer Program (Public) below with mode: "TEMPLATE" and every client-specific field (customerName, address, startDate, defaultStartTime, visitPattern) null.
- Method: GET
- Path:
/prod/programs/templates/{templateId}/public - Query Parameters:
companyId,shareCode— both required. - Success Response:
200 OK— see Get Customer Program (Public). - Error Responses:
400missing params;403wrong (or never-minted)shareCode;404unknown template.
Customer Programs
Managed by ADMIN and LEAD_TECHNICIAN employees (403 otherwise); the two GET endpoints are open to any authenticated employee. Customer programs are returned in the session bootstrap payload under customerPrograms (see accounts-endpoints.md).
Program record
{
"companyId": "uuid",
"programId": "uuid",
"customerId": "uuid",
"templateId": "uuid",
"name": "Airbnb Monthly Care — 12 Oak St",
"status": "ACTIVE",
"startDate": "2026-09-01",
"defaultStartTime": "09:00",
"visitFrequency": { "frequency": "MONTHLY", "interval": 1, "monthly": { "type": "DAY_OF_WEEK", "weekOfMonth": 1, "dayOfWeek": "TUESDAY" }, "end": { "type": "NEVER" } },
"defaultEmployeeIds": ["uuid"],
"jobType": "OTHER",
"address": { "streetAddress": "12 Oak St", "city": "Denver", "state": "CO", "zipCode": "80014" },
"estimateDocumentId": null,
"estimateJobId": null,
"oneTimeDocumentId": null,
"oneTimeJobId": null,
"sections": [],
"shareCode": "base62-32-chars",
"scheduleVersion": 1,
"visitNumberOffset": 0,
"materializedVisits": { "1": { "jobId": "pv-uuid-1-1", "dateTime": "2026-09-01T15:00:00.000Z" } },
"createdAt": "2026-08-17T12:00:00.000Z",
"updatedAt": "2026-08-17T12:00:00.000Z"
}
Field notes:
statusis one ofDRAFT,ACTIVE,PAUSED,CANCELLED.visitFrequencyis server-derived, never client-set — see "Visit schedule derivation" below. It uses the same recurrence shape as job recurrence (seejobs-endpoints.md) and is stored on the record for the materializer; a client-sentvisitFrequencyon create or update is silently ignored (not rejected, for compatibility with older clients).startDate(YYYY-MM-DD) +defaultStartTime(HH:MM, default09:00, company-local wall clock) anchor the series.sectionscopies the template's structure at creation time (the CLIENT performs the copy and sends the full expanded sections) — prices are snapshots, so later template/catalog changes never affect an existing program (grandfathering).templateIdis provenance only.shareCodeis server-managed (absent until minted via Create Program Share Link; 400 if sent on update, dropped if sent on create) — the stable opaque code gating the public program view. Templates carry the equivalent field for their public preview.scheduleVersion,visitNumberOffset, andmaterializedVisitsare server-managed (silently dropped if sent on create,400if sent on update).materializedVisitsmaps visit number → the materialized job; an entry whose job the office later deletes permanently cancels that visit (it is never re-created).- Optional on create:
templateId(string ornull),defaultStartTime,defaultEmployeeIds(default[], strings),jobType(defaultOTHER; aTradeTypevalue),address,estimateDocumentId,estimateJobId,oneTimeDocumentId,oneTimeJobId(each a string ornull). A non-nulladdressmust carry non-emptystreetAddress,city,state, andzipCode(400otherwise); an all-blank address object is stored asnull.
Visit schedule derivation
A program has no client-set visit schedule — the schedule is derived purely from the services (lines) and their frequencies:
- The candidate series is monthly, interval 1, anchored on
startDate's nth-weekday position atdefaultStartTime— e.g. a start date that is the second Monday of its month recurs on the second Monday of each month (never on a fixed day of the month). The ordinal isceil(day/7); a start date that is the fifth occurrence of its weekday recurs on the last such weekday (weekOfMonth: -1), so every month always has a candidate. The anchor occurrence itself falls on the exactstartDate. - A candidate becomes a real visit only when at least one included line is due on it (per each line's calendar frequency; a line the client declined —
included: false— never makes a visit exist). A program with anyMONTHLYline therefore gets a visit every month; a program of onlyQUARTERLYlines gets visits only in its selected quarters (e.g. Q2+Q4 lines → two visits a year), each in the program's quarter month — so a program started late in a quarter never gets a second visit a few weeks after the first (a Sep 17 start is followed by mid-December, not mid-October).
The derived recurrence is stored on the record as visitFrequency (server-managed); any visitFrequency in a request body is ignored.
Visit materialization
While a program is ACTIVE, the backend keeps the next N days of visits materialized as REAL job rows (returned by the normal jobs endpoints/session), where N is the company's programVisitHorizonDays setting (PUT /accounts/company, integer 1–730, default 90 when never set; echoed on the session payload): inline on create/activate/schedule-edit, plus a daily 13:30 UTC scheduled run that extends the rolling horizon. The window opens at midnight of today in the company time zone, not at the current instant: a program whose startDate is today still gets today's visit when defaultStartTime has already passed (it would otherwise silently start a month later), while occurrences from earlier days are never back-filled. Raising the setting takes effect on the next materialization (the daily run or the next program create/activate/edit); lowering it never deletes visit jobs that were already created under the wider window. Visit jobs have deterministic ids (pv-{programId}-{scheduleVersion}-{occurrenceIndex}), status: "SCHEDULED", employeeIds = the program's defaultEmployeeIds, a start time converted from the company time zone, a 1-hour default duration, the program's address when one is set (the property the program is for — a client can have several), and carry a programVisit checklist attribute (see the Visits section of this document). Candidates where no line is due (every line's frequency skips that visit) are not created — this is what makes the line frequencies fully determine the schedule.
Program edits reconcile materialized visits as follows:
- Content edit (sections/name changed, a line declined or re-included): future visits with NO recorded progress get their checklist rebuilt (and
programVisit.revisionbumped). Declining a line behaves like moving it off every visit (it is removed from in-progress visits unless already checked off); re-including one adds it back where it is due. The visit'sexcludedLineslist andvisitPriceare re-stamped on every future un-reported visit regardless of progress (a stamp of only those two fields does not bumprevision). Future visits WITH progress (a checked line, a room note/photo, an extra) get a line-level merge instead of a rebuild: the backend builds the visit's checklist from both the pre-edit and the post-edit sections and applies only the change in line due-ness — a line that became due on that visit (e.g. itsfrequency.quarterswas moved onto that visit's quarter) is added unchecked to its room (the room is created if the visit had none), and a line that stopped being due (quarter moved away, or line deleted) is removed unless it already has progress (checked, or a legacy per-line photo), in which case it stays. Lines whose due-ness did not change are left exactly as stored — their snapshot prices/names are grandfathered, and a line the technician removed from that visit via the checklist PATCH is never re-added. A room left with no lines, no note and no photos is dropped. Only visits whose merge actually changed something are written (revision bumped, counted invisitsRefreshed); a rename or price edit alone never touches an in-progress visit. Past and report-sent visits always keep their snapshot. Note a sections edit can still add/remove visits at the margin (a zero-progress checklist that becomes empty deletes that visit; new due months materialize on the same call). - Schedule edit (
startDate/defaultStartTimeactually changed — the comparison with the stored program is structural, so re-sending an unchanged program is never a schedule edit): future pristine visit jobs are deleted and re-materialized under a bumpedscheduleVersion; visit numbering continues after the highest kept visit. "Future" here also starts at the company-local midnight of today, so a pristine visit dated earlier today is replaced rather than duplicated. Content and address edits reach today's visits the same way. Pausing/cancelling/deleting a program still keeps any visit whose start time has passed. - Visits that are never deleted or rewritten by a program edit (whatever their date): a visit whose report was sent, that is invoiced (
programVisit.invoiceDocumentId), whose jobstatusis no longerSCHEDULED(completed, paid, in progress, cancelled by hand), that has job photos/videos or an inventory deduction, that carries checklist progress (a checked line, a room note/photo, an extra), or that any estimate/invoice references through the job. Such a visit keeps its visit number, checklist, price and address; the schedule-change delete is conditional on the job still being pristine, so a report/invoice/status change racing the edit also keeps it. The re-materialization then creates at most one visit per company-local calendar month: a month that already holds a kept upcoming visit gets no second visit from the new series. - Address edit: the new (or cleared)
addressis stamped onto every future un-reported visit job — including visits whose checklist already has progress (the address says where the property is, not what work was recorded); past and report-sent visits keep the address they happened at. These stamped visits are counted in the update response'svisitsRefreshed. - Status leaves ACTIVE (
PAUSED/CANCELLED/ back toDRAFT): future pristine visit jobs are deleted (the kept-visit rules above apply). Re-activating re-materializes.
Create Customer Program
- Method: POST
- Path:
/prod/programs/customer-programs - Headers:
Authorization: Bearer <token> - Request Body: the client-editable fields of the program record above — required:
customerId,name,status,startDate,sections. (visitFrequencymust NOT be relied on: it is server-derived and ignored when sent.) - Success Response:
201 Created{ "message": "Customer program created successfully", "programId": "uuid", "program": {}, "visitsCreated": 3 } - Error Responses:
400for validation failures (including an unknowncustomerId).
Get Customer Programs
- Method: GET
- Path:
/prod/programs/customer-programs - Query Parameters:
customerId— optional filter. - Headers:
Authorization: Bearer <token> - Success Response:
200 OK{ "customerPrograms": [] }
Get Customer Program
- Method: GET
- Path:
/prod/programs/customer-programs/{programId} - Headers:
Authorization: Bearer <token> - Success Response:
200 OK{ "program": {} } - Error Responses:
404 Not Found.
Update Customer Program
Per-field replace semantics: every field present in the body fully replaces the stored value; omitted fields are unchanged. companyId, programId, customerId, templateId, createdAt, updatedAt, and the server-managed fields are rejected with 400.
- Method: PUT
- Path:
/prod/programs/customer-programs/{programId} - Headers:
Authorization: Bearer <token> - Request Body: any subset of
name,status,startDate,defaultStartTime,defaultEmployeeIds,jobType,address,estimateDocumentId,estimateJobId,oneTimeDocumentId,oneTimeJobId,sections. AvisitFrequencyin the body is silently ignored (server-derived, re-computed from the mergedstartDateon every update). Anaddresschange propagates to future un-reported visit jobs (see Visit materialization). - Success Response:
200 OK{ "message": "Customer program updated successfully", "program": {}, "visitsCreated": 0, "visitsDeleted": 0, "visitsRefreshed": 2 } - Error Responses:
400validation,404 Not Found. Removing a line that was copied from the program's template (one carryingtemplateLineId) is a400naming the service, withcodeand the line:
Send the line back with{ "message": "Service 'Fan cleaning' came from the program's template and cannot be removed; uncheck it instead", "code": "TEMPLATE_LINE_REMOVED", "lineId": "uuid" }included: falseto decline it instead.
Delete Customer Program
Deletes the program and its FUTURE un-reported visit jobs; past visits and sent reports remain as normal job history.
- Method: DELETE
- Path:
/prod/programs/customer-programs/{programId} - Headers:
Authorization: Bearer <token> - Success Response:
200 OK{ "message": "Customer program deleted successfully", "visitsDeleted": 2 } - Error Responses:
404 Not Found.
Create Program Share Link
Mints (or returns the existing) STABLE shareCode for a customer program and hands back the public page URL — the link the estimator shares with the client. Re-posting always returns the same link, so earlier links a client already received stay valid (the visit report's reportCode model). ADMIN / LEAD_TECHNICIAN only, like all program management.
- Method: POST
- Path:
/prod/programs/customer-programs/{programId}/share-link - Headers:
Authorization: Bearer <token> - Request Body (optional — an empty/absent body just mints and returns the link):
Both fields are optional.{ "sendEmail": true, "recipientEmail": "override@example.net" }sendEmail: trueadditionally emails the link to the program's customer (subject "Your maintenance program from {companyName}", same house style as the visit report email);recipientEmailoverrides the customer's email on file. The email respectsemailUnsubscribedand platform email suppression. - Success Response:
200 OK{ "message": "Program share link created successfully", "shareUrl": "https://client.tradesmentechnologies.com/program-report/index.html?program=uuid&company=uuid&code=abc&env=prod", "shareCode": "base62-32-chars", "emailSent": true }emailSentisfalsewhen no email was requested, the customer has no email on file, is unsubscribed, or the recipient is suppressed — the link is still minted and returned. - Error Responses:
400 Bad Requestfor a malformedsendEmail/recipientEmail;404 Not Foundwhen the program does not exist.
Get Customer Program (Public)
No authentication — access is controlled by the opaque shareCode. Customer-facing view of the plan itself. The only price it carries is summary.visitPrice, the program's averaged price per visit (the same programVisitPrice every materialized visit is billed at — annual cost at program prices ÷ visits per year, rounded to cents; 0 when the program has no lines), shown on the page next to summary.visitsPerYear. No per-line prices and no annual/quarterly/one-time totals. The payload carries rooms/lines with quantities and calendar frequencies, the derived visit pattern, and a summary holding the visits-per-year count, the per-visit price and the DIY comparison. No internal ids or employee data. The occurrence/pricing/DIY math is computed server-side.
- Method: GET
- Path:
/prod/programs/customer-programs/{programId}/public - Query Parameters:
companyId,shareCode— both required. - Success Response:
200 OK
Field notes:{ "mode": "PROGRAM", "companyName": "Acme Maintenance", "companyLogo": "https://logos.example.net/logo.png", "companyPhone": "+13035551234", "companyEmail": "office@acme.example.net", "programName": "Airbnb Monthly Care — 12 Oak St", "customerName": "Jamie Rivera", "address": { "streetAddress": "12 Oak St", "city": "Denver", "state": "CO", "zipCode": "80014" }, "startDate": "2026-09-08", "defaultStartTime": "09:00", "visitPattern": { "weekOfMonth": 2, "dayOfWeek": "TUESDAY" }, "rooms": [ { "name": "Kitchen", "lines": [ { "name": "Ceiling fan cleaning", "variantLabel": null, "quantity": 2, "unit": "ea", "serviceType": "LINEAR", "frequency": { "type": "MONTHLY" }, "declined": false } ] } ], "summary": { "visitsPerYear": 12, "visitPrice": 140.0, "diyAnnualCost": 440.0, "diyAnnualMinutes": 720 } }visitPatternis the derived nth-weekday schedule (weekOfMonth1–4 or-1for "last").visitPriceis the averaged per-visit price described above (a number, never null). A line the client declined (included: falseon the program) is still listed withdeclined: true— the page shows it muted and tagged "Not included" so the client sees the service is offered — and takes no part insummary(visits, price, DIY); template lines are never declined.diyAnnualCost/diyAnnualMinutesarenullwhen no line carries DIY data (diyCost/diyTimeMinutesare stored per line — see Program Line above).modeis"PROGRAM"here and"TEMPLATE"on the template endpoint. - Error Responses:
400missing params;403wrong (or never-minted)shareCode;404unknown program.
The program and template views are rendered by the same static page as the visit report, at https://{web-client-domain}/program-report/index.html?program={programId}&company={companyId}&code={shareCode}&env=prod (or ?template={templateId} for the preview); the shareUrl fields above are exactly these URLs.
Program Visits
A materialized visit job carries a programVisit attribute on its (otherwise normal) job record:
{
"programId": "uuid",
"visitNumber": 4,
"oneTime": false,
"programName": "Airbnb Monthly Care — 12 Oak St",
"invoiceDocumentId": "uuid",
"revision": 1,
"rooms": [
{
"sectionId": "uuid",
"name": "Kitchen",
"note": "",
"lines": [
{
"lineId": "uuid",
"serviceType": "LINEAR",
"name": "Ceiling fan cleaning",
"quantity": 2,
"variantLabel": null,
"programPrice": 45.0,
"oneTimePrice": 60.0,
"completed": false
}
],
"photoIds": ["job-photo-uuid"]
}
],
"extras": [
{ "extraId": "uuid", "serviceId": null, "name": "Dryer vent", "quantity": 1, "price": 80.0, "performed": true }
],
"excludedLines": [
{ "sectionId": "uuid", "sectionName": "Kitchen", "lineId": "uuid", "name": "Duct cleaning", "quantity": 1, "variantLabel": null, "reason": "NOT_DUE" },
{ "sectionId": "uuid", "sectionName": "Kitchen", "lineId": "uuid", "name": "Oven cleaning", "quantity": 1, "variantLabel": null, "reason": "DECLINED" }
],
"visitPrice": 165.0,
"reportCode": "base62-32-chars",
"reportSentAt": "2026-09-01T18:00:00.000Z",
"report": { "grandTotal": 245.0, "generatedAt": "2026-09-01T18:00:00.000Z" }
}
visitPriceis the visit's price (server-managed): the program's annual cost at program prices (every included line × its yearly occurrences; declined lines cost nothing) divided evenly by the program's visits per year (rounded to cents). A visit is never priced by the services that happen to be due on it — on a quarterly program with a Q1-only service, that service's price is spread as a quarter on each of the four visits, including the three it does not appear on. The materializer stamps it on creation, a program edit re-stamps every future un-reported visit (regardless of checklist progress — the price describes the program, not the recorded work; a price-only stamp does not bumprevision), and a sent report keeps its frozen price. Absent on one-time visits and on visits materialized before the field existed; the total then falls back to summing the checklist lines.excludedLines(server-managed, never written by the checklist PATCH): the program's lines that are NOT on this visit's checklist, each with areason—NOT_DUE(the client signed up for it, but its calendar frequency lands on another visit, e.g. a once-a-year service in a different quarter) orDECLINED(the client did not sign up for it:included: falseon the program line). The report shows the two groups separately so a declined service reads as one the company offers, never as skipped or late work. Stamped when the visit is created (one-time visits list onlyDECLINEDlines, since every included line is due on a one-off) and re-stamped by every program edit on future un-reported visits; a line the checklist still carries (e.g. checked off before the client declined it) is never listed. Absent on visits stamped before the field existed — treat as[].programPriceon a checklist line is the line's program price (options are label-only);variantLabelnames the chosen option, if any. ForNON_LINEAR_1Dlines it is the total forquantity; otherwise per-unit.oneTimeistrue(andvisitNumberabsent) on a one-time program visit — a job created viaPOST /jobs/jobswith aprogramId(seejobs-endpoints.md, Create Job) for a client who booked the program's work once instead of subscribing. Its checklist holds every program line at one-time prices (novisitPrice— it is priced as the sum of those lines), is never rewritten by program edits, and is otherwise driven by the same visit endpoints below. Materialized visits omit the field (treat missing asfalse).rooms[].lineson the checklist PATCH is the client's line set verbatim — the app may send FEWER lines than the stored checklist (a technician removed a service from that visit); the missing line is simply gone from this visit's checklist and report. It does not change the price (visitPriceis the program's averaged per-visit price, unaffected by which lines the visit carries; only a legacy visit withoutvisitPricestops billing the removed line). The program and other visits are unaffected.revisionis bumped by the backend whenever a program edit regenerates this checklist.Visit photos belong to the room (
rooms[].photoIds), never to a service line, and there is no before/after distinction. Photo ids reference the job's ownphotosarray (uploaded via the normal job-photo endpoints); the app may transiently send not-yet-promoted local ids — they are stored verbatim and ignored wherever they don't resolve.photoIdsis validated as an optional array of strings on the checklist PATCH (treat missing as[]). Legacy shape: app versions before this change sentbeforePhotoIds/afterPhotoIdson each checklist line and on each extra (andrequireBeforePhoto/requireAfterPhotoon lines); those keys are still accepted (shape-checked as optional string arrays) and stored verbatim, and the public report folds a line's legacy ids into its room's photos (and an extra's into that extra'sphotos). New clients never send them.extrasare services added on the spot during the visit (extraIdis client-generated so offline replays don't duplicate). Extras carry no photos.reportCode/reportSentAt/reportare server-managed, set when the report is sent. The visit total isvisitPrice + sum(performed extras' price × quantity), rounded to cents. Skipping a service on the day never discounts it, and neither does a service not being due that visit — the price is the program's average. Only a legacy visit with novisitPriceis priced assum(ALL lines' programPrice × quantity — NON_LINEAR_1D lines counted as-is, regardless of completed)instead.invoiceDocumentIdis server-managed — the invoice created when the visit is completed (below); absent until then.
Visit invoices
A program visit — materialized or one-time — is invoiced when it is completed, i.e. by Send Visit Report, never up front: services may be skipped on the day and extras added, so only the final checklist says what to bill. The invoice is a normal INVOICE document on the job (returned by the normal document endpoints; the office sends it through the normal document flow):
- status
NEW,requireSignature: false, carryingprogramId, numbered with the job's per-type document counter (invoice "N" of the job; later invoices continue the sequence); - a single custom line named
"{programName} — Visit {n}"(or"{programName} — One-time visit"),quantity: 1,price= the report'sgrandTotal(the visit's averagedvisitPriceplus performed extras), anddescription: null(since 2026-09-21 — earlier invoices spelled out every line grouped by room plus anExtras:block, so older documents may still carry that text); the price does not depend on which lines were performed, and declined extras are not billed; - created once: a re-send never creates a second invoice, but does back-fill one for a completed visit that somehow has none (
programVisit.invoiceDocumentIdis the link). An invoice failure is logged and never fails the report send.
Update Visit Checklist
Whole-sub-record replace of the visit's runtime state (rooms + extras) — an autosave with last-write-wins semantics, safe to replay from an offline queue.
- Method: PATCH
- Path:
/prod/programs/visits/{jobId} - Headers:
Authorization: Bearer <token>(any employee) - Request Body:
Note:{ "programVisit": { "rooms": [], "extras": [] }, "baseRevision": 1 }baseRevisionmust equal the storedprogramVisit.revision— a mismatch means the program was edited (and the checklist regenerated) after the client cached it, and the write is rejected so a stale offline save cannot resurrect an old line set. Onlyroomsandextrasare writable; everything else onprogramVisitis server-managed. Edits are allowed after the report was sent (post-send corrections): the public report page renders the stored checklist live, so the edit shows on the already-delivered link, and the frozenreport.grandTotalis recomputed from the new checklist so the page total and a later re-send stay consistent. The completion-time invoice is NOT reconciled — correct it through the normal document endpoints. - Success Response:
200 OK{ "message": "Visit checklist updated successfully", "revision": 1 } - Error Responses:
400for a missingprogramVisit, abaseRevisionthat is not a positive integer, a malformed room/line (each room needs alinesarray, each line a stringlineId), or an extra without a stringextraId, a stringname, and a numericprice;404when the job is not a program visit;409whenbaseRevisionis stale.
Send Visit Report
Closes out a visit: stores the final checklist state (accepted verbatim — what the technician actually did is authoritative), snapshots the report, marks the job COMPLETED, and emails the client a link to the public report page. The email respects emailUnsubscribed and platform email suppression.
- Method: POST
- Path:
/prod/programs/visits/{jobId}/report - Headers:
Authorization: Bearer <token>(any employee) - Request Body:
Note: both fields are optional.{ "programVisit": { "rooms": [], "extras": [] }, "recipientEmail": "override@example.net" }programVisitcarries the review screen's final state; omitted, the stored state is used.recipientEmailoverrides the customer's email on file. Re-posting WITHOUTprogramVisitafter the report was sent re-sends the email (samereportCode, so earlier links stay valid); postingprogramVisitto an already-sent visit returns409(the report is immutable). - Success Response:
200 OK{ "message": "Visit report sent successfully", "reportUrl": "https://client.tradesmentechnologies.com/program-report/index.html?job=uuid&company=uuid&code=abc&env=prod", "reportSentAt": "2026-09-01T18:00:00.000Z", "grandTotal": 245.0, "emailSent": true, "invoiceDocumentId": "uuid" }emailSentisfalsewhen the customer has no email, is unsubscribed, or the recipient is suppressed — the visit is still closed out.invoiceDocumentIdis the visit's invoice (created by this call, or the existing one on a re-send — see Visit invoices);nullonly when invoice creation failed (logged; the next re-send retries). - Error Responses:
400whenprogramVisitis not an object with aroomsarray orrecipientEmailis not an email address;404when the job is not a program visit;409as described above.
Preview Visit Report
Hands the technician a link to the public report page for a visit that has not been sent yet, so the report can be checked in a browser before completing the job. Mints the visit's STABLE reportCode (the same code the later send reuses, so the preview link simply becomes the real report link) and changes nothing else — no email, no status change, no report snapshot. The page renders the stored checklist state (the app flushes its autosave first) with a live total. The page looks identical to a sent report — there is deliberately no "not sent" banner, because a client may legitimately be given the link for a visit whose report is never sent.
- Method: POST
- Path:
/prod/programs/visits/{jobId}/report/preview - Headers:
Authorization: Bearer <token>(any employee) - Request Body: none.
- Success Response:
200 OK{ "message": "Visit report preview link ready", "previewUrl": "https://client.tradesmentechnologies.com/program-report/index.html?job=uuid&company=uuid&code=abc&env=prod", "reportSent": false }reportSentistruewhen the report was already sent — the link is then the live report. - Error Responses:
404when the job is not a program visit.
Get Visit Report (Public)
No authentication — access is controlled by the opaque reportCode (the document-viewer model), which only Send Visit Report or Preview Visit Report mint. Strictly customer-facing: no per-service prices (grand total only) and no internal ids/employee data. Photo URLs are presigned for 1 hour. A visit whose code was minted by a preview but not sent yet renders as a draft: reportSentAt is null and grandTotal is computed live from the current checklist (the page renders it exactly like a sent report, with no draft/preview marker).
- Method: GET
- Path:
/prod/programs/visits/{jobId}/report/public - Query Parameters:
companyId,reportCode— both required. - Success Response:
200 OK
Each room lists four groups of services:{ "companyName": "Acme Maintenance", "companyLogo": "https://logos.example.net/logo.png", "companyPhone": "+13035551234", "companyEmail": "office@acme.example.net", "programName": "Airbnb Monthly Care — 12 Oak St", "customerName": "Jamie Rivera", "visitNumber": 4, "oneTime": false, "visitDate": "2026-09-01T15:00:00.000Z", "reportSentAt": "2026-09-01T18:00:00.000Z", "rooms": [ { "sectionName": "Kitchen", "note": "Wiped down all surfaces after the fan cleaning.", "performed": [ { "name": "Ceiling fan cleaning", "quantity": 2, "variantLabel": null } ], "skipped": [ { "name": "Duct cleaning", "quantity": 1, "variantLabel": null } ], "notDue": [ { "name": "Coil cleaning", "quantity": 1, "variantLabel": null } ], "declined": [ { "name": "Oven cleaning", "quantity": 1, "variantLabel": null } ], "photos": [ { "photoId": "uuid", "fileName": "kitchen.jpg", "contentType": "image/jpeg", "url": "https://presigned" } ] } ], "extras": [ { "name": "Dryer vent", "quantity": 1, "photos": [] } ], "grandTotal": 245.0 }performedandskipped(on the checklist — done / not done this visit),notDue(the client signed up for the service, but it is due on a later visit — e.g. a once-a-year service in another quarter) anddeclined(not part of the client's program; listed so the client sees the company offers it). A room where nothing was due this visit still appears when it hasnotDueordeclinedservices (with emptyperformed/skipped, no note, no photos). The groups come from the visit'sexcludedLines; visits stamped before the field existed return emptynotDue/declinedarrays. - Error Responses:
400missing params;403wrong (or never-minted)reportCode;404unknown visit (a visit whose code was minted by a preview but never sent renders as a draft, not a404).
The report is rendered by the static report page at https://{web-client-domain}/program-report/index.html?job={jobId}&company={companyId}&code={reportCode}&env=prod — the URL the report email links to.
One-time jobs (and the retired program paperwork)
There is no program acceptance step and no program-level estimate or invoice — a program is activated by setting its status, and every visit (recurring or one-time) is invoiced when it is completed (see Visit invoices).
- One-time job (
oneTimeJobId): for a client who declines the recurring program but wants the work done once. The client callsPOST /jobs/jobswith the program'sdefaultEmployeeIds,jobType, andprogramId, which makes the server build a one-timeprogramVisitchecklist (see Program Visits above andjobs-endpoints.md, Create Job); the response'sjobIdis thenPUTonto the program asoneTimeJobId. A program may get another one-time job later (the client booked again) — the new id simply replaces the old one; the earlier job stays on the customer's jobs. - Legacy fields:
estimateJobId,estimateDocumentId, andoneTimeDocumentIdwere written by earlier app versions (a program-level estimate/invoice, and a client-created one-time estimate). They are still accepted and stored verbatim, but current apps no longer write them.
All four link fields are stored verbatim and no server-side behavior reads them.