Tradesmen Technologies Tradesmen Technologies

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:
    {
      "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
        }
      ]
    }
    
    Note: The customers field 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 returns 400). 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: email must 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 a 400 naming the customer index and the reason; additionalEmails must be an array of valid addresses (blank entries are dropped, the rest are normalized the same way and de-duplicated); phoneNumber must be a valid 10-digit US number; address must be an object (its subfields streetAddress, city, state, zipCode are all optional); additionalAddresses must be an array of objects; additionalPhoneNumbers must be an array of valid 10-digit US numbers (each is normalized to E.164 like phoneNumber); customFields must be an object; smsOptedOut must be a boolean. Defaults when omitted: firstName, middleName, lastName, description, address, and parentCategoryId default to null; additionalEmails, additionalAddresses, and additionalPhoneNumbers default to empty arrays; email, phoneNumber, customFields, and smsOptedOut are simply absent from the stored record. Clients should treat an absent smsOptedOut as true (opted out) — SMS opt-in is explicit. The customFields object maps custom field IDs to their values: a string for STRING fields, a number for NUMBER fields, and a YYYY-MM-DD date string for DATE fields. 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 as example.com, does not reject the request — the customer is created with an emailWarning (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's name on record; if the company has no name the clause is omitted. Every created customer record is stamped with a server-generated createdAt field (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's customers array (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, customers missing/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
    [
      {
        "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"
      }
    ]
    
    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 createdAt may 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:
    {
      "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
    }
    
    Note: All fields are optional. Only provided fields will be updated. When providing address, it must be an object but its subfields (streetAddress, city, state, zipCode) are all optional. Each entry in additionalAddresses must be an object. phoneNumber must be a valid 10-digit US number when provided. When additionalPhoneNumbers is 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. email must be a syntactically valid address when provided (same rules as create; 400 otherwise) and is stored trimmed and lowercased; its domain is checked for a mail server and emailWarning is set or cleared accordingly on every write of the field. additionalEmails follows the same rules as on create and replaces the stored list. To clear email or phoneNumber, send null or an empty string — the attribute is removed from the record (clearing email also removes emailWarning, emailBounce and emailComplaint). Set parentCategoryId to a category ID to assign the customer to a category, or null to remove from a category. When customFields is provided it must be an object; it replaces the entire stored map on the customer record (it is not merged). When smsOptedOut is provided it must be a boolean (false = the customer has opted in to text messages). Every update stamps updatedAt on the record. When phoneNumber is 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 - Missing customerId in the path, missing request body, or a field fails validation (invalid phoneNumber/additionalPhoneNumbers entry, invalid email/additionalEmails, address/customFields not an object, additionalAddresses not an array of objects, smsOptedOut not 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 - Missing customerId in 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 description field, 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}/notes

  • Headers: Authorization: Bearer <token>

  • Request Body:

    {
      "text": "Prefers morning appointments"
    }
    

    Note: text is 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 - Missing customerId in the path, missing request body, or missing/blank text.
    • 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 - Missing customerId or noteId in the path.
    • 401 Unauthorized - Missing or invalid token.
    • 404 Not Found - The customer does not exist, or no note with the given noteId exists 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:
    {
      "name": "Residential",
      "parentCategoryId": "parent-category-uuid"
    }
    
    Note: parentCategoryId is 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 or name field.
    • 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:
    {
      "name": "Updated Name",
      "parentCategoryId": "new-parent-uuid"
    }
    
    Note: All fields are optional. Only provided fields will be updated. Set parentCategoryId to null to 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:
    {
      "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>"
    }
    
    Note: htmlBody is 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 text body is auto-wrapped in a simple HTML template. The body field is always required as it serves as the plain text fallback for email clients that do not render HTML. Images: every <img src> in htmlBody that 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's imageKeys. 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 if htmlBody is 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
    imageBase64 string Yes The image bytes, base64-encoded. A data:image/...;base64, prefix is accepted and stripped. Decoded size must be at most 5 MB.
    contentType string No* One of image/jpeg, image/jpg, image/png, image/gif, image/webp. *Required unless imageBase64 is 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 or imageBase64, 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
    {
      "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"
          }
        }
      ]
    }
    
    Note: sentHistory is an array of ISO timestamps recording each time the campaign was sent. htmlBody is only present if the campaign was created with rich HTML content. imageKeys lists the S3 keys of the uploaded images the body references (the images the campaign owns; absent on rows saved before 2026-09-24). copiedFromCampaignId is 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). lastSend is present once the campaign has been sent at least once and describes the most recent run: status is SENDING (in progress), SENT (every email delivered to SES), PARTIAL (some sends failed), or FAILED (no email went out); recipients is the number queued, emailsSent/emailsFailed advance while the run is in progress, emailsSkipped counts customers skipped at request time plus any that unsubscribed, bounced (emailBounce, see Email deliverability flags) or were deleted before their email went out, and completedAt is 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:
    {
      "subject": "Updated Subject",
      "body": "Updated plain text body",
      "htmlBody": "<h1>Updated</h1><p>Updated rich HTML body</p>"
    }
    
    Note: All fields are optional. Only provided fields will be updated. subject and body must be non-empty strings if provided. htmlBody must be a string if provided. When htmlBody is provided the campaign's imageKeys is 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; imagesDeleted in the response counts the deleted ones, imagesKept the ones kept for another campaign). An update without htmlBody leaves 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
    {
      "message": "Email campaign deleted successfully",
      "campaignId": "uuid",
      "imagesDeleted": 2,
      "imagesKept": 1
    }
    
    Note: imagesKept counts 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]), so imagesDeleted can 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.status is SENDING and it started less than an hour ago); the body carries that lastSend. 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.
    {
      "subject": "Copy of Holiday Promotion"
    }
    
    Note: subject is 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
    {
      "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"
      }
    }
    
    Note: campaign is 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 invalid subject.
    • 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 the imageUrl the upload returned (<uuid>.jpg). Only a bare image file name is accepted; the key is always resolved under the caller's own campaign-images/{companyId}/ prefix.
  • Success Response: 200 OK
    {
      "message": "Email campaign image deleted successfully",
      "imageId": "<uuid>.jpg",
      "imagesDeleted": 1
    }
    
  • Error Responses:
    • 400 Bad Request - imageId is 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 the campaignId of 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
    customerIds string[] 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
    {
      "message": "Campaign queued for sending",
      "emailsQueued": 10,
      "emailsSent": 10,
      "emailsSkipped": 2,
      "sentAt": "2026-03-08T14:00:00.000Z"
    }
    
    Note: emailsQueued is the number of eligible recipients handed to the background sender. emailsSent is a deprecated alias of emailsQueued kept for older app versions; the real number of delivered emails is lastSend.emailsSent on the campaign once the run finishes. emailsSkipped counts customers that were not found, do not have an email address on file, have unsubscribed from emails, have an @example.com address (a reserved domain that cannot receive mail; delivery to it is suppressed platform-wide), or carry an emailBounce flag (see Email deliverability flags — the address was reported undeliverable, so sending again would only burn quota). The sentAt timestamp is appended to the campaign's sentHistory array and identifies the run in lastSend. Each email includes an unsubscribe link in the footer.
  • No-recipient Response: 200 OK when none of the given customers is eligible. Nothing is queued and sentHistory is 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 (its lastSend.status is SENDING and it started less than an hour ago). The body carries that lastSend record. 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 with lastSend.status FAILED).

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: code is NO_MAIL_SERVER (the domain has no MX record and no address record, or publishes a null MX — typically a typo like gnail.com) or RESERVED_DOMAIN (an IANA-reserved name such as example.com or a .test/.invalid domain, 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 of email (set or removed) and removed when email is cleared. A domain that merely could not be resolved in time (DNS outage) never sets it.
  • emailBounce — set when an email to the customer's email hard-bounced (the mailbox or domain does not exist). bounceSubType is SES's classification (General, NoEmail, Suppressed, OnAccountSuppressionList, ...) and diagnosticCode is the receiving server's message when SES had one; both may be null. 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's email is changed or removed via PUT /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 sets emailUnsubscribed: true (exactly as if they had used the unsubscribe link), so campaigns stop immediately. It is cleared together with emailBounce when the address changes; emailUnsubscribed is 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 emailUnsubscribed on a customer row (via PUT /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: type must 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.