CRM Resources

Manage your ZingaConnect CRM data programmatically. These endpoints expose
contacts, lists, campaigns, templates, teams, agents and more through a
uniform list / read / create / update / delete pattern under
/papi/v1/crm/. Learn it once and it works the same for every resource.

Reads require data:read; writes require data:write:*. (These are the standard
data scopes — not the messaging:* scopes used by the Messaging API.)

The pattern

For any {resource} in the table below:

Method Path Purpose
GET /papi/v1/crm/{resource} List (supports limit, offset, q, filters)
GET /papi/v1/crm/{resource}/{id} Read one
POST /papi/v1/crm/{resource} Create
PATCH /papi/v1/crm/{resource}/{id} Update (only the fields you send)
DELETE /papi/v1/crm/{resource}/{id} Delete

List responses use the standard { data, total, limit, offset } envelope, and
every field is returned in both camelCase and snake_case (see
Conventions). Request bodies accept either form.

curl "https://api-v1.zingasuite.com/papi/v1/crm/contacts?limit=20&q=sam" \
  -H "Authorization: Bearer zk_live_xxx"

Unknown fields are refused

A create or update that carries a field the resource doesn't have is answered with
400 unknown_fields — it is never accepted-and-ignored, because a 201 that
stored none of what you sent is the hardest kind of bug to notice. The reply names
both what it didn't recognise and what it accepts:

{
  "detail": {
    "error": "unknown_fields",
    "fields": ["phone"],
    "writable": ["city", "country", "email", "first_name", "phone_e164", "..."],
    "message": "Contacts does not accept phone. …"
  }
}

Read-only fields are the exception: id, entityId, createdAt and friends are
accepted and ignored, so you can GET a row, change one field and PATCH the
whole object back.

One thing to strip when you do that with a contact: the tags, tagNames
and tagIds a read carries back are refused on a write (they are a relation with
their own endpoint), so drop them from the body you send.

Also mind that a read returns every field twice — last_name and lastName.
If you edit one copy of a round-tripped body, delete the other: a body whose two
spellings of the same field disagree is refused with
400 conflicting_field_spellings rather than one of them being picked and your
edit dropped. Identical copies (an untouched echo) are fine.

Available resources

Resource key What it is
contacts The shared contact spine — identity + per-channel marketing consent
lists Static or dynamic (filter-based) contact lists
campaigns Campaign definitions (drip / calendar / broadcast)
message-templates Email & SMS templates with {{merge_field}} tokens
whatsapp-templates WhatsApp templates — read + create/submit via Meta (see below)
teams Sales/agent teams
agents Team members with CRM access
accounts Companies/organizations
contact-profiles CRM profile (lifecycle stage, owner) for a contact
tags Contact tags — the vocabulary; attaching one to a contact is its own endpoint
email-headers, email-footers Reusable email masthead / sign-off blocks
custom-fields Custom field definitions
pipelines, pipeline-stages Deal pipelines and their stages
lost-reasons, lead-sources, assignment-rules Sales configuration

Three relations don't fit the uniform pattern and have their own endpoints:
contact tags (/crm/contacts/{id}/tags),
list membership (/crm/list-contacts) and
campaign steps.

Leads and deals are intentionally not exposed here. In the console they are
narrowed to each user's team, and a flat key-authed list would bypass that
scoping — so they remain portal-only for now.

Contacts

contacts is the same identity spine shared with ZingaShop. Notable fields:
firstName, lastName, displayName, email, phone_e164, whatsapp_e164,
companyName, city, country, the per-channel consent flags
(email_marketing_consent, sms_marketing_consent, whatsapp_marketing_consent),
locale, timezone, and metadata_json.

curl -X POST https://api-v1.zingasuite.com/papi/v1/crm/contacts \
  -H "Authorization: Bearer zk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "firstName": "Sam", "lastName": "Lee",
    "email": "sam@example.com", "phone_e164": "+14155551234",
    "email_marketing_consent": true
  }'

Two fields are filled in for you when you leave them out:

  • displayName is derived from firstName + lastName, so the contact reads
    as a person everywhere in the console (list rows, the inbox, campaign merge
    fields) without you having to concatenate it. Send your own to override it; a
    contact with neither name keeps displayName: null.
  • timezone defaults to your workspace timezone, which is what per-recipient
    send windows and quiet hours are clamped against. An update never resets it.

Who owns the contact

Send ownerAgentId on create or update to assign the agent responsible for the
person — the rep your own system already has against the lead:

curl -X PATCH https://api-v1.zingasuite.com/papi/v1/crm/contacts/<contact-id> \
  -H "Authorization: Bearer zk_live_xxx" -H "Content-Type: application/json" \
  -d '{ "ownerAgentId": "<agent-id>" }'

Get that agent id from GET /crm/agents, or from agentId on
GET /crm/users if you only hold the person's email. Every
contact response echoes ownerAgentId, ownerUserId and ownerName, so you can
round-trip the same identifier you sent.

A few details worth knowing:

  • Ownership is stored against the platform user behind the agent (that is
    what the console's access tiers and assignment rules read), so ownerUserId is
    accepted as an equivalent input if you hold a user id instead.
  • Omitting the field leaves the current owner alone; sending null unassigns.
  • An agent id from another workspace is rejected with 400 owner_agent_not_found
    rather than being silently ignored — and nothing else in the call is written.
  • ownerAgentId is not a column on the contact: it lives on the contact's CRM
    profile, which is created for you on first assignment. The
    contact-profiles resource remains available for the rest of that record
    (lifecycle stage, account, score, job title, custom fields).

Tags on a contact

Every contact response carries its tags three ways, so you can branch on
whichever you hold:

{
  "tags": [{ "id": "398cecc1…", "name": "bot-lead", "color": null }],
  "tagNames": ["bot-lead"],
  "tagIds": ["398cecc1…"]
}

Attaching and detaching is a sub-resource, not a field on the contact:

GET    /papi/v1/crm/contacts/{id}/tags
POST   /papi/v1/crm/contacts/{id}/tags
DELETE /papi/v1/crm/contacts/{id}/tags/{tagNameOrId}
# tag a contact by name — the tag is created if your workspace doesn't have it
curl -X POST https://api-v1.zingasuite.com/papi/v1/crm/contacts/<contact-id>/tags \
  -H "Authorization: Bearer zk_live_xxx" -H "Content-Type: application/json" \
  -d '{ "tags": ["bot-lead"] }'

# and remove it (the tag itself survives — this only detaches)
curl -X DELETE "https://api-v1.zingasuite.com/papi/v1/crm/contacts/<contact-id>/tags/bot-lead" \
  -H "Authorization: Bearer zk_live_xxx"
Body field Notes
tags Names or ids, up to 50 per call (tag, tagId, tagIds also accepted)
create true by default — set false to refuse names your workspace doesn't already have
color Applied only to tags this call creates; never repaints an existing one

Details worth knowing:

  • Matching on a name is case-insensitive, so Bot-Lead lands on the existing
    bot-lead instead of forking your vocabulary.
  • Idempotent per (contact, tag): a replay reports alreadyTagged and rewrites
    nothing. DELETE reports detached: false if the tag wasn't there.
  • Unknown ids come back in notFound rather than failing the whole batch — but
    if nothing in the request resolved, the call returns
    404 no_tags_resolved:<refs>. A no-op is never reported as success.
  • tags is not writable on the contact itself. POST /crm/contacts or
    PATCH /crm/contacts/{id} carrying tags (or tagIds, tagNames) returns
    400 tags_not_writable_inline naming this endpoint, because a tag array
    quietly dropped from a 200 response is worse than a refusal.
  • Tags are shared with ZingaShop through the same contact, so one applied here
    shows up on the customer in ZingaShop too.

To find everyone carrying a tag, filter the contact list — tag takes a name or
an id, and an unknown tag returns 404 tag_not_found rather than an empty page:

curl -G https://api-v1.zingasuite.com/papi/v1/crm/contacts \
  -H "Authorization: Bearer zk_live_xxx" --data-urlencode "tag=bot-lead"

The tag vocabulary itself is an ordinary resource: GET/POST/PATCH/DELETE /papi/v1/crm/tags (deleting a tag there removes it from every contact).

Lists

kind is static (members added explicitly) or dynamic (membership resolved
from a saved filter_json).

curl -X POST https://api-v1.zingasuite.com/papi/v1/crm/lists \
  -H "Authorization: Bearer zk_live_xxx" -H "Content-Type: application/json" \
  -d '{ "name": "VIP customers", "kind": "static", "description": "Top buyers" }'

Dynamic list filters

Every key in filter_json is optional and narrows the audience; a dynamic list
with no keys at all resolves to nobody, not everybody.

Key Meaning
tag_ids + tag_match Carries these CRM tags ("any" default, or "all")
sources contact.source is one of these (shop, crm, import, …)
consent Marketing-consented and not suppressed on each channel listed
q Free text over name / email / phone
campaign_responses Answered a campaign with one of these replies

campaign_responses segments on what people replied, which is how a follow-up
gets sent only to the ones who said yes:

{
  "campaign_responses": {
    "keywords": ["I will visit"],
    "campaign_ids": ["<campaign-id>"],
    "exclude": false
  }
}

A match is a WhatsApp quick-reply button whose label or payload equals one of
keywords, or a typed reply quoting one of the campaign's messages. Comparison
ignores case and extra spaces. campaign_ids is optional — omit it to match the
answer on any campaign. exclude: true inverts the rule, giving you everyone who
did not answer that way (the non-responders to nudge).

Membership is live: someone who replies tomorrow joins the list on their own. A
follow-up campaign set to continuous enrolment on that list therefore behaves
like an auto-responder, without any extra wiring.

List membership

Putting a contact in a list is a relation, not a table, so it has its own
endpoints rather than a resource key. Use either shape — flat or nested; they do
the same thing.

Method Path Purpose
GET /crm/list-contacts?listId=&contactId= Membership rows
POST /crm/list-contacts Add contacts (body carries listId)
DELETE /crm/list-contacts?listId=&contactId= Remove one contact
DELETE /crm/list-contacts/{membershipId} Remove by the row's own id
GET /crm/lists/{listId}/contacts Nested equivalent
POST /crm/lists/{listId}/contacts Nested equivalent
DELETE /crm/lists/{listId}/contacts/{contactId} Nested equivalent

Reads need data:read; writes need data:write:lists (membership is part of the
list) — data:write:list-contacts is accepted too.

Enrol contacts

curl -X POST https://api-v1.zingasuite.com/papi/v1/crm/list-contacts \
  -H "Authorization: Bearer zk_live_xxx" -H "Content-Type: application/json" \
  -d '{ "listId": "<list-id>", "contactId": "<contact-id>", "ownerAgentId": "<agent-id>" }'

Send contactIds instead of contactId for a batch of up to 500. source sets
the provenance stamped on each row (default api), which is how the console shows
whether a member arrived by hand, by tag or through the API. ownerAgentId is
optional and does exactly what it does on a contact — see
Who owns the contact — so one call can enrol a lead and
record the rep for it.

{
  "listId": "9c81…", "listName": "VIP customers",
  "added": 1, "alreadyMembers": 0, "notFound": 0, "notFoundIds": [],
  "audienceCount": 412,
  "owner": { "assigned": true, "ownerUserId": "3f2a…", "contacts": 1 },
  "data": [
    { "id": "7d0e…", "listId": "9c81…", "contactId": "b41c…", "status": "added",
      "source": "api", "ownerAgentId": "aa19…", "ownerName": "Priya Nair",
      "contact": { "displayName": "Sam Lee", "email": "sam@example.com",
                   "channels": { "email": true, "sms": false, "whatsapp": false } } }
  ]
}

The call is idempotent per (list, contact): a contact already enrolled comes
back with status: "already_member" and no row is rewritten, so a sync may
replay. Ids that aren't live contacts of your workspace are collected in
notFoundIds instead of failing the whole batch — a malformed id, a deleted
contact and another workspace's contact all land there. An ownerAgentId you send
is applied to every contact in the batch that is now a member, including ones that
were already there, so a replay can correct an owner.

audienceCount is the list's size after the call — the cheapest confirmation that
a campaign now has someone to send to.

Read membership

# who is in this list (paged; supports q)
curl "https://api-v1.zingasuite.com/papi/v1/crm/lists/<list-id>/contacts?limit=50" \
  -H "Authorization: Bearer zk_live_xxx"

# which lists is this contact in
curl "https://api-v1.zingasuite.com/papi/v1/crm/list-contacts?contactId=<contact-id>" \
  -H "Authorization: Bearer zk_live_xxx"

# is this pair a member?
curl "https://api-v1.zingasuite.com/papi/v1/crm/list-contacts?listId=<list-id>&contactId=<contact-id>" \
  -H "Authorization: Bearer zk_live_xxx"

Rows carry the contact's identity, its CRM owner, and channels — the per-channel
deliverability (consented and not suppressed) that the send path will apply,
so you can see up front who a campaign actually reaches. Omit both filters to page
every membership row in the workspace.

Dynamic lists are read-only here

A dynamic list's membership is its saved filter_json, so a membership row
against one would be ignored at send time. POST and DELETE therefore return
400 dynamic_list_not_editable — change the list's filter_json with
PATCH /crm/lists/{id} instead. Reads do work: a dynamic list resolves live
through its filter, and its rows come back with id: null (there is no row to
delete) and source: "filter". Every read response tells you which case you are
in via listKind and editable.

Removing

curl -X DELETE "https://api-v1.zingasuite.com/papi/v1/crm/list-contacts?listId=<list-id>&contactId=<contact-id>" \
  -H "Authorization: Bearer zk_live_xxx"

{"deleted": false} means the contact wasn't a member — the call is idempotent,
not an error. Removing a contact from a list never deletes the contact.

Campaigns

A campaign is an audience (a list) plus an ordered sequence of steps. Build it in
three parts.

1. Create the campaign shell. Fields: name, description, campaign_type
(drip / calendar / broadcast), list_id, enrollment_mode
(static / continuous), default_timezone. status is not writable here
— a new campaign starts as a draft and is activated via the action below.

curl -X POST https://api-v1.zingasuite.com/papi/v1/crm/campaigns \
  -H "Authorization: Bearer zk_live_xxx" -H "Content-Type: application/json" \
  -d '{ "name": "Summer drip", "campaign_type": "drip", "list_id": "<list-id>" }'

2. Wire the drip steps — each step fires a template on a channel at a delay
after enrolment. channel is email / sms / whatsapp; pass
messageTemplateId (email/SMS) or waTemplateId (WhatsApp), plus delayAmount
and delayUnit (minutes / hours / days / weeks / months / years).

curl -X POST https://api-v1.zingasuite.com/papi/v1/crm/campaigns/<campaign-id>/steps \
  -H "Authorization: Bearer zk_live_xxx" -H "Content-Type: application/json" \
  -d '{ "channel": "email", "messageTemplateId": "<template-id>", "delayAmount": 0, "delayUnit": "days" }'
Method Path Purpose
GET /crm/campaigns/{id}/steps List the steps
POST /crm/campaigns/{id}/steps Add a step
PATCH /crm/campaigns/{id}/steps/{stepId} Update a step (send the full step body)
DELETE /crm/campaigns/{id}/steps/{stepId} Remove a step

3. Activate. Activation validates the audience + active steps and enrols
contacts, so it runs through a dedicated action rather than a raw status write:

curl -X POST https://api-v1.zingasuite.com/papi/v1/crm/campaigns/<campaign-id>/activate \
  -H "Authorization: Bearer zk_live_xxx"

Activation fails with no_audience if the campaign targets no list,
no_active_steps if every step is switched off, and no_future_dates if a
calendar campaign's dated steps are all in the past.

You can set the audience as part of activating by passing listIds — useful for
recovering from no_audience without a separate update round-trip. Omitting the
key (or sending an empty array) leaves the existing audience untouched; it is
never read as "clear the audience":

curl -X POST https://api-v1.zingasuite.com/papi/v1/crm/campaigns/<campaign-id>/activate \
  -H "Authorization: Bearer zk_live_xxx" -H "Content-Type: application/json" \
  -d '{ "listIds": ["<list-id>", "<other-list-id>"] }'

Pause an active campaign (or resume a paused one) with
POST /papi/v1/crm/campaigns/{id}/pause.

Email & SMS templates

Resource key message-templates. One table serves both channels via channel
(email | sms): email uses subject + body_html, SMS uses body_text.
Content may contain {{merge_field}} tokens.

curl -X POST https://api-v1.zingasuite.com/papi/v1/crm/message-templates \
  -H "Authorization: Bearer zk_live_xxx" -H "Content-Type: application/json" \
  -d '{
    "channel": "email", "name": "Welcome", "category": "marketing",
    "subject": "Welcome, {{first_name}}!",
    "body_html": "<p>Hi {{first_name}}, thanks for joining.</p>"
  }'

Email headers

email-headers are reusable HTML header blocks (masthead / logo / banner) that
an email template prepends. Fields: name, body_html, is_active.

curl -X POST https://api-v1.zingasuite.com/papi/v1/crm/email-headers \
  -H "Authorization: Bearer zk_live_xxx" -H "Content-Type: application/json" \
  -d '{
    "name": "Company masthead",
    "body_html": "<div style=\"text-align:center\"><img src=\"https://cdn.example.com/logo.png\" alt=\"Acme\"></div>",
    "is_active": true
  }'

To apply it, set headerId (the header's id) when you create or update an
email message-templates record — its HTML is rendered above the template body.

WhatsApp templates

WhatsApp templates live on Meta's WhatsApp Business platform, so they aren't a
plain table you edit. There are two paths.

Read (generic CRUD) — list your templates and each one's Meta approval
status (LOCAL, PENDING, APPROVED, REJECTED, PAUSED, DISABLED). Only
APPROVED templates are sendable.

curl "https://api-v1.zingasuite.com/papi/v1/crm/whatsapp-templates?limit=50" \
  -H "Authorization: Bearer zk_live_xxx"

Create + submit to Meta — a dedicated endpoint creates the template and, by
default (submitNow: true), submits it to Meta for review. category is
MARKETING / UTILITY / AUTHENTICATION; components is Meta's component
array (HEADER / BODY / FOOTER / BUTTONS); the name is normalized (lowercased,
spaces to underscores).

Sample values. Meta requires a sample for every {{n}} variable at
submission. Provide them as a simple mapping in example — body for the BODY
variables (in order) and header for a text HEADER's variables. The mapping is
stored on the template (example_json), and at submit time it's turned into
Meta's example.body_text / example.header_text shape for you. Any variable
you don't supply defaults to "Sample", so a submission never fails just for a
missing sample.

curl -X POST https://api-v1.zingasuite.com/papi/v1/crm/whatsapp-templates \
  -H "Authorization: Bearer zk_live_xxx" -H "Content-Type: application/json" \
  -d '{
    "channelConfigId": "<channel-id>", "name": "order_update",
    "language": "en_US", "category": "UTILITY",
    "components": [{ "type": "BODY", "text": "Your order {{1}} has shipped." }],
    "example": { "body": ["A1234"] },
    "submitNow": true
  }'

So {{1}} is submitted to Meta with the sample A1234. (Media headers still
need a sample-media handle — upload that in the console.)

Edit a draft. While a template is still LOCAL (not yet submitted) you can
change it with PATCH /crm/whatsapp-templates/{id} — same fields as create
(components, example, name, language, category; optional submitNow).
Once it's been submitted to Meta it's immutable, and PATCH returns
400 not_editable. (DELETE isn't exposed on the API — remove templates in the
console.)

Submit an existing local draft later with
POST /papi/v1/crm/whatsapp-templates/{id}/submit (you may pass an example
mapping in that call to set the samples first). The template becomes sendable
once Meta approves it — poll status (or sync it in the console).

Teams & agents

teams groups agents; agents links a platform user to CRM access via
access_tier (own / team / all).

curl -X POST https://api-v1.zingasuite.com/papi/v1/crm/agents \
  -H "Authorization: Bearer zk_live_xxx" -H "Content-Type: application/json" \
  -d '{ "user_id": "<user-id>", "access_tier": "team", "title": "Account Executive", "team_id": "<team-id>" }'

An agent is a link: user_id is required and the row holds no name, email or
phone of its own (send them and you get 409 duplicate_or_invalid_reference). Get that user_id from the endpoints below.

Users & invitations

These are not generic resources — they mint platform users and workspace
memberships, so they sit behind their own gate: your key's data:read /
data:write:users scope plus the entity-administration capability Create &
manage users
on the key owner. Owners and admins have it implicitly; for anyone
else, grant it under Settings → Users. role accepts member or admin —
ownership is never grantable from an API key.

Method Path Purpose
GET /papi/v1/crm/users Members, each with agentId when they're already an agent
POST /papi/v1/crm/users Create a user directly — no email sent
GET /papi/v1/crm/users/invitations Invitations (status=pending by default)
POST /papi/v1/crm/users/invitations Invite by email or SMS
DELETE /papi/v1/crm/users/invitations/{id} Revoke a pending invitation

Look up who already exists

GET /crm/users is the lookup a sync needs: it tells you, per person, whether to
reuse a userId, create an agent, or provision from scratch. Filter with email
(exact) or q (substring over email and names).

curl "https://api-v1.zingasuite.com/papi/v1/crm/users?email=priya@acme.com" \
  -H "Authorization: Bearer zk_live_xxx"

Rows carry userId, email, name, role, isOwner, status, agentId and
isAgent. People who have only been invited have no core_users row yet, so
they can't be members — they come back separately in pendingInvitations (first
page only; pass includePending=false to skip them).

Option A — create the user directly (no email round-trip)

POST /crm/users creates the user, adds them to your workspace, and — when you
pass agent — gives them a CRM agent, all in one call. The response contains the
userId immediately, so an integration can go from "salesperson in my system" to
a working agent with no human in the loop.

No email is sent and the address is never checked. It isn't validated for
syntax, deliverability or ownership, and isEmailVerified stays false, so an
internal login-style identifier is accepted as-is. Omit email entirely and a
unique placeholder in the reserved .invalid TLD is synthesized
(no-email+…@users.zingasuite.invalid) for people who have no address at all.
The trade-off: such a user can only sign in if you set a password here (min 8
characters) or an admin sets one later — use an invitation when the person should
choose their own.

curl -X POST https://api-v1.zingasuite.com/papi/v1/crm/users \
  -H "Authorization: Bearer zk_live_xxx" -H "Content-Type: application/json" \
  -d '{
    "name": "Priya Nair", "email": "priya@acme.com", "role": "member",
    "agent": { "accessTier": "team", "title": "Account Executive", "teamId": "<team-id>" }
  }'
{
  "created": true,
  "user": { "userId": "3f2a…", "email": "priya@acme.com", "name": "Priya Nair",
            "isEmailVerified": false, "hasPassword": false, "agentId": "9c81…" },
  "membership": { "role": "member", "created": true },
  "agent": { "id": "9c81…", "created": true }
}

The call is idempotent per email, so a sync may replay it: an existing member
comes back with created: false and is never re-named, re-roled or
re-passworded, and an existing agent is returned untouched (re-team or re-title
via PATCH /crm/agents/{id}). If the email belongs to a ZingaSuite user who is
not in your workspace, you get 409 user_exists with their userId rather
than a silent graft — repeat the call with attachExisting: true to add them.

The email is what makes that idempotent, so a call that omits it can't be: each
one mints a fresh placeholder and therefore a fresh user. Store the returned
userId against your own record before you retry.

Option B — invite them

POST /crm/users/invitations emails (or texts) a signup link instead. The
recipient consents, chooses their own password, and may sign up with a different
address; the membership and agent actions apply when they accept — so no userId
exists until then. Destinations are validated here, since an undeliverable one
just burns a send. Re-posting the same destination refreshes the pending
invitation and re-sends the link rather than duplicating it.

curl -X POST https://api-v1.zingasuite.com/papi/v1/crm/users/invitations \
  -H "Authorization: Bearer zk_live_xxx" -H "Content-Type: application/json" \
  -d '{
    "channel": "email", "destination": "priya@acme.com", "displayName": "Priya Nair",
    "role": "member", "agent": { "accessTier": "team", "teamId": "<team-id>" }
  }'

channel is email or sms. Track delivery via deliveryStatus on
GET /crm/users/invitations, and revoke a link with the DELETE above.

Notes

  • Every write is scoped to your entity and validated against the key owner's
    permissions; unknown resource keys return 404 unknown_resource.
  • Uniqueness rules (e.g. a template's name per channel) surface as
    409 duplicate_or_invalid_reference.
  • For error codes and rate limits, see Errors and
    Rate limits.
Was this helpful?