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:
displayNameis derived fromfirstName+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 keepsdisplayName: null.timezonedefaults 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), soownerUserIdis
accepted as an equivalent input if you hold a user id instead. - Omitting the field leaves the current owner alone; sending
nullunassigns. - 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. ownerAgentIdis not a column on the contact: it lives on the contact's CRM
profile, which is created for you on first assignment. The
contact-profilesresource 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-Leadlands on the existing
bot-leadinstead of forking your vocabulary. - Idempotent per (contact, tag): a replay reports
alreadyTaggedand rewrites
nothing.DELETEreportsdetached: falseif the tag wasn't there. - Unknown ids come back in
notFoundrather 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. tagsis not writable on the contact itself.POST /crm/contactsor
PATCH /crm/contacts/{id}carryingtags(ortagIds,tagNames) returns
400 tags_not_writable_inlinenaming this endpoint, because a tag array
quietly dropped from a200response 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 return404 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.