API reference Accounts
Accounts 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>
When token authentication fails, every authenticated endpoint returns 401 Unauthorized with a body of this shape:
{
"message": "Unauthorized: Token revoked",
"reason": "SUPERSEDED"
}
The reason field is machine-readable and lets clients explain to the user why their session ended:
MISSING_TOKEN— noAuthorizationheader, or it is not a Bearer token.INVALID_TOKEN— the token is not recognized (never issued, or its record has already been cleaned up).SUPERSEDED— the token was revoked because the same employee logged in again (e.g. on another device). Only one active session is allowed per employee.EXPIRED— the token's lifetime elapsed.PASSWORD_CHANGED— the token was revoked because the employee changed their password from another session (Change Password below). Added afterSUPERSEDED/EXPIRED; older clients treat it as unrecognized.
If a token is both revoked and past its expiry, the reason reflects whichever happened first. The reason field was added after the initial release; clients should treat it as optional and fall back to a generic "session expired" handling when it is absent or unrecognized.
Idempotent Retries (Idempotency-Key)
Every mutating request (POST, PUT, PATCH, DELETE) on every endpoint may carry an Idempotency-Key header: a client-generated unique string of 16–128 characters from A-Z, a-z, 0-9, _, ., : and - (a UUID is ideal). The server remembers the first answer it gave for a key for 30 days and, for any later request with the same key, returns that answer again (same status and body, plus an Idempotent-Replay: true header) without acting a second time. This makes a retry safe when the first attempt may have reached the server but its answer was lost — a timeout, a dropped connection, a device that went offline and replays its queue later. Use a fresh key for every logical request and the same key for every attempt of it.
Rules:
- The header is optional; a request without it behaves as before. A malformed key is ignored, not rejected.
- Only answers that decided the request are remembered: every
2xx,3xxand4xxexcept401,403,408and429. A5xx, a401/403(the session was wrong, not the request) and408/429forget the key, so the next attempt runs for real. - While the first attempt is still running, a second one with the same key gets
503withRetry-After: 5and{"message": "...", "code": "IDEMPOTENT_REQUEST_IN_PROGRESS"}— retry it shortly. - A key reused for a different method or path is treated as a new request.
- A stored answer larger than 300 KB is replayed as its original status with
{"message": "Request already processed", "idempotentReplay": true}instead of the original body.
The timesheet endpoints additionally accept their own in-body keys (timesheetId on create, idempotencyKey on edit) from before this header existed; both mechanisms work together.
Base Path
/prod/accounts
Enums
TradeType (used for jobType)
APPLIANCE_REPAIRAUTO_REPAIRCARPENTRYCARPET_CLEANINGCONCRETEDRYWALLELECTRICALESTHETICIANFENCINGFLOORINGGARAGE_DOORGENERAL_CONTRACTINGGLASS_AND_GLAZINGGUTTERSHANDYMANHVACINSULATIONJUNK_REMOVALLANDSCAPINGLOCKSMITHMASONRYMOVINGPAINTINGPEST_CONTROLPET_GROOMINGPLASTERINGPLUMBINGPOOL_AND_SPAPOWER_WASHINGROOFINGSEPTICSIDINGSOLARTREE_SERVICEWELDINGWINDOW_AND_DOORWINDOW_CLEANINGOTHER
CompanyStatus
OPERATIONALDISABLEDUNPAID
EmployeeType
ADMINLEAD_TECHNICIANTECHNICIANCREW_TECHNICIAN— aTECHNICIANwho may also clock other employees in and out (crew clock-in/out,GET /timesheets/open); for every other permission it is treated exactly asTECHNICIAN(since 2026-10-22).
Endpoints
Create Company
Create a new company and its first admin employee.
Method: POST
Path:
/prod/accounts/companiesRequest Body:
{ "name": "Company Name", "companyEmail": "email@company.com", "companyPhoneNumber": "1234567890", "address": { "streetAddress": "123 Street", "city": "City", "state": "State", "zipCode": "12345" }, "timeZone": "America/Chicago", "firstName": "John", "middleName": "D", "lastName": "Doe", "employeeEmail": "john@company.com", "employeePhoneNumber": "0987654321", "employeeType": "ADMIN", "password": "securepassword123", "brand": "thermacrm" }Success Response:
201 Created{ "message": "Company and Employee created successfully", "companyId": "uuid", "employeeId": "uuid", "freeTrialEndDate": "2026-09-25" }Note:
employeePhoneNumbermust be a valid US phone number (10 digits, or 11 digits starting with1). It is normalized to E.164 (e.g.+10987654321) before storage.companyPhoneNumberis stored as provided.timeZoneis optional (an IANA time zone string, stored verbatim and returned by Login ascompanyTimeZone); when omitted the company starts onAmerica/New_York.companyEmailandemployeeEmailare trimmed and lowercased before the uniqueness check and storage; every other string field is trimmed.brandis optional: the app flavor the company signed up through —"tradesmen"(Tradesmen Tech) or"thermacrm"(ThermaCRM). It is stored on the company record asbrandand names the app in the internal sign-up notification email. Absent or unrecognized values resolve to"tradesmen", so app versions that never send it are unaffected. The stored brand decides one more thing: brands without a free tier start on an automatic free trial — a"thermacrm"sign-up gets a 14-day trial (freeTrialEndDate= the sign-up's UTC date + 14 days; the login response'ssubscription.freeTrialEndDatereflects it from the first login), while"tradesmen"gets none (its free tier applies). The response echoes the grantedfreeTrialEndDate(nullwhen no trial was granted). The stored brand also picks the sender address of every email sent for the company:"thermacrm"companies' email (login codes, password resets, documents, reports, reminders, campaigns, receipts, notices) comes fromnoreply@thermacrm.com, everything else fromnoreply@tradesmentechnologies.com; a company created beforebrandexisted has none and sends from the Tradesmen address. Nothing else on the backend reads the brand — the app build, not the stored brand, decides the user-facing behavior.Error Responses:
400 Bad Request: Missing body, password missing/too short, or invalidemployeePhoneNumber.409 Conflict: Email already in use by an existing company or employee.500 Internal Server Error: Unexpected server error.
Create Employee
Create a new employee within the authenticated admin's company.
Method: POST
Path:
/prod/accounts/employeesAuthentication: Required (Bearer Token). Caller must be an ADMIN employee.
Request Body:
{ "firstName": "Jane", "middleName": "A", "lastName": "Smith", "email": "jane@company.com", "phoneNumber": "5551234567", "employeeType": "LEAD_TECHNICIAN", "password": "securepassword123", "hasAppAccess": true, "hourlyRate": 2550, "clockInTypeRates": { "clock-in-type-uuid-work": 2550, "clock-in-type-uuid-travel": 1200 } }Note:
firstNameis the only field every employee needs (since 2026-10-22;lastNameandphoneNumberused to be required too).middleName,lastNameandphoneNumberare optional strings — a blank or omitted value is stored asnull, and names are trimmed. WhenphoneNumberis non-blank it must be a valid US phone number (10 digits, or 11 digits starting with1) and is normalized to E.164 (e.g.+15551234567) before storage. When provided,employeeTypemust be one of:ADMIN,LEAD_TECHNICIAN,TECHNICIAN,CREW_TECHNICIAN.hasAppAccessis optional and defaults totrue. Whentrue, the employee can log in and counts as a billable per-seat user, soemailandpasswordare required as well (password must be at least 8 characters);employeeTypedefaults toTECHNICIANwhen omitted. Whenfalse, the employee is a no-access record used only for assignment/tracking: they cannot log in and are excluded from per-seat billing, so onlyfirstNameis required —email,password, andemployeeType(role) are all optional (anypasswordsent is ignored, and the role is omitted from the record when not provided). Email uniqueness is still enforced when an email is provided.hourlyRateis optional for all employees (with or without app access) and is the employee's pay rate as a non-negative integer number of cents per hour (e.g.2550= $25.50/hr). When omitted, the employee has no rate on record.clockInTypeRatesis optional: the employee's rate per clock-in type, an object mapping aclockInTypeIdto a non-negative integer number of cents per hour (anullvalue leaves that type without a rate; ids are not checked against the company's list). Omitted ornull= no per-type rates. How an hour of each type is paid from these two fields is described under clock-in types; the app keepshourlyRateequal to the default type's rate.Welcome email: after an employee with app access is created, the server emails the new employee at the login
email(subjectYour {app} account is ready, sent from the company's brand address — seebrandunder Create Company): it names the company and the app, tells them to sign in with that email address, and that their administrator set their password (with "Forgot password?" on the sign-in screen as the way to choose their own), with a prominent button to the brand's website. The email never contains the password — the administrator hands it over. It is best-effort and server-side only: a delivery problem is logged and does not change the response, no email is sent for a no-access employee (hasAppAccess: false), and@example.comaddresses are never emailed.Success Response:
201 Created{ "message": "Employee created successfully", "employeeId": "uuid" }Error Responses:
400 Bad Request: Missing body, missing or invalid required fields, invalid email format, invalid employeeType, invalid phone number, password too short, non-booleanhasAppAccess, invalidhourlyRate(must be a non-negative integer), or invalidclockInTypeRates(must be an object of non-negative integer cents per clock-in type id).401 Unauthorized: Missing or invalid token.403 Forbidden: Caller is not an ADMIN employee.409 Conflict: Email already in use by an existing company or employee.500 Internal Server Error: Unexpected server error.
Login
Authenticate an employee and receive a token and company data.
- Method: POST
- Path:
/prod/accounts/login - Request Body:
{ "employeeEmail": "john@company.com", "password": "securepassword123", "mfaDeviceToken": "base64url_device_token" }mfaDeviceTokenis optional: it is the device-trust token returned by the Verify MFA Code endpoint after a previous successful code verification on this device. When it is present, valid, and within its 30-day trust window, the login completes directly; otherwise the endpoint responds with an MFA challenge (see below). - MFA challenge response:
200 OK. When MFA applies and the request does not carry a validmfaDeviceToken, the credentials are still verified, but instead of a session the endpoint emails a 6-digit code to the employee's login email and returns:
The client must then collect the code and call Verify MFA Code with{ "mfaRequired": true, "mfaToken": "base64url_challenge_token", "mfaExpiresAt": 1770973140, "deliveryMethod": "EMAIL", "emailMasked": "j***@company.com", "phoneNumberMasked": "***-***-7890" }mfaTokenand the code to complete the login.mfaExpiresAt(epoch seconds) is when the challenge and code expire (10 minutes).phoneNumberMaskedis present only when the phone number on file can receive SMS — it signals that the client may offer "Text me the code instead" via the Resend MFA Code endpoint (the escape hatch for a user who lost email access but still has their phone); when absent, do not offer the SMS option. Employees with multiple devices can hold a valid device-trust token on each; verifying a new device does not invalidate other devices' tokens. - Note: Only one active session is allowed per employee. Logging in revokes any existing tokens for the employee, so a previous session on another device will stop working; subsequent requests from that device receive
401withreason: "SUPERSEDED"(see Authentication above). The session token expires 12 hours after it is issued (expiresAt, epoch seconds); after that every authenticated call answers401withreason: "EXPIRED"and the employee must log in again. For non-ADMINemployees (LEAD_TECHNICIAN,TECHNICIAN,CREW_TECHNICIAN), thejobsarray only contains jobs assigned to them (where theiremployeeIdis in the job'semployeeIds).ADMINemployees receive all jobs for the company. Thedocumentsarray contains every estimate, invoice and custom document of the company — attached to a job or standalone — in the item shape ofGET /jobs/documents(see "Get All Documents" and "Standalone Documents" injobs-endpoints.md);ADMINemployees receive all of them, other employees the documents of the jobs in theirjobsarray plus the standalone documents they created. It is[]when the documents could not be read (the login still succeeds; clients refetch viaGET /jobs/documents), and absent from responses of backend versions before 2026-10-02. ThemerchantOnboardingStatusfield isnullif the company has not started merchant onboarding, or one of:PENDING,IN_REVIEW,APPROVED,REJECTED. ThetalusPayPublicKeyfield is only present whenmerchantOnboardingStatusisAPPROVED. ThestripeAccountfield is the company's Stripe "Pay over time" account (the account object instripe-endpoints.md:status,payLaterAvailable,payLaterMethods,payLaterCapabilities,requirementsDue,approvedAt,updatedAt) ornullwhen the company never started Pay over time onboarding. ThereviewLinksfield isnullif the company has not configured review links, or an object containing the configured links (e.g.,googleLink). ThelogoUrlfield isnullif the company has not uploaded a logo. ThecompanyTimeZonefield isnullif the company has not set a time zone, or a valid IANA time zone string (e.g.,"America/New_York"). ThecustomFieldsarray contains the company's custom field definitions for jobs (each withfieldId,entityType: "JOB",name,type, andcreatedAt). ThecustomerCustomFieldsarray contains the company's custom field definitions for customers, with the same shape butentityType: "CUSTOMER". ThecustomTextsarray contains the company's saved free-form text snippets for documents (each withcustomTextId,name,text,documentTypeofESTIMATEorINVOICE, andcreatedAt); the front end uses these to populate thecustomTextfield when creating or updating an estimate or invoice. ThecustomDocumentTemplatesarray contains the company's saved templates forCUSTOM_DOCUMENTdocuments (each withtemplateId,title,text,requireSignature, andcreatedAt); the front end clones these into a new document via the Create Document endpoint withtype: "CUSTOM_DOCUMENT". Thesubscriptionobject contains the company's SaaS subscription details:statusis one ofNEVER_SUBSCRIBED,ACTIVE,PAYMENT_FAILED,CANCELLED;tradesmenTechPublicKeyis the TalusPay public key for tokenizing SaaS payment methods (distinct fromtalusPayPublicKeywhich is for collecting customer payments);freeTrialEndDateis the trial end date (YYYY-MM-DD) ornull;hasPaymentMethodindicates whether the company has a card on file;paymentMethodLast4is the last 4 digits of the card/bank account on file ornullif unknown (e.g. subscriptions created before this field existed);billingDayOfMonthis the day of month for recurring billing ornull;maxEmployeesis the subscribed employee limit ornull;lastPaymentDateandlastPaymentAmount(in cents) reflect the most recent charge ornull;cancelAtPeriodEnd(boolean) andcancelledAt(timestamp ornull) describe a pending cancellation;pricingcontainsbasePriceandadditionalUserPrice(in cents) andisCustomPricing(whether the company has a custom rate). ACANCELLEDsubscription is reported asACTIVEwhile a free trial is still running (today is beforefreeTrialEndDate), so the company keeps access until the trial ends. TheadditionalChargesarray contains the company's configured document charges (each withid,typeofTAX/CONVENIENCE/CUSTOM,label, andpercentage); these are applied as a percentage of an invoice's line-items total (before deposit) when creating an invoice. ThetipPercentagesarray contains the company's configured tip options (e.g.[10, 15, 20]) that the customer can choose from when paying an invoice on the doc viewer. Both default to[]. TheprogramTemplatesandcustomerProgramsarrays contain the company's maintenance-program templates and per-client programs (shapes inprograms-endpoints.md);programSavingsThresholdPercentis the company's display threshold for the program DIY-savings line (percent, defaults to 50 when never set);programVisitHorizonDaysis the rolling number of days of maintenance-program visit jobs kept materialized per ACTIVE program (integer, defaults to 90 when never set — seeprograms-endpoints.md). ThecustomerEquipmentarray contains all of the company's equipment records — equipment left at customers' properties (record shape inequipment-endpoints.md). ThecustomerSubscriptionsarray contains every recurring charge the company bills its customers (shape incustomer-subscriptions-endpoints.md). TheclockInTypesarray is the company's list of clock-in types with their pay rates (shape intimesheets-endpoints.md, "Clock-in types");[]when the company uses none. TheequipmentCatalogarray is the company's list of often-installed equipment (each{catalogItemId, name, brand, model, notes, attributes}; see "Equipment Catalog" inequipment-endpoints.md), used by the app to prefill a new equipment record;[]when never configured. TheinventoryItemsarray is every inventory item of the company (item shape ininventory-endpoints.md),inventoryTrucksis the company's truck list andinventoryCategoriesits item-category list (each{categoryId, name}; both[]when never configured) — the same three listsGET /inventoryreturns. Each entry in thecustomersarray includes a server-generatedcreatedAttimestamp (UTC ISO 8601) recording when the customer record was created; customers created before this field existed may lack it, so consumers should treat it as optional. Each entry in theemployeesarray includes ahasAppAccessboolean (defaults totruefor employees created before this field existed); no-access employees (false) are listed for assignment/tracking but cannot log in. Each entry also includes anhourlyRatefield (pay rate in cents per hour, ornullwhen no rate is set) and aclockInTypeRatesobject (the employee's cents-per-hour rate per clock-in type,{}when none are set — seetimesheets-endpoints.md); both are compensation data, so ADMIN callers see every employee's values while non-ADMIN callers see only their own (nullfor everyone else). A login attempt by an employee withhasAppAccess: falsereturns403 Forbidden. - Success Response:
200 OK{ "token": "base64url_token", "tokenType": "Bearer", "expiresAt": 1770973140, "employeeId": "uuid", "companyId": "uuid", "companyName": "Company Name", "companyEmail": "email@company.com", "companyPhoneNumber": "1234567890", "companyAddress": { "streetAddress": "123 Street", "city": "City", "state": "State", "zipCode": "12345" }, "logoUrl": "https://s3.amazonaws.com/bucket/company-logo.png", "companyTimeZone": "America/New_York", "firstName": "John", "middleName": "D", "lastName": "Doe", "email": "john@company.com", "phoneNumber": "0987654321", "employeeType": "ADMIN", "maxEmployees": 1, "additionalCharges": [ { "id": "uuid", "type": "TAX", "label": "Sales Tax", "percentage": 8.25, "appliesTo": "INVOICE" }, { "id": "uuid", "type": "CONVENIENCE", "label": "Card Processing Fee", "percentage": 3, "appliesTo": "PAYMENT", "waivedForAch": true } ], "tipPercentages": [10, 15, 20], "categories": [ { "companyId": "uuid", "categoryId": "uuid", "name": "Category Name", "parentCategoryId": null, "createdAt": "2024-01-01T00:00:00.000Z" } ], "services": [ { "companyId": "uuid", "serviceId": "uuid", "name": "Service Name", "description": "Optional description", "unit": "sqft", "price": 5.0, "cost": 3.0, "parentCategoryId": "uuid", "createdAt": "2024-01-01T00:00:00.000Z" } ], "nonLinearServices": [ { "companyId": "uuid", "serviceId": "uuid", "name": "Non-Linear Service Name", "description": "Optional description", "cost": 50.0, "parentCategoryId": "uuid", "pricingModel": "non_linear_1d", "prices": { "1": 100, "5": 400 }, "createdAt": "2024-01-01T00:00:00.000Z" } ], "twoDNonLinearServices": [ { "companyId": "uuid", "serviceId": "uuid", "name": "2D Non-Linear Service Name", "description": "Optional description", "cost": 75.0, "parentCategoryId": "uuid", "pricingModel": "non_linear_2d", "dimXName": "Width", "dimYName": "Height", "prices": { "10": { "10": 100, "20": 150 }, "20": { "10": 180, "20": 250 } }, "createdAt": "2024-01-01T00:00:00.000Z" } ], "jobs": [ { "companyId": "uuid", "jobId": "uuid", "displayId": 1, "customerId": "uuid", "jobType": "MOVING", "description": "Sample job description", "status": "SCHEDULED", "startDateTime": "2024-01-01T10:00:00.000Z", "endDateTime": "2024-01-01T14:00:00.000Z", "createdAt": "2024-01-01T00:00:00.000Z", "photos": [ { "photoId": "uuid", "fileName": "site-photo.jpg", "contentType": "image/jpeg", "size": 12345, "s3Key": "companyId/jobId/uuid.jpg" } ] } ], "documents": [ { "companyId": "uuid", "documentId": "uuid", "documentCode": "secure-code", "displayId": "1", "jobId": "uuid", "type": "ESTIMATE", "status": "SENT", "hours": 0, "depositPrice": 0, "requireSignature": true, "services": [], "oneDPrices": [], "twoDPrices": [], "createdBy": "employee-uuid", "createdAt": "2024-01-01T00:00:00.000Z" } ], "customers": [ { "companyId": "uuid", "customerId": "uuid", "firstName": "Jane", "lastName": "Smith", "email": "jane@example.com", "phoneNumber": "1112223333", "additionalEmails": ["extra@example.com"], "address": { "streetAddress": "456 Avenue", "city": "City", "state": "State", "zipCode": "12345" }, "createdAt": "2024-01-01T00:00:00.000Z" } ], "employees": [ { "companyId": "uuid", "employeeId": "uuid", "firstName": "John", "middleName": "D", "lastName": "Doe", "email": "john@company.com", "phoneNumber": "0987654321", "employeeType": "ADMIN", "hasAppAccess": true, "hourlyRate": 2550, "clockInTypeRates": { "clock-in-type-uuid-work": 2550, "clock-in-type-uuid-travel": 1200 }, "description": "Expert plumber with 10 years of experience.", "photoUrl": "https://s3.amazonaws.com/bucket/employee-photos/companyId/employeeId/photo.png" } ], "webhooks": [ { "code": "ABCDEFghijklmnopQRSTUVwxyz012345", "source": "Zapier" } ], "merchantOnboardingStatus": "APPROVED", "talusPayPublicKey": "pub_xxxxxxxxxxxxxxxxxxxxxxxxxxxx", "stripeAccount": null, "flags": { "newJobFeature": true, "betaTesting": false }, "reviewLinks": { "googleLink": "https://g.page/r/your-business/review", "otherLink": "https://yelp.com/biz/your-business", "updatedAt": "2026-01-01T00:00:00.000Z" }, "pipelines": [ { "companyId": "uuid", "pipelineId": "uuid", "name": "Sales Pipeline", "statuses": ["LEAD", "CONTACTED", "QUOTED", "WON", "LOST"], "createdAt": "2024-01-01T00:00:00.000Z" } ], "customFields": [ { "companyId": "uuid", "fieldId": "uuid", "entityType": "JOB", "name": "Project Number", "type": "STRING", "createdAt": "2024-01-01T00:00:00.000Z" } ], "customerCustomFields": [ { "companyId": "uuid", "fieldId": "uuid", "entityType": "CUSTOMER", "name": "Birthday", "type": "DATE", "createdAt": "2024-01-01T00:00:00.000Z" } ], "customTexts": [ { "companyId": "uuid", "customTextId": "uuid", "name": "Default Estimate Terms", "text": "This estimate is valid for 30 days.", "documentType": "ESTIMATE", "createdAt": "2024-01-01T00:00:00.000Z" } ], "customDocumentTemplates": [ { "companyId": "uuid", "templateId": "uuid", "title": "Liability Waiver", "text": "I waive all liability...", "requireSignature": true, "createdAt": "2024-01-01T00:00:00.000Z" } ], "customerCategories": [ { "companyId": "uuid", "categoryId": "uuid", "name": "Residential", "parentCategoryId": null, "createdAt": "2024-01-01T00:00:00.000Z" } ], "programTemplates": [], "customerPrograms": [], "customerEquipment": [], "customerSubscriptions": [], "equipmentCatalog": [], "inventoryItems": [], "inventoryTrucks": [], "inventoryCategories": [], "clockInTypes": [], "programSavingsThresholdPercent": 50, "programVisitHorizonDays": 90, "subscription": { "status": "ACTIVE", "tradesmenTechPublicKey": "pub_xxxxxxxxxxxxxxxxxxxxxxxxxxxx", "freeTrialEndDate": "2026-05-01", "hasPaymentMethod": true, "paymentMethodLast4": "4242", "billingDayOfMonth": 15, "maxEmployees": 5, "lastPaymentDate": "2026-04-01T09:00:00.000Z", "lastPaymentAmount": 11500, "cancelAtPeriodEnd": false, "cancelledAt": null, "pricing": { "basePrice": 4500, "additionalUserPrice": 3000, "isCustomPricing": false } } } - Note: Each entry in
jobscarries adisplayId— a human-friendly, company-scoped sequential job number (an integer starting at1; see the Create Job endpoint injobs-endpoints.md). It is absent on jobs created before the feature was added. A job with attached videos also carries avideosarray (each{videoId, fileName, contentType, size, s3Key, uploadedAt}plusthumbnailS3Key,durationMs,width,heightwhen known — see "Job Videos" injobs-endpoints.md). The embeddedjobsdo not include their documents, so documentdisplayIds are not part of the login payload. - Error Responses:
400 Bad Request: Missing body, or missingemployeeEmail/password.401 Unauthorized: Invalid credentials (the message is intentionally generic and does not reveal whether the email exists).403 Forbidden: The employee exists but hashasAppAccess: false.429 Too Many Requests: The account is temporarily locked after 5 consecutive failed login attempts. The lockout lasts 15 minutes; aRetry-Afterheader gives the remaining seconds. A successful login resets the counter.500 Internal Server Error: Unexpected server error.
Verify MFA Code
Complete an MFA login by submitting the 6-digit code emailed to the employee (or texted, after a Resend MFA Code with method: "SMS"), together with the mfaToken from the Login endpoint's MFA challenge response. On success, the response is the full Login success payload (token, company data, etc. — identical shape to Login) plus two extra fields the device should persist:
- Method: POST
- Path:
/prod/accounts/login/mfa/verify - Request Body:
{ "mfaToken": "base64url_challenge_token", "code": "123456" } - Success Response:
200 OK— the Login success payload with these additional fields:
The client should store{ "mfaDeviceToken": "base64url_device_token", "mfaDeviceTokenExpiresAt": 1773565140 }mfaDeviceTokensecurely on the device and send it in future Login requests to skip MFA untilmfaDeviceTokenExpiresAt(epoch seconds, 30 days after verification). The trust window is fixed at verification time; it does not extend on later logins. Each device gets its own token, so trusting one device never shortens or revokes another's window. - Note: The code allows up to 5 attempts before the challenge is invalidated; the challenge is single-use and expires 10 minutes after issuance. After an invalidated or expired challenge, restart from the Login endpoint to get a new code.
- Error Responses:
400 Bad Request: Missing body, or missingmfaToken/code.401 Unauthorized: Invalid or expired challenge token, wrong code, or attempts exhausted (the message is intentionally generic and does not distinguish these cases).403 Forbidden: The employee'shasAppAccesswas set tofalseafter the challenge was issued.500 Internal Server Error: Unexpected server error.
Resend MFA Code
Re-deliver the verification code for a pending MFA challenge, by email again or by SMS instead (for a user who no longer has access to their inbox but still has their phone). A new code is generated each time — previous codes stop working — and the 10-minute expiry restarts. The same mfaToken remains valid for Verify MFA Code.
- Method: POST
- Path:
/prod/accounts/login/mfa/resend - Request Body:
{ "mfaToken": "base64url_challenge_token", "method": "SMS" }methodmust beEMAILorSMS. Only offerSMSwhen the Login challenge response includedphoneNumberMasked. - Success Response:
200 OK{ "deliveryMethod": "SMS", "mfaExpiresAt": 1770973740, "destinationMasked": "***-***-7890" } - Note: At most 3 re-sends are allowed per challenge (the initial email does not count); the 5-attempt guess limit is shared across all codes for the challenge and does not reset on re-send. After the cap, restart from the Login endpoint.
- Error Responses:
400 Bad Request: Missing body, missingmfaToken, invalidmethod, ormethod: "SMS"when the phone number on file cannot receive text messages (the emailed code stays valid in that case).401 Unauthorized: Invalid or expired challenge token.429 Too Many Requests: The re-send cap for this challenge is exhausted.500 Internal Server Error: Unexpected server error.
Validate Token
Check whether the caller's auth token is still valid. Useful for the front end to detect expired or invalidated sessions without performing a heavier authenticated request.
- Method: GET
- Path:
/prod/accounts/validate-token - Authentication: Required (Bearer Token).
- Request Body: None.
- Success Response:
200 OK{ "valid": true, "companyId": "uuid", "employeeId": "uuid" } - Error Responses:
401 Unauthorized: Missing, invalid, revoked, or expired token. The body includes the machine-readablereasonfield described under Authentication. The front end should treat any 401 as a signal to redirect the user to the login screen, and may usereasonto tell the user why (e.g.SUPERSEDED→ "signed in on another device").500 Internal Server Error: Unexpected server error.
Forgot Password
Request a password reset code. A 6-digit code is emailed to the employee's address if an account exists. The endpoint always returns 200 to avoid leaking whether an email is registered.
- Method: POST
- Path:
/prod/accounts/forgot-password - Request Body:
{ "employeeEmail": "john@company.com" } - Success Response:
200 OK
Note: The reset code expires after 60 minutes. Only one active reset code exists per employee at a time; requesting a new code invalidates any previous code. Addresses ending in{ "message": "If an account exists for that email, a password reset code has been sent." }@example.comare never emailed (the domain cannot receive mail); the endpoint still answers200. - Error Responses:
400 Bad Request: Missing body or missingemployeeEmail.500 Internal Server Error: Unexpected server error.
Reset Password
Complete a password reset using the 6-digit code emailed by the Forgot Password endpoint. On success, the employee's password is updated and any existing login sessions are invalidated.
- Method: POST
- Path:
/prod/accounts/reset-password - Request Body:
Note:{ "employeeEmail": "john@company.com", "code": "123456", "newPassword": "newSecurePassword123" }newPasswordmust be at least 8 characters. The code allows up to 5 attempts before it is invalidated. - Success Response:
200 OK{ "message": "Password reset successfully" } - Error Responses:
400 Bad Request: Missing body, missing fields,newPasswordtoo short, or invalid/expired reset code.500 Internal Server Error: Unexpected server error.
Change Password
Change the signed-in employee's password. The caller proves they know the current password; on success the new password is stored, every other session of the employee is revoked (those devices receive 401 with reason: "PASSWORD_CHANGED"), any pending password-reset code is invalidated, and a "your password was changed" notice is emailed to the employee's address (sent from the company brand's sender; best-effort — an email failure never fails the change; never sent to @example.com). The calling session stays valid, so no new token is issued.
- Method: POST
- Path:
/prod/accounts/change-password - Headers:
Authorization: Bearer <token> - Request Body:
Note: both fields are required strings.{ "currentPassword": "oldSecurePassword123", "newPassword": "newSecurePassword456" }newPasswordmust be at least 8 characters and different fromcurrentPassword. Checking the current password counts against the same per-email brute-force limit as Login (5 consecutive failures lock the email for 15 minutes, for both endpoints). - Success Response:
200 OK{ "message": "Password changed successfully", "otherSessionsSignedOut": 1 }otherSessionsSignedOutis the number of other sessions that were revoked. - Error Responses: 4xx bodies carry
messageplus a machine-readablecode. A wrong current password is deliberately not a401/403— clients treat those as "session ended".400 Bad Request,code:INVALID_REQUEST— missing body, orcurrentPassword/newPasswordmissing or not a string.PASSWORD_TOO_SHORT—newPasswordis shorter than 8 characters.SAME_PASSWORD—newPasswordequalscurrentPassword.INVALID_CURRENT_PASSWORD— the current password is wrong.IMPERSONATED_SESSION— the caller is a support session acting on the employee's behalf, which may not change the employee's password.
401 Unauthorized: Missing, invalid, revoked, or expired token (see Authentication).404 Not Found: The employee no longer exists.429 Too Many Requests,code: "TOO_MANY_ATTEMPTS": Too many failed attempts for this email; aRetry-Afterheader gives the seconds remaining.500 Internal Server Error: Unexpected server error.
Upload Logo
Upload a company logo as a base64 string.
- Method: POST
- Path:
/prod/accounts/companies/logo - Headers:
Authorization: Bearer <token> - Request Body:
{ "logoBase64": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", "contentType": "image/png" } - Success Response:
200 OK{ "message": "Logo uploaded successfully", "logoUrl": "https://s3.amazonaws.com/bucket/logo.png" } - Error Responses:
400 Bad Request: Missing body or missinglogoBase64.401 Unauthorized: Missing or invalid token.500 Internal Server Error: Unexpected server error.
Update Employee Profile
Update the authenticated employee's email or phone number.
- Method: PATCH
- Path:
/prod/accounts/employees/profile - Authentication: Required (Bearer Token)
- Request Body:
Note: At least one of{ "email": "new-email@example.com", "phoneNumber": "1234567890" }emailorphoneNumbermust be provided. When provided,phoneNumbermust be a valid US phone number (10 digits, or 11 digits starting with1) and is normalized to E.164 (e.g.+11234567890) before storage. - Success Response:
200 OK{ "message": "Profile updated successfully" } - Error Responses:
400 Bad Request: Missing body, invalid email format, invalid phone number, or no fields provided.401 Unauthorized: Missing or invalid token.409 Conflict: Email already in use by an existing company or employee.500 Internal Server Error: Unexpected server error.
Update Employee
Update an employee's details. Only ADMIN employees can update other employees.
- Method: PUT
- Path:
/prod/accounts/employees/{employeeId} - Authentication: Required (Bearer Token). Caller must be an ADMIN employee.
- Path Parameters:
employeeId(required): The ID of the employee to update.
- Request Body:
Note: All fields are optional. At least one field must be provided (an empty string counts as provided).{ "firstName": "Jane", "middleName": "A", "lastName": "Smith", "email": "jane@company.com", "phoneNumber": "5551234567", "employeeType": "LEAD_TECHNICIAN", "hasAppAccess": false, "hourlyRate": 2550, "clockInTypeRates": { "clock-in-type-uuid-work": 2550, "clock-in-type-uuid-travel": 1200 } }firstNamemay be changed but never blanked — it is the only field every employee needs.middleName,lastNameandphoneNumbercan be cleared with an empty string ornull(stored asnull; names are trimmed).employeeTypemust be one of:ADMIN,LEAD_TECHNICIAN,TECHNICIAN,CREW_TECHNICIAN. WhenphoneNumberis non-blank it must be a valid US phone number (10 digits, or 11 digits starting with1) and is normalized to E.164 (e.g.+15551234567) before storage. An empty-string ornullemailremoves the stored address, but only for an employee without app access (or one the same request setshasAppAccess: falseon) — an employee who can log in signs in with it, so blanking it answers400(email is required for an employee with app access). Email uniqueness is enforced across employees and companies.hasAppAccess(boolean) toggles whether the employee can log in and is counted as a billable per-seat user; setting it tofalserevokes app access and removes them from per-seat billing. Note that flipping it back totruedoes not by itself create login credentials — the employee must have (or set, via the forgot/reset-password flow) an email and password to actually sign in.hourlyRateis the employee's pay rate as a non-negative integer number of cents per hour (e.g.2550= $25.50/hr); set it tonullto clear the rate.clockInTypeRates(the per-clock-in-type rates described under Create Employee) replaces the whole map — a type left out of the object loses its rate — andnullclears it. - Success Response:
200 OK{ "message": "Employee updated successfully", "employeeId": "uuid" } - Error Responses:
400 Bad Request: Missing body, missing employeeId, no valid fields provided, blankfirstName, invalid email format, blankemailon an employee with app access, invalid phone number, invalid employeeType, non-booleanhasAppAccess, invalidhourlyRate(must be a non-negative integer ornull), or invalidclockInTypeRates(must be an object of non-negative integer cents per clock-in type id, ornull).401 Unauthorized: Missing or invalid token.403 Forbidden: Caller is not an ADMIN employee.404 Not Found: Target employee not found in the caller's company.409 Conflict: Email already in use by an existing company or employee.500 Internal Server Error: Unexpected server error.
List Employees
Returns all employees for the authenticated user's company, along with the company's max employee limit.
- Method: GET
- Path:
/prod/accounts/employees - Authentication: Required (Bearer Token).
- Success Response:
200 OK{ "employees": [ { "companyId": "uuid", "employeeId": "uuid", "firstName": "John", "middleName": "D", "lastName": "Doe", "email": "john@company.com", "phoneNumber": "0987654321", "employeeType": "ADMIN", "hasAppAccess": true, "hourlyRate": 2550, "clockInTypeRates": { "clock-in-type-uuid-work": 2550, "clock-in-type-uuid-travel": 1200 }, "description": "Expert plumber with 10 years of experience.", "photoUrl": "https://s3.amazonaws.com/bucket/employee-photos/companyId/employeeId/photo.png" } ], "maxEmployees": 5 }
| Field | Type | Description |
|---|---|---|
| employees | array | List of employee objects for the company |
| maxEmployees | number | Maximum number of employees allowed for the company (defaults to 1) |
Note: hourlyRate is the employee's pay rate in cents per hour (e.g. 2550 = $25.50/hr), or null when no rate is set, and clockInTypeRates is their rate per clock-in type ({} when none are set; see timesheets-endpoints.md). Both are compensation data: ADMIN callers see every employee's values; non-ADMIN callers see only their own (both are null for everyone else regardless of what is set). The same visibility rule applies to the employees array in the Login response.
- Error Responses:
401 Unauthorized: Missing or invalid token.500 Internal Server Error: Unexpected server error.
Delete Employee
Delete an employee from the authenticated admin's company.
- Method: DELETE
- Path:
/prod/accounts/employees/{employeeId} - Authentication: Required (Bearer Token). Caller must be an ADMIN employee.
- Path Parameters:
employeeId(required): The ID of the employee to delete.
- Notes: The caller cannot delete themselves.
- Success Response:
200 OK{ "message": "Employee deleted successfully", "employeeId": "uuid" } - Error Responses:
400 Bad Request: MissingemployeeIdin path.401 Unauthorized: Missing or invalid token.403 Forbidden: Caller is not an ADMIN employee, or caller attempted to delete themselves.404 Not Found: Employee not found in the caller's company.500 Internal Server Error: Unexpected server error.
Update Company
Update the authenticated admin's company details.
Method: PATCH
Path:
/prod/accounts/companiesAuthentication: Required (Bearer Token). Caller must be an ADMIN employee.
Request Body:
{ "name": "Updated Company Name", "email": "new-email@company.com", "phoneNumber": "5551234567", "address": { "streetAddress": "789 New St", "city": "New City", "state": "NC", "zipCode": "99999" }, "timeZone": "America/Chicago", "additionalCharges": [ { "type": "TAX", "label": "Sales Tax", "percentage": 8.25 }, { "id": "existing-charge-id", "type": "CUSTOM", "label": "Disposal Fee", "percentage": 2 }, { "type": "CONVENIENCE", "label": "Card Processing Fee", "percentage": 3, "appliesTo": "PAYMENT", "waivedForAch": true } ], "tipPercentages": [10, 15, 20] }Note: All fields are optional, but at least one must be provided (unknown fields are ignored; a body with no allowed field answers
400with theallowedFieldslist in the body). Allowed fields:name,email,phoneNumber,address,timeZone,additionalCharges,tipPercentages,programSavingsThresholdPercent,programVisitHorizonDays. String fields are trimmed;emailis also lowercased before the uniqueness check and storage. When providingaddress, all subfields (streetAddress,city,state,zipCode) are required.additionalChargesreplaces the company's full charge list. Each entry requirestype(one ofTAX,CONVENIENCE,CUSTOM), a non-emptylabel(max 100 chars), and apercentagein(0, 100]. Anidis optional — supply it to preserve an existing charge's id, otherwise one is generated.appliesTo(optional, one ofINVOICE,PAYMENT; defaultINVOICE) says when the charge is applied:INVOICEcharges are snapshotted onto every new invoice (see Create Document injobs-endpoints.md); aPAYMENTcharge is a payment-time fee — it never appears on the invoice itself and is added only when the customer pays electronically (a keyed or tapped card, a saved payment method, the emailed document page, or "Pay over time"), as a percentage of the amount being paid (before any tip).waivedForAch(optional boolean, stored onPAYMENTcharges only, defaultfalse) skips that fee when the payment is made from a bank account (ACH). A cancelled or declined payment leaves no fee behind; once a payment with the fee is approved, the fee is recorded on the paid document (paymentFeeAmount/paymentFees, seepayments-endpoints.md) and stays on it. The stored list always carriesappliesTo(andwaivedForAchforPAYMENTentries), so the login response and company reads return them.tipPercentagesreplaces the company's tip options; each entry must be a number in(0, 100]. Send[]to clear either list.programSavingsThresholdPercentis a number in[0, 100]— the percent spread required before the maintenance-program DIY-savings line is shown (defaults to 50 when never set; seeprograms-endpoints.md).programVisitHorizonDaysis an integer in[1, 730]— how many days ahead of today maintenance-program visit jobs are kept materialized (defaults to 90 when never set; shrinking it does not delete already-created visit jobs).Success Response:
200 OK{ "message": "Company updated successfully" }Error Responses:
400 Bad Request: Missing body, no valid fields provided, a string field that is not a string, invalid email format, anaddressmissing a subfield, an invalidadditionalChargesortipPercentagesentry,programSavingsThresholdPercentoutside[0, 100], orprogramVisitHorizonDaysnot an integer in[1, 730].401 Unauthorized: Missing or invalid token.403 Forbidden: Caller is not an ADMIN employee.409 Conflict: Email already in use by an existing company or employee.500 Internal Server Error: Unexpected server error.
Delete Company
Schedule deletion of the authenticated admin's company and all associated business data. This is the back-end of the in-app Delete Account flow required by the App Store.
Deletion is not immediate: the company is placed on a 7-day hold. The response includes deletionScheduledFor (the UTC timestamp after which the purge runs). During the hold the account remains fully usable — employees stay signed in and all data is intact — so the deletion can be cancelled (see Cancel Company Deletion). Once the hold elapses, a scheduled job permanently deletes every record scoped to the companyId across the company's tables (employees, services, customers, jobs, documents, payments, calendar connections and event mappings, pipelines, custom fields/texts/documents, email campaigns, customer categories, employee profiles, equipment, customer subscriptions, inventory items and movements, merchant accounts, customer vault, review links, reminder configs, SaaS subscription, free trial, and the Stripe account record), removing the company record itself last.
Calling this endpoint again while a deletion is already pending is a no-op: it returns the existing deletionScheduledFor without extending the hold.
When a deletion is newly scheduled in production, an internal notification email is sent to the Tradesmen support inbox naming the company and the employee who requested it. A second internal email is sent to the same inbox when the scheduled purge actually runs (subject Company permanently deleted: {name}), or if the purge fails and will be retried on the next run (subject Company purge FAILED (will retry): {name}). These are server-side only and do not affect the API response.
- Method: DELETE
- Path:
/prod/accounts/companies - Authentication: Required (Bearer Token). Caller must be an ADMIN employee.
- Request Body: None
- Success Response:
200 OK
When a deletion is already pending, the message is{ "message": "Company deletion scheduled. The account remains active until the hold period ends, after which all data is permanently deleted. Cancel any time before then to keep the account.", "companyId": "uuid", "deletionScheduledFor": "2026-06-11T12:00:00.000Z" }"Company deletion is already scheduled"anddeletionScheduledForreflects the original schedule. - Error Responses:
401 Unauthorized: Missing or invalid token.403 Forbidden: Caller is not an ADMIN employee.404 Not Found: Company not found.500 Internal Server Error: Unexpected server error.
Cancel Company Deletion
Cancel a pending company-deletion hold scheduled via Delete Company. Must be called before the hold period elapses; once the purge runs, the company and its data no longer exist.
- Method: POST
- Path:
/prod/accounts/companies/cancel-deletion - Authentication: Required (Bearer Token). Caller must be an ADMIN employee.
- Request Body: None
- Success Response:
200 OK{ "message": "Company deletion cancelled", "companyId": "uuid" } - Error Responses:
401 Unauthorized: Missing or invalid token.403 Forbidden: Caller is not an ADMIN employee.404 Not Found: Company not found.409 Conflict: No pending deletion to cancel.500 Internal Server Error: Unexpected server error.
Update Feature Flags
Update feature flags for the authenticated employee's company or their own account.
- Method: PUT
- Path:
/prod/accounts/feature-flags/{tier} - Authentication: Required (Bearer Token). For
companytier, caller must be an ADMIN employee. Foraccounttier, any authenticated employee can update their own flags. - Path Parameters:
tier(required): The tier to update. Must be one of:company,account.
- Request Body:
Note:{ "flags": { "darkMode": true, "betaFeature": false } }flagsmust be an object. Its contents replace any existing flags for the target. - Success Response:
200 OK{ "message": "Feature flags updated successfully", "targetId": "COMPANY#uuid" } - Error Responses:
400 Bad Request: Invalid tier, missing body, or missing/invalidflagsobject.401 Unauthorized: Missing or invalid token.403 Forbidden: Caller is not an ADMIN employee (company tier only).500 Internal Server Error: Unexpected server error.
Update Employee Page
Set or update an employee's public-facing profile page (description and/or photo). This data is displayed to customers.
- Method: PUT
- Path:
/prod/accounts/employees/{employeeId}/page - Authentication: Required (Bearer Token). Caller must be an ADMIN employee.
- Path Parameters:
employeeId(required): The ID of the employee whose page to update.
- Request Body:
Note: At least one of{ "description": "Expert plumber with 10 years of experience in residential and commercial plumbing.", "photoBase64": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", "contentType": "image/png" }descriptionorphotoBase64must be provided. Both are optional individually — omitted fields are preserved from the existing profile. Setdescriptiontonullto clear it.contentTypeis optional and defaults toimage/pngif not provided. - Success Response:
200 OK
Note:{ "message": "Employee page updated successfully", "photoUrl": "https://s3.amazonaws.com/bucket/employee-photos/companyId/employeeId/photo.png" }photoUrlisnullif no photo has been uploaded. - Error Responses:
400 Bad Request: Missing body, missing employeeId, neither description nor photo provided, ordescriptionis neither a string nornull.401 Unauthorized: Missing or invalid token.403 Forbidden: Caller is not an ADMIN employee.404 Not Found: Target employee not found in the caller's company.500 Internal Server Error: Unexpected server error.
Update Max Employees
Update the maximum number of employees allowed for the company.
- Method: PATCH
- Path:
/prod/accounts/companies/max-employees - Authentication: Required (Bearer Token). Caller must be an ADMIN employee.
- Request Body:
Note:{ "maxEmployees": 10 }maxEmployeesmust be a positive integer. - Success Response:
200 OK{ "message": "Max employees updated successfully", "maxEmployees": 10 } - Error Responses:
400 Bad Request: Missing body, missingmaxEmployees, or invalid value (must be a positive integer).401 Unauthorized: Missing or invalid token.403 Forbidden: Caller is not an ADMIN employee.500 Internal Server Error: Unexpected server error.