GraphQL API

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!): ContactApi

CONTACTS_READ

Reads one contact. Returns null when there is no such contact in the org you are acting on.

Returns ContactApi

FieldTypeDescription
idID!The contact ID.
displayNameStringName as shown in the inbox. Masked without PII_READ.
avatarUrlStringProfile photo. Masked without PII_READ.
phoneStringPhone number. Masked without PII_READ.
emailStringEmail address. Masked without PII_READ.
channels[String!]!Channels this contact has used.
erasedBoolean!true when the contact has been erased on request.
externalIdStringYour 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

ArgumentTypeDescription
filter.searchStringFree-text search. Requires PII_READ - it searches identity fields, so a masked key cannot use it. Makes the call expensive.
filter.externalIdStringYour own identifier.
filter.phoneStringPhone number.
filter.channelContactChannelTELEGRAM, WHATSAPP, VIBER, INSTAGRAM or WEB. Expensive.
filter.fields[ContactApiFieldFilterInput!]Match custom field values - { key, value }. Expensive.
filter.createdAfter / createdBeforedateCreated-date range.
sortDirSortDirectionSort direction.
pagination.firstIntPage size. Default 20, clamped to 100 for keys.
pagination.afterIDCursor 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:

ModelHow it works
You keep your own user recordsSend us a channel identity plus your externalId. Your system stays the source of truth.
You have no user recordsUse 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!

FieldTypeDescription
externalIdStringYour identifier for this person. The idempotency key.
displayNameStringName shown in the inbox.
firstName / lastNameStringGiven and family name.
phoneStringPhone number, E.164.
languageStringPreferred language code.
fields[ContactApiFieldInput!]Custom field values as { key, value } - the field's key, not its ID.
channelContactApiChannelInputBind 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

FieldTypeDescription
kindContactChannelKind!WHATSAPP, TELEGRAM or VIBER.
phoneStringFor WHATSAPP.
telegramIdStringFor TELEGRAM.
viberUserIdStringFor VIBER.
integrationIdStringWhich 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!): ContactApi

CONTACTS_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

FieldTypeDescription
displayNameStringName shown in the inbox.
firstNameStringGiven name.
lastNameStringFamily name.
phoneStringPhone number, E.164.
languageStringPreferred 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_MANAGE

Keys 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_MANAGE

SetFieldValueDto

FieldTypeRequiredDescription
peopleIdString!YesThe contact.
fieldIdString!YesThe field definition.
valueJSONNoThe 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:

RequirementDetail
ScopeCONTACTS_ERASE - deliberately separate from CONTACTS_MANAGE.
RoleOWNER, ADMIN or EDITOR. A key is held to the same role bar as a person.
OrgOrg-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!

FieldTypeRequiredDescription
contactIdID!YesThe contact to erase.
organisationIdID!YesThe org it belongs to.
reasonStringNoWhy - recorded for your own audit trail.

Returns ErasePreviewType!

FieldTypeDescription
contactIdID!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!

FieldTypeDescription
contactIdID!The contact that was erased.
erasedAtDateTime!When it happened.
affectedCounts[EraseCount!]!What was actually destroyed.
erasureLogIdID!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

  1. previewErase - read willErase and warnings.
  2. Put the counts in front of a human and get an explicit confirmation.
  3. eraseContact with the same contactId and a reason naming your ticket.
  4. Record erasureLogId and erasedAt.

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:

IDUsed by
ContactApi.idcontactApi, updateContactApi
externalId (yours)upsertContactApi, contactApiByExternalId - survives merges
field keythe fields array on a contact write
peopleIdthe 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.

On this page