zhyper docs

Contacts

A contact is a person your team talks to. It belongs to exactly one profile, carries the details you keep about them (name, email, company, notes, tags, custom fields), and links to the accounts you reach them through.

Reads need contacts:read, writes need contacts:write. This is the same pair that guards custom field definitions: the definitions and the values they hold are one vertical, and a key that can read one can read the other.

The shape of a contact

{
  "id": "a1b2c3d4e5f6a7b8c9d0e1f2",
  "profileId": "b1c2d3e4f5a6b7c8d9e0f1a2",
  "displayName": "Ayse Yilmaz",
  "email": "ayse@example.com",
  "company": "Acme Ltd",
  "avatarUrl": null,
  "notes": "Renewal in March",
  "locale": "tr-TR",
  "timezone": "Europe/Istanbul",
  "tags": ["VIP"],
  "isSubscribed": true,
  "isBlocked": false,
  "customFields": { "customer-segment": "enterprise" },
  "channels": [],
  "firstSeenAt": "2026-02-01T09:00:00.000Z",
  "createdAt": "2026-08-12T10:00:00.000Z",
  "updatedAt": "2026-08-12T10:00:00.000Z"
}

firstSeenAt is deliberately separate from createdAt. When you import someone from another system they are created today but you have known them for months; squashing the two would lose the answer to "when did they become a customer" with no way to get it back.

profileId cannot be changed after creation. Tags, custom field values and channels are all profile-scoped, so moving the row would invalidate every one of them.

Creating one:

export async function createContact(
  zhyper: ZhyperClient,
  input: {
    profileId: string;
    displayName: string;
    email: string;
    tags: string[];
    firstSeenAt?: string;
  },
): Promise<Schemas['ContactDto']> {
  // `firstSeenAt` verilmezse SIMDIYE kurulur. Baska bir sistemden tasidiginiz
  // kisi icin GECMIS bir tarih gonderin: `createdAt` satirin bugun acildigini
  // soyler, `firstSeenAt` onu ne zamandir tanidiginizi.
  return zhyper.request<Schemas['ContactDto']>('createContact', {
    body: {
      profileId: input.profileId,
      displayName: input.displayName,
      email: input.email,
      tags: input.tags,
      ...(input.firstSeenAt === undefined ? {} : { firstSeenAt: input.firstSeenAt }),
    },
  });
}

Tags

Tags are stored normalised and projected back to you as a flat array. Two consequences follow, and both are deliberate:

Tags are case-insensitive. Creating VIP and then vip gives you one tag, not two, and ?tags=vip finds contacts tagged VIP. The casing that survives is the one written first, because the tag list is shown to your users and forcing everything to lower case would turn Enterprise Customer into enterprise customer. If tags were case-sensitive, the tag facet would list the same tag twice and a filter would silently miss half the matches.

Tags have ceilings. A contact carries at most 25 tags, and a profile defines at most 500 distinct tags. The profile ceiling exists because every list response carries the complete tag facet without pagination: at 500 tags the facet adds about 8.5 KB to each response, and 1000 would double that. Both numbers can be raised without breaking a client; they start at the smaller defensible value.

A tag that no contact carries anymore is removed. The facet only ever lists values that will actually return results.

Both consequences are executed against the live API by the test suite:

export async function tagCasingIsPreserved(
  zhyper: ZhyperClient,
  input: { profileId: string; firstCasing: string; secondCasing: string },
): Promise<{ tags: string[]; facet: string[] }> {
  const first = await zhyper.request<Schemas['ContactDto']>('createContact', {
    body: {
      profileId: input.profileId,
      displayName: 'Tag casing probe',
      tags: [input.firstCasing],
    },
  });

  // AYNI etiket, farkli yazim. Iki etiket olusmaz: ilk yazilan casing kalir ve
  // ikinci kisi de ona baglanir.
  await zhyper.request<Schemas['ContactDto']>('updateContact', {
    path: { contactId: first.id },
    body: { tags: [input.secondCasing] },
  });

  const list = await zhyper.request<Schemas['ContactListDto']>('listContacts', {
    query: { profileId: input.profileId, limit: 50 },
  });

  const contact = list.data.find((row) => row.id === first.id);
  return { tags: [...(contact?.tags ?? [])], facet: [...list.filters.tags] };
}

Listing and filtering

GET /v1/contacts supports both pagination models:

  • the cursor model shared by the rest of this API — limit plus the opaque cursor from nextCursor;
  • the offset model — limit plus skip, which additionally returns total, skip and limit. skip stops at 100000; a larger value is 400 invalid_skip with the message skip must not exceed 100000.

Combining skip and cursor is a 400, because the two answer "where am I" differently and a request that mixes them has no single correct next page.

If you have more than 100000 contacts and need to walk all of them, use the cursor. It has no ceiling, and it is the correct tool anyway: an offset that deep re-counts every row it skips on every request, and it silently repeats or drops rows when contacts are created while you page. There is no sort parameter: contacts come back newest first, always. A second sort axis would mean a second cursor shape, and this endpoint deliberately has one.

Filters: profileId, search (substring over displayName, email and company), tag and tags (comma separated, OR semantics), and isSubscribed. Every response also carries filters.tags — the tags in scope, alphabetically.

export async function contactsTagged(
  zhyper: ZhyperClient,
  input: { profileId: string; tag: string },
): Promise<string[]> {
  // Filtre etiketin YAZIMINA duyarli degildir: `vip` sorgusu `VIP` etiketli
  // kisileri de getirir.
  const list = await zhyper.request<Schemas['ContactListDto']>('listContacts', {
    query: { profileId: input.profileId, tag: input.tag, limit: 50 },
  });

  // `displayName` OPSIYONELDIR — bir kisi yalniz bir kanal kimligiyle de
  // yaratilabilir. Gosterime cikaran her istemcinin bir yedegi olmali.
  return list.data.map((contact) => contact.displayName ?? contact.email ?? contact.id);
}

Channels

A channel binds a contact to one connected account plus the identifier that account knows them by:

{
  "id": "c1d2e3f4a5b6c7d8e9f0a1b2",
  "contactId": "a1b2c3d4e5f6a7b8c9d0e1f2",
  "accountId": "d1e2f3a4b5c6d7e8f9a0b1c2",
  "platform": "whatsapp",
  "externalId": "905551112233",
  "displayIdentifier": "Ayse",
  "createdAt": "2026-08-12T10:00:00.000Z"
}

platform is derived from the account, never taken from the request. You may send it, in which case it is checked against the account and a mismatch is a 400 rather than a silent write to the wrong platform.

The triple (platform, accountId, externalId) is unique. Creating a contact on an identifier that already has a channel is a 409; the same row inside a bulk import is skipped instead, and its tags are merged onto the contact that already owns the identifier. The difference is intentional: a single call states an intent ("this is a new person"), an import states a source ("these are the rows I have").

Conversation identity and last-active timestamps are not part of this API version. They belong to the messages area, which does not exist yet; returning zeros would be worse than returning nothing.

Blocking is local, and stays local

isBlocked is a tenant-side suppression flag. Setting it stops zhyper from including that contact in your own sends. It does not call any provider's block API.

zhyper separately exposes /v1/whatsapp/block-users and /v1/whatsapp/block-users/status, which read and write WhatsApp's own block list. These two surfaces are independent by design, and they can disagree: a contact can be blocked here and not blocked on WhatsApp, or the reverse. That is not an inconsistency to be reconciled — they answer two different questions. "Do not let my campaigns reach this person" is a decision about your data. "Refuse this person's messages at WhatsApp" is a decision about your WhatsApp account, and it leaves a permanent mark there.

If you want both, call both. Nothing in this API will do it for you, and a test in the API suite exists specifically to keep it that way.

export async function suppressLocally(
  zhyper: ZhyperClient,
  contactId: string,
): Promise<boolean> {
  // Bu bayrak YALNIZ Zhyper tarafinda bastirir. Saglayicinin kendi engelleme
  // listesine (or. `/v1/whatsapp/block-users`) DOKUNMAZ; ikisini de istiyorsaniz
  // ikisini de cagirmalisiniz.
  const contact = await zhyper.request<Schemas['ContactDto']>('updateContact', {
    path: { contactId },
    body: { isBlocked: true },
  });

  return contact.isBlocked;
}

Custom field values

Definitions live under /v1/custom-fields and are profile-scoped. Values hang off a contact and are addressed by the definition's slug:

PUT    /v1/contacts/{contactId}/fields/{slug}
DELETE /v1/contacts/{contactId}/fields/{slug}

The value is validated against the definition's type: text wants a string, number a finite number (true is rejected, not coerced), boolean a boolean, date an ISO-8601 date or date-time string, and select a member of the definition's options.

Clearing a value that is not set is a 404, the same way deleting a definition twice is: a 200 would make "there was one and I removed it" indistinguishable from "there was never one".

Deleting the definition removes the value from every contact. This was promised when the definition endpoints shipped and is now true; the wire contract did not change.

export async function setSegment(
  zhyper: ZhyperClient,
  input: { profileId: string; contactId: string; segment: string },
): Promise<{ slug: string; value: unknown }> {
  // Tanim once gelir ve PROFIL kapsamlidir; deger tanimin `slug`i ile adreslenir.
  const definition = await zhyper.request<Schemas['CustomFieldDto']>('createCustomField', {
    body: {
      profileId: input.profileId,
      name: 'Customer segment',
      slug: 'customer-segment',
      type: 'select',
      options: ['enterprise', 'smb'],
    },
  });

  const stored = await zhyper.request<Schemas['ContactFieldValueDto']>(
    'setContactFieldValue',
    {
      path: { contactId: input.contactId, slug: definition.slug },
      body: { value: input.segment },
    },
  );

  return { slug: stored.slug, value: stored.value };
}

The slug is derived if you do not send one

slug is optional on POST /v1/custom-fields. Omit it — or send null — and it is derived from name:

namederived slug
Customer Segmentcustomer-segment
Café Visitscafe-visits
Größegrosse
Renewal date!!renewal-date

The rules, in order: diacritics are reduced to ASCII, letters are lower-cased, every run of characters that is not a letter or digit becomes a single -, and leading and trailing - are dropped. A result longer than 64 characters is truncated rather than refused, and the truncation is deterministic, so the address stays something you can work out in advance. If truncation makes two names land on the same slug you get the 409 below, and its message names the slug that was derived.

Two errors sit close together here, and in neither of them do we invent an identifier for you:

  • 400 slug_not_derivable — nothing survived the rules, because the name carried no letters or digits at all. A slug is an address: you write fields/customer-segment into your own code. A random one would be an address you would have to read back from us before you could use it, which is exactly the convenience the derivation exists to provide. Send an explicit slug instead.
  • 400 invalid_slug — the slug you did send does not fit the format: 1 to 64 characters of lowercase letters, digits, - or _, starting with a letter or a digit. slug: "" lands here rather than deriving, and that catches people out: an empty string is not the same as omitting the field. Omitting it asks us to derive one; sending an empty one asks for an address that cannot exist.

The two codes are separate on purpose. invalid_slug means "the slug you sent is malformed"; slug_not_derivable means "you sent none and none came out of the name". One code for both would send you to fix the wrong field.

A slug already taken on that profile is 409 custom_field_slug_taken, whether you sent it or we derived it. No numeric suffix is appended, for the same reason as above: the address you get back has to be the address you asked for.

Once created, a slug cannot be changed. PATCH /v1/custom-fields/{fieldId} accepts name and options, and no slug. Renaming a field changes its label; the address stays where your code already points.

Bulk import

POST /v1/contacts/bulk takes up to 1000 rows. A row that fails validation does not fail the import: it comes back in errors[] under a 200, with the index it had in your request. Rows whose channel already belongs to someone appear in skipped[] with the id of the contact that absorbed their tags. Exceeding 1000 rows is rejected outright, before any work is done, so you learn about it in one request rather than after the import finishes.

export async function importContacts(
  zhyper: ZhyperClient,
  input: {
    profileId: string;
    rows: Array<{
      displayName: string;
      email?: string;
      // Kanal baglama ICE AKTARIM sirasinda yapilabilir; ikisi BIRLIKTE gider.
      accountId?: string;
      externalId?: string;
    }>;
  },
): Promise<{
  created: number;
  skipped: number;
  errors: Array<{ index: number; code: string }>;
}> {
  // Gecersiz bir satir ICE AKTARIMI DUSURMEZ: her satir kendi transaction'inda
  // kosar, cevap 200 doner ve bozuk satir `errors[]` icinde ISTEKTEKI
  // INDEKSIYLE gelir. Kaynak dosyanizdaki satiri boylece bulabilirsiniz.
  const result = await zhyper.request<Schemas['BulkCreateContactsResponseDto']>(
    'bulkCreateContacts',
    { body: { profileId: input.profileId, contacts: input.rows } },
  );

  return {
    created: result.created.length,
    skipped: result.skipped.length,
    errors: result.errors.map((error) => ({ index: error.index, code: error.code })),
  };
}

Deleting

DELETE /v1/contacts/{contactId} removes the contact together with its channels, tag links and custom field values. There is no soft-delete window in this API version: when the call returns, the row is gone.

On this page