GraphQL Customer API
Who can use this feature?
- Creating the credentials this API needs requires the Pro or Plus plan.
- You'll need your App ID and an API key from Settings → General → Manage keys. See Generate your API key for the steps.
Overview
The GraphQL Customer API gives you read access to the contacts Chatty has collected for your store: names, emails, channels, order counts, total spend, and chat activity. Use it to sync contacts into a CRM, push segments to an email tool, or build a custom dashboard.
It's a single GraphQL endpoint, so you ask for exactly the fields you need in one request.
Two things to know before you design your integration:
- The API is read-only. The schema exposes queries only. There is no mutation and no subscription, so you cannot create, edit, or delete a contact through it.
- There is no server-side filtering, searching, or sorting.
customersaccepts pagination arguments and nothing else. To find contacts by email, channel, or spend, page through the list and filter in your own code. See Filtering and sorting.
Endpoint
POST https://graphql.chatty.net/graphqlAll requests are POST with a JSON body containing your query, plus optional variables and operationName.
GET is not supported. The server also rejects a request that carries no Content-Type header, or a Content-Type of application/x-www-form-urlencoded, multipart/form-data, or text/plain, as a possible cross-site request forgery. Always send Content-Type: application/json.
Authentication
Every request needs two headers:
| Header | Value |
|---|---|
x-api-id | Your App ID |
x-api-secret | Your Secret key, the sk_ value |
Both come from Settings → General → Manage keys, the same panel that issues keys for the Chat Conversations API.
A missing or invalid key does not return 401 here. The response is HTTP 200 with "data": null and an errors array whose extensions.code is UNAUTHENTICATED, so branch on the body rather than the status. A client that only checks the HTTP status reads a rejected request as a successful one. See Errors and rate limits.
Keep your Secret key private. It authenticates as your store. Never expose it in front-end code or a public repository. If it leaks, delete it in Settings → General → Manage keys and create a replacement. Deletion takes up to 5 minutes to take effect. See Authentication.
Queries
customer: fetch one contact
| Argument | Type | Required | Description |
|---|---|---|---|
id | ID! | Yes | The Chatty customer ID. |
Returns null when no contact matches the ID. Because the return type is nullable, a missing contact is not an error: you get HTTP 200, "customer": null, and no errors array.
customers: list contacts (paginated)
Relay-style cursor pagination. Use first/after to page forward, last/before to page backward.
| Argument | Type | Required | Description |
|---|---|---|---|
first | Int | No | Number of records to return forward. |
after | String | No | Cursor to start after. |
last | Int | No | Number of records to return backward. |
before | String | No | Cursor to end before. |
These four arguments are the complete set. customers takes no filter, search, sort, or date-range argument.
Every argument is optional and none has a schema default, so customers { totalCount } is a valid query. Pair first with after and last with before; mixing forward and backward arguments in one call has no defined meaning.
Filtering and sorting
The API returns the full contact list for your store in the order the server produces it. There is no argument to narrow or reorder that list, and no argument that exposes the sort key. Handle it on your side:
- Filter by a field such as
email,type,channels,ordersCount, ortotalSpentafter you receive each page. - Sort by loading the pages you need and sorting in your own code.
- Sync incrementally by paging until
updatedAton the records you read falls before your last sync time, then stopping. This works only if the list order is stable between runs, so verify it against your own data before you rely on it. - Look up one contact with the
customerquery when you already hold the ID. It is far cheaper than scanning the list.
If you need a filtered feed rather than a full scan, Webhooks push events as they happen instead.
Schema
Customer
| Field | Type | Description |
|---|---|---|
id | ID! | Chatty customer ID. |
shopId | String! | Your store identifier. |
shopifyCustomerId | String | Matching Shopify customer ID, if any. |
firstName | String! | First name. Empty string when not set. |
lastName | String | Last name. |
email | String | Email address. |
phone | String | Phone number. |
ipLocation | String | Location derived from IP. |
ipAddress | String | Last seen IP address. |
type | CustomerType! | Contact type (see enum below). CUSTOMER when not classified. |
channels | [Channel!]! | Channels the contact has used. [ONLINE_STORE] when not set. |
ordersCount | Int! | Number of orders placed. |
totalSpent | Float! | Lifetime spend. |
createdAt | DateTime! | When the contact was first seen. |
updatedAt | DateTime | Last update timestamp. |
lastChatAt | DateTime | Last time the contact chatted. |
fullName | String! | Convenience full name, firstName plus lastName. |
A ! means the field is never null. Every other field can come back null, so treat lastName, email, phone, ipLocation, ipAddress, shopifyCustomerId, updatedAt, and lastChatAt as optional in your own types.
Customer has no nested object field. Every field is a scalar, an enum, or a list of enums, so a contact never expands into further sub-selections.
Scalars
| Scalar | Shape |
|---|---|
ID | Opaque string. Send it as a string, don't parse it. |
String | UTF-8 text. |
Int | Whole number between -2,147,483,648 and 2,147,483,647. |
Float | Double-precision decimal. |
Boolean | true or false. |
DateTime | ISO 8601 date-time string at UTC, such as 2026-08-03T12:00:00.000Z. |
Connection types
customers returns a CustomerConnection:
| Type | Fields |
|---|---|
CustomerConnection | edges [CustomerEdge!]!, nodes [Customer!]!, pageInfo PageInfo!, totalCount Int! |
CustomerEdge | cursor String!, node Customer! |
PageInfo | hasNextPage Boolean!, hasPreviousPage Boolean!, startCursor String, endCursor String |
| Field | Type | Description |
|---|---|---|
edges | [CustomerEdge!]! | Each contact wrapped with its own cursor. |
nodes | [Customer!]! | The same contacts without the cursors. |
pageInfo | PageInfo! | Where you are in the list. |
totalCount | Int! | Total contacts in the store, not the size of this page. |
cursor | String! | Position of one contact, used with after or before. |
node | Customer! | The contact at the end of the edge. |
hasNextPage | Boolean! | More pages exist after this one. |
hasPreviousPage | Boolean! | More pages exist before this one. |
startCursor | String | Cursor of the first contact on this page. |
endCursor | String | Cursor of the last contact on this page. |
Select nodes when you only walk forward with endCursor. Select edges when you need to remember a position part-way through a page, for example to resume a long sync.
Enums
CustomerType: CUSTOMER, GUEST, ANONYMOUS
| Value | Meaning |
|---|---|
CUSTOMER | A registered customer with an account. |
GUEST | A shopper who has not created an account. |
ANONYMOUS | An unidentified visitor. |
Channel: ONLINE_STORE, EMAIL, WHATSAPP, FACEBOOK, INSTAGRAM
| Value | Meaning |
|---|---|
ONLINE_STORE | The online storefront. |
EMAIL | Email communication. |
WHATSAPP | WhatsApp messaging. |
FACEBOOK | Facebook messaging. |
INSTAGRAM | Instagram messaging. |
These two enums are the complete set. New values can be added later, so handle an unfamiliar value instead of failing on it.
Explore the schema yourself
The endpoint answers GraphQL introspection without credentials, so you can generate typed clients before your key is issued.
curl -s -X POST "https://graphql.chatty.net/graphql" \
-H "Content-Type: application/json" \
-d '{"query":"{ __schema { queryType { name } types { name kind } } }"}'Introspection returns the shape only. Any query that reads contact data still needs both headers.
Examples
List the first 50 contacts
query {
customers(first: 50) {
totalCount
pageInfo { hasNextPage endCursor }
nodes {
id
fullName
email
channels
ordersCount
totalSpent
lastChatAt
}
}
}Page to the next 50
query NextPage($after: String!) {
customers(first: 50, after: $after) {
pageInfo { hasNextPage endCursor }
nodes { id email }
}
}Page backward from a cursor
query PreviousPage($before: String!) {
customers(last: 50, before: $before) {
pageInfo { hasPreviousPage startCursor }
nodes { id email }
}
}Keep a cursor for every contact
query WithCursors {
customers(first: 25) {
edges {
cursor
node { id email }
}
pageInfo { hasNextPage endCursor }
}
}Count contacts without reading them
query ContactCount {
customers(first: 1) {
totalCount
}
}Fetch a single contact
query {
customer(id: "CUSTOMER_ID") {
fullName
email
totalSpent
ordersCount
}
}Pull the fields a CRM sync needs
query CrmSync($after: String) {
customers(first: 100, after: $after) {
pageInfo { hasNextPage endCursor }
nodes {
id
email
firstName
lastName
phone
shopifyCustomerId
ordersCount
totalSpent
updatedAt
}
}
}Send "after": null on the first call and the cursor from the previous page after that.
Call it with curl
curl -s -X POST "https://graphql.chatty.net/graphql" \
-H "x-api-id: YOUR_APP_ID" \
-H "x-api-secret: YOUR_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"query":"{ customers(first: 10) { nodes { id email totalSpent } } }"}'Send variables with curl
curl -s -X POST "https://graphql.chatty.net/graphql" \
-H "x-api-id: $CHATTY_APP_ID" \
-H "x-api-secret: $CHATTY_SECRET" \
-H "Content-Type: application/json" \
-d '{"query":"query NextPage($after: String!) { customers(first: 50, after: $after) { pageInfo { hasNextPage endCursor } nodes { id email } } }","variables":{"after":"'"$NEXT_CURSOR"'"},"operationName":"NextPage"}'operationName is optional with a single operation and required when your document holds more than one.
Pagination
The API uses Relay-style cursor pagination. To walk the full list:
- Request
customers(first: N). - Read
pageInfo.endCursorandpageInfo.hasNextPage. - While
hasNextPageis true, requestcustomers(first: N, after: endCursor).
Points worth knowing:
totalCountcounts the whole store, not the page. Use it for a progress bar, and still stop onhasNextPage.- Cursors are opaque. They are not IDs or offsets. Pass back exactly what you received and don't build one yourself.
- A cursor has no stated lifetime. Treat a long-stored cursor as unreliable and restart the walk if a resumed page looks wrong.
- Choose the page size by cost, not by habit. The schema puts no cap on
firstand nothing rejects a large page up front, so the real limits are query cost and response size. A page with a handful of fields can be much larger than a page with every field. - Backward paging needs a cursor to start from.
laston its own gives you the tail of the list under the same ordering, andbeforemoves you toward the front.
Errors
Errors never arrive as a plain body. You always get a GraphQL envelope with an errors array, and the HTTP status depends on where the request failed.
{
"data": null,
"errors": [
{
"message": "Missing x-api-id or x-api-secret",
"locations": [{ "line": 1, "column": 3 }],
"path": ["customers"],
"extensions": { "code": "UNAUTHENTICATED" }
}
]
}messageis human-readable and can change. Branch onextensions.codeinstead.pathpoints at the field that failed. It is absent when the request never reached a field.locationsmarks the spot in your query text.
| Status | Code | When |
|---|---|---|
400 | BAD_REQUEST | The body carries no query. |
400 | GRAPHQL_PARSE_FAILED | The query text is not valid GraphQL syntax. |
400 | GRAPHQL_VALIDATION_FAILED | The syntax parses but the query is wrong: an unknown field, a missing required argument, or an argument of the wrong type. |
200 | UNAUTHENTICATED | x-api-id or x-api-secret is missing, or the secret is invalid. |
The 400 cases are bugs in your query and fail the same way on every retry. Fix the query rather than retrying it.
UNAUTHENTICATED is the case that catches integrations out. It arrives as HTTP 200 with "data": null, so a client that only checks the status treats a rejected request as a successful empty one. Check for an errors array on every response.
A query can also succeed in part. If one field resolves and another fails, you get HTTP 200 with both data and errors populated, and the failed field is null. Read errors even when data is present. See Errors and rate limits.
Rate limiting
This API is metered by query cost, not by request count. It uses a token bucket: every query spends from a budget that refills over time. Chatty does not publish the size of that budget or the refill rate, so treat the exact figures as unknown and design for backoff rather than for a number.
Two things are worth knowing before you build against it:
- GraphQL responses carry no rate limit headers. The Chat Conversations API returns
X-RateLimit-LimitandX-RateLimit-Remaining; this endpoint returns neither, so you cannot watch a counter drop. If a response includesextensions.cost, that is the only cost signal available. - Nothing rejects a large query up front. The schema sets no cap on
first, and the server accepts deeply aliased or deeply nested queries without a complexity check, so an expensive query is your responsibility to avoid.
A customers query costs roughly first × the cost of the fields you select, so asking for 100 contacts with a handful of fields is far cheaper than asking for 100 with everything. Lower first before you drop fields.
In practice: page in modest sizes, run one walk at a time rather than several in parallel, and back off before retrying any failure you cannot trace to your own query.
Need help?
If you run into issues with your key or a query, contact the Chatty support team from your dashboard. For no-code options, see Klaviyo, Zendesk, or Joy.
Chatty Help Center