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 —
limitplus the opaquecursorfromnextCursor; - the offset model —
limitplusskip, which additionally returnstotal,skipandlimit.skipstops at100000; a larger value is400 invalid_skipwith the messageskip 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:
name | derived slug |
|---|---|
Customer Segment | customer-segment |
Café Visits | cafe-visits |
Größe | grosse |
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 writefields/customer-segmentinto 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 explicitsluginstead.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.
Accounts
An account is one connected destination: a Mastodon handle, a Discord channel, a Pinterest board, a LinkedIn page. It belongs to exactly one profile, and it is what a post target p
Platform capabilities
Platforms do not agree on anything. One takes four images, another takes one. One accepts a 9:16 video and rejects 1:1. One has no analytics per post but plenty per location. Hard-