Skip to content

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.

ScopeAllows
contacts:readList and read contacts, tags, imports, segments and previews
contacts:writeCreate, update, tag, opt out, record consent, import and erase contacts; manage tags and segments
campaigns:readList and read campaigns, recipients and campaign settings
campaigns:writeCreate and edit campaign drafts, test sends, campaign settings
campaigns:launchLaunch, 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.
  • attributes are merged into the existing ones (at most 30 per contact).
  • tagIds are added; existing tags are kept.
  • consent records 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

EndpointPurpose
GET /v1/contactsList 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}/tagsReplace 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 }
  • tags
  • externalId
  • source: how the contact was added (whatsapp, api, import, dashboard)
  • lastInboundAt: their last message to you
EndpointPurpose
POST /v1/contacts/{id}/consentRecord an opt-in: { "source": "form", "proof": { "text", "url", "ip", "capturedAt" } }
POST /v1/contacts/{id}/opt-outOpt the contact out: { "reason": "…" }
GET /v1/contacts/{id}/consent-eventsThe 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 options field before the file field. Column names are matched case-insensitively.
  • Processing. The import runs in the background (202, status queued). Poll GET /v1/contacts/imports/{id} for status, processedRows, createdCount, updatedCount, skippedCount and errorCount.
  • Errors. GET /v1/contacts/imports/{id}/errors lists up to 1,000 rows that were not imported, with the reason. Further errors are only counted.
  • Consent. consent is 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}/cancel stops 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"] }] }
    ]
  }
}
ConditionOperators
taghas_any, has_all, has_none with tagIds
attributeeq, neq, contains, not_contains (text, case-insensitive), gt, gte, lt, lte (numbers), in, not_in (lists), exists, not_exists
consentis, is_not with unknown, opted_in or opted_out
created_at, last_inbound_atbefore, after (timestamp), within_days, not_within_days (number of days), exists, not_exists
conversationexists, not_exists, optionally with an instanceId
phonestarts_with leading digits, e.g. a country code
importContacts added or updated by an import (importId)
EndpointPurpose
GET /v1/segments, POST /v1/segmentsList and create
GET, PATCH, DELETE /v1/segments/{id}Read, change, delete
POST /v1/segments/previewPreview a rule: { "rule" }
GET /v1/segments/{id}/previewPreview a saved segment

A preview returns:

  • count: matching contacts, capped at 1,000,000 (see capped)
  • 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.

FieldPurpose
segmentId or audienceA saved segment, or an inline rule
message{ "type": "text" | "image" | "document", "content" }. Media comes from POST /v1/media
instanceIds1 to 25 WhatsApp numbers
instanceStrategysingle, round_robin (spread evenly, stable per contact) or sticky (keep ongoing conversations on their number)
sendDelayMsMinimum gap between messages per number for this campaign; default: the project setting
messageTtlSecondsGive up on a message that cannot be sent in time (default: the project setting)
skipIfMissingVariablesSkip 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_unavailable or plan_changed.
    • POST /v1/campaigns/{id}/resume continues it. If a number can no longer send, pass { "reassignInstanceIds": [...] } to move its waiting recipients to other numbers.
  • cancelled: POST /v1/campaigns/{id}/cancel. Waiting recipients are cancelled and queued messages withdrawn (they fail with code CANCELLED).
  • 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, cancelled
  • skippedByReason

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 with GET /v1/messages/{id})
  • timestamps: queuedAt, sentAt, deliveredAt, readAt, failedAt

Skip reasons:

skipReasonMeaning
opted_outThe contact opted out (before the snapshot or before their message was released)
no_consentOpt-in mode and no recorded opt-in
no_addressNo WhatsApp address
contact_deletedThe contact was erased
missing_variableA variable had no value and the campaign skips such contacts

Settings

GET /v1/campaign-settings and PUT /v1/campaign-settings configure the project:

  • consentMode
  • sendDelayMs
  • 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

EventWhen
campaign.startedThe campaign started (its audience was frozen)
campaign.paused / campaign.resumedPaused by a user or automatically (reason), resumed
campaign.completedEvery recipient was handled; includes final stats
campaign.cancelled / campaign.failedCancelled, or could not start (reason)
contact.opted_out / contact.opted_inConsent changed (API, dashboard, import or a keyword message)
  • Campaign events. data has campaignId, name, status, reason, stats and occurredAt.
  • Contact events. data has contactId, phone, status, source, keyword, messageId and occurredAt.
  • Message events. Messages sent by a campaign carry campaignId in their message.* events.