API reference Customers
Customers Service API Endpoints
The production API base URL is https://api.tradesmentechnologies.com/.
Authentication
Most endpoints require a Bearer token in the Authorization header:
Authorization: Bearer <your_token>
Mutating requests may carry an Idempotency-Key header so a retry never acts twice — see Idempotent Retries in the Accounts API documentation.
Base Path
/prod/customers
Endpoints
Create Customers
Create one or more customers for the company.
- Method: POST
- Path:
/prod/customers/customers - Headers:
Authorization: Bearer <token> - Request Body:
Note: The{ "customers": [ { "firstName": "Jane", "middleName": "Marie", "lastName": "Smith", "description": "Residential Customer", "address": { "streetAddress": "456 Avenue", "city": "City", "state": "State", "zipCode": "12345" }, "email": "jane@example.com", "phoneNumber": "1112223333", "additionalEmails": ["extra@example.com"], "additionalAddresses": [ { "streetAddress": "789 Second St", "city": "Other City", "state": "Other State", "zipCode": "67890" } ], "additionalPhoneNumbers": ["4445556666"], "parentCategoryId": "category-uuid", "customFields": { "field-uuid-1": "ACME-123", "field-uuid-2": 5000, "field-uuid-3": "2027-01-01" }, "smsOptedOut": false } ] }customersfield is required and must be a non-empty array, with a maximum of 1000 customers per request — larger imports must be split into multiple requests (a request with more than 1000 returns400). Every field on an individual customer object is optional — a customer can be created with as little as an empty object. When provided, fields are still type/shape-validated:emailmust be a syntactically valid address (exactly one@, a domain with a top-level domain such as.com, no spaces) and is stored trimmed and lowercased — an invalid one is a400naming the customer index and the reason;additionalEmailsmust be an array of valid addresses (blank entries are dropped, the rest are normalized the same way and de-duplicated);phoneNumbermust be a valid 10-digit US number;addressmust be an object (its subfieldsstreetAddress,city,state,zipCodeare all optional);additionalAddressesmust be an array of objects;additionalPhoneNumbersmust be an array of valid 10-digit US numbers (each is normalized to E.164 likephoneNumber);customFieldsmust be an object;smsOptedOutmust be a boolean. Defaults when omitted:firstName,middleName,lastName,description,address, andparentCategoryIddefault tonull;additionalEmails,additionalAddresses, andadditionalPhoneNumbersdefault to empty arrays;email,phoneNumber,customFields, andsmsOptedOutare simply absent from the stored record. Clients should treat an absentsmsOptedOutastrue(opted out) — SMS opt-in is explicit. ThecustomFieldsobject maps custom field IDs to their values: a string forSTRINGfields, a number forNUMBERfields, and aYYYY-MM-DDdate string forDATEfields. The operation is all-or-nothing: all customers are validated before any are written, and if a write fails partway through it is rolled back, so either every customer in the request is created or none are. Validation errors include the index of the offending customer. After validation, the domain of every distinct email is checked for a mail server (MX or address record); a domain that has none, or a reserved one such asexample.com, does not reject the request — the customer is created with anemailWarning(see Email deliverability flags) so the office can see the address is probably a typo. The check is time-boxed (8 s for a bulk request) and a domain that cannot be resolved in time is left unflagged. A welcome SMS is only sent when the request creates a single customer (and that customer has a phone number). Any request creating 2 or more customers skips the welcome SMS entirely. The welcome SMS names the company that opted the customer in (e.g. "...text message notifications from Acme Plumbing"), using the company'snameon record; if the company has no name the clause is omitted. Every created customer record is stamped with a server-generatedcreatedAtfield (UTC ISO 8601 timestamp, e.g."2026-03-15T12:00:00.000Z"); it is returned as part of each customer object in the login response'scustomersarray (see the Accounts service documentation) and cannot be set or modified by the client. Customers created before this field existed (prior to 2026-02-10) may not have it. - Success Response:
201 Created{ "message": "Customers created successfully", "customerIds": ["uuid1", "uuid2"] } - Error Responses:
400 Bad Request- Missing body,customersmissing/not a non-empty array, more than 1000 customers in one request, or a customer fails field validation (message includes the offending index).401 Unauthorized- Missing or invalid token.500 Internal Server Error- Unexpected server error.
Get All Customers
Retrieve all customers for the authenticated company. Every employee (regardless of role) receives the full customer list — this matches the customers array returned by the login response (see the Accounts service documentation). Useful for refreshing the client list without performing a full re-login.
- Method: GET
- Path:
/prod/customers/customers - Headers:
Authorization: Bearer <token> - Success Response:
200 OK
Note: each customer object has the same shape as the customers returned in the login response. Optional fields may be absent on records that never set them (see Create Customers for per-field defaults), and[ { "companyId": "uuid", "customerId": "uuid", "firstName": "Jane", "middleName": "Marie", "lastName": "Smith", "description": "Residential Customer", "address": { "streetAddress": "456 Avenue", "city": "City", "state": "State", "zipCode": "12345" }, "email": "jane@example.com", "phoneNumber": "+11112223333", "additionalEmails": ["extra@example.com"], "additionalAddresses": [], "additionalPhoneNumbers": ["+14445556666"], "parentCategoryId": "category-uuid", "customFields": { "field-uuid-1": "ACME-123" }, "smsOptedOut": false, "createdAt": "2026-03-15T12:00:00.000Z" } ]createdAtmay be absent on customers created before 2026-02-10. - Error Responses:
401 Unauthorized- Missing or invalid token.500 Internal Server Error- Unexpected server error.
Update Customer
Update an existing customer.
- Method: PUT
- Path:
/prod/customers/customers/{customerId} - Headers:
Authorization: Bearer <token> - Request Body:
Note: All fields are optional. Only provided fields will be updated. When providing{ "firstName": "Jane", "middleName": "Marie", "lastName": "Smith", "description": "Residential Customer", "address": { "streetAddress": "456 Avenue", "city": "City", "state": "State", "zipCode": "12345" }, "email": "jane@example.com", "phoneNumber": "1112223333", "additionalEmails": ["extra@example.com"], "additionalAddresses": [ { "streetAddress": "789 Second St", "city": "Other City", "state": "Other State", "zipCode": "67890" } ], "additionalPhoneNumbers": ["4445556666"], "parentCategoryId": "category-uuid", "customFields": { "field-uuid-1": "ACME-123", "field-uuid-2": 5000, "field-uuid-3": "2027-01-01" }, "smsOptedOut": false }address, it must be an object but its subfields (streetAddress,city,state,zipCode) are all optional. Each entry inadditionalAddressesmust be an object.phoneNumbermust be a valid 10-digit US number when provided. WhenadditionalPhoneNumbersis provided it must be an array of valid 10-digit US numbers (each is normalized to E.164) and it replaces the entire stored array.emailmust be a syntactically valid address when provided (same rules as create;400otherwise) and is stored trimmed and lowercased; its domain is checked for a mail server andemailWarningis set or cleared accordingly on every write of the field.additionalEmailsfollows the same rules as on create and replaces the stored list. To clearemailorphoneNumber, sendnullor an empty string — the attribute is removed from the record (clearingemailalso removesemailWarning,emailBounceandemailComplaint). SetparentCategoryIdto a category ID to assign the customer to a category, ornullto remove from a category. WhencustomFieldsis provided it must be an object; it replaces the entire stored map on the customer record (it is not merged). WhensmsOptedOutis provided it must be a boolean (false= the customer has opted in to text messages). Every update stampsupdatedAton the record. WhenphoneNumberis added or changed to a different number, the same welcome SMS as on create is sent to the new number (best effort). - Success Response:
200 OK{ "message": "Customer updated successfully", "customerId": "uuid" } - Error Responses:
400 Bad Request- MissingcustomerIdin the path, missing request body, or a field fails validation (invalidphoneNumber/additionalPhoneNumbersentry, invalidemail/additionalEmails,address/customFieldsnot an object,additionalAddressesnot an array of objects,smsOptedOutnot a boolean).401 Unauthorized- Missing or invalid token.404 Not Found- Customer not found.500 Internal Server Error- Unexpected server error.
Delete Customer
Delete an existing customer.
- Method: DELETE
- Path:
/prod/customers/customers/{customerId} - Headers:
Authorization: Bearer <token> - Success Response:
200 OK{ "message": "Customer deleted successfully", "customerId": "uuid" } - Error Responses:
400 Bad Request- MissingcustomerIdin the path.401 Unauthorized- Missing or invalid token.404 Not Found- Customer not found.500 Internal Server Error- Unexpected server error.
Customer Notes
Notes are stored as a list on the customer record under the notes field. Each note captures the authenticated employee who created it and the timestamp it was added.
Not to be confused with the customer
descriptionfield, which is a single free-text descriptor set when a customer is created. The two are independent attributes.
A note has the following shape:
{
"noteId": "uuid",
"text": "Prefers morning appointments",
"employeeId": "uuid",
"createdAt": "2026-05-12T18:30:00.000Z"
}
The full notes array is returned as part of the customer record by customer read endpoints.
Add Customer Note
Append a note to a customer. The employeeId is set automatically from the authenticated user.
Method: POST
Path:
/prod/customers/customers/{customerId}/notesHeaders:
Authorization: Bearer <token>Request Body:
{ "text": "Prefers morning appointments" }Note:
textis required; it is trimmed and must not be blank.Success Response:
201 Created{ "message": "Note added successfully", "note": { "noteId": "uuid", "text": "Prefers morning appointments", "employeeId": "uuid", "createdAt": "2026-05-12T18:30:00.000Z" } }Error Responses:
400 Bad Request- MissingcustomerIdin the path, missing request body, or missing/blanktext.401 Unauthorized- Missing or invalid token.500 Internal Server Error- Unexpected server error.
Note: this endpoint does not check that the customer exists — there is no
404.
Delete Customer Note
Delete a note from a customer by its noteId.
- Method: DELETE
- Path:
/prod/customers/customers/{customerId}/notes/{noteId} - Headers:
Authorization: Bearer <token> - Success Response:
200 OK{ "message": "Note deleted successfully" } - Error Responses:
400 Bad Request- MissingcustomerIdornoteIdin the path.401 Unauthorized- Missing or invalid token.404 Not Found- The customer does not exist, or no note with the givennoteIdexists on the customer.500 Internal Server Error- Unexpected server error.
Customer Categories
Customer categories allow organizing customers into a hierarchical tree structure. Categories can contain sub-categories, forming a tree of arbitrary depth. Customers can be assigned to any category via their parentCategoryId field.
Create Customer Category
Create a new customer category.
- Method: POST
- Path:
/prod/customers/categories - Headers:
Authorization: Bearer <token> - Request Body:
Note:{ "name": "Residential", "parentCategoryId": "parent-category-uuid" }parentCategoryIdis optional. When omitted, the category is created as a root-level category. When provided, the category becomes a child of the specified parent category. - Success Response:
201 Created{ "message": "Customer category created successfully", "categoryId": "uuid" } - Error Responses:
400 Bad Request- Missing request body ornamefield.401 Unauthorized- Missing or invalid token.500 Internal Server Error- Unexpected server error.
Update Customer Category
Update an existing customer category.
- Method: PUT
- Path:
/prod/customers/categories/{categoryId} - Headers:
Authorization: Bearer <token> - Request Body:
Note: All fields are optional. Only provided fields will be updated. Set{ "name": "Updated Name", "parentCategoryId": "new-parent-uuid" }parentCategoryIdtonullto make it a root-level category. - Success Response:
200 OK{ "message": "Customer category updated successfully" } - Error Responses:
400 Bad Request- Missing request body, categoryId, or no fields to update.401 Unauthorized- Missing or invalid token.404 Not Found- Category not found.500 Internal Server Error- Unexpected server error.
Delete Customer Category
Delete a customer category and all its sub-categories. Customers in the deleted categories are unlinked (their parentCategoryId is set to null) but not deleted.
- Method: DELETE
- Path:
/prod/customers/categories/{categoryId} - Headers:
Authorization: Bearer <token> - Success Response:
200 OK{ "message": "Customer category and its sub-categories deleted successfully" } - Error Responses:
400 Bad Request- Missing categoryId.401 Unauthorized- Missing or invalid token.500 Internal Server Error- Unexpected server error.
Email Campaigns
Email campaigns allow ADMIN employees to save reusable email templates and send them to a specified list of customers within the company. Each campaign tracks a history of when it was sent.
Create Email Campaign
Save a new email campaign.
- Method: POST
- Path:
/prod/customers/email-campaigns - Headers:
Authorization: Bearer <token> - Request Body:
Note:{ "subject": "Holiday Promotion", "body": "We are running a special promotion this holiday season!", "htmlBody": "<h1>Holiday Promotion</h1><p>We are running a <strong>special promotion</strong> this holiday season!</p>" }htmlBodyis optional. When provided, it is used as the rich HTML content in the email body (wrapped in a plain template: a greeting line, then the body, then a footer naming the company with the unsubscribe link — no logo or company-name header, so the body carries the campaign's own branding). When omitted, the plain textbodyis auto-wrapped in a simple HTML template. Thebodyfield is always required as it serves as the plain text fallback for email clients that do not render HTML. Images: every<img src>inhtmlBodythat points at one of the company's uploaded campaign images (see Upload Email Campaign Image) makes that image owned by the campaign — its key is recorded in the campaign'simageKeys. Images referenced from another company's prefix, other buckets or foreign hosts are ignored. Ownership is shared: several campaigns (e.g. a campaign and its copies, see Copy Email Campaign) can own the same image, and an image is deleted from S3 only once no campaign of the company references it. - Success Response:
201 Created{ "message": "Email campaign created successfully", "campaignId": "uuid" } - Error Responses:
400 Bad Request- Missing request body, subject, or body field. Also returned ifhtmlBodyis provided but is not a string.401 Unauthorized- Missing or invalid token.403 Forbidden- Caller is not an ADMIN employee.500 Internal Server Error- Unexpected server error.
Upload Email Campaign Image
Store an image for use inside a campaign's rich HTML body and get back a permanent public URL. The app calls this when an ADMIN inserts an image in the campaign editor, then references the returned URL from an <img src> in htmlBody. Images are linked, never attached, so each email stays small (SES bills attachment bytes per recipient; a linked image costs nothing per send). The file lands in the public-read logos bucket under campaign-images/{companyId}/ with a one-year immutable cache header. An uploaded image belongs to whichever campaign's htmlBody references it once saved: it is deleted when that campaign is deleted or when an update drops it from the body. An image that never makes it into a saved campaign is the app's to discard via Delete Email Campaign Image.
- Method: POST
- Path:
/prod/customers/email-campaigns/images - Headers:
Authorization: Bearer <token> - Request Body:
{ "imageBase64": "/9j/4AAQSkZJRgABAQAAAQABAAD...", "contentType": "image/jpeg" }Field Type Required Description imageBase64stringYes The image bytes, base64-encoded. A data:image/...;base64,prefix is accepted and stripped. Decoded size must be at most 5 MB.contentTypestringNo* One of image/jpeg,image/jpg,image/png,image/gif,image/webp. *Required unlessimageBase64is a data URL, whose type is used instead. - Success Response:
201 Created{ "message": "Email campaign image uploaded successfully", "imageUrl": "https://{logos-bucket}.s3.amazonaws.com/campaign-images/<companyId>/<uuid>.jpg" } - Error Responses:
400 Bad Request- Missing request body orimageBase64, unsupported or missing content type, invalid base64, or a decoded image over 5 MB.401 Unauthorized- Missing or invalid token.403 Forbidden- Caller is not an ADMIN employee.500 Internal Server Error- Unexpected server error.
List Email Campaigns
Fetch all saved email campaigns for the company.
- Method: GET
- Path:
/prod/customers/email-campaigns - Headers:
Authorization: Bearer <token> - Success Response:
200 OK
Note:{ "campaigns": [ { "companyId": "uuid", "campaignId": "uuid", "subject": "Holiday Promotion", "body": "We are running a special promotion this holiday season!", "htmlBody": "<h1>Holiday Promotion</h1><p>We are running a <strong>special promotion</strong> this holiday season!</p>", "imageKeys": ["campaign-images/<companyId>/<uuid>.jpg"], "createdAt": "2026-03-08T12:00:00.000Z", "createdBy": "employee-uuid", "sentHistory": [ "2026-03-08T14:00:00.000Z", "2026-03-15T14:00:00.000Z" ], "lastSend": { "sentAt": "2026-03-15T14:00:00.000Z", "status": "SENT", "sentBy": "employee-uuid", "recipients": 1200, "emailsSent": 1198, "emailsFailed": 2, "emailsSkipped": 35, "completedAt": "2026-03-15T14:02:11.000Z" } } ] }sentHistoryis an array of ISO timestamps recording each time the campaign was sent.htmlBodyis only present if the campaign was created with rich HTML content.imageKeyslists the S3 keys of the uploaded images the body references (the images the campaign owns; absent on rows saved before 2026-09-24).copiedFromCampaignIdis present on a campaign created by Copy Email Campaign and names the campaign it was copied from (informational only; the original may since have been deleted).lastSendis present once the campaign has been sent at least once and describes the most recent run:statusisSENDING(in progress),SENT(every email delivered to SES),PARTIAL(some sends failed), orFAILED(no email went out);recipientsis the number queued,emailsSent/emailsFailedadvance while the run is in progress,emailsSkippedcounts customers skipped at request time plus any that unsubscribed, bounced (emailBounce, see Email deliverability flags) or were deleted before their email went out, andcompletedAtis set when the run reaches a final status. - Error Responses:
401 Unauthorized- Missing or invalid token.403 Forbidden- Caller is not an ADMIN employee.500 Internal Server Error- Unexpected server error.
Update Email Campaign
Update an existing email campaign.
- Method: PUT
- Path:
/prod/customers/email-campaigns/{campaignId} - Headers:
Authorization: Bearer <token> - Request Body:
Note: All fields are optional. Only provided fields will be updated.{ "subject": "Updated Subject", "body": "Updated plain text body", "htmlBody": "<h1>Updated</h1><p>Updated rich HTML body</p>" }subjectandbodymust be non-empty strings if provided.htmlBodymust be a string if provided. WhenhtmlBodyis provided the campaign'simageKeysis recomputed from it and every previously owned image the new body no longer references is deleted from S3 unless another campaign of the company (e.g. a copy) still references it (best effort;imagesDeletedin the response counts the deleted ones,imagesKeptthe ones kept for another campaign). An update withouthtmlBodyleaves the images alone. - Success Response:
200 OK{ "message": "Email campaign updated successfully", "campaignId": "uuid", "imagesDeleted": 0, "imagesKept": 0 } - Error Responses:
400 Bad Request- Missing request body, no fields to update, or invalid field types.401 Unauthorized- Missing or invalid token.403 Forbidden- Caller is not an ADMIN employee.404 Not Found- Campaign not found.500 Internal Server Error- Unexpected server error.
Delete Email Campaign
Permanently delete a campaign together with every image its body owns (imageKeys plus any campaign image the stored htmlBody references) that no other campaign of the company still references — an image shared with a copy stays until the last campaign referencing it is deleted or drops it from its body. Sent history is lost with the row.
- Method: DELETE
- Path:
/prod/customers/email-campaigns/{campaignId} - Headers:
Authorization: Bearer <token> - Success Response:
200 OK
Note:{ "message": "Email campaign deleted successfully", "campaignId": "uuid", "imagesDeleted": 2, "imagesKept": 1 }imagesKeptcounts owned images left in place because another campaign still references them. Image deletion is best effort — the row is gone even if S3 refuses an object (logged as[CampaignImages]), soimagesDeletedcan be lower than the number of unshared images; if the reference lookup itself fails, no image is deleted and all of them count as kept. - Error Responses:
400 Bad Request- Missing campaignId path parameter.401 Unauthorized- Missing or invalid token.403 Forbidden- Caller is not an ADMIN employee.404 Not Found- Campaign not found.409 Conflict- A send of this campaign is still running (lastSend.statusisSENDINGand it started less than an hour ago); the body carries thatlastSend. Wait for it to finish, then delete.500 Internal Server Error- Unexpected server error.
Copy Email Campaign
Create a new campaign with the same body and htmlBody as an existing one. The copy links the same uploaded images (its imageKeys equal the original's) instead of duplicating them; each image stays in S3 until no campaign references it any more. The copy starts with an empty sentHistory and no lastSend, and carries copiedFromCampaignId.
- Method: POST
- Path:
/prod/customers/email-campaigns/{campaignId}/copy - Headers:
Authorization: Bearer <token> - Request Body: optional.
Note:{ "subject": "Copy of Holiday Promotion" }subjectis optional (trimmed; must be a non-empty string when present). When omitted the copy keeps the original's subject verbatim. The app sends a localized "Copy of …" subject. - Success Response:
201 Created
Note:{ "message": "Email campaign copied successfully", "campaignId": "new-uuid", "campaign": { "companyId": "uuid", "campaignId": "new-uuid", "subject": "Copy of Holiday Promotion", "body": "We are running a special promotion this holiday season!", "htmlBody": "<p>We are running a special promotion!</p><img src=\"https://<logos-bucket>.s3.amazonaws.com/campaign-images/<companyId>/<uuid>.jpg\">", "imageKeys": ["campaign-images/<companyId>/<uuid>.jpg"], "createdAt": "2026-09-28T12:00:00.000Z", "createdBy": "employee-uuid", "sentHistory": [], "copiedFromCampaignId": "uuid" } }campaignis the stored row, in the same shape as an entry of List Email Campaigns. The copy is written only if the original still exists unchanged; if it is edited while being copied the server retries against the new version. - Error Responses:
400 Bad Request- Missing campaignId, a body that is not JSON, or an invalidsubject.401 Unauthorized- Missing or invalid token.403 Forbidden- Caller is not an ADMIN employee.404 Not Found- Campaign not found (or deleted while being copied).409 Conflict- The campaign kept changing while being copied; try again.500 Internal Server Error- Unexpected server error.
Delete Email Campaign Image
Delete one uploaded campaign image that no saved campaign references. The app calls this to discard images it uploaded while editing and then removed before saving, or that belong to a new campaign the user abandoned. An image a saved campaign owns is refused — it is deleted with the last campaign that references it, or when the last referencing campaign's update drops it from the body.
- Method: DELETE
- Path:
/prod/customers/email-campaigns/images/{imageId} - Headers:
Authorization: Bearer <token> - Path Parameters:
imageId(required) - The file name at the end of theimageUrlthe upload returned (<uuid>.jpg). Only a bare image file name is accepted; the key is always resolved under the caller's owncampaign-images/{companyId}/prefix.
- Success Response:
200 OK{ "message": "Email campaign image deleted successfully", "imageId": "<uuid>.jpg", "imagesDeleted": 1 } - Error Responses:
400 Bad Request-imageIdis missing or not an image file name.401 Unauthorized- Missing or invalid token.403 Forbidden- Caller is not an ADMIN employee.409 Conflict- A saved campaign owns the image; the body carries thecampaignIdof one such campaign.500 Internal Server Error- Unexpected server error.
Send Email Campaign
Send a saved email campaign to a specified list of customers. The request resolves the eligible recipients and queues the send; the emails themselves go out in the background at a paced rate (about 10 per second), so a 1,000-recipient campaign takes roughly two minutes to finish after the request returns. Progress is visible on the campaign's lastSend field (see List Email Campaigns).
- Method: POST
- Path:
/prod/customers/email-campaigns/{campaignId}/send - Headers:
Authorization: Bearer <token> - Request Body:
{ "customerIds": ["customerId1", "customerId2"] }Field Type Required Description customerIdsstring[]Yes Array of customer IDs to send the campaign to. Must not be empty; every entry must be a non-empty string. Duplicates are ignored. At most 10,000 unique IDs per send. - Success Response:
202 Accepted
Note:{ "message": "Campaign queued for sending", "emailsQueued": 10, "emailsSent": 10, "emailsSkipped": 2, "sentAt": "2026-03-08T14:00:00.000Z" }emailsQueuedis the number of eligible recipients handed to the background sender.emailsSentis a deprecated alias ofemailsQueuedkept for older app versions; the real number of delivered emails islastSend.emailsSenton the campaign once the run finishes.emailsSkippedcounts customers that were not found, do not have an email address on file, have unsubscribed from emails, have an@example.comaddress (a reserved domain that cannot receive mail; delivery to it is suppressed platform-wide), or carry anemailBounceflag (see Email deliverability flags — the address was reported undeliverable, so sending again would only burn quota). ThesentAttimestamp is appended to the campaign'ssentHistoryarray and identifies the run inlastSend. Each email includes an unsubscribe link in the footer. - No-recipient Response:
200 OKwhen none of the given customers is eligible. Nothing is queued andsentHistoryis not updated.{ "message": "No eligible recipients; nothing was sent", "emailsQueued": 0, "emailsSent": 0, "emailsSkipped": 2, "sentAt": null } - Error Responses:
400 Bad Request- Missing campaignId path parameter, missing/empty customerIds array, a non-string entry, or more than 10,000 unique IDs.401 Unauthorized- Missing or invalid token.403 Forbidden- Caller is not an ADMIN employee.404 Not Found- Campaign not found.409 Conflict- A previous send of this campaign is still running (itslastSend.statusisSENDINGand it started less than an hour ago). The body carries thatlastSendrecord. Wait for it to finish before sending again.500 Internal Server Error- Unexpected server error, or the background sender could not be queued (the run is then recorded withlastSend.statusFAILED).
Email deliverability flags
Three server-managed fields describe whether mail to a customer's email can be expected to arrive. emailWarning is set at capture (create, update, incoming webhook) from a DNS check of the address's domain; emailBounce and emailComplaint come from Amazon SES reporting a hard bounce or spam complaint after a send. None of them is ever accepted on create or update, and all appear on customer objects wherever customers are returned (the login/session bootstrap customers array and GET /customers/customers). A customer without any of them has had no delivery problem reported.
{
"customerId": "cust-123",
"email": "jane@example.com",
"emailWarning": {
"code": "NO_MAIL_SERVER",
"domain": "gnail.com",
"checkedAt": "2026-09-25T09:00:00.000Z"
},
"emailBounce": {
"bouncedAt": "2026-09-24T23:06:00.000Z",
"bounceSubType": "General",
"diagnosticCode": "smtp; 550 5.1.1 The email account that you tried to reach does not exist",
"messageId": "0100019..."
},
"emailComplaint": {
"complainedAt": "2026-09-25T10:00:00.000Z",
"feedbackType": "abuse",
"messageId": "0100019..."
},
"emailUnsubscribed": true
}
emailWarning— set when the address was saved and its domain could not receive mail:codeisNO_MAIL_SERVER(the domain has no MX record and no address record, or publishes a null MX — typically a typo likegnail.com) orRESERVED_DOMAIN(an IANA-reserved name such asexample.comor a.test/.invaliddomain, which can never receive mail). The address is still stored and mail to it is still attempted; the flag is advice for the office. It is recomputed on every write ofemail(set or removed) and removed whenemailis cleared. A domain that merely could not be resolved in time (DNS outage) never sets it.emailBounce— set when an email to the customer'semailhard-bounced (the mailbox or domain does not exist).bounceSubTypeis SES's classification (General,NoEmail,Suppressed,OnAccountSuppressionList, ...) anddiagnosticCodeis the receiving server's message when SES had one; both may benull. Soft bounces (full mailbox, temporary failures) never set it. Campaign sends skip customers with this flag; transactional emails (documents, receipts, reminders) are still attempted. The flag is cleared automatically when the customer'semailis changed or removed viaPUT /customers/customers/{customerId}— a corrected address starts clean. A flag on a customer whose address was fine (a transient outage classified as permanent) is likewise cleared by re-saving the address under a different spelling, or by support.emailComplaint— set when the customer reported an email from the platform as spam. Because the customer has asked not to be mailed, the same event also setsemailUnsubscribed: true(exactly as if they had used the unsubscribe link), so campaigns stop immediately. It is cleared together withemailBouncewhen the address changes;emailUnsubscribedis not touched by an address change.
Both flags are stamped on every customer row that carries the bounced address, in every company — the address is dead regardless of who sent to it.
The unsubscribe list is shared across companies and environments
emailUnsubscribed on a customer row is only the per-row mirror. The authoritative record of an unsubscribe is an address-keyed list shared by every company: when a person clicks the unsubscribe link (or reports an email as spam), their normalized email address is put on that list, and every campaign send, job/document reminder, program visit report and program share email checks the recipient address against it at send time — whether or not the customer row being mailed carries the flag. In practice:
- A person who unsubscribed from one company's emails is not mailed campaigns or reminders by any other company that has the same address on file, in either environment.
- Clearing
emailUnsubscribedon a customer row (viaPUT /customers/customers/{customerId}) does not resubscribe the address — the shared list still blocks it. Resubscribing is a support action. - Transactional email (estimates, invoices, receipts, login codes) is unaffected by the list.
- Campaign sends skip a listed address (counted in
emailsSkipped). If the list cannot be read, the send fails (500) with nothing queued rather than risk mailing an unsubscribed address; reminders and reports fall back to the row flag alone.
Customer Custom Fields
Customer custom fields allow companies to define additional fields that can be attached to customers. Each custom field has a name and a type (STRING, NUMBER, DATE, or URL). Custom field definitions are returned in the login response under customerCustomFields so all employees can see the available fields. (Job-scoped custom field definitions are returned separately under customFields.)
Custom field values are set per-customer via the customFields object on the customer record. The object maps field IDs to their values: a string for STRING fields, a number for NUMBER fields, a YYYY-MM-DD date string for DATE fields, and a string for URL fields. A URL field stores and validates exactly like a STRING; the only difference is on the front end, which renders the value as a link that opens it in a browser.
Create Customer Custom Field
Create a new customer custom field definition. Admin only.
- Method: POST
- Path:
/prod/customers/custom-fields - Headers:
Authorization: Bearer <token> - Request Body:
{ "name": "Birthday", "type": "DATE" } - Note:
typemust be one of:STRING,NUMBER,DATE,URL. - Success Response:
201 Created{ "message": "Custom field created successfully", "fieldId": "uuid" } - Error Responses:
400 Bad Request- Missing request body, name, or invalid type.401 Unauthorized- Missing or invalid token.403 Forbidden- Caller is not an ADMIN employee.500 Internal Server Error- Unexpected server error.
Update Customer Custom Field
Update an existing customer custom field definition. Admin only.
- Method: PUT
- Path:
/prod/customers/custom-fields/{fieldId} - Headers:
Authorization: Bearer <token> - Request Body: (All fields optional)
{ "name": "Updated Field Name", "type": "NUMBER" } - Success Response:
200 OK{ "message": "Custom field updated successfully" } - Error Responses:
400 Bad Request- Missing request body, fieldId, invalid type, or no fields to update.401 Unauthorized- Missing or invalid token.403 Forbidden- Caller is not an ADMIN employee.404 Not Found- Custom field not found (or is a job-scoped field).500 Internal Server Error- Unexpected server error.
Delete Customer Custom Field
Delete a customer custom field definition. Admin only.
- Method: DELETE
- Path:
/prod/customers/custom-fields/{fieldId} - Headers:
Authorization: Bearer <token> - Success Response:
200 OK{ "message": "Custom field deleted successfully" } - Error Responses:
400 Bad Request- Missing fieldId.401 Unauthorized- Missing or invalid token.403 Forbidden- Caller is not an ADMIN employee.404 Not Found- Custom field not found (or is a job-scoped field).500 Internal Server Error- Unexpected server error.