Skip to content

ResourcesCustomers

Customers

A customer is a person or company you write with. Mail from one of their addresses or domains gets their reply time, and your team sees their notes and fields next to the conversation. Reply times explains the feature; this page covers the API. Scopes: customers:read and customers:write. Customers belong to the whole organization, so a key limited to some mailboxes or domains gets 403 from these endpoints.

Last updated

The customer object

FieldTypeDescription
idstringCustomer ID, starting with cust_.
namestring or nullThe person's name.
companystring or nullThe company they work for.
notesstring or nullPrivate notes your team sees next to the customer's conversations.
service_level_idstring or nullThe reply time this customer gets. Null means the mailbox default.
fieldsobjectValues of your customer fields, keyed by field key.
identifiersarrayEmail addresses and domains that identify the customer's mail, each with kind (email or domain) and value.
external_idsarrayIDs of the customer in other systems, each with source, external_id and url. One per source.
created_atstringISO 8601 timestamp.
updated_atstringISO 8601 timestamp.

An identifier only matches a sender whose address Shipmail can verify, so a forged From never gets a customer's reply time. Free email domains such as gmail.com, disposable domains and your own domains cannot be identifiers.

Sync from a CRM or billing system

PUT/v1/customers/external/:source/:external_id

Creates the customer that source knows by external_id and returns 201, or updates it in place and returns 200. Sending the same body again changes nothing, so run it on every CRM change. source is a lowercase slug such as hubspot or stripe. Percent-encode the external ID: it is decoded exactly once, so contact%2F42 is the ID contact/42.

curl -X PUT https://shipmail.to/api/v1/customers/external/hubspot/contact%2F42 \
  -H "Authorization: Bearer sm_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Jane Doe",
    "company": "Acme",
    "service_level_id": "svl_...",
    "fields": { "plan": "enterprise" },
    "identifiers": [{ "kind": "domain", "value": "acme.com" }],
    "url": "https://app.hubspot.com/contacts/42"
  }'

An address or domain that already belongs to another customer is never moved. The request is refused with 409, and the error names the owner, so you can merge the two records yourself:

{
  "error": {
    "type": "conflict",
    "message": "acme.com already belongs to customer cust_....",
    "details": [
      {
        "field": "identifiers",
        "message": "This identifier belongs to another customer.",
        "code": "identifier_conflict",
        "params": { "kind": "domain", "value": "acme.com", "customer_id": "cust_..." }
      }
    ]
  }
}

List and find customers

GET/v1/customers

Cursor pagination with these optional filters:

  • email or domain: the customer with that exact identifier.
  • external_source with external_id: the customer another system knows by that ID.
  • q: search names, companies and identifiers.
GET/v1/customers/:id

Create, update and delete

POST/v1/customers
PATCH/v1/customers/:id
DELETE/v1/customers/:id

Create and update take name, company, notes, service_level_id, fields, identifiers and external_ids. An update changes only what you send; identifiers, external_ids and fields replace the stored set. Changing the reply time moves the deadlines of the customer's open conversations. Deleting a customer puts their conversations back on the mailbox default.

Reply times and fields

GET/v1/service-levels
GET/v1/customer-fields

Both are read-only and managed in the app. A reply time has name, reply_target_minutes and business_hours (null when the clock always runs). A field has the key you use in a customer's fields, a label and a type: text, number, date, url or boolean.

On inbox conversations

Conversations from the mailbox inbox API carry customer_id and reply_target. The reply target is null when no reply is owed or reply times are off for the mailbox.

"reply_target": {
  "state": "at_risk",
  "service_level_id": "svl_...",
  "started_at": "2026-11-01T08:00:00.000Z",
  "warn_at": "2026-11-01T11:00:00.000Z",
  "due_at": "2026-11-01T12:00:00.000Z"
}

state is on_track, at_risk once a quarter of the time is left, or missed after the deadline. The webhooks conversation.reply_target_at_risk and conversation.reply_target_missed fire at those moments.