Customers
Customers are the accounts Sonora tracks: their revenue, stage and renewal, who owns them, your custom fields, and the ids other systems know them by. Reading them needs a key with the customers:read scope. Creating them, changing their fields and managing their account teams need customers:write as well; archiving them needs customers:archive (with customers:read); defining custom fields needs custom-fields:manage (with customers:read). Every field is in the API reference; this page covers how to use them.
List customers
Section titled “List customers”curl "https://api.usesonora.com/v1/customers?stage=Onboarding&limit=50" \ -H "Authorization: Bearer sonora_sk_…"{ "data": [ { "id": "c1d2…", "name": "Acme", "websiteUrl": "https://acme.com", "emailDomains": ["acme.com"], "arr": 120000, "stage": "Onboarding", "tier": "Enterprise", "owner": { "name": "Olive Ortiz", "email": "olive@yourco.com" }, "renewalDate": "2027-03-31", "contractStartDate": "2026-04-01", "contractLengthDays": 365, "summary": "Rolled out to the support team in September; …", "customFields": { "segment": "Enterprise", "csm": [{ "name": "Tess Tran", "email": "tess@yourco.com" }] }, "externalIds": [{ "source": "salesforce", "id": "001Ab00000XyZ12AAA" }], "health": { "state": "healthy", "label": "Healthy", "score": 7.4, "atRisk": false, "computedAt": "2026-10-14T06:00:03.000Z" }, "ecsat": { "score": 8.1, "windowDays": 90 }, "engagementsCount": 214, "lastEngagementAt": "2026-10-14T16:02:11.000Z", "archivedAt": null, "createdAt": "2026-04-01T12:00:00.000Z", "updatedAt": "2026-10-14T16:02:12.418Z", "links": { "self": "https://api.usesonora.com/v1/customers/c1d2…" } } ], "nextCursor": "MjAyNi0wNC0wMVQx…"}Customers come newest first. Narrow the list with:
| Parameter | |
|---|---|
search |
Matches the name, website or an email domain — the same search as the customers page. |
stage, tier |
Comma-separated exact values, ignoring case; a customer in any of them matches (stage=Onboarding,Renewal). |
owner |
The owner’s email address. |
includeArchived |
true to list archived customers too. |
createdAfter, updatedAfter |
ISO 8601 instants with an offset. |
owner is null when nobody owns the account, and has a name but a null email when your CRM names an owner who isn’t a Sonora user. arr is a number of your workspace’s currency units.
Custom fields
Section titled “Custom fields”customFields carries your workspace’s custom fields by key — only the fields Sonora shows, never ones an import marked sensitive or hidden. GET /customer-fields describes each: its label, its type, and a select field’s options. A user field holds people, as [{ "name", "email" }].
Define custom fields
Section titled “Define custom fields”You can add, rename and delete the fields themselves, as the column controls on the customers page do. These calls need the custom-fields:manage scope, which only someone who can change your workspace’s settings can grant: a field belongs to every customer, so changing one changes them all.
Create a field with POST /customer-fields:
curl -X POST https://api.usesonora.com/v1/customer-fields \ -H "Authorization: Bearer sonora_sk_…" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 9b2f…" \ -d '{ "label": "Renewal risk", "type": "select", "options": ["Low", "Medium", "High"] }'{ "key": "renewal_risk", "label": "Renewal risk", "type": "select", "format": null, "options": ["Low", "Medium", "High"], "multiple": false}The answer is 201 Created with the field, as GET /customer-fields lists it. Use its key to read and write values in customFields. Every customer starts without one.
type |
Holds | Also takes |
|---|---|---|
string |
Text | format: url or email |
number |
A number | format: currency |
boolean |
true or false |
|
date |
YYYY-MM-DD |
|
select |
One of options |
options (required): 1 to 50 unique values |
user |
People in your workspace | multiple: true to hold several |
These are the types Add column offers. Some fields your imports bring in have other types, such as datetime or a multi-select, and the API reads those but can’t create them.
- The key comes from the label.
Renewal riskbecomesrenewal_risk. Keys never change, so a field that was renamed keeps its old key, and a new field that would take it gets the next free one instead:renewal_risk_2. Read the key from the answer rather than working it out yourself. - One label, one field. A label another field already shows, ignoring case, is
409 customer_field_exists, with that field’s key indetails.keyso a sync can carry on with it. Fields Sonora hides keep their labels too, and then the error doesn’t name the key. - Retry safely by sending an
Idempotency-Key. See Idempotency.
A label is up to 100 characters, counted once runs of spaces are collapsed, and needs a letter or number to make a key from: "---" is 422 invalid_fields. A body whose parts don’t fit together, such as options on a string or currency on a string, is 400 invalid_request.
Rename a field, or add options to a select, with PATCH /customer-fields/{key}:
curl -X PATCH https://api.usesonora.com/v1/customer-fields/renewal_risk \ -H "Authorization: Bearer sonora_sk_…" \ -H "Content-Type: application/json" \ -d '{ "label": "Churn risk", "options": ["Low", "Medium", "High", "Critical"] }'The answer is the field as saved. The key and every customer’s value stay as they are, so anything that reads the field by key keeps working through a rename.
options is the whole list in the order Sonora should show it: every option the field has, plus any to add. Options can be added and reordered, never removed, because customers may hold them, and so may the CRM values Sonora is holding back behind an edit. Leaving one out is 422 invalid_fields, naming what’s missing, and nothing in the call is saved. Send the options as GET /customer-fields reads them; each is trimmed, and still names an option an import stored with spaces around it. An option you add is 1 to 100 characters, like a new field’s, but one the field already has is taken back as it reads, however long. A select can be given up to 50 options, and one an import gave more can be reordered but not added to: adding past that is 422. A field’s type can’t change either. options on a field that isn’t a select is 422 too. A call that changes nothing, a label the field already shows included, answers 200 with the field as it is.
A select an import defined keeps the options you add: its next run adds any new options of its own after yours instead of replacing the list.
Delete a field with DELETE /customer-fields/{key}. It answers 204 No Content.
Deleting a field erases every customer’s value for it, as deleting its column in Sonora does, and nothing brings them back. Edits that took the field over from your CRM end with it. The values go because keys are reused: a field created later with the same label gets the same key, and it must not start out holding the old field’s data. An import that maps the field may define it again on its next run.
A key that isn’t a field, or is one Sonora hides (an import marked it sensitive or hidden), is 404 customer_field_not_found. The API never shows those fields, so it can’t change or delete them either.
Each change is recorded against the key that made it, with the names of the parts it changed.
Health and enrichments
Section titled “Health and enrichments”Every customer carries Sonora’s read on it, the same one the customers page shows.
health is the account’s health. state is a key on your workspace’s health scale, thriving, healthy, emerging_concerns or at_risk unless you’ve defined your own states, and label is how Sonora names it. Branch on atRisk rather than on a key, because your scale decides which states count as at risk. score runs from 0 to 10, higher is healthier. It is null when the state is no_data, meaning Sonora had nothing to score. health is null when your workspace doesn’t score health or hasn’t scored this customer yet.
If your workspace has more than one health score, health is the one the customer’s page leads with: the first score in your order that applies to the customer. A customer none of them has scored yet carries Sonora’s standard health assessment instead, so health is only null when there is neither.
ecsat is estimated satisfaction. Sonora scores what the customer says on calls, emails and tickets from 0 to 10, and ecsat.score averages those scores over the last windowDays (90). It is null when nothing in that window was scored.
Enrichments are the other fields Sonora’s AI fills in for your workspace, such as a churn reason or a champion’s strength. They aren’t in the response unless you ask, because a workspace can have dozens:
curl "https://api.usesonora.com/v1/customers/c1d2…?include=enrichments" \ -H "Authorization: Bearer sonora_sk_…"{ "id": "c1d2…", "name": "Acme", "enrichments": [ { "configId": "5e0b…", "title": "Churn reason", "outputType": "select", "value": "Price", "reason": "Procurement asked twice for a discount before renewal.", "computedAt": "2026-10-12T09:14:55.000Z" } ]}include=enrichments works on the list too, alongside totalCount: ?include=enrichments,totalCount. Without it the enrichments field is left out altogether. With it, every customer has the field, and a customer with no values gets []. Each entry is the latest value of one enrichment. configId names the enrichment, and Enrichments explains how to read each outputType and how to look up what an enrichment means.
Look up a customer by your own id
Section titled “Look up a customer by your own id”GET /customers/{customerId} takes Sonora’s id — or, with idType, the id your own system uses for the account:
curl "https://api.usesonora.com/v1/customers/001Ab00000XyZ12?idType=salesforce" \ -H "Authorization: Bearer sonora_sk_…"idType is sonora (the default), salesforce (15- or 18-character Account id), hubspot (Company id), domain (the account’s website domain), or custom_field:<key> (the value of one of your custom fields). It resolves exactly as the account of a workflow start does, so both always agree on which customer an id means. The answer adds the accountTeam and overriddenFields (see below), and carries an ETag header. A Sonora, Salesforce or HubSpot id finds a customer even once it’s archived (with archivedAt set); a domain or custom-field value matches live customers only, because an archived account can share it with a live one. No match is 404 customer_not_found.
Keep another system in step
Section titled “Keep another system in step”To mirror customers into a warehouse or another tool, read them oldest change first and resume from where you stopped:
- First run:
GET /customers?sort=updatedAt&includeArchived=true, followingnextCursorto the last page. Remember the largestupdatedAtyou saw. - Each later run:
GET /customers?sort=updatedAt&includeArchived=true&updatedAfter=<that updatedAt minus 5 minutes>.
The few minutes of overlap matter: a change is stamped when it starts, so one that commits slowly can land just before a time you have already read past. Upsert by id and the overlap costs nothing. Keep includeArchived=true so you see a customer get archived, rather than simply stop appearing.
updatedAt also moves on Sonora’s own bookkeeping — a new call or email bumps engagementsCount — so a sync will see some customers again with nothing you store changed.
A cursor belongs to the order it came from: one from sort=updatedAt is refused under the default order, and the reverse.
Create a customer
Section titled “Create a customer”POST /customers adds a customer by its domain, as Add customer on the customers page does:
curl -X POST https://api.usesonora.com/v1/customers \ -H "Authorization: Bearer sonora_sk_…" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: signup-8841" \ -d '{ "domain": "acme-cloud.com", "name": "Acme Cloud" }'The answer is 201 Created with the new customer — the same shape as GET /customers/{customerId} — and its ETag.
The domain is what makes it the customer. It becomes the customer’s website and its email domain, so the calls, emails and tickets Sonora sees from @acme-cloud.com land on it. Send it however you have it: https://www.acme-cloud.com/pricing is kept as acme-cloud.com. name is optional; without it Sonora makes one from the domain (acme-cloud.com becomes “Acme Cloud”).
Some domains can’t be a customer’s, and answer 422 invalid_fields with the reason under details.issues: a personal mailbox provider like gmail.com, your own company’s domain, or something that isn’t a domain at all.
One customer per domain. If a customer already has the domain, nothing is created and the call answers 409 customer_exists, with that customer’s id:
{ "error": { "code": "customer_exists", "message": "acme-cloud.com already belongs to a customer.", "details": { "customerId": "c1d2…" }, "requestId": "…" }}That holds for an archived customer too, because the domain is still its. Unarchive it rather than create another. So a sync that creates customers can treat 409 customer_exists as “already there” and carry on with details.customerId.
A create sets only the name and domain. Set the rest — owner, stage, ARR, custom fields — with PATCH on the new id. Like any PATCH, that takes those fields over: if your CRM later syncs the same account, it won’t overwrite them.
Send an Idempotency-Key so a retry after a timeout can’t create the customer twice: the retry gets the first call’s answer back, marked Idempotent-Replayed: true. See Idempotency.
Update a customer
Section titled “Update a customer”PATCH /customers/{customerId} changes a customer’s fields. Send only the ones you want to change:
curl -X PATCH "https://api.usesonora.com/v1/customers/001Ab00000XyZ12?idType=salesforce" \ -H "Authorization: Bearer sonora_sk_…" \ -H "Content-Type: application/json" \ -d '{ "stage": "Expansion", "arr": 180000, "owner": { "email": "tess@yourco.com" }, "customFields": { "segment": "Enterprise", "csm": [{ "email": "olive@yourco.com" }] } }'The answer is the customer as saved — the same shape as GET /customers/{customerId}, with a new ETag. You can change name, arr, stage, tier, renewalDate, contractStartDate, owner and any custom field Sonora shows; a field you leave out is kept, and null clears one. Anything else in the body — summary, emailDomains, a field from the response like id — is refused with 400 invalid_request.
People are named by email: the owner is { "email": … } (or null for nobody), and a user custom field takes a list of them. Each must be someone in your workspace. A name next to the email is ignored, so a person you read can be sent back as is. An owner your CRM names who isn’t a Sonora user reads as { "name": "…", "email": null }; sending that back leaves the owner as it is, but it can’t name a new one.
What a write takes over
Section titled “What a write takes over”A write through the API takes the field over from your CRM, exactly as an edit in Sonora does. Sonora keeps the value you wrote, and while it does, your CRM’s changes to that field are held back rather than applied — otherwise the next sync would quietly undo your write. Someone can hand the field back to the CRM from the customer’s page in Sonora (“Resume sync”), and the CRM’s latest value returns.
overriddenFields on a customer lists the fields held this way, whoever took them over — standard fields by name (arr, owner), custom fields as customFields.<key>:
{ "id": "c1d2…", "stage": "Expansion", "overriddenFields": ["customFields.segment", "arr", "stage"], … }Because of this, a value equal to what the customer already holds is not a change, and doesn’t take the field over. That makes it safe to send back a whole record you read and edited: only the fields that differ are taken over. Send arr: 120000 to a customer whose CRM says 120000 and nothing happens; send 130000 and arr is yours.
All or nothing
Section titled “All or nothing”A PATCH saves every field or none. If any value can’t be saved — an ARR below zero, a select option the field doesn’t have, an email that isn’t anyone in your workspace — the call answers 422 invalid_fields with every problem in details.issues, and the customer is untouched:
{ "error": { "code": "invalid_fields", "message": "Some fields can't be saved, so nothing was.", "details": { "issues": [ { "path": "arr", "message": "ARR must be between 0 and 9,999,999,999.99." }, { "path": "customFields.segment", "message": "…" } ] }, "requestId": "…" }}An archived customer can’t be changed: unarchive it first. Until then a PATCH answers 409 customer_archived.
Don’t overwrite someone else’s change
Section titled “Don’t overwrite someone else’s change”Two systems updating one customer can overwrite each other. To stop that, send the ETag you got with the customer as If-Match: if any field you could change has moved since, nothing is written and the call answers 412 precondition_failed. Read the customer again and decide what to write. See Updating for how ETags work.
Each change is recorded against the key that made it, with the fields it changed — never the person who created the key.
Archive and unarchive
Section titled “Archive and unarchive”POST /customers/{customerId}/archive archives a customer, as Archive on its page in Sonora does. It needs the customers:archive scope:
curl -X POST "https://api.usesonora.com/v1/customers/001Ab00000XyZ12/archive?idType=salesforce" \ -H "Authorization: Bearer sonora_sk_…"The answer is the customer, now with archivedAt set. Archiving does what it does in Sonora:
- the customer drops out of lists unless you ask for archived ones (
includeArchived=true); - its contacts are archived with it;
- workflow actions waiting on it are dismissed, and its active runs stop.
Your CRM’s imports never bring it back. Sonora also archives customers on its own, for example when your CRM’s inclusion rules stop matching an account, and an import restores those when the account qualifies again. A customer archived through the API stays archived until someone unarchives it, in Sonora or with the API.
POST /customers/{customerId}/unarchive restores a customer however it was archived. Its contacts and the dismissed workflow actions come back with it, but the runs that were stopped don’t restart.
Both are safe to repeat. Archiving an archived customer, or unarchiving a live one, changes nothing and answers 200 with the customer as it is. That includes a customer Sonora archived on its own: archiving it again through the API leaves it as Sonora archived it.
To unarchive, name the customer by its Sonora, Salesforce or HubSpot id. Those find an archived customer, and a domain or custom-field idType doesn’t (see Look up a customer).
Account team
Section titled “Account team”A customer’s accountTeam lists the people who work the account besides its owner. Add someone with PUT, naming them by email, and remove them with DELETE. Both need customers:write:
curl -X PUT "https://api.usesonora.com/v1/customers/c1d2…/account-team/tess%40yourco.com" \ -H "Authorization: Bearer sonora_sk_…" \ -H "Content-Type: application/json" \ -d '{ "role": "Solutions engineer" }'{ "id": "c1d2…", "owner": { "name": "Olive Ortiz", "email": "olive@yourco.com" }, "accountTeam": [{ "name": "Tess Tran", "email": "tess@yourco.com", "role": "Solutions engineer" }], …}curl -X DELETE "https://api.usesonora.com/v1/customers/c1d2…/account-team/tess%40yourco.com" \ -H "Authorization: Bearer sonora_sk_…"PUT answers 200 with the customer, and DELETE answers 204 No Content. URL-encode the email: @ is %40.
- The body is optional.
rolesets the member’s role on the account, andnullclears it. Leave it out and a new member gets no role, while an existing member keeps theirs — as does someone taken off the team and put back, who returns with the role they had, as in Sonora. So aPUTwith no body just makes sure someone is on the team. - Both are safe to repeat. Adding someone who’s already on the team with the same role, or removing someone who isn’t on it, changes nothing.
- People are named by email, and each must be someone in your workspace. An email that isn’t is
404 person_not_found. - The owner is always on the team, so they never appear in
accountTeam. Adding or removing them is409 account_team_owner; change theownerinstead. - A removal sticks. If your CRM syncs account teams (Salesforce’s Account Team), someone you remove stays off even while the CRM still lists them. A role you set on a CRM-synced member lasts until the next sync, which writes the CRM’s role back.
An archived customer’s team can’t be changed, so both calls answer 409 customer_archived until you unarchive it.