واجهة برمجة واتساب

Contacts & Tags

Create, update, tag and delete WhatsApp contacts over the StartMessaging API: upsert by phone, sync with updatedSince, opt-in rules, tags and pipeline stages.

Overview

Contacts are the people your workspace talks to on WhatsApp. The same contacts appear in the dashboard’s CRM, inbox and campaign audiences, so anything you write through the API is what your team sees.

A contact is created automatically when a customer messages you or when you send to a new number with POST /api/v1/messages. Use the contacts endpoints to sync customers from your shop, CRM or ERP, keep tags and custom fields up to date, and record consent.

Method & pathScopeWhat it does
GET /api/v1/contactscontacts:readList contacts, with filters
POST /api/v1/contactscontacts:writeCreate or update a contact by phone (upsert)
GET /api/v1/contacts/{idOrPhone}contacts:readGet one contact
PATCH /api/v1/contacts/{idOrPhone}contacts:writeChange only the fields you send
DELETE /api/v1/contacts/{idOrPhone}contacts:writeSoft-delete a contact
GET /api/v1/tagscontacts:readTags in use, with counts
GET /api/v1/pipeline-stagescontacts:readThe stages of your leads pipeline

All paths are relative to the API host, for example https://waba-api.startmessaging.com/api/v1/contacts. Every request needs Authorization: Bearer sm_live_your_api_key.

{idOrPhone} is either the contact’s id or its phone number in any format. URL-encode the + as %2B, or send the digits with the country code (919812345678). A number without a country code is read against your workspace’s country. A contact without a phone number (see waUserId below) can only be addressed by its id.

Plans. The contacts endpoints work on every plan that includes the developer API, Developer included, even though a Developer workspace’s dashboard only shows contacts. Putting a contact into a pipeline stage (pipelineStageId) needs the sales pipeline, on Business. The free trial caps a workspace at 1,000 contacts.

The contact object

{
  "id": "3f0c2a52-8a51-4c0e-9d0a-2b7f8f3a9c11",
  "phone": "+919812345678",
  "waUserId": "IN.13491208655302741918",
  "name": "Asha Verma",
  "email": "asha@example.com",
  "tags": ["vip", "delhi"],
  "attributes": { "city": "Pune", "orderId": "A-1042" },
  "optedIn": true,
  "source": "api",
  "pipelineStageId": null,
  "assignedToUserId": null,
  "followUpAt": null,
  "createdAt": "2026-10-02T10:00:00.000Z",
  "updatedAt": "2026-10-02T10:05:00.000Z"
}
FieldDescription
idContact id (UUID).
phonePhone number in E.164, or null for someone with a WhatsApp username whom Meta names only by their user id (waUserId), until they share their number by talking to you. A few contacts created before numbers were normalised hold it without the leading +.
waUserIdMeta’s business-scoped user id (a country code, a period, then up to 128 letters and digits), once Meta has sent one, or null. Stable for your business; it changes only if the person changes their number.
name, emailOr null.
tagsTags, in the order they were added.
attributesCustom fields. Keys and values are text.
optedInfalse when this person opted out (for example by replying STOP). Messages to them are refused until they opt back in. Turning off your marketing messages inside WhatsApp does not change it (see below).
sourceHow the contact first arrived: whatsapp, manual, csv, link, api or ctwa_ad (their first message came from a Click-to-WhatsApp ad, and the ad is in the ctwa_* attributes).
pipelineStageIdA stage from GET /api/v1/pipeline-stages, or null when the contact is not in the pipeline.
assignedToUserIdThe workspace member who owns this contact, or null.
followUpAtWhen to follow up, or null.
createdAt, updatedAtupdatedAt is what updatedSince filters on.

List contacts

GET /api/v1/contacts returns contacts newest first, in the standard list shape { "items": [...], "nextCursor": "..." }. Pass nextCursor back as cursor until it is null; limit is 1–100 (default 25). See Pagination & Idempotency.

Query parameterDescription
phoneOnly the contact with this number, in any format (URL-encode + as %2B).
tagOnly contacts with this tag. Repeat it to require every tag: ?tag=vip&tag=delhi.
optedIntrue for contacts you may message, false for those who opted out. Only the words true and false are accepted.
updatedSinceIncremental sync: only contacts changed at or after this time, oldest change first. An ISO-8601 date, or a date and time with an offset (2026-10-01T00:00:00Z).
curl "https://waba-api.startmessaging.com/api/v1/contacts?tag=vip&optedIn=true&limit=50" \
  -H "Authorization: Bearer sm_live_your_api_key"
const res = await fetch(
  "https://waba-api.startmessaging.com/api/v1/contacts?tag=vip&optedIn=true&limit=50",
  { headers: { Authorization: "Bearer sm_live_your_api_key" } }
);
const { data } = await res.json();
console.log(data.items.length, data.nextCursor);
import requests

res = requests.get(
    "https://waba-api.startmessaging.com/api/v1/contacts",
    headers={"Authorization": "Bearer sm_live_your_api_key"},
    params={"tag": "vip", "optedIn": "true", "limit": 50},
)
page = res.json()["data"]
print(len(page["items"]), page["nextCursor"])

Incremental sync with updatedSince

To mirror contacts into your own database:

  1. Call GET /api/v1/contacts?updatedSince=... and page through with cursor, keeping updatedSince unchanged while paging (a cursor belongs to its query).
  2. Upsert each row by id, and store the updatedAt of the last row you processed as your checkpoint.
  3. Start the next sync a few minutes before that checkpoint. Changes commit in parallel, so a row can land slightly behind one you already saw. Seeing a row twice is harmless; missing one is not.

For the first sync, use a date from before your workspace existed, such as 2020-01-01. Deleted contacts drop out of the list rather than coming back as a change. Without updatedSince, the list is ordered by createdAt and updatedAt is not a usable checkpoint.

Create or update a contact (upsert)

POST /api/v1/contacts creates a contact, or updates the one that already has this phone number, so your system can send a customer as it knows them without looking them up first.

FieldDescription
phoneRequired. E.164 is safest; a number without a country code is read against your workspace’s country.
nameUp to 120 characters. null or "" clears it on an existing contact.
emailnull clears it on an existing contact.
tagsA list replaces the contact’s tags. { "add": [...], "remove": [...] } changes them and leaves the rest alone, which is usually what an upsert wants.
attributesMerged into the contact’s custom fields. A null value deletes that field.
optedInA new contact’s starting consent (default true). See Opt-in and opt-out.
  • New number: 201 Created, created: true.
  • Known number: 200 OK, created: false. Only the fields you send change.

Send an Idempotency-Key header to retry safely.

curl -X POST "https://waba-api.startmessaging.com/api/v1/contacts" \
  -H "Authorization: Bearer sm_live_your_api_key" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: customer-1042-sync" \
  -d '{
    "phone": "+919812345678",
    "name": "Asha Verma",
    "email": "asha@example.com",
    "tags": { "add": ["website-order"] },
    "attributes": { "city": "Pune", "lastOrderId": "A-1042" }
  }'
const res = await fetch("https://waba-api.startmessaging.com/api/v1/contacts", {
  method: "POST",
  headers: {
    Authorization: "Bearer sm_live_your_api_key",
    "Content-Type": "application/json",
    "Idempotency-Key": "customer-1042-sync",
  },
  body: JSON.stringify({
    phone: "+919812345678",
    name: "Asha Verma",
    email: "asha@example.com",
    tags: { add: ["website-order"] },
    attributes: { city: "Pune", lastOrderId: "A-1042" },
  }),
});
const { data } = await res.json(); // res.status is 201 (created) or 200 (updated)
console.log(data.created, data.contact.id);
import requests

res = requests.post(
    "https://waba-api.startmessaging.com/api/v1/contacts",
    headers={
        "Authorization": "Bearer sm_live_your_api_key",
        "Idempotency-Key": "customer-1042-sync",
    },
    json={
        "phone": "+919812345678",
        "name": "Asha Verma",
        "email": "asha@example.com",
        "tags": {"add": ["website-order"]},
        "attributes": {"city": "Pune", "lastOrderId": "A-1042"},
    },
)
data = res.json()["data"]
print(res.status_code, data["created"], data["contact"]["id"])

Response:

{
  "data": {
    "contact": {
      "id": "3f0c2a52-8a51-4c0e-9d0a-2b7f8f3a9c11",
      "phone": "+919812345678",
      "waUserId": null,
      "name": "Asha Verma",
      "email": "asha@example.com",
      "tags": ["website-order"],
      "attributes": { "city": "Pune", "lastOrderId": "A-1042" },
      "optedIn": true,
      "source": "api",
      "pipelineStageId": null,
      "assignedToUserId": null,
      "followUpAt": null,
      "createdAt": "2026-10-02T10:00:00.000Z",
      "updatedAt": "2026-10-02T10:00:00.000Z"
    },
    "created": true
  },
  "meta": { "requestId": "...", "timestamp": "2026-10-02T10:00:00.000Z" }
}

A new contact counts towards your plan’s contact limit, if it has one (the free trial allows 1,000). When the limit is full, creating one answers 403 PLAN_LIMIT_REACHED with details.limit: "max_contacts". Numbers you already have can still be updated and messaged. Delete contacts you no longer need or upgrade; retrying will not help.

Request limits

  • tags: at most 100 per list, each 1–100 characters.
  • attributes: at most 50 keys per request, keys up to 64 characters, values text up to 500 characters (or null to delete).

Get a contact

curl "https://waba-api.startmessaging.com/api/v1/contacts/%2B919812345678" \
  -H "Authorization: Bearer sm_live_your_api_key"

A phone that matches no contact, or is not a phone number at all, is 404 CONTACT_NOT_FOUND, like an unknown id.

Update a contact

PATCH /api/v1/contacts/{idOrPhone} changes only the fields you send.

FieldDescription
name, emailnull clears them.
tagsA list replaces them. { "add": [...], "remove": [...] } changes them in one step and is safe while other systems tag the same contact. A tag in both lists ends up removed.
attributesMerged; a null value deletes that field.
optedInfalse records an opt-out. true records that they opted back in: the only way the API lifts an opt-out.
pipelineStageIdA stage id from GET /api/v1/pipeline-stages, or null to take the contact out of the pipeline. Putting a contact into a stage needs the sales pipeline (Business); otherwise 403 PLAN_FEATURE_REQUIRED with details.feature: "sales_pipeline". Taking one out (null) works on every plan. Moving a contact from a stage that is not won into a stage marked as won records a conversion, credited to the campaign last delivered to them within the conversion window, or to no campaign when none was. Moving them out of every won stage removes that conversion.
assignedToUserIdAn active member with the Agent role or above, or null to unassign. Otherwise 422 ASSIGNEE_NOT_MEMBER.
followUpAtAn ISO-8601 date, or date and time with an offset. null clears it.
curl -X PATCH "https://waba-api.startmessaging.com/api/v1/contacts/%2B919812345678" \
  -H "Authorization: Bearer sm_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "tags": { "add": ["vip"], "remove": ["lead"] },
    "attributes": { "city": "Mumbai", "coupon": null },
    "followUpAt": "2026-10-05T09:30:00+05:30"
  }'
await fetch(
  `https://waba-api.startmessaging.com/api/v1/contacts/${encodeURIComponent("+919812345678")}`,
  {
    method: "PATCH",
    headers: {
      Authorization: "Bearer sm_live_your_api_key",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      tags: { add: ["vip"], remove: ["lead"] },
      attributes: { city: "Mumbai", coupon: null },
    }),
  }
);
requests.patch(
    "https://waba-api.startmessaging.com/api/v1/contacts/%2B919812345678",
    headers={"Authorization": "Bearer sm_live_your_api_key"},
    json={
        "tags": {"add": ["vip"], "remove": ["lead"]},
        "attributes": {"city": "Mumbai", "coupon": None},
    },
)

An unknown stage id is 404 PIPELINE_STAGE_NOT_FOUND.

Delete a contact

DELETE /api/v1/contacts/{idOrPhone} answers 204 No Content. It is a soft delete:

  • The contact disappears from lists and lookups and stops counting towards your plan’s contact limit.
  • Its conversations keep their history, but their contactId becomes null until the number writes in or is messaged again.
  • Adding the same number later creates a new contact.
  • Consent is not deleted. If the person opted out, the opt-out stays on the number (see below).

Write-only keys

contacts:write does not include contacts:read. A key with write but not read gets back only the id, never the record:

  • POST /api/v1/contacts → { "contact": { "id": "..." }, "created": true }
  • PATCH /api/v1/contacts/{idOrPhone} → { "id": "..." }

Opt-in and opt-out

WhatsApp requires consent for business messages, and the API enforces it. A send to an opted-out contact is refused with 422 CONTACT_OPTED_OUT, and campaigns skip them.

How someone opts out:

  • They reply with an opt-out keyword: STOP, UNSUBSCRIBE, OPT OUT, OPTOUT, CANCEL, रुको or بند. The whole message must be the keyword; case and surrounding spaces don’t matter.
  • You record it: PATCH with optedIn: false, POST /api/v1/contacts with optedIn: false, or the dashboard.

How someone opts back in:

  • They send an opt-in keyword: START, SUBSCRIBE, OPT IN or OPTIN (same matching rules).
  • You record a real opt-in with PATCH /api/v1/contacts/{idOrPhone} and optedIn: true.

POST /api/v1/contacts never opts anyone back in. optedIn is a new contact’s starting consent. On a number you already have, only optedIn: false is applied; optedIn: true is ignored. This stops a routine order sync from cancelling a customer’s STOP.

An opt-out belongs to the number, not the contact. It is stored against the phone number as well as the contact, so deleting the contact does not remove it. Sends to the number are still refused, and a contact created for it again (by POST /api/v1/contacts with any optedIn, a CSV import, or the customer writing in) starts opted out. Only START or a PATCH with optedIn: true lifts it. Opt-outs are per workspace.

A contact imported by CSV without confirmed consent starts opted out but has not opted out of anything: deleting it frees the number.

Turning off marketing inside WhatsApp

WhatsApp also lets a customer stop marketing messages from your business in the chat itself. That choice is about marketing only, and it is not an opt-out:

  • optedIn does not change, and no contact.opted_out webhook fires.
  • Utility and authentication templates, and your replies, still reach them.
  • Campaigns that send a marketing template skip them for 90 days, and the campaign report counts them as skipped. If they turn marketing back on in WhatsApp, campaigns include them again.
  • Meta refuses marketing templates sent to them from anywhere, the API included (Meta error 131050).

Campaigns also skip, for 30 days, a number Meta recently reported as unable to receive WhatsApp messages (not on WhatsApp, an outdated app, or terms not accepted). A message from them clears it. Neither mark is part of the contact object in the API.

Tags

GET /api/v1/tags lists every tag on a contact in your workspace with how many contacts carry it, most used first. The whole list comes in one page (nextCursor is always null).

curl "https://waba-api.startmessaging.com/api/v1/tags" \
  -H "Authorization: Bearer sm_live_your_api_key"
{
  "data": {
    "items": [
      { "tag": "vip", "count": 42 },
      { "tag": "website-order", "count": 17 }
    ],
    "nextCursor": null
  },
  "meta": { "requestId": "...", "timestamp": "2026-10-02T10:00:00.000Z" }
}

Tags exist only on contacts: add a tag to a contact and it appears here.

Pipeline stages

GET /api/v1/pipeline-stages lists the columns of your leads pipeline, left to right. Use a stage’s id as a contact’s pipelineStageId.

{
  "data": {
    "items": [
      { "id": "9b1e4c3d-2f6a-4b8e-a1d0-5c7e9f2b3a44", "name": "New", "position": 0, "isDefault": true },
      { "id": "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f", "name": "Interested", "position": 1, "isDefault": false }
    ],
    "nextCursor": null
  },
  "meta": { "requestId": "...", "timestamp": "2026-10-02T10:00:00.000Z" }
}

Stages are created and renamed in the dashboard. New contacts are not placed in the default stage automatically.

Contact webhooks

Subscribe to these events (they need contacts:read) to hear about contacts without polling. See Webhooks.

EventWhen
contact.createdA contact was created: by an inbound message, the dashboard or the API. Not sent per row of a CSV import.
contact.opted_outConsent was withdrawn: STOP, the dashboard or the API. Turning off marketing inside WhatsApp does not fire it.
contact.opted_inConsent was given back: START, the dashboard or the API.
{
  "id": "evt_5b2e8f9a-1c3d-4e5f-8a9b-0c1d2e3f4a5b",
  "type": "contact.opted_out",
  "createdAt": "2026-10-02T10:00:00.000Z",
  "workspaceId": "...",
  "data": {
    "contact": {
      "id": "3f0c2a52-8a51-4c0e-9d0a-2b7f8f3a9c11",
      "phone": "+919812345678",
      "waUserId": null,
      "name": "Asha Verma",
      "email": null,
      "tags": ["vip"],
      "optedIn": false,
      "source": "api",
      "createdAt": "2026-10-02T09:00:00.000Z"
    }
  }
}

A contact created already opted out (because the number opted out earlier) is announced by contact.created with optedIn: false, with no contact.opted_out, since consent did not change.

FAQ

Should I use POST or PATCH to sync customers? Use POST /api/v1/contacts. It upserts by phone, so you do not need to look the contact up first. Use tags: { "add": [...] } so you do not wipe tags your team added in the dashboard.

A customer asked to hear from us again. How do I opt them back in? PATCH /api/v1/contacts/{idOrPhone} with "optedIn": true, only when they really did opt in. POST with optedIn: true is ignored on a number you already have.

I deleted a contact but sends still fail with CONTACT_OPTED_OUT. Why? Opt-outs belong to the phone number and survive deleting the contact. The customer must send START, or you must record a real opt-in with PATCH and optedIn: true.

Why do I get 403 PLAN_LIMIT_REACHED when creating a contact? Your plan’s contact limit is full (details.limit is max_contacts). Deleted contacts stop counting, so remove ones you no longer need, or upgrade.

Why does my write return only an id? Your API key has contacts:write but not contacts:read. Add the read scope to the key if you need the full record back.