Contacts
Read and write contacts, and manage the custom fields attached to them
Scopes: CONTACTS_READ to read, CONTACTS_MANAGE to write, CONTACTS_ERASE to erase. Unmasked names, phones, emails and field values additionally need PII_READ.
Contacts are the most PII-dense part of the API. A key without PII_READ still gets rows back, but displayName, phone, email and every field value come back null - and it cannot use them as search filters either.
contactApi
contactApi(id: ID!): ContactApiCONTACTS_READ
Reads one contact. Returns null when there is no such contact in the org you are acting on.
Returns ContactApi
| Field | Type | Description |
|---|---|---|
id | ID! | The contact ID. |
displayName | String | Name as shown in the inbox. Masked without PII_READ. |
avatarUrl | String | Profile photo. Masked without PII_READ. |
phone | String | Phone number. Masked without PII_READ. |
email | String | Email address. Masked without PII_READ. |
channels | [String!]! | Channels this contact has used. |
erased | Boolean! | true when the contact has been erased on request. |
externalId | String | Your identifier for this person, echoed back. Not PII-gated - a masked key still gets it. |
ContactApi is a deliberately narrow, API-specific view - not the full internal person record. It uses id, not _id.
contactsApi
contactsApi(filter: ContactApiFilterInput = {}, pagination: ContactApiPaginationInput = {}): ContactApiPage!CONTACTS_READ
Lists and searches contacts.
Arguments
| Argument | Type | Description |
|---|---|---|
filter.search | String | Free-text search. Requires PII_READ - it searches identity fields, so a masked key cannot use it. Makes the call expensive. |
filter.externalId | String | Your own identifier. |
filter.phone | String | Phone number. |
filter.channel | ContactChannel | TELEGRAM, WHATSAPP, VIBER, INSTAGRAM or WEB. Expensive. |
filter.fields | [ContactApiFieldFilterInput!] | Match custom field values - { key, value }. Expensive. |
filter.createdAfter / createdBefore | date | Created-date range. |
sortDir | SortDirection | Sort direction. |
pagination.first | Int | Page size. Default 20, clamped to 100 for keys. |
pagination.after | ID | Cursor from the previous page - the nextCursor of the last result. |
Note that contacts page differently from the rest of the API: first/after, not limit/cursor.
ContactApiPage returns items, nextCursor and totalCount.
A channel or fields filter matching more than 10,000 people is refused with a 400 asking you to narrow it. Combine it with another filter - a created-date range is usually the easiest - rather than paging through everyone on a channel.
Example
query Contacts {
contactsApi(filter: { search: "alex" }, pagination: { first: 25 }) {
items { id displayName phone channels }
nextCursor
}
}Keying Contacts on Your Own ID
If you already have users in your own product, you do not need to store Wexio's contact IDs. Pass your own identifier as externalId and Wexio keys the contact on it.
That gives you two ways to work:
| Model | How it works |
|---|---|
| You keep your own user records | Send us a channel identity plus your externalId. Your system stays the source of truth. |
| You have no user records | Use ours - Wexio's contacts and field definitions become your schema. |
externalId is a stable handle: it survives a contact merge, where Wexio's own peopleId does not. It is also returned on contact reads and writes, so the round trip is closed - you can always map a Wexio contact back to your own user without storing our ids.
upsertContactApi
upsertContactApi(input: ContactApiWriteInput!): ContactApi!CONTACTS_MANAGE
Creates or updates a contact, idempotently keyed on externalId. Two upserts with the same externalId touch one contact - no duplicate, no need to check first.
Arguments - input: ContactApiWriteInput!
| Field | Type | Description |
|---|---|---|
externalId | String | Your identifier for this person. The idempotency key. |
displayName | String | Name shown in the inbox. |
firstName / lastName | String | Given and family name. |
phone | String | Phone number, E.164. |
language | String | Preferred language code. |
fields | [ContactApiFieldInput!] | Custom field values as { key, value } - the field's key, not its ID. |
channel | ContactApiChannelInput | Bind a messaging identity. See below. |
Binding a Channel Identity
channel is the part that prevents duplicates. It attaches a channel identity to the contact before that person ever writes to you, so when a real message arrives from that phone or Telegram ID it resolves to the same contact.
ContactApiChannelInput
| Field | Type | Description |
|---|---|---|
kind | ContactChannelKind! | WHATSAPP, TELEGRAM or VIBER. |
phone | String | For WHATSAPP. |
telegramId | String | For TELEGRAM. |
viberUserId | String | For VIBER. |
integrationId | String | Which connected integration. Defaults to the org's integration for that channel, so you can usually omit it. |
There is no WEB here - a website-widget identity is created by the visitor, not bound ahead of time.
Example - import a customer from your own system and pre-bind their WhatsApp number:
mutation Upsert {
upsertContactApi(input: {
externalId: "crmco-user-88213"
displayName: "Alex Moreno"
phone: "+15551234567"
language: "en"
fields: [{ key: "customer_tier", value: "gold" }]
channel: { kind: WHATSAPP, phone: "+15551234567" }
}) {
id
externalId
displayName
channels
}
}Run the same mutation again with the same externalId and it updates that contact rather than creating a second one.
Without the channel binding, a contact you created by externalId and a person who later messages you from their phone are two separate contacts until someone merges them. Bind the identity at import time and the problem never arises.
contactApiByExternalId
contactApiByExternalId(externalId: String!): ContactApiCONTACTS_READ
Reads a contact by your identifier. Returns null when no contact carries that externalId.
This is the read that pairs with upsertContactApi - you never have to store Wexio's IDs on your side.
query ByMyId {
contactApiByExternalId(externalId: "crmco-user-88213") {
id
externalId
displayName
phone
channels
}
}Identity fields come back null without PII_READ, exactly as on the other contact reads - so a key that can import people cannot necessarily read their phone numbers back.
externalId is the exception: it is your identifier, not the person's PII, so it is returned even to a masked key. That is what makes the round trip work without PII_READ - you can always tell which of your users a contact corresponds to.
createContactApi / updateContactApi
createContactApi(input: ContactApiWriteInput!): ContactApi!
updateContactApi(id: ID!, input: ContactApiWriteInput!): ContactApi!CONTACTS_MANAGE
Prefer upsertContactApi when you have your own identifier for the person - one idempotent call replaces "read, then create or update", and repeating it is safe. Use createContactApi / updateContactApi when you are addressing a contact by Wexio's own id.
ContactApiWriteInput
| Field | Type | Description |
|---|---|---|
displayName | String | Name shown in the inbox. |
firstName | String | Given name. |
lastName | String | Family name. |
phone | String | Phone number, E.164. |
language | String | Preferred language code. |
fields | [ContactApiFieldInput!] | Custom field values to set in the same call. Each entry is { key: String!, value: JSON } - the field's key, not its ID. |
Example
mutation Upsert {
createContactApi(input: {
displayName: "Alex Moreno"
firstName: "Alex"
lastName: "Moreno"
phone: "+15551234567"
language: "en"
fields: [{ key: "customer_tier", value: "gold" }]
}) { id displayName }
}The returned DTO is masked unless the key also holds PII_READ - so a write-only key gets back null for the very values it just set. That is expected.
Custom Fields
Wexio contacts carry custom fields in two layers: a definition (the field itself - name, type, options) and a value per contact.
Definitions
getPeopleFieldDefinitions(filter: PeopleFieldsFilterDto): [PeopleFieldDefinition!]! # CONTACTS_READ
createPeopleFieldDefinition(input: CreateFieldDefinitionDto!): PeopleFieldDefinition! # CONTACTS_MANAGE
updatePeopleFieldDefinition(id: String!, input: UpdateFieldDefinitionDto!): PeopleFieldDefinition! # CONTACTS_MANAGE
deletePeopleFieldDefinition(id: String!): Boolean! # CONTACTS_MANAGEKeys manage CUSTOM fields only. A key cannot create, modify or delete the built-in fields Wexio ships, and setPeopleFieldValue rejects a system field outright - even with CONTACTS_MANAGE. You manage only the fields your org defined.
Values
getPeopleFieldValues(peopleId: String!, filter: PeopleFieldsFilterDto): [PeopleFieldValue!]! # CONTACTS_READ
getPeopleFieldValue(id: String!): PeopleFieldValue! # CONTACTS_READ
setPeopleFieldValue(input: SetFieldValueDto!): PeopleFieldValue! # CONTACTS_MANAGE
deletePeopleFieldValue(peopleId: String!, fieldId: String!): Boolean! # CONTACTS_MANAGESetFieldValueDto
| Field | Type | Required | Description |
|---|---|---|---|
peopleId | String! | Yes | The contact. |
fieldId | String! | Yes | The field definition. |
value | JSON | No | The value. Omit or pass null to clear it. |
Example
mutation SetTier {
setPeopleFieldValue(input: {
peopleId: "6804f4d5a6f9f35f6e66f1a2"
fieldId: "6805c1a0b4d5e6f70123ab45"
value: "gold"
}) { _id value }
}Field values are person PII: a key without PII_READ gets them masked on read, even values it set itself.
Erasure (GDPR)
Erasure is not an update - it destroys a contact and the data attached to it, permanently, to satisfy a right-to-erasure request.
It is gated twice over:
| Requirement | Detail |
|---|---|
| Scope | CONTACTS_ERASE - deliberately separate from CONTACTS_MANAGE. |
| Role | OWNER, ADMIN or EDITOR. A key is held to the same role bar as a person. |
| Org | Org-scoped. A caller can only erase contacts in the org it is acting on. |
previewErase
previewErase(input: EraseInput!): ErasePreviewType!CONTACTS_ERASE
A dry run. Changes nothing, and tells you exactly what the real call would destroy.
Arguments - input: EraseInput!
| Field | Type | Required | Description |
|---|---|---|---|
contactId | ID! | Yes | The contact to erase. |
organisationId | ID! | Yes | The org it belongs to. |
reason | String | No | Why - recorded for your own audit trail. |
Returns ErasePreviewType!
| Field | Type | Description |
|---|---|---|
contactId | ID! | The contact the preview is for. |
willErase | [EraseCount!]! | What would be destroyed, as { collection, count } pairs. |
warnings | [String!]! | Anything you should see before proceeding. Empty when there is nothing to flag. |
Example
query Preview {
previewErase(input: {
contactId: "6804f4d5a6f9f35f6e66f1c9"
organisationId: "6716b2f0a1c34d0012ab89ef"
}) {
contactId
willErase { collection count }
warnings
}
}{
"data": {
"previewErase": {
"contactId": "6804f4d5a6f9f35f6e66f1c9",
"willErase": [
{ "collection": "messages", "count": 412 },
{ "collection": "chats", "count": 3 },
{ "collection": "fieldValues", "count": 11 }
],
"warnings": []
}
}
}Always call this first and show the result to a human. It is the only chance to notice that the wrong contact was selected, or that a merged contact carries far more history than expected.
eraseContact
eraseContact(input: EraseInput!): EraseResultType!CONTACTS_ERASE
Performs the erasure. Same input as the preview.
Returns EraseResultType!
| Field | Type | Description |
|---|---|---|
contactId | ID! | The contact that was erased. |
erasedAt | DateTime! | When it happened. |
affectedCounts | [EraseCount!]! | What was actually destroyed. |
erasureLogId | ID! | The erasure record - keep it as your proof the request was fulfilled. |
Example
mutation Erase {
eraseContact(input: {
contactId: "6804f4d5a6f9f35f6e66f1c9"
organisationId: "6716b2f0a1c34d0012ab89ef"
reason: "Subject access request #4471"
}) {
erasedAt
erasureLogId
affectedCounts { collection count }
}
}This is irreversible. There is no undo, and no equivalent of a merge split to put the data back. Compare affectedCounts against the preview's willErase - a mismatch means something changed between the two calls.
Store the erasureLogId and your reason on your side. The erasure record is what lets you answer "prove you deleted it" later, and reason is the only field carrying your own ticket reference into it.
A Safe Erase Flow
previewErase- readwillEraseandwarnings.- Put the counts in front of a human and get an explicit confirmation.
eraseContactwith the samecontactIdand areasonnaming your ticket.- Record
erasureLogIdanderasedAt.
Do not skip step 2 in an automated pipeline. An erasure triggered by a bad contact ID cannot be walked back.
Names from WhatsApp
A contact's WhatsApp profile name is used only to fill a gap. It replaces an empty name, or one that is just the phone number - it never overwrites a name set by you or by an operator.
So a name you import survives, and a contact you created without one stops reading as a bare number once they message. You do not have to choose between the two.
isWhatsAppIdEditable
isWhatsAppIdEditable(peopleId: String!): Boolean!CONTACTS_READ
Whether this contact's WhatsApp identity can still be changed. Check it before offering an edit - a contact with live WhatsApp history is locked.
A Note on IDs
Two different identifiers appear here, and mixing them is the easiest mistake to make:
| ID | Used by |
|---|---|
ContactApi.id | contactApi, updateContactApi |
externalId (yours) | upsertContactApi, contactApiByExternalId - survives merges |
field key | the fields array on a contact write |
peopleId | the people-field operations, and ContactPayload.peopleId on webhooks |
Webhook payloads also carry contactId - the merge-winner ID. Key your own records on that if you want contact merges to reconcile cleanly.