Skip to main content

API & Webhooks

Everything in your account — customers, requests, jobs, estimates, invoices, payments — over a plain REST API, and a signed webhook for every state change. Built for Zapier, Make, a website form builder, or software your own developer writes.

Machine-readable: OpenAPI 3.1. This page and that document are generated from the same source.

Zapier / Make quick-start

Trigger a Zap when something happens here

  1. In Zapier, start a Zap with Webhooks by Zapier → Catch Hook and copy the hook URL. In Make, use Webhooks → Custom webhook.
  2. In TradesBackbone open Settings → Integrations → Webhooks, add an endpoint, paste the URL, and tick the events you want (or none, to receive everything).
  3. Copy the signing secret shown once. Verify the x-tb-signature header with it (snippet below) — or skip verification for a private hook URL you never share.
  4. Create a test job, estimate or invoice; the sample payload for every event is listed under Webhooks.

Push data into TradesBackbone from a Zap

  1. Open Settings → API keys and create a key (Elite plan). Choose read & write. Copy it — it is shown once.
  2. Add a Webhooks by Zapier → Custom Request step (Make: HTTP → Make a request).
  3. Method POST, URL https://tradesbackbone.com/api/v1/requests (or any endpoint below), header Authorization: Bearer YOUR_KEY, header Content-Type: application/json.
  4. Body: JSON with the fields in the endpoint's table — e.g. a lead from your website becomes a work request that rings the office.
curl -X POST https://tradesbackbone.com/api/v1/requests \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"name":"Dana Reyes","phone":"801-555-0142","title":"Water heater leaking","source":"website"}'

Authentication

Every request carries Authorization: Bearer ms_tradesbackbone_…. Keys are created by the account owner at Settings → API keys and are included on the Elite plan; a key from a plan without API access answers 403 upgrade_required on every call, and a revoked key answers 401.

Scopes. A key is minted read or read & write. GET needs read; POST and PATCH need write. A read-only key on a write answers 403 insufficient_scope.

Tenant. A key belongs to one business and every call is scoped to it. A record that belongs to another business answers 404 — never 403, so an id cannot be probed.

Rate limit. 600 requests per minute per key. Past that: 429 rate_limited with Retry-After. Every keyed response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset.

Conventions

  • Lists return { data: [...], nextCursor }. Pass ?cursor= back to get the next page; limit is 1–200 (default 50). Most lists take ?updatedSince=<ISO-8601>, which is how a polling trigger asks for “what changed since my last run”.
  • Single records return { data: {...} }; creates answer 201.
  • Errors are { error: "human sentence", code: "machine_code" } — validation_error, not_found, insufficient_scope, upgrade_required, rate_limited, checklist_incomplete.
  • Money is in dollars with cents as decimals: 189.5 is $189.50. Nothing is in cents.
  • Dates are ISO-8601 in UTC. A date without a time (2026-10-02) is accepted for job scheduling.
  • Ids are opaque strings; do not parse them.

Endpoints

Base URL https://tradesbackbone.com. $KEY in the examples is your API key.

business

GET/api/v1/business— Billing settings

What an integration needs to price a document the way TradesBackbone will: `defaultTaxRate`, `laborPercent` (null = off), `defaultPaymentTermsDays` (+ `defaultPaymentTerms` label), `nextInvoiceNumber`, `nextEstimateNumber`; plus the profile a business check-up reads: `verticalPackId`, `businessHours` (as stored), `address` (one line), `serviceArea` (as stored).

Returns { data: { id, name, defaultTaxRate, laborPercent, defaultPaymentTermsDays, defaultPaymentTerms, nextInvoiceNumber, nextEstimateNumber, verticalPackId, businessHours, address, serviceArea } }

curl -H "Authorization: Bearer $KEY" https://tradesbackbone.com/api/v1/business

items

GET/api/v1/items— List price-book items

The Item Library. Put an item's `id` on a line as `itemLibraryId` and its `name` as `itemName` so item reports and job costing see the line. `laborPercent` is the item's own labor percent (100 = all labor, 0 = all materials, null = the business percent). Cost basis is never returned.

Query

limitintegerPage size, 1–200. Default 50.
cursorstringThe `nextCursor` from the previous page.
updatedSinceISO-8601Only records changed at or after this time — the polling-trigger primitive.
qstringName match with case, punctuation and a possessive or plural `s` ignored (also sku and description). Rows carry `match`: `exact` or `partial`, exact first; one page, no cursor. Several matches or only partials: pick none rather than guess.

Returns { data: Item[], nextCursor }

curl -H "Authorization: Bearer $KEY" "https://tradesbackbone.com/api/v1/items?q=gate"

Customers

GET/api/v1/customers— List customers

Newest first. Use `email` or `phone` for a Zapier-style "find customer" step — both are exact matches (phone compares the last 10 digits).

Query

limitintegerPage size, 1–200. Default 50.
cursorstringThe `nextCursor` from the previous page.
updatedSinceISO-8601Only records changed at or after this time — the polling-trigger primitive.
emailstringExact email, case-insensitive.
phonestringPhone number; digits only are compared.
qstringName search over business, first and last name: case-insensitive, with apostrophes, punctuation, spacing and a possessive or plural `s` ignored ("Wexler's" finds "Wexlers LLC" and "Wexler Fabrication"). Each row then carries `match`: `exact` (the whole name, ignoring a company suffix like LLC) or `partial`; exact rows come first. Returns candidates; picking one is the caller's call.
externalSource / externalIdstringFind the record a key made.
needsReviewtrue | falseOnly records waiting on (or cleared by) a person.

Returns { data: Customer[], nextCursor }

curl -H "Authorization: Bearer $KEY" "https://tradesbackbone.com/api/v1/customers?email=dana@example.com"
POST/api/v1/customers— Create a customer

An individual (firstName required) or a business (customerType BUSINESS, businessName required). `smsConsent: true` records that the customer agreed to texts; without it the record is opted out of SMS. "Same as" merges: when the office merges a needsReview customer draft into an existing customer on To check, the draft's `externalSource` + `externalId` keep working and mean that existing customer (a re-send updates or proposes a change on it, never a new draft); a job, estimate or invoice sent with the merged-away `customerId` lands on the existing customer.

Body (JSON)

customerTypeINDIVIDUAL | BUSINESSDefault INDIVIDUAL.
firstNamestringRequired for an individual.
lastNamestring
businessNamestringRequired for a business.
emailstring
phonestring
addressstring
citystring
statestring
zipstring
notesstring
tagsstring[]Up to 100.
smsConsentbooleanThe business confirmed the customer agreed to texts.
paymentTermsDaysinteger 0–365 | nullPayment terms: 0 = due on receipt, 30 = Net 30. A new invoice with no dueDate is due on its date plus these days.
skipWarmingboolean`true` for a migration: no new-lead welcome. A customer sent with `needsReview: true` is never warmed.
externalSourcestringYour system's name, 1-40 of letters, digits, _ . : - (e.g. `voice`). Send with `externalId`; together they make the call an upsert.
externalIdstringYour stable id for THIS record (up to 200 chars). Same key again updates the record it made, with only the fields you send; it never creates a second one.
needsReviewboolean`true` lands the record flagged on the To check list (/dashboard/review) until a person clicks Looks right. The API can set it, never clear it. Any write with `externalSource` is always flagged, whatever this says. A keyed re-send onto a record a person has already checked changes nothing: it answers 202 `upsert: "change_proposed"` with a `changeId`, and the change waits on To check for someone to apply or dismiss.
sourceQuotestringThe words the record came from (up to 2000 chars). Kept on the record for the office, appended and de-duplicated across calls; never shown to the customer.
mycelReview{ approvalId, approvedAt, approvedByEmail? }Martello fleet only (service token + X-Martello-Actor; an API key is ignored). The person who approved this write in Mycel Approvals counts as the To check review: the record is cleared in the same request when the signed actor is a Mycel owner/admin whose email is ONE active member of this business holding the record's write permission. The response then carries `review: "reviewed_in_mycel"`; otherwise `review: "to_check"` (or `"already_reviewed"`) and `reviewDetail.reason`. Audited as REVIEWED via mycel-approvals.

Returns 201 { data: Customer, upsert? } | 200 { data: Customer, upsert: "updated" }

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d '{"firstName":"Dana","lastName":"Reyes","email":"dana@example.com","phone":"801-555-0142"}' https://tradesbackbone.com/api/v1/customers
GET/api/v1/customers/{id}— Get a customer

One record. A customer in another business answers 404. A customer draft the office merged into another one answers 404 `customer_merged` with `mergedInto` (the id it became).

Returns { data: Customer }

curl -H "Authorization: Bearer $KEY" https://tradesbackbone.com/api/v1/customers/CUSTOMER_ID
PATCH/api/v1/customers/{id}— Update a customer

Send only the fields to change. Emits `customer.updated`.

Body (JSON)

firstName / lastName / businessNamestring
email / phone / address / city / state / zip / notesstring | null
tagsstring[]Replaces the tag list.
taxExemptboolean

Returns { data: Customer }

curl -X PATCH -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d '{"phone":"801-555-0199"}' https://tradesbackbone.com/api/v1/customers/CUSTOMER_ID
DELETE/api/v1/customers/{id}— Delete a customer

Removes the customer and their automated-message history. Answers 204 whether or not the id existed in your business — never a 404 that would reveal another business's ids. Emits `customer.deleted` when a row was removed.

Returns 204, no body

curl -X DELETE -H "Authorization: Bearer $KEY" https://tradesbackbone.com/api/v1/customers/CUSTOMER_ID
GET/api/v1/customers/{id}/documents— A customer's drafts and open invoices, live

Unsent drafts (invoices and estimates, `status: "unsent"`) and open invoices (`status: "sent"`, not yet paid) for one customer, each with its due date. An unsent invoice's `dueIfSentToday` is the date it would get if sent now: payment terms count from the send day; a date someone picked is kept. Also the settings an action would change: tax exemption, payment terms, labor percent. Read-only.

Returns { data: { customer: { id, name, taxExempt, exemptionNumber, paymentTermsDays, effectiveTermsDays, effectiveTerms }, laborPercent, unsentDrafts: { kind, id, number, label, status, rawStatus, total, taxAmount, dueDate, dueDateFromTerms, dueIfSentToday, laborPercentLinesToSplit, needsReview, createdAt, href }[], openInvoices: { kind, id, number, label, status, rawStatus, total, amountPaid, balance, dueDate, daysOverdue, sentAt, href }[], asOf } }

curl -H "Authorization: Bearer $KEY" https://tradesbackbone.com/api/v1/customers/CUSTOMER_ID/documents
GET/api/v1/customers/{id}/tax-exempt— A customer's sales-tax exemption

Whether the customer is tax-exempt, the exemption number, and (when exempt) the unsent drafts that still carry tax.

Returns { data: { taxExempt, exemptionNumber, draftsStillTaxed: { kind, id, number, status, taxAmount, total, label }[] } }

curl -H "Authorization: Bearer $KEY" https://tradesbackbone.com/api/v1/customers/CUSTOMER_ID/tax-exempt
POST/api/v1/customers/{id}/tax-exempt— Mark tax-exempt and remove tax from unsent drafts

The customer record's Mark tax-exempt / Charge tax again switch and its Remove tax from these drafts button. Sent, viewed and paid documents are never changed. `removeTaxFromDrafts` on a customer who is not exempt is 409 `not_exempt`.

Body (JSON)

taxExemptbooleanSet the switch. Send this, `removeTaxFromDrafts: true`, or both.
exemptionNumberstringUp to 100 characters; empty clears it.
removeTaxFromDraftsbooleanRe-price the customer's unsent drafts at 0% tax.

Returns 200 { data: { taxExempt, exemptionNumber, draftsStillTaxed[], changed: { kind, id, number, total, newTotal, label }[] } } | 403 actor_not_allowed { reason }

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -H "Idempotency-Key: APPROVAL_ID" -d '{"taxExempt":true,"removeTaxFromDrafts":true}' https://tradesbackbone.com/api/v1/customers/CUSTOMER_ID/tax-exempt
POST/api/v1/customers/{id}/payment-terms— Set a customer's payment terms

Days until an invoice is due, counted from the day it is sent (0 = due on receipt). `null` = use the business default. Sent invoices never change.

Body (JSON)

paymentTermsDays*integer 0-365 | null

Returns 200 { data: { paymentTermsDays, paymentTerms, effectiveTermsDays, effectiveTerms, fromBusinessDefault } }

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d '{"paymentTermsDays":30}' https://tradesbackbone.com/api/v1/customers/CUSTOMER_ID/payment-terms

Work requests

GET/api/v1/requests— List work requests

What customers asked for before it became a job or a quote.

Query

limitintegerPage size, 1–200. Default 50.
cursorstringThe `nextCursor` from the previous page.
updatedSinceISO-8601Only records changed at or after this time — the polling-trigger primitive.
statusNEW | CONTACTED | ASSESSMENT_SCHEDULED | CONVERTED | DECLINED
customerIdstring

Returns { data: WorkRequest[], nextCursor }

curl -H "Authorization: Bearer $KEY" "https://tradesbackbone.com/api/v1/requests?status=NEW"
POST/api/v1/requests— Create a work request

The intake for a lead from a website form builder, an ads platform or a lead service. Finds or creates the customer by phone/email, then emits `request.created`. Needs a name, a title, and a phone or an email.

Body (JSON)

name*stringThe person asking.
title*stringWhat they need done.
phonestringPhone or email is required.
emailstring
detailsstring
preferredTimesstring
address / city / state / zipstring
customerIdstringAttach to an existing customer instead of matching by contact.
sourcestringWhere the lead came from. Default "api".

Returns 201 { data: WorkRequest }

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d '{"name":"Dana Reyes","phone":"801-555-0142","title":"Water heater leaking","source":"website"}' https://tradesbackbone.com/api/v1/requests
GET/api/v1/requests/{id}— Get a work request

Returns { data: WorkRequest }

curl -H "Authorization: Bearer $KEY" https://tradesbackbone.com/api/v1/requests/REQUEST_ID

Jobs

GET/api/v1/jobs— List jobs

Each row also carries `assignedToEmail` (the assignee's email, null when unassigned), `lastActivityAt`, `missedWindow`, `customerName`, `daysSinceCompleted` (null until completed) and `approvedEstimateId` / `approvedEstimateTotal` (the won estimate behind the job, null when none).

Query

limitintegerPage size, 1–200. Default 50.
cursorstringThe `nextCursor` from the previous page.
updatedSinceISO-8601Only records changed at or after this time — the polling-trigger primitive.
statusSCHEDULED | IN_PROGRESS | COMPLETE | INVOICED | CANCELLED
uninvoicedtrueDone jobs (COMPLETE) with no invoice other than a canceled one: money not yet asked for. Overrides `status`.
recurringtrueRecurring series only: a series head (isRecurring) or one of its visits, newest first.
customerIdstring
externalSource / externalIdstringFind the record a key made.
needsReviewtrue | falseOnly records waiting on (or cleared by) a person.

Returns { data: Job[], nextCursor }

curl -H "Authorization: Bearer $KEY" "https://tradesbackbone.com/api/v1/jobs?status=SCHEDULED&limit=20"
POST/api/v1/jobs— Create or upsert a job

Books work for an existing customer. Emits `job.created` (and `job.scheduled` when a date is given). With `externalSource` + `externalId` it is an idempotent upsert keyed on the JOB (never the customer: one customer can have many open jobs); a key that holds a job for a different customer is 409 `customer_mismatch`, so jobs are never merged or moved. A job flagged `needsReview` sends nothing to the customer (no reminder; a completion is held, with its review request) until a person checks it.

Body (JSON)

customerId*string
title*string
descriptionstring
scheduledDateISO-8601
scheduledStartTime / scheduledEndTimeHH:MM
address / city / state / zipstringJob site, if not the customer's address.
assignedToIdstringA team member id.
statusJobStatusDefault SCHEDULED.
estimateIdstringAn APPROVED estimate of yours (409 `estimate_not_approved` for a bid). The estimate is linked back to the job. Prefer POST /api/v1/estimates/[id]/job.
notesstring
externalSourcestringYour system's name, 1-40 of letters, digits, _ . : - (e.g. `voice`). Send with `externalId`; together they make the call an upsert.
externalIdstringYour stable id for THIS record (up to 200 chars). Same key again updates the record it made, with only the fields you send; it never creates a second one.
needsReviewboolean`true` lands the record flagged on the To check list (/dashboard/review) until a person clicks Looks right. The API can set it, never clear it. Any write with `externalSource` is always flagged, whatever this says. A keyed re-send onto a record a person has already checked changes nothing: it answers 202 `upsert: "change_proposed"` with a `changeId`, and the change waits on To check for someone to apply or dismiss.
sourceQuotestringThe words the record came from (up to 2000 chars). Kept on the record for the office, appended and de-duplicated across calls; never shown to the customer.
mycelReview{ approvalId, approvedAt, approvedByEmail? }Martello fleet only (service token + X-Martello-Actor; an API key is ignored). The person who approved this write in Mycel Approvals counts as the To check review: the record is cleared in the same request when the signed actor is a Mycel owner/admin whose email is ONE active member of this business holding the record's write permission. The response then carries `review: "reviewed_in_mycel"`; otherwise `review: "to_check"` (or `"already_reviewed"`) and `reviewDetail.reason`. Audited as REVIEWED via mycel-approvals.

Returns 201 { data: Job, upsert? } | 200 { data: Job, upsert: "updated" }

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d '{"customerId":"CUSTOMER_ID","title":"Furnace tune-up","scheduledDate":"2026-10-02"}' https://tradesbackbone.com/api/v1/jobs
GET/api/v1/jobs/{id}— Get a job

With the customer and the assignee.

Returns { data: Job }

curl -H "Authorization: Bearer $KEY" https://tradesbackbone.com/api/v1/jobs/JOB_ID
PATCH/api/v1/jobs/{id}— Update a job

Change the status, date, assignee, title or notes. Marking a job COMPLETE runs the same checklist gate as the field app (409 `checklist_incomplete` if required items are open) and emits `job.status_changed` + `job.completed`.

Body (JSON)

statusJobStatus
scheduledDateISO-8601Emits `job.scheduled` when it changes.
scheduledStartTime / scheduledEndTimeHH:MM
assignedToIdstring | ""Empty string unassigns.
title / description / notesstring
address / city / state / zipstring
needsReview / sourceQuotetrue / stringFlag for review and add the words the change came from (see POST).

Returns { data: Job }

curl -X PATCH -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d '{"status":"COMPLETE"}' https://tradesbackbone.com/api/v1/jobs/JOB_ID
DELETE/api/v1/jobs/{id}— Delete a job

Answers 204 whether or not the id existed in your business. Emits `job.deleted` when a row was removed.

Returns 204, no body

curl -X DELETE -H "Authorization: Bearer $KEY" https://tradesbackbone.com/api/v1/jobs/JOB_ID

Estimates

GET/api/v1/estimates— List estimates

With line items.

Query

limitintegerPage size, 1–200. Default 50.
cursorstringThe `nextCursor` from the previous page.
updatedSinceISO-8601Only records changed at or after this time — the polling-trigger primitive.
statusDRAFT | SENT | APPROVED | DECLINED | EXPIRED
customerIdstring
staleDaysinteger 1-3650Only SENT estimates the customer has not answered, sent more than this many days ago, oldest first. Every row carries `sentAt`.
externalSource / externalIdstringFind the record a key made.
needsReviewtrue | falseOnly records waiting on (or cleared by) a person.

Returns { data: (Estimate & { customerName })[], nextCursor }

curl -H "Authorization: Bearer $KEY" "https://tradesbackbone.com/api/v1/estimates?staleDays=14"
POST/api/v1/estimates— Create or upsert an estimate

A DRAFT with one or more line items: a price, never a charge. Sending it to the customer is done from the dashboard. With `externalSource` + `externalId` it is an idempotent upsert; a re-send updates only a DRAFT (409 `not_draft` once sent, 409 `customer_mismatch` for another customer) and `lineItems`, when sent, replace the lines and re-total.

Body (JSON)

customerId*string
jobIdstring
lineItems*{ description, quantity, unitPrice, itemLibraryId?, category?, taxable?, split?, optional?, lineKey?, parentKey?, parentLineId?, expand?, mode?, visits? }[]unitPrice in dollars. Tax falls on taxable lines only. The labor percent split applies exactly as on POST /api/v1/invoices. Grouping (products in services): a line may carry `lineKey`, and a product line `parentKey` naming an earlier line's key (or `parentLineId` = a stored line id when re-sending lines you read back); the stored line returns `parentLineId`, the id of its service line. `expand: true` on a line that names a service/bundle `itemLibraryId` (or `expand: true` on the body for every such line) adds it the way the office does: the service line plus the products it includes, per the shop default, the service setting, or the line's `mode` (`EXPANDED` | `ROLLED_UP`); `visits` overrides the visit count, `quantity` multiplies the service and its products, and an expanded line needs no description or price.
taxRatenumber 0–1Defaults to the business rate.
validUntilISO-8601
notesstring
externalSourcestringYour system's name, 1-40 of letters, digits, _ . : - (e.g. `voice`). Send with `externalId`; together they make the call an upsert.
externalIdstringYour stable id for THIS record (up to 200 chars). Same key again updates the record it made, with only the fields you send; it never creates a second one.
needsReviewboolean`true` lands the record flagged on the To check list (/dashboard/review) until a person clicks Looks right. The API can set it, never clear it. Any write with `externalSource` is always flagged, whatever this says. A keyed re-send onto a record a person has already checked changes nothing: it answers 202 `upsert: "change_proposed"` with a `changeId`, and the change waits on To check for someone to apply or dismiss.
sourceQuotestringThe words the record came from (up to 2000 chars). Kept on the record for the office, appended and de-duplicated across calls; never shown to the customer.
mycelReview{ approvalId, approvedAt, approvedByEmail? }Martello fleet only (service token + X-Martello-Actor; an API key is ignored). The person who approved this write in Mycel Approvals counts as the To check review: the record is cleared in the same request when the signed actor is a Mycel owner/admin whose email is ONE active member of this business holding the record's write permission. The response then carries `review: "reviewed_in_mycel"`; otherwise `review: "to_check"` (or `"already_reviewed"`) and `reviewDetail.reason`. Audited as REVIEWED via mycel-approvals.

Returns 201 { data: Estimate, upsert? } | 200 { data: Estimate, upsert: "updated" }

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d '{"customerId":"CUSTOMER_ID","lineItems":[{"description":"Annual service","quantity":1,"unitPrice":189}]}' https://tradesbackbone.com/api/v1/estimates
GET/api/v1/estimates/{id}— Get an estimate

With line items and pricing options.

Returns { data: Estimate }

curl -H "Authorization: Bearer $KEY" https://tradesbackbone.com/api/v1/estimates/ESTIMATE_ID
PATCH/api/v1/estimates/{id}— Update an estimate

Record what the customer said (approved, declined) or that the bid lapsed (expired), or change notes / validity. DRAFT or SENT may move; APPROVED cannot be declined or expired (409 `estimate_approved`) and DECLINED / EXPIRED are closed (409 `estimate_closed`). The same status again is a no-op (`changed: false`). An approval recorded here is marked `approvalRecordedBy` (`api_key`, or `mycel:<person>`) with no approver IP: the customer's own signature only comes from their approval link. Approving opens no job and raises no deposit invoice; queued quote follow-ups are canceled. On the service-token path a person (X-Martello-Actor) is required (403 `actor_required`).

Body (JSON)

status"APPROVED" | "DECLINED" | "EXPIRED"
approverNamestringAPPROVED only: who said yes, as you know it ("Pat, by phone").
selectedOptionIdstringAPPROVED only, required for a Good/Better/Best estimate (400 `option_required`).
notesstring
validUntilISO-8601 | null

Returns { data: Estimate, changed: boolean, jobId: string | null }

curl -X PATCH -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d '{"status":"DECLINED"}' https://tradesbackbone.com/api/v1/estimates/ESTIMATE_ID
POST/api/v1/estimates/{id}/job— Create the job for an approved estimate

A won bid becomes ONE job: SCHEDULED, for the estimate's customer and address, linked both ways (job.estimateId and estimate.jobId), title and description from the agreed lines unless you send them. 201 `created: true`; 200 `created: false` with the existing job when the estimate already has one (the body is ignored); 409 `estimate_not_approved` while it is still a bid. On the service-token path a person (X-Martello-Actor) is required (403 `actor_required`).

Body (JSON)

title / description / notesstringDefaults from the estimate's lines.
scheduledDateYYYY-MM-DDOptional; unscheduled when absent.
scheduledStartTime / scheduledEndTimeHH:MM
assignedToIdstringA team member id (400 `unknown_assignee`).
needsReview / sourceQuotetrue / stringFlag the job for a person and keep the words it came from.

Returns 201 { data: Job, created: true, estimateId } | 200 { data: Job, created: false, estimateId }

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d '{"scheduledDate":"2026-10-20"}' https://tradesbackbone.com/api/v1/estimates/ESTIMATE_ID/job

recurring-due

GET/api/v1/recurring-due— Repeat work that is due and not booked

Service programs, memberships with included visits, and recurring job series whose next visit is past due by more than `graceDays` and has nothing on the schedule. Read-only.

Query

graceDaysinteger 0-365Days past due before a row is listed. Default 0.

Returns { data: { kind: "program" | "membership" | "job_series", id, customerId, customerName, title, interval, lastVisitAt, nextDueAt, booked }[], truncated }

curl -H "Authorization: Bearer $KEY" "https://tradesbackbone.com/api/v1/recurring-due?graceDays=14"

Invoices

GET/api/v1/invoices— List invoices

Each row carries `total`, `amountPaid`, `balance`, `issuedAt`, `dueDate` and `daysOverdue`.

Query

limitintegerPage size, 1–200. Default 50.
cursorstringThe `nextCursor` from the previous page.
updatedSinceISO-8601Only records changed at or after this time — the polling-trigger primitive.
statusDRAFT | SCHEDULED | SENT | VIEWED | PAID | OVERDUE | CANCELLED
outstandingtrueShorthand for every status that still owes money.
overduetrueOwed AND past its due date (before today, UTC). Never a draft. Overrides `status` / `outstanding`.
customerIdstring
qstringCustomer name, forgiving: case, apostrophes, punctuation and a plural/possessive s are ignored, so `Wexlers` finds "Wexler's Welding". Company or person name.
numberintegerThe invoice number (`1002` or `#1002`).
jobIdstringInvoices for one job.
externalSource / externalIdstringFind the record a key made.
needsReviewtrue | falseOnly records waiting on (or cleared by) a person.

Returns { data: InvoiceSummary[] (also jobId, jobTitle, createdAt, lines: first 5 { description, quantity, unitPrice }), nextCursor }

curl -H "Authorization: Bearer $KEY" "https://tradesbackbone.com/api/v1/invoices?outstanding=true"
POST/api/v1/invoices— Create or upsert an invoice

A DRAFT from line items — same numbering and tax rules as the dashboard. Emits `invoice.created`. Sending is a dashboard action; the API never sends or charges. With `externalSource` + `externalId` it is an idempotent upsert that updates only a DRAFT (409 `not_draft` after it is sent); the job and estimate links are fixed at create.

Body (JSON)

customerId*string
jobIdstringThe job flips to INVOICED.
estimateIdstring
lineItems*{ description, quantity, unitPrice, itemName?, itemLibraryId?, category?, taxable?, split?, lineKey?, parentKey?, parentLineId?, expand?, mode?, visits? }[]unitPrice in dollars. `itemLibraryId` / `itemName` link the line to a price-book item (see GET /api/v1/items). Tax on labor only: when the business sets a labor percent (`laborPercent`), each new priced line is split into a taxable LABOR line and an untaxed MATERIALS line per unit price (labor = unitPrice x percent, rounded half-up to the cent; materials = the rest; an Item Library item can override the percent; tax-exempt customers are never split). A split that would leave a $0 side saves as one whole line. A line sent with `category: "MATERIALS"` or `taxable: false` is kept whole; with the setting on, a whole Materials line sent without `taxable` is untaxed, and otherwise a line sent without `taxable` is taxed. `quantity` and `unitPrice` are rounded to the cent; `split: false` keeps any line exactly as sent, `split: true` forces the split. Stored halves carry `splitPart` and are never split again. An unknown `itemLibraryId` is 400 `unknown_item`. Grouping (products in services): a line may carry `lineKey`, and a product line `parentKey` naming an earlier line's key (or `parentLineId` = a stored line id when re-sending lines you read back); the stored line returns `parentLineId`, the id of its service line. `expand: true` on a line that names a service/bundle `itemLibraryId` (or `expand: true` on the body for every such line) adds it the way the office does: the service line plus the products it includes, per the shop default, the service setting, or the line's `mode` (`EXPANDED` | `ROLLED_UP`); `visits` overrides the visit count, `quantity` multiplies the service and its products, and an expanded line needs no description or price.
taxRatenumber 0–1Defaults to the business rate.
dueDateYYYY-MM-DDA date you send is kept. Omit it and the due date comes from the customer's `paymentTermsDays` (else the business default terms), counted from the day the invoice is SENT; with no terms at all it is 30 days after sending.
notesstring
externalSourcestringYour system's name, 1-40 of letters, digits, _ . : - (e.g. `voice`). Send with `externalId`; together they make the call an upsert.
externalIdstringYour stable id for THIS record (up to 200 chars). Same key again updates the record it made, with only the fields you send; it never creates a second one.
needsReviewboolean`true` lands the record flagged on the To check list (/dashboard/review) until a person clicks Looks right. The API can set it, never clear it. Any write with `externalSource` is always flagged, whatever this says. A keyed re-send onto a record a person has already checked changes nothing: it answers 202 `upsert: "change_proposed"` with a `changeId`, and the change waits on To check for someone to apply or dismiss.
sourceQuotestringThe words the record came from (up to 2000 chars). Kept on the record for the office, appended and de-duplicated across calls; never shown to the customer.
mycelReview{ approvalId, approvedAt, approvedByEmail? }Martello fleet only (service token + X-Martello-Actor; an API key is ignored). The person who approved this write in Mycel Approvals counts as the To check review: the record is cleared in the same request when the signed actor is a Mycel owner/admin whose email is ONE active member of this business holding the record's write permission. The response then carries `review: "reviewed_in_mycel"`; otherwise `review: "to_check"` (or `"already_reviewed"`) and `reviewDetail.reason`. Audited as REVIEWED via mycel-approvals.

Returns 201 { data: Invoice, upsert? } | 200 { data: Invoice, upsert: "updated" }

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d '{"customerId":"CUSTOMER_ID","lineItems":[{"description":"Furnace tune-up","quantity":1,"unitPrice":189}]}' https://tradesbackbone.com/api/v1/invoices
POST/api/v1/invoices/tax-preview— Preview an invoice's tax before creating it

Prices the lines exactly as POST /api/v1/invoices would (labor percent split, tax on taxable lines, 0% for a tax-exempt customer) and writes nothing. `taxHints` describe what a person said: `status` prices the draft as it will be after the customer's tax status changes, and `onApprove.request` is the POST /api/v1/customers/{id}/tax-exempt call that makes that change (it also removes tax from the customer's unsent drafts when they become exempt). `laborPercent`, `ratePercent` and `statedTaxAmount` are only compared (`checks[]`), never applied. A read: a read-only key may call it.

Body (JSON)

customerIdstringOmit for a customer not created yet (then `onApprove.customerCreateFields`).
lineItems*same as POST /api/v1/invoices
taxRatenumber 0–1Defaults to the business rate.
taxHints{ status?: "taxable"|"exempt", taxOnLabor?, laborPercent?, ratePercent?, statedTaxAmount?, exemptionNumber? }Percents are percents (6.75, not 0.0675); amounts in dollars.

Returns { data: { customer: { id, taxExempt, exemptionNumber, status } | null, taxStatusAfterApprove, label, ratePercent, laborPercent, split, lines[], subtotal, taxableSubtotal, taxAmount, total, taxLines: { label, ratePercent, base, amount }[], checks: { field, said, tb, matches, text }[], onApprove: { taxStatusChange: "none"|"set_exempt"|"clear_exempt", request: { method, path, body } | null, draftsLosingTax[], customerCreateFields? } } }

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d '{"customerId":"CUSTOMER_ID","lineItems":[{"description":"Fence repair","quantity":1,"unitPrice":600}],"taxHints":{"taxOnLabor":true,"statedTaxAmount":16.2}}' https://tradesbackbone.com/api/v1/invoices/tax-preview
GET/api/v1/invoices/{id}— Get an invoice

With line items, payments, `amountPaid` and `balance`.

Returns { data: Invoice }

curl -H "Authorization: Bearer $KEY" https://tradesbackbone.com/api/v1/invoices/INVOICE_ID
POST/api/v1/invoices/{id}/send— Send a draft invoice

The invoice screen's Send button: emails or texts the invoice with its pay link and starts the due clock. Only a DRAFT or SCHEDULED invoice (409 `not_draft` otherwise; a paid one says to send the receipt). 422 `not_sendable` when the customer has no address for that channel. A service call must carry the approver (maa/1 actor) holding invoice.write.

Body (JSON)

channel*"EMAIL" | "SMS"
ccstring[] | "a@x, b@y"EMAIL only. Omitted = the customer's remembered invoice CCs.

Returns 200 { data: { invoiceId, number, status, sentAt, channel, to[] } } | 409 not_draft | 403 actor_not_allowed { reason }

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -H "Idempotency-Key: APPROVAL_ID:send" -d '{"channel":"EMAIL"}' https://tradesbackbone.com/api/v1/invoices/INVOICE_ID/send
POST/api/v1/invoices/{id}/reminder— Send a payment reminder for one past-due invoice

The same path as Remind all in this bucket, for one invoice: quiet hours, opt-out, text consent and the 24-hour dedupe all apply, and the channel falls back per customer (picked text, no phone or consent, has email: email). `message` replaces the composed reminder text (a text keeps its opt-out line; an email keeps the amount and pay button). 409 `not_outstanding`, `not_past_due` or `reminded_recently` (with `lastReminderAt`); 422 `no_reachable_channel` (with `reasons`). Never a tech.

Body (JSON)

channel*"sms" | "email" | "both"
messagestring, up to 1000The approved reminder wording. Omitted = the shop's own reminder template.

Returns 200 { data: { invoiceId, invoice, queued: true, channels[], fellBack, to: { sms?, email? }, scheduledFor } }

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -H "Idempotency-Key: APPROVAL_ID:remind" -d '{"channel":"email"}' https://tradesbackbone.com/api/v1/invoices/INVOICE_ID/reminder
POST/api/v1/invoices/{id}/receipt— Email the receipt for a paid invoice

The invoice screen's Email receipt button. 400 when the invoice is not paid. Pairs with POST /api/v1/payments: record the payment, then send the receipt.

Body (JSON)

ccstring[] | "a@x, b@y"Omitted = the customer's remembered invoice CCs.

Returns 200 { data: { invoiceId, sentTo[], sentAt } }

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -H "Idempotency-Key: APPROVAL_ID:receipt" -d '{}' https://tradesbackbone.com/api/v1/invoices/INVOICE_ID/receipt

drafts

GET/api/v1/drafts/labor-percent— Drafts the labor percent would change

Unsent drafts with one-price lines the current labor percent would change. Empty when the setting is off. Read-only.

Query

customerIdstringOne customer's drafts only.

Returns { data: { laborPercent, drafts: { kind, id, number, customerName, lines, before, after, label }[] } }

curl -H "Authorization: Bearer $KEY" https://tradesbackbone.com/api/v1/drafts/labor-percent
POST/api/v1/drafts/labor-percent— Apply the labor percent to unsent drafts

Settings' Apply the labor percent to these drafts button: splits every unsent draft's one-price lines into taxable labor and untaxed materials at the current labor percent. Sent invoices are never changed. 409 `labor_percent_off` when the setting is blank.

Body (JSON)

customerIdstringOne customer's drafts only. Omit for all.

Returns 200 { data: { laborPercent, changed[], remaining[] } }

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d '{}' https://tradesbackbone.com/api/v1/drafts/labor-percent

review-queue

GET/api/v1/review-queue— To check list

Everything on the To check screen (/dashboard/review) for the whole business: records written by an integration that wait on a person (`kind: "record"`, up to 100 per kind) and open "Changed since you checked" items (`kind: "change"`). `total` is the number to show. Read-only.

Returns { data: { kind, entity, id, changeId?, title, customerName, externalSource, updatedAt, href, completionHeld? }[], counts: { job, estimate, invoice, customer, changes }, total, reviewPath }

curl -H "Authorization: Bearer $KEY" https://tradesbackbone.com/api/v1/review-queue

knowledge

GET/api/v1/knowledge— What the assistant knows

The business profile and price book generated from your settings, plus your Knowledge entries the assistant may use with customers. Team-only and archived entries are never returned. `sharedVault: true` means entries live in the connected Mycel vault and only the profile and price book are returned here. Read-only.

Query

limitintegerMost entries to return, 1-200. Default 200.

Returns { data: { kind: "profile" | "price_book" | "entry", id?, path, folder, title, content, body (same as content), audience: "customer", updatedAt }[], sharedVault }

curl -H "Authorization: Bearer $KEY" https://tradesbackbone.com/api/v1/knowledge

Payments

GET/api/v1/payments— List payments

Money received, newest first. Defaults to the last 30 days.

Query

limitintegerPage size, 1–200.
cursorstring
from / toISO-8601Received-at bounds.
invoiceIdstring
includeReturnedtrueInclude payments that bounced back.

Returns { data: Payment[], nextCursor }

curl -H "Authorization: Bearer $KEY" "https://tradesbackbone.com/api/v1/payments?from=2026-09-01T00:00:00Z"
POST/api/v1/payments— Record a payment

A payment taken by hand — cash, check, or other (Zelle, Venmo). Partial amounts are fine; the invoice flips PAID when the balance reaches zero. Emits `payment.recorded`, then `invoice.paid` if it settled. Card and bank payments are recorded by Stripe, not here. Send `Idempotency-Key` so a retry can never record it twice (a replay answers the first result with `Idempotent-Replay: true`). A call naming a person (maa/1 actor) must be a member allowed to record payments; it is recorded as them.

Body (JSON)

invoiceId*string
amount*numberDollars. Not more than the balance.
method*CASH | CHECK | OTHER
referencestringCheck number, transfer id.
notestring
receivedAtISO-8601Defaults to now; never in the future.

Returns 201 { data: { paymentId, invoiceId, amount, settled, balance, invoiceStatus } }

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d '{"invoiceId":"INVOICE_ID","amount":189,"method":"CHECK","reference":"1187"}' https://tradesbackbone.com/api/v1/payments

prospects

POST/api/v1/prospects— Upsert prospects (bulk)

Up to 500 businesses per call. A row with an externalId is matched only by source + externalId, and a miss creates a new prospect (two businesses that share a phone stay two rows). A row without one is matched, in order, by email or phone, then website domain, then company name + street. A match is updated with the fields sent (a person's status, notes and visits are never touched), anything else is created untouched. A row on the do-not-contact list is skipped. Prospects are data: nothing on this route sends an email, a text or a campaign. Rows with an address and no coordinates are placed on the map (some now, the rest when a visit plan is made). A key with only the restricted "prospects" scope can reach this route and nothing else.

Body (JSON)

sourcestringApplied to rows that do not name their own (e.g. "kleos").
segmentstringApplied to rows that do not name their own.
prospects[]*objectexternalId, company, firstName, lastName, title, email, phone, website, address, city, state, zip, latitude, longitude, segment, source, sourceUrl, sourcedAt, customFields{} (openedAt, trade…). Each needs a company, phone or email.

Returns { data: { received, created, updated, unchanged, skipped, results: [{ index, outcome, id, matchedBy: externalId|identity|website|nameAddress|none, reason }] }, geocoding: { placed, failed, remaining } }

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d '{"source":"kleos","segment":"new-business","prospects":[{"externalId":"k-1001","company":"Acme Roofing","phone":"(303) 555-0142","address":"12 Main St","city":"Denver","state":"CO","zip":"80202","customFields":{"openedAt":"2026-09-20","trade":"roofing"}}]}' https://tradesbackbone.com/api/v1/prospects
GET/api/v1/prospects— List prospects

Read back what was sent, cursor-paged by id.

Query

limitinteger1–500, default 100.
cursorstringnextCursor from the previous page.
source / segment / city / externalIdstringExact filters (city ignores case).
updatedSinceISO-8601Only rows changed since.

Returns { data: Prospect[], nextCursor }

curl -H "Authorization: Bearer $KEY" "https://tradesbackbone.com/api/v1/prospects?source=kleos&limit=200"

Business summary

GET/api/v1/business-summary— Business summary

Today's numbers: jobs, outstanding receivables, recent activity — the same read the Martello executive layer uses. invoices.outstandingBalance is NET of payments that stuck (what is still owed) and equals the sum of the ar buckets; invoices.outstandingGross is the face value of every outstanding invoice. Estimates are bids, never money owed: estimates.approvedValue is won work (APPROVED), estimates.openValue is bids still in play (DRAFT + SENT), and estimates.totalValue is every estimate in every status (kept for compatibility).

Returns { customers, jobs, estimates: { total, totalValue, approvedValue, openValue, byStatus }, invoices: { total, outstandingBalance, outstandingGross }, ar: { current, d30, d60, d90 }, margin, recentActivity }

curl -H "Authorization: Bearer $KEY" https://tradesbackbone.com/api/v1/business-summary

Webhook catalog

GET/api/v1/webhook-events— Webhook event catalog

Every event an endpoint can subscribe to, with a sample payload each, plus the header names and the signing rule.

Returns { data: { event, label, sample }[], headers, signing }

curl -H "Authorization: Bearer $KEY" https://tradesbackbone.com/api/v1/webhook-events

Webhooks

Add endpoints at Settings → Integrations → Webhooks (Elite plan). Each receives a POST with the JSON below and three headers: x-tb-event, x-tb-timestamp (unix seconds) and x-tb-signature. An endpoint with no event list receives every event — what a catch-hook wants.

Delivery. 3 attempts over about 23 seconds (a fresh timestamp and signature on each), retrying only on a network failure, a timeout, 408, 429 or a 5xx. After that the delivery is marked failed; every attempt is listed under Deliveries on the endpoint. An endpoint that fails 15 deliveries in a row is switched off until you switch it back on. Answer 2xx quickly and do the work afterwards.

Signature. HMAC-SHA256 over `${timestamp}.${rawBody}` with the endpoint's secret, hex-encoded — the timestamp is inside the signed string, so a captured delivery cannot be replayed later. Reject anything older than five minutes.

import crypto from "node:crypto";

export function verifyTradesBackbone(req, secret) {
  const ts = Number(req.headers["x-tb-timestamp"]);
  const sig = req.headers["x-tb-signature"];
  if (Math.abs(Date.now() / 1000 - ts) > 300) return false;        // replay window
  const expected = crypto.createHmac("sha256", secret)
    .update(`${ts}.${req.rawBody}`)                                // timestamp + RAW body
    .digest("hex");
  return expected.length === sig.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
}

Envelope

{
  "event": "invoice.paid",
  "businessId": "clx1biz…",
  "createdAt": "2026-10-02T21:14:00.000Z",
  "data": { "invoiceId": "clx1inv…", "total": 1250, "paymentMethod": "CARD" }
}

Events

job.createdA job is booked
{
  "jobId": "clx1job…",
  "customerId": "clx1cus…",
  "title": "Furnace tune-up",
  "status": "SCHEDULED",
  "scheduledDate": "2026-10-02T00:00:00.000Z"
}
job.scheduledA job is given a date, or its date moves
{
  "jobId": "clx1job…",
  "customerId": "clx1cus…",
  "scheduledDate": "2026-10-02T00:00:00.000Z",
  "previousScheduledDate": null
}
job.status_changedA job changes status
{
  "jobId": "clx1job…",
  "customerId": "clx1cus…",
  "status": "IN_PROGRESS",
  "previousStatus": "SCHEDULED"
}
job.completedA job is completed
{
  "jobId": "clx1job…",
  "customerId": "clx1cus…",
  "completedAt": "2026-10-02T21:14:00.000Z"
}
estimate.sentAn estimate goes out
{
  "estimateId": "clx1est…",
  "customerId": "clx1cus…",
  "total": 1250,
  "channel": "EMAIL"
}
estimate.approvedA customer approves an estimate
{
  "estimateId": "clx1est…",
  "customerId": "clx1cus…",
  "total": 1250,
  "approverName": "Dana Reyes"
}
estimate.declinedAn estimate is declined
{
  "estimateId": "clx1est…",
  "customerId": "clx1cus…",
  "total": 1250
}
invoice.createdAn invoice is created
{
  "invoiceId": "clx1inv…",
  "number": "INV-1042",
  "customerId": "clx1cus…",
  "jobId": "clx1job…",
  "total": 1250,
  "status": "DRAFT"
}
invoice.sentAn invoice goes out
{
  "invoiceId": "clx1inv…",
  "customerId": "clx1cus…",
  "total": 1250,
  "channel": "EMAIL",
  "dueDate": "2026-11-01T00:00:00.000Z"
}
invoice.paidAn invoice is paid in full
{
  "invoiceId": "clx1inv…",
  "total": 1250,
  "paymentMethod": "CARD"
}
payment.recordedA payment is recorded (cash, check, card or bank)
{
  "paymentId": "clx1pay…",
  "invoiceId": "clx1inv…",
  "customerId": "clx1cus…",
  "amount": 500,
  "method": "CHECK",
  "reference": "1187",
  "settled": false
}
customer.createdA new customer is added
{
  "customerId": "clx1cus…",
  "firstName": "Dana",
  "lastName": "Reyes",
  "email": "dana@example.com",
  "phone": "+18015550142"
}
customer.updatedA customer's details change
{
  "customerId": "clx1cus…",
  "changed": [
    "phone",
    "address"
  ]
}
customer.deletedA customer is deleted (from the app or over the API)
{
  "customerId": "clx1cus…"
}
job.deletedA job is deleted
{
  "jobId": "clx1job…"
}
request.createdA new work request comes in
{
  "requestId": "clx1req…",
  "customerId": "clx1cus…",
  "title": "Water heater leaking",
  "source": "website"
}
bill.recordedA vendor bill is recorded
{
  "billId": "clx1bil…",
  "vendorId": "clx1ven…",
  "total": 312.4,
  "dueOn": "2026-10-15T00:00:00.000Z"
}
timesheet.approvedHours are approved
{
  "timeEntryId": "clx1tim…",
  "memberId": "clx1mem…",
  "jobId": "clx1job…",
  "minutes": 255
}

Need an endpoint or an event that is not here?

Tell us what you are connecting from the feedback page — the surface grows with what customers plug in.