Public API · v1

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.

Base URL https://api.getsetsales.in

Authentication

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.

Header on every request
Authorization: Bearer gss_live_xxxxxxxxxxxxxxxxxxxx

An x-api-key: gss_live_… header is accepted as an equivalent: same key, either spelling.

Rotating invalidates the old key immediately. There's no overlap window, so deploy the new key everywhere it's used before rotating in production.

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.

To message many people at once, schedule a campaign instead of calling /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.

Header
Idempotency-Key: order-10482
SituationWhat you get
Same key, same body, first call finishedThe original response again, with Idempotent-Replayed: true. Nothing is re-sent.
409Same key while the first call is still in progress. Wait a moment and retry.
422Same 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).

POST/public/v1/messages
FieldTypeRequiredNotes
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.
Request — cURL
# 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"
  }'
Request — Node
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"],
  }),
});
Hi Raksha, your order #1234 is confirmed.
✓✓ delivered
Response — 200 OK
{
  "messageId": "wamid.HBgMOTE5...",
  "status": "sent"
}
How it's billed: the platform fee is held from your wallet when the message is sent and settled when Meta reports delivery; if the message fails, the hold is given back. A send that would take the wallet below the ₹100 minimum is refused with 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.

POST/public/v1/contacts
FieldTypeRequiredNotes
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.
Request — cURL
# 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"]
  }'
Request — Node
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"],
  }),
});
Response — 200 OK
{
  "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"
  }
}
Contacts created here are tagged source: "api", so you can always tell them apart from ones added manually or via CSV import on the Contacts page.
Contact allowance: creating a new contact is refused with 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.
Response — 402 when the contact book is full
{
  "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.

GET/public/v1/contacts?search=&tag=&limit=&cursor=
QueryTypeRequiredNotes
searchstringoptionalMatches name or phone.
tagstringoptionalOnly contacts with this tag.
limitnumberoptionalPage size.
cursorstringoptionalThe cursor from the previous page. Omit for the first page.
Response — 200 OK
{
  "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.

POST/public/v1/campaigns
FieldTypeRequiredNotes
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.
Request — cURL
# 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"]
  }'
Request — Node
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] })),
  }),
});
Response — 200 OK
{
  "campaignId": "aug-promo_ab12cd",
  "campaignName": "aug-promo_ab12cd",
  "totalContacts": 2,
  "scheduledFor": "2026-09-10 18:30 IST",
  "estimatedCostInr": 0.3
}
Wallet is checked up front — the request is rejected with 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.
One number's daily limit still applies — Meta's own 24-hour messaging tier (1K/10K/100K unique customers) binds regardless of campaign size. Check the sending number's tier on the Businesses page before large sends.

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.

POST/public/v1/tickets
FieldTypeRequiredNotes
subjectstringrequiredOne line saying what it's about.
descriptionstringoptionalThe details.
prioritystringoptionalurgent, high, medium or low.
contactPhonestringoptionalLinks the ticket to that customer's WhatsApp chat.
contactNamestringoptionalShown on the ticket.
tagsstring[]optionalLabels for filtering on the Tickets page.
dueAtstringoptionalISO 8601 time, e.g. 2026-11-01T18:30:00+05:30.
Request — cURL
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"
  }'
Response — 200 OK
{
  "ticket": {
    "ticketNumber": 1042,
    "status": "open",
    "subject": "Refund request from website form",
    ...
  }
}

GET List tickets NEW

Your account's tickets, newest first.

GET/public/v1/tickets?status=&limit=
QueryTypeRequiredNotes
statusstringoptionalopen, pending, resolved or closed.
limitnumberoptionalDefault 100, maximum 500.
Response — 200 OK
{ "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.

GET/public/v1/meetings/slots
Needs the Meetings add-on: set up in the app's Meetings tab, during its free trial or with an active subscription. Without it you get 400 (not set up) or 402 (add-on not active).
Request — cURL
curl https://api.getsetsales.in/public/v1/meetings/slots \
  -H "Authorization: Bearer gss_live_xxxxxxxxxxxx"
Response — 200 OK
{
  "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:

  1. The time is held for them straight away. Two bookings can never overlap.
  2. 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.
  3. You get the "new meeting booked" alert, if it's switched on in the Meetings tab.
  4. Reminders go out at the times set in the Meetings tab, and the meeting shows in the Meetings tab.
POST/public/v1/meetings
FieldTypeRequiredNotes
phonestringrequiredThe customer's WhatsApp number with country code, e.g. 919999999999. A 10-digit Indian number gets 91 added.
namestringoptionalUsed in their messages ("Hi Raksha") and shown in the Meetings tab.
startAtstringrequiredA startAt from /meetings/slots, unchanged.
Request — cURL
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"
  }'
Request — Node (book the first free time)
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 */ }
Response — 200 OK
{
  "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
  }
}
Times go stale. A time that has been booked since you fetched the slots, or is no longer bookable, returns 409. Fetch /meetings/slots again and offer the fresh times.
The booking stands even if a message fails. If the WhatsApp confirmation couldn't be sent (for example, the wallet is too low), the meeting is still booked and 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.

GET/public/v1/drips
Response — 200 OK
{ "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.

POST/public/v1/drips/{dripId}/enroll
FieldTypeRequiredNotes
phonestringone ofOne WhatsApp number with country code. A 10-digit Indian number gets 91 added.
phonesstring[]one ofUp to 5,000 numbers in one call.
Request — cURL
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" }'
Response — 200 OK
{ "enrolled": 1, "skipped": 0 }
A paused drip returns 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>" }
StatusMeaning
400A required field is missing or invalid, or no connected number matches phoneNumberId. On meetings: Meetings isn't set up for the account.
401Missing or invalid API key. Check the key is current (not rotated out).
402Wallet 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.
403The account isn't approved yet, or the feature isn't enabled for it (e.g. tickets).
404The business behind the API key no longer exists, or (on drips) no drip has that id.
409On 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.
422An Idempotency-Key was reused for a different message.
429Over 20 requests a second. Wait the Retry-After seconds, then retry.
502Meta rejected the send. The message carries Meta's own reason (e.g. template mismatch, recipient opted out).
500Something failed on our side. Safe to retry (send an Idempotency-Key with messages).