Messages, Contacts & Campaigns
The endpoints an external CRM needs: send an approved WhatsApp template on your number, schedule a full campaign to thousands of contacts, and keep a contact's attributes in sync — all authenticated with your business's own API key.
https://api.getsetsales.inAuthentication
Every request carries your business's API key as a bearer token. Get or rotate it from Settings → API Key in the app — the raw key is shown exactly once at generation time; only its hash is kept on our side, so if it's lost, rotate rather than retrieve.
Authorization: Bearer gss_live_xxxxxxxxxxxxxxxxxxxx
An x-api-key: gss_live_… header is accepted as an equivalent — same key, either spelling.
POST Send a template message
Sends an approved WhatsApp template to one recipient, on behalf of your business's connected number.
| Field | Type | Required | Notes |
|---|---|---|---|
| to | string | required | Recipient's number with country code, digits only — e.g. 919999999999, no leading +. |
| templateName | string | required | Must already be approved on the sending number's WABA (check the Templates page). |
| languageCode | string | optional | Defaults to en. Must match the template's approved language exactly. |
| parameters | string[] | optional | Body variable values in order — fills {{1}}, {{2}}, etc. Omit entirely for a template with no variables. |
| headerImageUrl | string | optional | Required for templates approved with an IMAGE header — Meta rejects the send without it. A public https URL to a JPG or PNG; Meta fetches it itself, so it cannot be behind auth. |
| phoneNumberId | string | optional | Which connected number to send from. Defaults to the business's first number if you only have one. |
# templateName must already be Meta-approved curl -X POST \ https://api.getsetsales.in/public/v1/messages \ -H "Authorization: Bearer gss_live_xxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "to": "919999999999", "templateName": "order_confirmation", "languageCode": "en", "parameters": ["Raksha", "#1234"], "headerImageUrl": "https://cdn.yoursite.com/banner.jpg" }'
await fetch("https://api.getsetsales.in/public/v1/messages", { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", }, body: JSON.stringify({ to: "919999999999", templateName: "order_confirmation", languageCode: "en", parameters: ["Raksha", "#1234"], }), });
{
"messageId": "wamid.HBgMOTE5...",
"status": "sent"
}
POST Update contact attributes
Creates the contact if the phone number is new, or merges into the existing one. Attributes merge shallowly — keys you send overwrite, everything else already on the contact is left alone.
| Field | Type | Required | Notes |
|---|---|---|---|
| phone | string | required | Identity key for the contact. Normalised the same way on both sides, so formatting (spaces, +) doesn't matter. |
| name | string | optional | Only overwrites if you send a non-empty value — omitting it never blanks an existing name. |
| attributes | object | optional | Flat string→string map. Merged, not replaced — sending {"plan":"pro"} updates just that key and leaves every other stored attribute untouched. |
| tags | string[] | optional | Added to the contact's existing tags (union) — this endpoint never removes a tag. |
# updates plan + csat, leaves other attributes as-is curl -X POST \ https://api.getsetsales.in/public/v1/contacts \ -H "Authorization: Bearer gss_live_xxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "phone": "919999999999", "attributes": { "plan": "pro", "csat_score": "9" }, "tags": ["upgraded"] }'
await fetch("https://api.getsetsales.in/public/v1/contacts", { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", }, body: JSON.stringify({ phone: "919999999999", attributes: { plan: "pro", csat_score: "9" }, tags: ["upgraded"], }), });
{
"contact": {
"phone": "919999999999",
"name": "Raksha Verma",
"attributes": { "plan": "pro", "csat_score": "9" },
"tags": ["upgraded"],
"engagement": "replied",
"source": "api",
"createdAt": "2026-07-02T09:14:00.000Z",
"updatedAt": "2026-08-09T11:02:00.000Z"
}
}
source: "api", so you can always tell them apart from ones added manually or via CSV import on the Contacts page.Reading contacts back: the same route also accepts GET /public/v1/contacts?search=&tag=&limit=&cursor= for paginated lookups against the same contact book.
POST Schedule a campaign
Creates and schedules a template campaign to up to 10,000 contacts in one call — the exact same campaign the in-app wizard builds. Batching, pacing, opt-out filtering, duplicate handling and billing all behave identically, and the campaign appears on the Campaigns page with live delivered/read/reply stats.
| Field | Type | Required | Notes |
|---|---|---|---|
| campaignName | string | required | Short prefix for the campaign id — letters, numbers, - and _ only (e.g. aug-promo). |
| templateName | string | required | Must already be approved on the sending number's WABA. |
| contacts | object[] | required | Up to 10,000 of { phone, name?, params? }. phone is digits with country code; params fills the template's {{1}}…{{N}} for that contact. |
| parameters | string[] | optional | Fixed body params used wherever a contact has no params of its own. |
| templateCategory | string | optional | UTILITY (default) or MARKETING — used for the cost estimate and billing category. |
| phoneNumberId | string | optional | Which connected number sends the campaign. Defaults to the business's first number. |
| headerImageUrl | string | optional | Required for templates approved with an IMAGE header. Public https JPG or PNG, used as the header for every message in the campaign. |
| scheduleAt | string | optional | IST send time, "YYYY-MM-DD HH:mm" (ISO with offset also accepted). Omit to send as soon as possible (~2 minutes out). Must be at least a minute in the future. |
# omit scheduleAt to send immediately curl -X POST \ https://api.getsetsales.in/public/v1/campaigns \ -H "Authorization: Bearer gss_live_xxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "campaignName": "aug-promo", "templateName": "wa_update_n", "templateCategory": "UTILITY", "headerImageUrl": "https://cdn.yoursite.com/banner.jpg", "scheduleAt": "2026-09-10 18:30", "contacts": [ { "phone": "919999999999", "name": "Raksha", "params": ["Raksha", "AIMA"] }, { "phone": "919888888888", "name": "Ravi" } ], "parameters": ["there", "AIMA"] }'
await fetch("https://api.getsetsales.in/public/v1/campaigns", { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", }, body: JSON.stringify({ campaignName: "aug-promo", templateName: "wa_update_n", scheduleAt: "2026-09-10 18:30", contacts: leads.map((l) => ({ phone: l.phone, name: l.name, params: [l.name, l.program] })), }), });
{
"campaignId": "aug-promo_ab12cd",
"campaignName": "aug-promo_ab12cd",
"totalContacts": 2,
"scheduledFor": "2026-09-10 18:30 IST",
"estimatedCostInr": 0.3
}
402 if the balance can't cover the whole campaign's platform fee, so a campaign never starts that it can't finish. Delivery itself is still billed per message on Meta's delivery receipt, same as wizard campaigns.Errors
Every error response shares one envelope — an HTTP status and a message string:
{ "error": "<description>" }
| Status | Meaning |
|---|---|
| 401 | Missing or invalid Authorization header — check the key is current (not rotated out). |
| 400 | A required field is missing, or (on /messages) no connected number matches the given phoneNumberId. |
| 403 | The business isn't approved yet — sends are blocked until an admin approves the account. |
| 402 | Wallet balance is at or below the ₹100 minimum — or, on /campaigns, can't cover the campaign's estimated cost. Top up first. |
| 404 | The business behind the API key no longer exists. |
| 502 | Meta rejected the send — the message field carries Meta's own rejection reason (e.g. template mismatch, recipient opted out). |
| 500 | Something failed on our side. Safe to retry. |