API Reference
GraphQL Customer API
💬Get free consultation

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 SettingsGeneralManage 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. customers accepts 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/graphql

All 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:

HeaderValue
x-api-idYour App ID
x-api-secretYour Secret key, the sk_ value

Both come from SettingsGeneralManage 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 SettingsGeneralManage keys and create a replacement. Deletion takes up to 5 minutes to take effect. See Authentication.


Queries

customer: fetch one contact

ArgumentTypeRequiredDescription
idID!YesThe 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.

ArgumentTypeRequiredDescription
firstIntNoNumber of records to return forward.
afterStringNoCursor to start after.
lastIntNoNumber of records to return backward.
beforeStringNoCursor 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, or totalSpent after you receive each page.
  • Sort by loading the pages you need and sorting in your own code.
  • Sync incrementally by paging until updatedAt on 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 customer query 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

FieldTypeDescription
idID!Chatty customer ID.
shopIdString!Your store identifier.
shopifyCustomerIdStringMatching Shopify customer ID, if any.
firstNameString!First name. Empty string when not set.
lastNameStringLast name.
emailStringEmail address.
phoneStringPhone number.
ipLocationStringLocation derived from IP.
ipAddressStringLast seen IP address.
typeCustomerType!Contact type (see enum below). CUSTOMER when not classified.
channels[Channel!]!Channels the contact has used. [ONLINE_STORE] when not set.
ordersCountInt!Number of orders placed.
totalSpentFloat!Lifetime spend.
createdAtDateTime!When the contact was first seen.
updatedAtDateTimeLast update timestamp.
lastChatAtDateTimeLast time the contact chatted.
fullNameString!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

ScalarShape
IDOpaque string. Send it as a string, don't parse it.
StringUTF-8 text.
IntWhole number between -2,147,483,648 and 2,147,483,647.
FloatDouble-precision decimal.
Booleantrue or false.
DateTimeISO 8601 date-time string at UTC, such as 2026-08-03T12:00:00.000Z.

Connection types

customers returns a CustomerConnection:

TypeFields
CustomerConnectionedges [CustomerEdge!]!, nodes [Customer!]!, pageInfo PageInfo!, totalCount Int!
CustomerEdgecursor String!, node Customer!
PageInfohasNextPage Boolean!, hasPreviousPage Boolean!, startCursor String, endCursor String
FieldTypeDescription
edges[CustomerEdge!]!Each contact wrapped with its own cursor.
nodes[Customer!]!The same contacts without the cursors.
pageInfoPageInfo!Where you are in the list.
totalCountInt!Total contacts in the store, not the size of this page.
cursorString!Position of one contact, used with after or before.
nodeCustomer!The contact at the end of the edge.
hasNextPageBoolean!More pages exist after this one.
hasPreviousPageBoolean!More pages exist before this one.
startCursorStringCursor of the first contact on this page.
endCursorStringCursor 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

ValueMeaning
CUSTOMERA registered customer with an account.
GUESTA shopper who has not created an account.
ANONYMOUSAn unidentified visitor.

Channel: ONLINE_STORE, EMAIL, WHATSAPP, FACEBOOK, INSTAGRAM

ValueMeaning
ONLINE_STOREThe online storefront.
EMAILEmail communication.
WHATSAPPWhatsApp messaging.
FACEBOOKFacebook messaging.
INSTAGRAMInstagram 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:

  1. Request customers(first: N).
  2. Read pageInfo.endCursor and pageInfo.hasNextPage.
  3. While hasNextPage is true, request customers(first: N, after: endCursor).

Points worth knowing:

  • totalCount counts the whole store, not the page. Use it for a progress bar, and still stop on hasNextPage.
  • 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 first and 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. last on its own gives you the tail of the list under the same ordering, and before moves 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" }
    }
  ]
}
  • message is human-readable and can change. Branch on extensions.code instead.
  • path points at the field that failed. It is absent when the request never reached a field.
  • locations marks the spot in your query text.
StatusCodeWhen
400BAD_REQUESTThe body carries no query.
400GRAPHQL_PARSE_FAILEDThe query text is not valid GraphQL syntax.
400GRAPHQL_VALIDATION_FAILEDThe syntax parses but the query is wrong: an unknown field, a missing required argument, or an argument of the wrong type.
200UNAUTHENTICATEDx-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-Limit and X-RateLimit-Remaining; this endpoint returns neither, so you cannot watch a counter drop. If a response includes extensions.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.