Contacts, segments & campaigns API
Contacts are the people a project can reach. Segments are saved audiences. Campaigns send one personalized message to the eligible contacts of an audience, paced per WhatsApp number. Read the campaigns guide first for how consent, pacing and delivery work.
| Scope | Allows |
|---|---|
contacts:read | List and read contacts, tags, imports, segments and previews |
contacts:write | Create, update, tag, opt out, record consent, import and erase contacts; manage tags and segments |
campaigns:read | List and read campaigns, recipients and campaign settings |
campaigns:write | Create and edit campaign drafts, test sends, campaign settings |
campaigns:launch | Launch, unschedule, pause, resume and cancel campaigns |
Contacts work on every plan; the number of contacts you can add through the API, imports and the dashboard is limited
by the plan (403 CONTACT_LIMIT_REACHED). People who message you are always added. Segments and campaigns need a plan
with campaigns (403 FEATURE_NOT_AVAILABLE with details.feature = "campaigns").
Contacts
Create or update
curl -X POST https://api.whatstig.com/v1/contacts \
-H "Authorization: Bearer $WHATSTIG_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"phone": "+92 300 1234567",
"externalId": "crm-4411",
"displayName": "Ayesha Khan",
"attributes": { "city": "Lahore", "plan": "gold" },
"tagIds": ["tag_..."],
"consent": { "status": "opted_in", "source": "form", "proof": { "text": "Ticked “WhatsApp updates” at checkout", "url": "https://shop.example.com/checkout" } }
}'
The request upserts by externalId (when given), else by phone number:
- Numbers without a country code use the project's default country.
attributesare merged into the existing ones (at most 30 per contact).tagIdsare added; existing tags are kept.consentrecords an opt-in with its proof. A contact who opted out stays opted out: the response then has"consentIgnored": true.
The response is { contact, created, consentIgnored }.
POST /v1/contacts/batch takes { "contacts": [...] } with up to 1,000 of the same objects. Each one succeeds or
fails on its own; the response lists results[] with status (created, updated, failed) and an error for
failed rows.
Read, search and change
| Endpoint | Purpose |
|---|---|
GET /v1/contacts | List with q (name or number), tagId, consentStatus (unknown, opted_in, opted_out) or segmentId |
GET /v1/contacts/{id} | Read a contact |
PATCH /v1/contacts/{id} | Set displayName, notes, externalId and attributes (replaced as a whole) |
PUT /v1/contacts/{id}/tags | Replace the contact's tags: { "tagIds": [...] } |
POST /v1/contacts/bulk-tags | { "contactIds": [...] } (up to 1,000) or { "segmentId" }, plus add and/or remove tag IDs |
DELETE /v1/contacts/{id} | Erase the contact with their conversations, messages, notes and media |
A contact has:
consent:{ status, source, at, optedOutAt }tagsexternalIdsource: how the contact was added (whatsapp,api,import,dashboard)lastInboundAt: their last message to you
Consent
| Endpoint | Purpose |
|---|---|
POST /v1/contacts/{id}/consent | Record an opt-in: { "source": "form", "proof": { "text", "url", "ip", "capturedAt" } } |
POST /v1/contacts/{id}/opt-out | Opt the contact out: { "reason": "…" } |
GET /v1/contacts/{id}/consent-events | The consent history with source, proof and who recorded it, newest first |
Recording an opt-in for a contact who opted out fails with 409 CONTACT_OPTED_OUT. Only the person can re-subscribe,
by sending START (or another opt-in keyword). An owner or admin can override in the dashboard with a note; the
override is written to the audit log.
When you erase a contact who opted out, a hash of the number is kept. Adding the number again (API, import or an inbound message) restores the opt-out.
CSV imports
curl -X POST https://api.whatstig.com/v1/contacts/imports \
-H "Authorization: Bearer $WHATSTIG_API_KEY" \
-F 'options={"mapping":{"phone":"Phone","displayName":"Name","attributes":{"city":"City"},"tags":{"column":"Groups","separator":";"}},"tagIds":[],"consent":{"text":"Signed up for WhatsApp updates on our website"},"updateExisting":true}' \
-F 'file=@contacts.csv;type=text/csv'
- Format. UTF-8 CSV with a header row, up to 20 MB. Send the
optionsfield before thefilefield. Column names are matched case-insensitively. - Processing. The import runs in the background (
202, statusqueued). PollGET /v1/contacts/imports/{id}forstatus,processedRows,createdCount,updatedCount,skippedCountanderrorCount. - Errors.
GET /v1/contacts/imports/{id}/errorslists up to 1,000 rows that were not imported, with the reason. Further errors are only counted. - Consent.
consentis your attestation that everyone in the file opted in. It is stored as the proof of each contact's opt-in. Contacts who opted out stay opted out. - Tags. Tags named in the tags column are created when missing.
- Cancelling.
POST /v1/contacts/imports/{id}/cancelstops after the batch in progress. Contacts already imported are kept.
Tags
GET /v1/tags, POST /v1/tags ({ "name", "color" }), PATCH /v1/tags/{id} and DELETE /v1/tags/{id} manage the
project's tags, shared by contacts and team inbox conversations.
Segments
A segment is a rule: a group of conditions combined with and or or, optionally negated, nested up to 4 levels with
at most 50 conditions.
{
"name": "Lahore VIPs active this quarter",
"rule": {
"type": "group",
"op": "and",
"rules": [
{ "type": "tag", "op": "has_any", "tagIds": ["tag_..."] },
{ "type": "attribute", "key": "city", "op": "eq", "value": "Lahore" },
{ "type": "last_inbound_at", "op": "within_days", "value": 90 },
{ "type": "group", "op": "or", "negate": true, "rules": [{ "type": "attribute", "key": "plan", "op": "in", "value": ["trial", "free"] }] }
]
}
}
| Condition | Operators |
|---|---|
tag | has_any, has_all, has_none with tagIds |
attribute | eq, neq, contains, not_contains (text, case-insensitive), gt, gte, lt, lte (numbers), in, not_in (lists), exists, not_exists |
consent | is, is_not with unknown, opted_in or opted_out |
created_at, last_inbound_at | before, after (timestamp), within_days, not_within_days (number of days), exists, not_exists |
conversation | exists, not_exists, optionally with an instanceId |
phone | starts_with leading digits, e.g. a country code |
import | Contacts added or updated by an import (importId) |
| Endpoint | Purpose |
|---|---|
GET /v1/segments, POST /v1/segments | List and create |
GET, PATCH, DELETE /v1/segments/{id} | Read, change, delete |
POST /v1/segments/preview | Preview a rule: { "rule" } |
GET /v1/segments/{id}/preview | Preview a saved segment |
A preview returns:
count: matching contacts, capped at 1,000,000 (seecapped)breakdown: how many a campaign would message under the project's consent mode (eligible), and how many it would skip (optedOut,noConsent,noAddress)- 20 sample contacts
A rule that takes longer than 5 seconds to evaluate fails with 400 SEGMENT_TOO_COMPLEX. Editing a segment does not
change campaigns that already launched: they keep the rule they started with.
Campaigns
Create and launch
curl -X POST https://api.whatstig.com/v1/campaigns \
-H "Authorization: Bearer $WHATSTIG_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Eid sale",
"segmentId": "seg_...",
"message": { "type": "text", "content": { "text": "Hi {{contact.firstName | \"there\"}}, our Eid sale starts today!" } },
"instanceIds": ["wa_...", "wa_..."],
"instanceStrategy": "round_robin"
}'
curl -X POST https://api.whatstig.com/v1/campaigns/cmp_.../launch \
-H "Authorization: Bearer $WHATSTIG_API_KEY" \
-H "Idempotency-Key: launch-eid-sale" \
-H "Content-Type: application/json" \
-d '{"scheduledAt": "2026-11-02T09:00:00+05:00"}'
A campaign is a draft until you launch it. Drafts can be changed with PATCH /v1/campaigns/{id}; pass version to
detect concurrent edits.
| Field | Purpose |
|---|---|
segmentId or audience | A saved segment, or an inline rule |
message | { "type": "text" | "image" | "document", "content" }. Media comes from POST /v1/media |
instanceIds | 1 to 25 WhatsApp numbers |
instanceStrategy | single, round_robin (spread evenly, stable per contact) or sticky (keep ongoing conversations on their number) |
sendDelayMs | Minimum gap between messages per number for this campaign; default: the project setting |
messageTtlSeconds | Give up on a message that cannot be sent in time (default: the project setting) |
skipIfMissingVariables | Skip contacts with a missing variable instead of sending without it |
Variables in text and captions:
{{contact.name}},{{contact.firstName}},{{contact.displayName}},{{contact.phone}}{{attr.<key>}}
Each can have a fallback, e.g. {{contact.firstName | "there"}}. Unknown variables fail validation.
POST /v1/campaigns/render-preview renders a message for one contact (or sample values).
Launching (campaigns:launch, Idempotency-Key required) checks everything again:
- the audience, the message and its media
- every number is usable
- the plan's active campaign limit (
403 CAMPAIGN_LIMIT_REACHED)
The campaign is then scheduled to start now or at scheduledAt (up to 90 days ahead).
POST /v1/campaigns/{id}/unschedule takes it back to draft before it starts.
Lifecycle
draft → scheduled → running ⇄ paused → completed
↘ cancelled / failed
- running:
- The audience is frozen when the campaign starts. Contacts added later are not included.
- Recipients are released into the numbers' send queues a few at a time.
- paused:
- By you:
POST /v1/campaigns/{id}/pause. Messages already queued (at most a couple per number) still go out. - Automatically, with a
pauseReason:instance_logged_out,instance_restricted,instance_disconnected(for 15 minutes),instance_deleted,quota_exceeded,media_unavailableorplan_changed. POST /v1/campaigns/{id}/resumecontinues it. If a number can no longer send, pass{ "reassignInstanceIds": [...] }to move its waiting recipients to other numbers.
- By you:
- cancelled:
POST /v1/campaigns/{id}/cancel. Waiting recipients are cancelled and queued messages withdrawn (they fail with codeCANCELLED). - completed: every recipient was handled.
- failed: the campaign could not start (for example the plan no longer includes campaigns).
POST /v1/campaigns/{id}/duplicate copies a campaign into a new draft. DELETE /v1/campaigns/{id} removes a draft or a
finished campaign from the list. POST /v1/campaigns/{id}/test sends the rendered message to one contact now (not
counted in the campaign).
Progress
GET /v1/campaigns/{id} includes stats, refreshed every few seconds while the campaign runs:
total,pending,queued,sent,delivered,read,failed,unknown,skipped,cancelledskippedByReason
Each recipient is counted once, at its furthest status: a read message counts in read, not in delivered.
GET /v1/campaigns/{id}/recipients?status=&skipReason= lists every recipient. Each one has:
status,skipReason,failureCode- the
messageId(look it up withGET /v1/messages/{id}) - timestamps:
queuedAt,sentAt,deliveredAt,readAt,failedAt
Skip reasons:
skipReason | Meaning |
|---|---|
opted_out | The contact opted out (before the snapshot or before their message was released) |
no_consent | Opt-in mode and no recorded opt-in |
no_address | No WhatsApp address |
contact_deleted | The contact was erased |
missing_variable | A variable had no value and the campaign skips such contacts |
Settings
GET /v1/campaign-settings and PUT /v1/campaign-settings configure the project:
consentModesendDelayMs- the opt-out and opt-in keywords and their confirmation replies
defaultMessageTtlSeconds
Switching consentMode to opt_out is only possible in the dashboard, by an owner or admin, with an acknowledgement.
Webhooks
| Event | When |
|---|---|
campaign.started | The campaign started (its audience was frozen) |
campaign.paused / campaign.resumed | Paused by a user or automatically (reason), resumed |
campaign.completed | Every recipient was handled; includes final stats |
campaign.cancelled / campaign.failed | Cancelled, or could not start (reason) |
contact.opted_out / contact.opted_in | Consent changed (API, dashboard, import or a keyword message) |
- Campaign events.
datahascampaignId,name,status,reason,statsandoccurredAt. - Contact events.
datahascontactId,phone,status,source,keyword,messageIdandoccurredAt. - Message events. Messages sent by a campaign carry
campaignIdin theirmessage.*events.