Tradesmen Technologies Tradesmen Technologies

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_REPAIR
  • AUTO_REPAIR
  • CARPENTRY
  • CARPET_CLEANING
  • CONCRETE
  • DRYWALL
  • ELECTRICAL
  • ESTHETICIAN
  • FENCING
  • FLOORING
  • GARAGE_DOOR
  • GENERAL_CONTRACTING
  • GLASS_AND_GLAZING
  • GUTTERS
  • HANDYMAN
  • HVAC
  • INSULATION
  • JUNK_REMOVAL
  • LANDSCAPING
  • LOCKSMITH
  • MASONRY
  • MOVING
  • PAINTING
  • PEST_CONTROL
  • PET_GROOMING
  • PLASTERING
  • PLUMBING
  • POOL_AND_SPA
  • POWER_WASHING
  • ROOFING
  • SEPTIC
  • SIDING
  • SOLAR
  • TREE_SERVICE
  • WELDING
  • WINDOW_AND_DOOR
  • WINDOW_CLEANING
  • OTHER

JobStatus

  • NEW
  • ACCEPTED
  • SCHEDULED
  • IN_PROGRESS
  • COMPLETED
  • PAID
  • CANCELLED

CustomFieldType

  • STRING
  • NUMBER
  • DATE
  • URL

DocumentType

  • ESTIMATE
  • INVOICE
  • CUSTOM_DOCUMENT

DocumentStatus

  • NEW
  • SENT
  • ACCEPTED
  • DECLINED
  • PAID
  • EXPIRED

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:

  • CASH
  • CHECK
  • CARD — a card run on another terminal or processor
  • OTHER

RecurrenceFrequency

  • DAILY
  • WEEKLY
  • MONTHLY
  • YEARLY

WeekDay

  • SUNDAY
  • MONDAY
  • TUESDAY
  • WEDNESDAY
  • THURSDAY
  • FRIDAY
  • SATURDAY

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 date
  • ON_DATE — the recurrence ends on a specific date
  • AFTER_OCCURRENCES — the recurrence ends after a fixed number of occurrences

RecurrenceEditScope

  • THIS_INSTANCE — the edit applies to a single occurrence of a repeating job
  • THIS_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 of RecurrenceFrequency. Controls the base cadence.
  • interval (required): positive integer. Use 1 for "every day/week/month/year", 2 for "every other", etc.
  • daysOfWeek (required when frequency is WEEKLY, forbidden otherwise): a non-empty array of WeekDay values. Duplicates are ignored. For example, ["MONDAY", "WEDNESDAY"] means the job recurs twice a week.
  • monthly (required when frequency is MONTHLY, forbidden otherwise): describes how the monthly recurrence is anchored.
    • type: "DAY_OF_MONTH" — set dayOfMonth to an integer 1-31 (e.g., "monthly on day 16").
    • type: "DAY_OF_WEEK" — set weekOfMonth (1-5 for first through fifth, or -1 for the last occurrence in the month) and dayOfWeek (a WeekDay). 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 on end are ignored.
    • type: "ON_DATE" — set date to an ISO-8601 date string; the recurrence stops on or before that date.
    • type: "AFTER_OCCURRENCES" — set occurrences to 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 a THIS_INSTANCE edit. When expanding a job's recurrence into calendar instances, skip any occurrence whose start datetime is in this list — the detached occurrence exists as its own standalone job row instead. Absent (or null) when no instance has been edited. Cleared automatically when recurrence is 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 the jobId of 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, and programId are optional. customerId may be omitted or null to create a job with no client; the stored job then carries customerId: 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 explicit email (Send Document returns 404 without one). color defaults to null and accepts any string (e.g., a hex color like "#FF0000"). The address object is optional; when provided, it must contain streetAddress, city, state, and zipCode. An address (or destinationAddress) sent as null or as an object whose subfields are all empty strings is treated as not provided. The destinationAddress object is optional and follows the same structure as address — useful for moving companies or any job that involves a destination. The customFields object maps custom field IDs to their values. For STRING and URL fields the value is a string, for NUMBER fields the value is a number, and for DATE fields the value is an object with value (a date string in YYYY-MM-DD format) and colorApplied (a boolean indicating whether the expired color has been applied to the job). A URL field stores and validates exactly like a STRING; the front end renders it as a link that opens the value in a browser. When pipelineId is provided, the status is validated against both the default JobStatus enum values and the pipeline's custom statuses. The recurrence field may be omitted or set to null to 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: displayId is a human-friendly, company-scoped sequential job number (an integer starting at 1). It is allocated automatically at creation time, is unique within the company, and increases monotonically. Deleting a job does not free its displayId for reuse, so numbers are never recycled (gaps are expected after deletions). jobId remains the canonical UUID identifier used in all other endpoint paths; displayId is intended for display only. Jobs created before this feature was added do not have a displayId.
  • One-time program visit (programId): when programId names one of the company's maintenance programs (see programs-endpoints.md), the job is created as a one-time program visit: it carries a programVisit checklist 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 effective programPrice, oneTime: true, and no visitNumber. 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 in programs-endpoints.md) — nothing is invoiced up front. The created programVisit is echoed in the 201 response (alongside jobId/displayId) so the client can render it without a refetch. 400 when the program does not exist, when a customerId is given that is not the program's client (an omitted/null customerId inherits the program's client), when the program has no service lines, or when a recurrence is 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/jobs

  • Headers: Authorization: Bearer <token>

  • Note: For non-ADMIN employees (LEAD_TECHNICIAN, TECHNICIAN, CREW_TECHNICIAN), only jobs assigned to them (where their employeeId is in the job's employeeIds) are returned. ADMIN employees 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: displayId is the company-scoped sequential job number (see Create Job above); it is absent on jobs created before the feature was added. customFields is only present on jobs that have custom field values set. address is only present on jobs that have an address set; destinationAddress is only present on jobs that have a destination set. For DATE type custom fields, the value is an object with value (the date) and colorApplied (whether the expired color was applied). The recurrence field is null for non-recurring jobs and jobs created before this feature was added. Repeating jobs may additionally carry recurrenceExceptions, and jobs created by an instance edit carry parentJobId (and, for detached single occurrences, parentInstanceDateTime) — see Instance-level edits under Recurrence.

    paidAmount is 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's status to PAID via Update Document — e.g. for a cash or check payment) also contribute their full document total; moving such an invoice back out of PAID backs the amount out again. It defaults to 0 for 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 DATE type custom fields, set colorApplied to true after applying the expired color to the job to prevent re-application. If startDateTime is provided and status is NOT provided in the request, the job's status will be automatically updated to SCHEDULED if its current status is NEW or ACCEPTED — this auto-status transition only applies when the job has no pipelineId. When a job has a pipelineId (existing or being set), the status is validated against both the default JobStatus enum values and the pipeline's custom statuses. The address and destinationAddress fields can each be sent as null (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 containing streetAddress, city, state, and zipCode. The recurrence field can be sent as null to 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's recurrenceExceptions (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, price is per unit (Total = price * quantity, or price * effectiveQuantity when dimension fields are present — see Line-Item Dimensions under Create Document).
    • For oneDPrices and twoDPrices, price is the total price for the specified quantity or dimensions (non-linear).
  • Inventory usage (inventoryUsage): the request may also carry the technician-confirmed parts usage (see Inventory usage & deduction below); null clears 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: job is the full job row as stored after the update and its side effects (only a few fields are shown above) — in particular it carries inventoryDeduction when 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 (null clears). The parts the technician confirmed were used on the job. Validation: items is an array of at most 100 {itemId, name, quantity} entries (itemId non-empty string, unique; name optional string ≤ 200 chars; quantity finite number > 0) — it may be empty, which means "nothing was used"; truckId string or null; edited boolean (default false); sourceDocumentId string or null. The server stamps updatedAt and updatedBy (the caller's employeeId) 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 is COMPLETED/PAID and stock has been deducted: { appliedAt, appliedBy (employeeId or null on public/webhook paths), truckId (or null), lines: [{ itemId, name, quantity, fromTruck, fromWarehouse }] }. An empty lines array 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/endedAt are ISO-8601 UTC instants; endedAt is null on the one open period of a job that is IN_PROGRESS right now (measure it up to the current time). startedBy/endedBy are the employeeId whose action changed the status (null on public and webhook paths, and endedBy is null while open). source is STATUS (recorded by a status change) or MANUAL (added by an admin). editedAt/editedBy are present only on a period an admin corrected.
  • inProgressRevision — an integer that increases with every write of the list. Send it back as baseRevision when 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 be ADMIN)
  • 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 its startedBy/source, and gains editedAt/editedBy when its times changed. An entry without a periodId is 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 after startedAt, or null/omitted for an open period. An open period is allowed only while the job's status is IN_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's inProgressRevision as the client last read it (0 for 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 with 409 so 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
    }
    
    inProgressPeriods is the stored list, sorted oldest first.
  • Error Responses: 400 for a missing body, a periods value that is not an array or has more than 200 entries, an invalid entry (the message is prefixed with periods[<index>]: — invalid or missing startedAt, invalid endedAt, endedAt before startedAt, an unknown, blank or repeated periodId), more than one open period, an open period that is not the latest or on a job that is not IN_PROGRESS, overlapping periods, or a baseRevision that is not a non-negative integer; 403 if the caller is not an ADMIN; 404 if the job does not exist; 409 when the stored list no longer matches baseRevision (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 is IN_PROGRESS with 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: customerId may be null (a job with no client) on Update Job and Edit Job Instance alike; sending null detaches the client.
  • Note: editScope (a RecurrenceEditScope) and instanceStartDateTime (the start datetime of the occurrence being edited, exactly as produced by expanding the job's recurrence) 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. If startDateTime/endDateTime are 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's recurrenceExceptions, and a new standalone one-off job is created carrying the edits, with its own jobId and displayId, plus parentJobId and parentInstanceDateTime pointing back at the series. The recurrence field is not allowed with this scope. Editing the same occurrence again returns 409 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 (with parentJobId) is created starting at the occurrence, carrying the edits and the remainder of the recurrence (for an AFTER_OCCURRENCES end, the remaining occurrence count; NEVER/ON_DATE ends carry over unchanged). A recurrence field in the request replaces the new segment's recurrence (null makes the new segment a one-off job). When the recurrence is omitted (inherited) and startDateTime moves 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 recurrence is always taken verbatim. Entries in the series' recurrenceExceptions on or after the split move to the new job. If instanceStartDateTime equals the series' startDateTime, nothing precedes the split, so the series job is updated in place (no new job) and the response is 200 OK with the original jobId and no displayId/parentJobId.
  • Error Responses: 400 for a non-repeating job, an instanceStartDateTime outside the series (before its start or past its end), a recurrence field with THIS_INSTANCE scope, or no updatable fields; 404 if the job does not exist; 409 for a THIS_INSTANCE edit of an already-detached occurrence.
  • Success Response: 201 Created
    {
      "message": "Job instance updated successfully",
      "jobId": "uuid",
      "displayId": 12,
      "parentJobId": "uuid"
    }
    
  • Note: jobId is the newly created job (the detached occurrence or the "this and future" segment); parentJobId is the original series job. For a THIS_AND_FUTURE split the message is "Job series split successfully". Google Calendar sync runs for both jobs: the series event gains an EXDATE (or a truncated RRULE), 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 an ESTIMATE or INVOICE document whose status is SENT, ACCEPTED, or PAID. 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) and CUSTOM_DOCUMENT documents 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 (never null). Standalone documents are therefore never returned by Get Job Documents; they are listed by Get All Documents and by the login response's documents array.
  • 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 a customerId (or whose client was deleted) can only be sent to an explicit email, renders on the public viewer without a customer name, gets no reminders, and is skipped by the QuickBooks sync. A job's document never carries customerId — its client is always the job's.
  • displayId is "S-<n>" (e.g. "S-12"): a per-company, per-type counter, monotonically increasing and never reused after a delete. The S- 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 with 400; 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 own customerId is 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 its displayId ("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 (a SENT invoice does not complete it, an ACCEPTED estimate does not accept it). A document whose status is PAID cannot be attached (400): its payment was recorded without a job, so the job's paidAmount would not include it. customerId may not be sent in the same request (400). There is no detach — jobId can 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 or null clears 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 — the employeeId of 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: ADMIN employees 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 (including signatureUrl / cardholderSignatureUrl when signed). A standalone document has no jobId and may carry customerId:
    [
      {
        "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, price is per unit (Total = price * quantity, or price * effectiveQuantity when dimension fields are present — see Line-Item Dimensions under Create Document).
    • For oneDPrices and twoDPrices, price is the total price for the specified quantity or dimensions (non-linear).
  • Success Response: 200 OK
    [
      {
        "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"
      }
    ]
    
    Note: displayId is 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. The categories array 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. category on service items is null when not assigned to a category. The customText field 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). Includes signatureUrl if a signature has been captured.
  • Error: 400 Bad Request, 401 Unauthorized, 404 Not Found, or 500 Internal Server Error

Create Document

Create an estimate, invoice, or custom document.

  • Method: POST

  • Path: /prod/jobs/documents

  • Headers: Authorization: Bearer <token>

  • Job or standalone: jobId is optional. With a jobId the document belongs to that job (everything below is unchanged). Omitted, null or "" creates a standalone document — see Standalone Documents above — which additionally accepts an optional customerId (the client; ignored when a jobId is sent). Every document accepts an optional name (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, and pointOfSale are optional. programId links the document to a maintenance program and programSummary is a client-computed object (typically annualTotal, 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, see programs-endpoints.md); both are stored verbatim and no server-side behavior reads them. serviceId, description, unit, cost, allowInitials, initials, category, prices, and costs in the price arrays (services, oneDPrices, twoDPrices) are optional. width, length, and diameter are optional dimension fields on services items — see Line-Item Dimensions below; when a services item has dimensions, quantity becomes optional too (it acts as a multiplier and defaults to 1). unit is a free-form label such as sqft or hour; when omitted it is stored as null and the doc viewer hides the Unit column if no line items have a unit. cost tracks the business's cost for accounting purposes. prices stores the full pricing table from the service definition (quantity-to-price map for oneDPrices, nested x-to-y-to-price map for twoDPrices). costs stores the per-quantity cost map for oneDPrices. allowInitials defaults to false. initials holds the customer's initials when captured. category references 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 requires categoryId and name; parentCategoryId is optional (null for top-level categories, or the categoryId of a parent category for nesting). Categories can be nested to arbitrary depth.

  • Service Pricing:

    • For services, price is per unit (Total = price * quantity, or price * effectiveQuantity when dimension fields are present — see Line-Item Dimensions under Create Document).
    • For oneDPrices and twoDPrices, price is the total price for the specified quantity or dimensions (non-linear).
  • Line-Item Dimensions (services items only): A services item may carry optional measured dimensions from which square footage is calculated: width + length (rectangle) or diameter (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 * effectiveQuantity
    

    Rules:

    • width and length must be provided together; diameter is mutually exclusive with them. All dimensions must be positive finite numbers.
    • With dimensions present, quantity becomes an optional multiplier (e.g. 3 identical 10×12 rooms) and defaults to 1; without dimensions the existing rule stands (quantity required).
    • price is per square foot for a dimensioned item. unit remains a free-form display label (typically "sqft").
    • Because sqft is 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 → sqft 78.54; at price: 2 the line total is 157.08.
    • Absent dimension fields are stored as null on the document record, so readers must treat both missing and null as "no dimensions".
    • The document viewer shows the measurement in the Quantity column (e.g. 10 × 12 = 120, π (10/2)² = 78.54, or 2 × (π (10/2)²) = 157.08 with a quantity multiplier). Circles spell out the area formula (π and the diameter) rather than the ⌀ symbol.
  • Partial Billing (Invoices Only): percent is an optional number in (0, 100] indicating the share of (lineItemsTotal - depositPrice) this invoice bills for. Example: line items total $100, depositPrice $20, percent 50 → the invoice charges 50% of $80 = $40. Only valid when type is INVOICE; setting it on an estimate returns 400. When omitted, behavior is equivalent to percent: 100.

  • Custom Text: customText is 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): pointOfSale is 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; true on any type other than INVOICE returns 400. Only true is 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): additionalCharges is an optional array of charges added to the invoice balance, each a percentage of the full line-items total (before deposit). Each entry requires type (one of TAX, CONVENIENCE, CUSTOM), a non-empty label (max 100 chars), and a percentage in (0, 100]. Stored as a snapshot on the document (any id is dropped). Only valid when type is INVOICE; setting it on an estimate or custom document returns 400. Companies typically populate it from the configured charge catalog returned in the login response (additionalCharges) — but only its appliesTo: "INVOICE" entries: a company charge with appliesTo: "PAYMENT" is a payment-time fee that never belongs on the invoice (see Update Company in accounts-endpoints.md). The server enforces this: a snapshot entry sent with appliesTo: "PAYMENT" is dropped, and so is any entry matching one of the company's current PAYMENT charges on type, label and percentage (the stored snapshot never carries appliesTo). Partial-billing interaction: when the invoice also has a percent < 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): notes is 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-empty text; employeeId and createdAt (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. noteId values 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 generated noteIds) are echoed back in the success response under notes.

  • Custom Documents: When type is CUSTOM_DOCUMENT, the document is a free-form attachment to a job (e.g. a liability waiver) with no services or pricing. The required fields are type, title, text, and requireSignature (jobId is optional, as for every document type); other fields (services, oneDPrices, twoDPrices, hours, depositPrice, categories, percent, customText) must be omitted and are ignored if sent. text has 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 sets status to ACCEPTED (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 notes were provided in the request, the response also includes a notes array containing the stored notes with their server-generated noteIds.

  • Display ID: displayId is a human-friendly, per-job document number derived from the parent job's displayId. The first document of a given type on a job reuses the job's number (e.g. job 1 → 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 per type, 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). documentId remains the canonical UUID used in all endpoint paths; displayId is for display only. It is a string (note the "1-2" form). Documents created on a job that predates the job displayId feature have no displayId (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-string jobId / customerId / name or a name over 120 characters), 401 Unauthorized, 404 Not Found (the referenced jobId does not exist for the company), or 500 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, price is per unit (Total = price * quantity, or price * effectiveQuantity when dimension fields are present — see Line-Item Dimensions under Create Document).
    • For oneDPrices and twoDPrices, price is 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"
    }
    
  • Maintenance Program Link: programId and programSummary are optional and stored verbatim (no server-side behavior reads them); legacy of the retired program-level estimate/invoice flow — see programs-endpoints.md. Server-built visit invoices (created at visit completion) carry programId.
  • Name and client: name (string, max 120 characters; null or blank clears it) may be set on any document. customerId (string, or null to clear) may be set only on a standalone document — on a document that belongs to a job it is rejected with 400. 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 → otherwise 404; rejected with 400 when the document is PAID; the document's customerId is removed). See Standalone Documents, "Attaching to a job".
  • Attached Photos: includedPhotoIds is 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's photos, otherwise the request fails with 400. 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: includedVideoIds mirrors includedPhotoIds for job videos: an optional array of job video IDs to display on the customer-facing document viewer (the Send Document endpoints write it via videoIds). Every ID must exist on the parent job's videos, otherwise 400; [] 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: hidden is 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; send false to 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): percent must be a number in (0, 100] and is only valid when the document is an INVOICE (either via type in the same request, or when the existing document is already an invoice). Sending null clears the field. See the Create Document section for semantics.
  • Custom Text: customText is an optional string (max 5000 characters). Sending null clears the field. See the Create Document section for semantics.
  • Additional Charges (Invoices Only): additionalCharges replaces 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) an INVOICE.
  • Custom Documents: For CUSTOM_DOCUMENT documents, title and text may also be updated. title must be a non-empty string and text must be a non-empty string of at most 20000 characters.
  • Marking an invoice paid: Setting an INVOICE's status to PAID (for an off-system cash/check payment) adds the invoice's full document total to the associated job's paidAmount counter, just as an in-app payment would (a standalone invoice has no job, so only its own status changes). Moving the invoice back out of PAID reverses it. This applies only to INVOICE documents and only on an actual status change (re-saving an already-PAID invoice does not double-count).
    • paidAt: a status change into PAID stamps paidAt (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 existing paidAt is never overwritten, and leaving PAID does not clear it.
    • paymentMethod / paymentNote (optional, since 2026-10-07): how the payment was made outside the app. paymentMethod is a ManualPaymentMethod (CASH, CHECK, CARD, OTHER) and may only be sent together with status: "PAID" in the same request — on its own, or with any other status, the request is rejected with 400; an unknown value is 400. paymentNote (string, max 200 characters, trimmed; blank clears) requires paymentMethod. They are stored on the document as manualPaymentMethod and manualPaymentNote (the request field names are not the attribute names) and returned as stored by every document read; the public viewer returns manualPaymentMethod but strips manualPaymentNote (it is internal, e.g. a check number). A later status change out of PAID removes 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 (missing documentId), 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 unless requireSignature is true. 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 the source note 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/accept

  • Request Body:

    {
      "companyId": "uuid",
      "documentCode": "secure-code",
      "signatureBase64": "data:image/png;base64,..."
    }
    

    Note: signatureBase64 is always optional for invoices, and optional for estimates unless requireSignature is 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: signatureKey is 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 already ACCEPTED or PAID), 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:
    {
      "companyId": "uuid",
      "documentCode": "secure-code",
      "signatureBase64": "data:image/png;base64,...",
      "cardholderName": "Jane Cardholder"
    }
    
    Note: signatureBase64 is required. cardholderName is optional.
  • Success Response: 200 OK
    {
      "message": "Cardholder signature saved successfully",
      "cardholderSignatureKey": "signatures/companyId/documentId-cardholder.png"
    }
    
  • Error Responses:
    • 400 Bad Request: Missing parameters, or signatureBase64 missing.
    • 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
    {
      "message": "Document declined successfully"
    }
    
    Note: reason is optional; when provided it is stored on the document as declineReason. Declining sets status to DECLINED and stamps declinedAt.
  • Error Responses:
    • 400 Bad Request: Missing companyId or documentCode.
    • 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: bccEmail is 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 with 400.
  • Self-Only Send: markAsSent is optional and defaults to true. When explicitly false (used by the app's "send only to me" option, together with email set to the sending user's own address), the email is delivered but the document is NOT recorded as sent to the client: the NEW → SENT status transition, sentAt/lastSentAt/lastSentEmail bookkeeping, 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: email is optional. If not provided, the document will be sent to the customer's default email address. photoIds is 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's photos array (or the request is rejected with 400). Passing an empty array clears any previously selected photos. videoIds is optional and behaves identically for the job's videos array (stored as includedVideoIds; the viewer shows the videos below the photos). Sending a document also updates its status from NEW to SENT (first send only). When the document is an invoice, the associated job's status is automatically updated to COMPLETED. A standalone document (no job) is sent to its own customerId's email by default, completes no job, and rejects a non-empty photoIds / videoIds with 400. 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: emailBody is 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), and javascript:/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: phoneNumber is optional. When provided it must be a valid 10-digit US number that matches the customer's primary phoneNumber or one of their additionalPhoneNumbers (compared after normalizing both sides to E.164, so the formatting does not matter); when omitted, the customer's primary phoneNumber is 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 own customerId.
  • 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 no displayId). The first text a customer ever receives also ends with Reply 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: photoIds and videoIds are optional and behave exactly as on Send Document (validated against the job's photos / videos, stored as includedPhotoIds / includedVideoIds, an empty array clears, a standalone document rejects a non-empty list with 400).
  • Status updates: identical to Send Document: the first send moves the document from NEW to SENT and sets sentAt; every send sets lastSentAt and lastSentPhone (the number texted, E.164 — the text counterpart of lastSentEmail, which is left untouched). When the document is an invoice of a job, the job's status is set to COMPLETED. There is no markAsSent option.
  • History: a text for a job's document is also recorded in the job's SMS history (GET /notifications/sms/{jobId}) with messageType DOCUMENT and the documentId. 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 400 whose body carries a machine-readable code next to message. A refused text is never a 401/403 (the app signs the user out on those):
    • 400 Bad Request, code NO_CLIENT: the document's job (or the standalone document) has no customer.
    • 400 Bad Request, code NO_PHONE_NUMBER: the customer has no phone number that can receive a text.
    • 400 Bad Request, code INVALID_PHONE_NUMBER: phoneNumber is not a valid US number or is not on the customer's record.
    • 400 Bad Request, code SMS_OPTED_OUT: the number has opted out of text messages (replied STOP; managed by AWS). Nothing is recorded as sent.
    • 400 Bad Request (no code): missing documentId, malformed JSON, or a photo / video ID that is not on the job.
    • 404 Not Found: Document, job, or customer not found.

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 /prod prefix. Not part of the main API.
  • Headers: none. The endpoint is public: code is the credential, exactly like the documentCode in 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 as shortCode (absent until then). A document has one code for life. Clients never write it.
  • Success Response: 302 Found with Location set 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).

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. url is the full short link. Works for job documents and standalone documents alike, at every status.
  • Error Responses:
    • 400 Bad Request: Missing documentId.
    • 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: documentIds is 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 with 400. 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: email is optional. If not provided, the email is sent to the shared customer's default email address.
  • BCC Copy: bccEmail is 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 with 400.
  • Self-Only Send: markAsSent is optional, defaults to true, and behaves exactly like the single Send Document endpoint. When explicitly false, the email is delivered but no document is recorded as sent: every document's NEW → SENT transition, sentAt/lastSentAt/lastSentEmail bookkeeping, and all invoice job-completion updates are skipped. photoIds / videoIds (when provided) are still persisted per document.
  • Photo selection: photoIds is optional. It is an object keyed by documentId; each value is an array of that document's job photo IDs to display in the customer-facing document viewer. Every key must appear in documentIds, and every photo ID must exist on the associated job's photos array (otherwise the request is rejected with 400). Passing an empty array for a document clears any previously selected photos for it.
  • Video selection: videoIds is optional and has the same shape and rules as photoIds, validated against each job's videos array and stored as includedVideoIds.
  • Status updates: Sending updates each document's status from NEW to SENT (first send only). For every document that is an invoice, the associated job's status is set to COMPLETED. 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-empty photoIds / videoIds entry with 400.
  • Custom Email Body: emailBody is 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/invalid documentIds, documents spanning multiple customers, invalid photoIds / videoIds, emailBody too 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, and size are 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 from fileName.
  • Size cap: 200 MB (maxBytes is echoed in responses). The client must declare the exact byte size when requesting the upload URL; the presigned PUT signs that Content-Length, so an upload of a different size fails S3's signature check. The metadata step re-verifies with HeadObject and 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 MB are 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) and size (exact byte length, positive integer, at most maxBytes) are required; fileName is optional. Upload the video with PUT uploadUrl using the same Content-Type and a Content-Length equal to size, and the JPEG thumbnail with PUT thumbnailUploadUrl (Content-Type: image/jpeg; skip it when no thumbnail is available). Both URLs are valid for expiresIn seconds (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/unsupported contentType, missing or non-positive-integer size, or size above maxBytes (body carries maxBytes).
    • 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: videoId and s3Key are 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, and height are optional. The stored size comes from S3 (HeadObject), not from the request. A thumbnailS3Key whose 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: Missing videoId/s3Key, a key that does not belong to this job video, an unsupported contentType, a negative numeric field, or an object larger than maxBytes (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: thumbnailUrl is null when the video has no thumbnail; durationMs is null when unknown. URLs are valid for expiresIn seconds.
  • 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 Found if the job does not exist, or if no note with the given noteId exists 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, price is per unit (Total = price * quantity, or price * effectiveQuantity when dimension fields are present — see Line-Item Dimensions under Create Document).
    • For oneDPrices and twoDPrices, price is the total price for the specified quantity or dimensions (non-linear).
  • Success Response: 200 OK
    {
      "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" }
      ]
    }
    
    Note: The cost field is stripped from all service arrays in public responses. The notes field is only included when notes have been added to the document (see Document Notes); the internal employeeId field is stripped from each note in public responses. The customerName, customerAddress, and customerPhone fields are resolved via the document's job (or, for a standalone document, its own customerId); 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. The categories array contains the company's categories (resolved from the company-level categories table) for name resolution in the viewer. category on service items is null when not assigned to a category. The paymentEnabled field is true when the company has an approved merchant account with TalusPay. The payLaterEnabled field is true (and payLaterMethods lists 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 of paymentEnabled: a company may have either, both or neither. The viewer then starts the payment with POST /payments/stripe/checkout-session (see stripe-endpoints.md). The reviewLinks field is only included when the document is a paid invoice (type: "INVOICE", status: "PAID") and the company has configured review links. The photos field is only included when photos were selected via the send endpoint's photoIds parameter; each url is a presigned S3 GET URL valid for 1 hour. Likewise videos is only included when videos were selected via videoIds; each entry carries a presigned playback url (1 hour) and, when the upload included a poster frame, a presigned thumbnailUrl. The programReport field ({"url": "...", "reportSent": true}) is only included when the document is an INVOICE whose job is a maintenance-program visit (see programs-endpoints.md); url is 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) and reportSent is false while 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). The additionalCharges field is the snapshot stored on the invoice (percentage-of-total charges applied to the balance); the tipPercentages field reflects the company's configured tip options and is only included when the company has configured any. The paymentFeeSchedule field lists the company's payment-time fees (its appliesTo: "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 unless waivedForAch is true, shows the resulting total per option before the customer chooses, and reports the applied fees on POST /payments/transaction (paymentFees / paymentFeeAmount, see payments-endpoints.md); a cancelled or declined payment leaves no fee. Once a document has been paid with a fee, it carries paymentFeeAmount (dollars) and paymentFees (the applied { type, label, percentage, amount } list) alongside tipAmount / tipPercentage, and keeps them. Only the fields that were set will be present. The internal name, createdBy and manualPaymentNote fields are stripped (manualPaymentMethod is returned as stored).
  • Error Responses:
    • 400 Bad Request: Missing companyId or documentCode query parameter.
    • 403 Forbidden: Invalid document code, or the document's expiresAt has 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 Found if 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 Found if the document does not exist, or if no note with the given noteId exists 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: statusOrder is optional. It defines the display order of all statuses (both default and custom) for the UI. statusColors is 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: type must be one of: STRING, NUMBER, DATE, URL. The expiredColor field is optional and only valid for DATE type 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: expiredColor can be updated on DATE type fields. Set expiredColor to null to remove it. When the type is changed away from DATE, expiredColor is 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: name is the label shown in the front-end picker. text is the snippet that will be copied into the document's customText field; max 5000 characters. documentType must be a DocumentType value (ESTIMATE, INVOICE, or CUSTOM_DOCUMENT); the app only offers ESTIMATE and INVOICE snippets, since customText exists 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: title is the label shown in the front-end picker and copied as the document title. text is the body content (max 20000 characters). requireSignature indicates 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 createdAt within the specified date range. Every metric in the response is wrapped in a { value, description } object — value holds the number, map, or list that the client displays, and description is 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). The revenue.total field reflects only payments processed through the system (authorized/captured on TalusPay), while revenue.totalCollected also includes the computed totals of invoices marked as paid outside the system (i.e., PAID invoices with no corresponding approved payment record). The settledRevenue block reflects funds actually settled to the merchant's bank account, aggregated from transaction_settlement webhook events received from TalusPay. Settlement events lag the original transaction by 1–3 business days, so the same dollar may appear in revenue for one month and in settledRevenue for the next — this is expected and not double-counting. The estimateConversionRate is the ratio of accepted estimates to total sent estimates (0 to 1). The invoiceConversionRate is the ratio of paid invoices to total sent invoices (0 to 1); invoices with status NEW are excluded from both the numerator and denominator. The totalInvoiceCost is the sum of the cost fields (the company's internal cost, not the customer-facing price) across all invoice line items in the period. The averageJobValue is total revenue divided by the number of completed or paid jobs. The byEmployee map uses employee IDs as keys. The customFieldSums array contains the sum of each numeric custom field across all jobs in the period (only fields with type NUMBER are included; STRING fields are excluded). The jobs.inProgressTime object reports the time the jobs created in the period have spent in the IN_PROGRESS status (see In-Progress Time), in whole seconds: totalSeconds across all of them, jobCount (how many have any recorded time), averageSeconds (totalSeconds / jobCount, rounded; 0 when no job has time) and longestSeconds (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. Passing type skips the database query for the excluded data type.
  • Note: All data is filtered by createdAt within the specified date range. Monetary values in the documents array are in dollars (computed from line items). Monetary values in the payments array are in dollars (converted from cents). Webhook payments (internal system records) are excluded from the payments array. The paymentId field has the TXN# prefix stripped for cleaner display. If a document's linked job or customer has been deleted, the corresponding fields (jobType, customerName, customerEmail) will be null; a standalone document (see Standalone Documents) has jobId: null and jobType: null, with its customer resolved from its own customerId. A payment's jobId, jobType and customer fields are resolved through the payment's job, so a payment without one (e.g. a standalone invoice or a customer-subscription charge) carries null there. The profit field is calculated as total - cost. Documents include all types (estimates, invoices and custom documents) regardless of status. When type is specified, only the matching key (documents or payments) 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