GetSetSales API
Everything an external CRM, website or backend needs: send an approved WhatsApp template on your number, keep contacts in sync, schedule campaigns to thousands of contacts, open support tickets, and let people book meetings. Every call is 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 access in the app. The raw key is shown exactly once when it's generated; 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.
Rate limits NEW
Each account may make 20 requests a second across all endpoints. Past that you get 429 Too Many Requests with a Retry-After header in seconds: wait that long, then try again.
/messages in a loop. A campaign is paced for you and isn't limited by this rate.Retrying safely NEW
On POST /public/v1/messages, send an Idempotency-Key header: any unique string up to 255 characters made of letters, digits, -, _, : or ., such as your order ID. If the call times out and you retry with the same key and body, you get the first result back, marked Idempotent-Replayed: true, and the message is not sent again.
Idempotency-Key: order-10482
| Situation | What you get |
|---|---|
| Same key, same body, first call finished | The original response again, with Idempotent-Replayed: true. Nothing is re-sent. |
| 409 | Same key while the first call is still in progress. Wait a moment and retry. |
| 422 | Same key reused for a different message body. |
Keys are kept for 24 hours. A call that was refused or failed doesn't use up its key, so you can fix the problem and retry with the same one.
POST Send a template message
Sends an approved WhatsApp template to one recipient, on behalf of your business's connected number. Send an Idempotency-Key header so a retried call can never send the message twice (see Retrying safely).
| 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. For an Authentication (one-time code) template, send the code as the only value, e.g. ["482913"] (up to 15 characters, no spaces): it fills the message and its Copy code button. |
| 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 your account's default (first) number. |
# 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" \ -H "Idempotency-Key: order-1234" \ -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"
}
402, so a burst of calls can never spend more than your balance. Your account's first 100 messages are free.POST Create or update a contact
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.402 once the account holds its full allowance (10,000 contacts, more with a contact pack). Updating a contact that already exists is never refused.{
"error": "Your account holds up to 10,000 contacts right now. Add or renew a contact pack to add more.",
"code": "contact_limit",
"limit": 10000
}
GET List contacts
Paginated lookups against your contact book: the same contacts as the Contacts page.
| Query | Type | Required | Notes |
|---|---|---|---|
| search | string | optional | Matches name or phone. |
| tag | string | optional | Only contacts with this tag. |
| limit | number | optional | Page size. |
| cursor | string | optional | The cursor from the previous page. Omit for the first page. |
{
"contacts": [ { "phone": "919999999999", "name": "Raksha Verma", "tags": ["upgraded"], ... } ],
"cursor": "eyJQSyI6..." // absent on the last page
}
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.POST Create a ticket NEW
Opens a support ticket, for example from your website's contact form. It appears on the Tickets page with source "api". Needs the ticket system switched on for your account (Settings); otherwise you get 403.
| Field | Type | Required | Notes |
|---|---|---|---|
| subject | string | required | One line saying what it's about. |
| description | string | optional | The details. |
| priority | string | optional | urgent, high, medium or low. |
| contactPhone | string | optional | Links the ticket to that customer's WhatsApp chat. |
| contactName | string | optional | Shown on the ticket. |
| tags | string[] | optional | Labels for filtering on the Tickets page. |
| dueAt | string | optional | ISO 8601 time, e.g. 2026-11-01T18:30:00+05:30. |
curl -X POST \ https://api.getsetsales.in/public/v1/tickets \ -H "Authorization: Bearer gss_live_xxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "subject": "Refund request from website form", "priority": "high", "contactPhone": "919999999999", "contactName": "Raksha" }'
{
"ticket": {
"ticketNumber": 1042,
"status": "open",
"subject": "Refund request from website form",
...
}
}
GET List tickets NEW
Your account's tickets, newest first.
| Query | Type | Required | Notes |
|---|---|---|---|
| status | string | optional | open, pending, resolved or closed. |
| limit | number | optional | Default 100, maximum 500. |
{ "tickets": [ { "ticketNumber": 1042, "status": "open", ... } ] }
GET Free meeting times NEW
Lets your website or CRM book meetings into the same calendar your WhatsApp bot uses. Returns the times that are free right now: your available hours from the Meetings tab, minus meetings already booked, the gap after each meeting and your minimum notice. Show them in a booking picker, then book one with POST /public/v1/meetings.
400 (not set up) or 402 (add-on not active).curl https://api.getsetsales.in/public/v1/meetings/slots \
-H "Authorization: Bearer gss_live_xxxxxxxxxxxx"
{
"title": "Setup call",
"durationMinutes": 30,
"timezone": "Asia/Kolkata",
"days": [
{
"date": "2026-10-14",
"times": [
{
"startAt": "2026-10-14T06:30:00.000Z",
"endAt": "2026-10-14T07:00:00.000Z",
"label": "12:00 PM"
}
]
}
]
}
date and label are India time, ready to show. startAt and endAt are UTC; send startAt back unchanged to book.
POST Book a meeting NEW
Books one of the times from /meetings/slots for a customer, exactly as if they'd booked it in WhatsApp:
- The time is held for them straight away. Two bookings can never overlap.
- They get a WhatsApp confirmation with the meeting link and two buttons: I am available confirms, Please reschedule lets them pick a new free time in the chat.
- You get the "new meeting booked" alert, if it's switched on in the Meetings tab.
- Reminders go out at the times set in the Meetings tab, and the meeting shows in the Meetings tab.
| Field | Type | Required | Notes |
|---|---|---|---|
| phone | string | required | The customer's WhatsApp number with country code, e.g. 919999999999. A 10-digit Indian number gets 91 added. |
| name | string | optional | Used in their messages ("Hi Raksha") and shown in the Meetings tab. |
| startAt | string | required | A startAt from /meetings/slots, unchanged. |
curl -X POST \ https://api.getsetsales.in/public/v1/meetings \ -H "Authorization: Bearer gss_live_xxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "phone": "919999999999", "name": "Raksha", "startAt": "2026-10-14T06:30:00.000Z" }'
const h = { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }; const slots = await (await fetch( "https://api.getsetsales.in/public/v1/meetings/slots", { headers: h } )).json(); const first = slots.days[0]?.times[0]; const res = await fetch("https://api.getsetsales.in/public/v1/meetings", { method: "POST", headers: h, body: JSON.stringify({ phone: "919999999999", name: "Raksha", startAt: first.startAt }), }); if (res.status === 409) { /* taken meanwhile: fetch the slots again */ }
{
"meeting": {
"meetingId": "mtg_mv57t1gg42e725",
"title": "Setup call",
"startAt": "2026-10-14T06:30:00.000Z",
"endAt": "2026-10-14T07:00:00.000Z",
"durationMinutes": 30,
"meetingLink": "https://meet.google.com/abc-defg-hij",
"customerPhone": "919999999999",
"customerName": "Raksha",
"status": "booked"
},
"notified": {
"customer": { "ok": true },
"owner": { "ok": true } // only when the booking alert is on
}
}
409. Fetch /meetings/slots again and offer the fresh times.notified.customer is { "ok": false, "reason": "…" }. Confirmation and reminder messages are free within your account's first 100 messages, then ₹0.15 each from your wallet.GET List drip campaigns NEW
Your drip campaigns (a series of templates each person gets over minutes, hours or days), so you know which dripId to enroll people into.
{ "drips": [ { "dripId": "drip_mv5abc12", "name": "New lead follow-up", "status": "active", "messages": 3 } ] }
POST Add people to a drip NEW
Starts a drip for one or more people, e.g. when your website form is submitted. They get its messages on the drip's schedule, and leave it when they reply, reach its goal (pay, book, an attribute or tag), or stop answering, as set up in the app. Someone who has been in this drip before is skipped, so calling twice never sends twice.
| Field | Type | Required | Notes |
|---|---|---|---|
| phone | string | one of | One WhatsApp number with country code. A 10-digit Indian number gets 91 added. |
| phones | string[] | one of | Up to 5,000 numbers in one call. |
curl -X POST https://api.getsetsales.in/public/v1/drips/drip_mv5abc12/enroll -H "Authorization: Bearer gss_live_xxxxxxxxxxxx" -H "Content-Type: application/json" -d '{ "phone": "919999999999" }'
{ "enrolled": 1, "skipped": 0 }
409; resume it in the app first. An unknown dripId returns 404.Errors
Every error response shares one envelope: an HTTP status and a message you can show or log. Some carry an extra code, e.g. contact_limit.
{ "error": "<description>" }
| Status | Meaning |
|---|---|
| 400 | A required field is missing or invalid, or no connected number matches phoneNumberId. On meetings: Meetings isn't set up for the account. |
| 401 | Missing or invalid API key. Check the key is current (not rotated out). |
| 402 | Wallet balance below the ₹100 minimum, or (on /campaigns) too low to cover the campaign. On /contacts: the contact book is full. On meetings: the Meetings add-on isn't active. |
| 403 | The account isn't approved yet, or the feature isn't enabled for it (e.g. tickets). |
| 404 | The business behind the API key no longer exists, or (on drips) no drip has that id. |
| 409 | On meetings: that time is no longer free. On drips: the drip is paused. With an Idempotency-Key: the first call with that key is still in progress. |
| 422 | An Idempotency-Key was reused for a different message. |
| 429 | Over 20 requests a second. Wait the Retry-After seconds, then retry. |
| 502 | Meta rejected the send. The message carries Meta's own reason (e.g. template mismatch, recipient opted out). |
| 500 | Something failed on our side. Safe to retry (send an Idempotency-Key with messages). |