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
| limit | integer | Page size, 1–200. Default 50. |
| cursor | string | The `nextCursor` from the previous page. |
| updatedSince | ISO-8601 | Only records changed at or after this time — the polling-trigger primitive. |
| email | string | Exact email, case-insensitive. |
| phone | string | Phone number; digits only are compared. |
| q | string | Name 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 / externalId | string | Find the record a key made. |
| needsReview | true | false | Only 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)
| customerType | INDIVIDUAL | BUSINESS | Default INDIVIDUAL. |
| firstName | string | Required for an individual. |
| lastName | string | |
| businessName | string | Required for a business. |
| email | string | |
| phone | string | |
| address | string | |
| city | string | |
| state | string | |
| zip | string | |
| notes | string | |
| tags | string[] | Up to 100. |
| smsConsent | boolean | The business confirmed the customer agreed to texts. |
| paymentTermsDays | integer 0–365 | null | Payment terms: 0 = due on receipt, 30 = Net 30. A new invoice with no dueDate is due on its date plus these days. |
| skipWarming | boolean | `true` for a migration: no new-lead welcome. A customer sent with `needsReview: true` is never warmed. |
| externalSource | string | Your system's name, 1-40 of letters, digits, _ . : - (e.g. `voice`). Send with `externalId`; together they make the call an upsert. |
| externalId | string | Your 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. |
| needsReview | boolean | `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. |
| sourceQuote | string | The 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 / businessName | string | |
| email / phone / address / city / state / zip / notes | string | null | |
| tags | string[] | Replaces the tag list. |
| taxExempt | boolean | |
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)
| taxExempt | boolean | Set the switch. Send this, `removeTaxFromDrafts: true`, or both. |
| exemptionNumber | string | Up to 100 characters; empty clears it. |
| removeTaxFromDrafts | boolean | Re-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