SMSGatewayCenter Blog

Contact Groups and Segments: The Data Model Behind a Group Send

A group send names a list, not a set of people. This is the engineering guide to what a contact group actually is on SMSGatewayCenter, how the eight group and contact endpoints fit together, why success is not always code 200, and how to keep your own contact system in sync with a list your code cannot enumerate.

Featured image for Contact Groups and Segments: The Data Model Behind a Group Send
Geometric illustration of overlapping audience clusters feeding into a single opaque container, with individual delivery records fanning out from the other side.
A group send addresses a container, not the people inside it. The individual records only appear afterwards, in the delivery report feed.

Table of Contents

  1. The Short Answer
  2. TL;DR
  3. What a Group Actually Is on This Platform
  4. The Eight Endpoints and What Each One Can Address
  5. One Group, Five Spellings of Its Identifier
  6. Success Is Not Code 200
  7. One Contact, One Group
  8. Enumerating a Group With an Endpoint That Has No Filter
  9. The Group Send Blind Spot
  10. The Send Response Union Is Now Complete
  11. Overlapping Groups and duplicatecheck
  12. When Does Membership Resolve
  13. Keeping Two Contact Systems in Sync
  14. Consent, DND and DLT for a List You Cannot Enumerate
  15. The Sync and Audit Playbook
  16. A Schema That Survives All of This
  17. Ten Mistakes That Cost Real Money
  18. Decision Matrix: Platform Groups, Your Own Lists, or Both
  19. Build Order
  20. The Checklist
  21. Unspecified Behaviour and How to Code Around It
  22. FAQs

The Short Answer

A contact group on SMSGatewayCenter is a server-side list identified by a numeric groupId, and when you send to it with sendMethod=group the API returns the group identifier and a transaction identifier but no recipient information at all. There is no mobile field and no invalidMobile field in that response. Your application therefore cannot know, at submission time, who it just messaged. The only place individual recipients ever appear is the delivery report feed. That single fact drives everything else in this article: your contact model has to be authoritative on your side, the platform group has to be treated as a projection of it, and delivery report ingestion has to be promoted from monitoring to system of record.

TL;DR

  • A group is addressed by groupId, but the endpoint that creates a group does not return the id it just generated. You call SMSApi/group/read afterwards and match on the name you chose.
  • The identifier is spelled five different ways across the family: groupname, id, groupid, group and groupId, depending on which endpoint you are calling. Write a mapping layer once.
  • SMSApi/group/delete returns "code": "224" on success, not "200". Any success check written as code == "200" reports a successful delete as a failure. Branch on status instead.
  • In the documented contact/read response every contact row carries exactly one groupId, never an array. A person in three audiences is three contact records with three different cId values and the same mobileNo.
  • contact/create places a contact by groupname, a mutable human-typed string. contact/update places it by groupid. The same relationship is name-keyed on one endpoint and id-keyed on the other.
  • group/update renames a group. Nothing warns you that any cached name, any hardcoded groupname in an integration, or any operator runbook now points at a group that no longer answers to that name.
  • contact/read accepts no group filter, no pagination and no date range. To enumerate one group you download the entire address book and filter client side.
  • duplicatecheck defaults to true, so sending to two overlapping groups silently collapses the overlap. Your expected recipient count and your billed count will differ and the send response will not explain why.
  • The SMSApi/send response is a union keyed on sendMethod with no discriminator inside the body. The bulkupload variant returns the smallest shape of all: status, transactionId, statusCode and reason only.
  • For a group send, consent evidence and suppression proof must come from your own records plus the delivery report feed. It can never come from the send call, because the send call does not name anybody.

What a Group Actually Is on This Platform

Start from what the API actually returns rather than from what the word “group” suggests. Here is the documented sample response from the Read Group endpoint, verbatim in shape:

{
    "response": {
        "api": "group",
        "action": "read",
        "status": "success",
        "msg": "success",
        "code": "200",
        "count": 2,
        "groupnameList": [
            {
                "groupname": {
                    "groupId": "16322",
                    "count": "1",
                    "groupName": "test12",
                    "groupType": "PHONEBOOK",
                    "creationTime": "1562969551262"
                }
            },
            {
                "groupname": {
                    "groupId": "18373",
                    "count": "0",
                    "groupName": "TEST1234",
                    "groupType": "PHONEBOOK",
                    "creationTime": "1563027427155"
                }
            }
        ]
    }
}

Five things in that payload matter more than they look.

The list is double wrapped. The array is response.groupnameList, and each element is an object whose single key is groupname, and the group itself sits one level below that. So the path to the first group is response.groupnameList[0].groupname.groupId. This is the same double-wrapping convention used by campaign/read and template/read, and it is worth writing one unwrapping helper rather than repeating the path in every call site.

There are two fields called count at different levels with different JSON types. The envelope count is the unquoted integer 2, meaning two groups were returned. The per-group count is the quoted string "1", meaning one contact is in that group. A naive flattening or a schema generator will collide these. Name them explicitly in your model, something like groups_returned and member_count, and coerce the inner one to an integer at the boundary.

The word “group” appears in three casings in one response. The wrapper is groupnameList, the element key is groupname, and the field is groupName. This is not a typographical detail when you are writing a parser in a case-sensitive language, and it is exactly the kind of thing a hand-written struct gets right on Tuesday and wrong on Thursday.

groupType is "PHONEBOOK" and only that one value is documented. Treat it as an open enumeration. Store the raw string, never map it onto a closed enum that throws on an unrecognised value, and log any value you have not seen before. The platform has WhatsApp group features documented separately in the knowledge base, so a second group type appearing later would not be surprising.

creationTime is a quoted millisecond epoch with no timezone. Same convention as creationTime and lastupdatedTimestamp elsewhere in this API. Parse it as UTC milliseconds, store it as a proper timestamp, and never display it without converting.

So a group is a named, numbered, typed, timestamped, server-side container with a member count. What it is not is a query. There is nothing in this response that describes a rule for membership. Membership is enumerated, not computed. That distinction is the reason the word “segment” is doing more work in marketing copy than it does in the API, and it is worth being precise about it before you design anything on top.


The Eight Endpoints and What Each One Can Address

The group and contact families are eight endpoints, all under https://unify.smsgateway.center/SMSApi/, all accepting POST, all authenticated the same way as the rest of the SMS API. If you have not set up authentication yet, the four mechanisms are covered on the authentication page and in our guide to connecting a customer SMS account with OAuth.

EndpointPathRequired parameters beyond auth and outputWhat it addressesReturns the object?
Create groupSMSApi/group/creategroupnameA new group, by nameNo
Read groupsSMSApi/group/readnoneAll groups in the accountYes, full list
Update groupSMSApi/group/updateid, groupnameOne group, by numeric idNo
Delete groupSMSApi/group/deleteidOne group, by numeric idNo
Create contactSMSApi/contact/creategroupname, contactname, mobilenoA new contact, placed by group nameNo
Read contactsSMSApi/contact/readnoneAll contacts in the accountYes, full list
Update contactSMSApi/contact/updateid, groupid, contactname, mobilenoOne contact, by numeric idNo
Delete contactSMSApi/contact/deleteidOne contact, by numeric idNo

Read that table as a capability statement rather than a reference and three gaps show up immediately.

No create endpoint returns the identifier it created. group/create responds with api, action, status, msg and code, and nothing else. So does contact/create. This is the same behaviour as apikey/create, which also withholds the thing it just made. The workaround is a read-after-write, and the read has no filter, so the workaround is a full-list read. On a small account that is fine. On an account with two hundred thousand contacts, creating one contact and then confirming its cId means downloading two hundred thousand rows.

No read endpoint takes a filter. Both group/read and contact/read take authentication and output and nothing else. No id lookup, no name search, no group filter on contacts, no pagination, no cursor, no date range. This mirrors schedule/read, which has the same shape. It is the single biggest constraint in the family and section eight is about living with it.

No endpoint adds an existing contact to a second group. contact/create takes a name and a number and a group and makes a new row. contact/update takes groupid as a required field and moves the row. There is no documented join or membership endpoint. The consequence is section seven.

Diagram comparing the write path and read path of the SMSGatewayCenter group API, showing that the same group identifier is called groupname, id, groupid or group on the write endpoints but is returned as groupId on the read endpoints, and noting that create endpoints return no identifier at all.
The identifier does not keep its name between the endpoint that returns it and the endpoint that consumes it.

One Group, Five Spellings of Its Identifier

Line up the parameter names and the problem is obvious.

WhereParameter or fieldType of value
group/creategroupnameHuman-typed name
contact/creategroupnameHuman-typed name
group/updateidNumeric group id
group/deleteidNumeric group id
contact/updategroupidNumeric group id
SMSApi/sendgroupIds or names, comma separated
group/read responsegroupIdNumeric group id, quoted string
contact/read responsegroupIdNumeric group id, quoted string

Contacts have the same problem in miniature: contact/read returns the contact key as cId, while contact/update and contact/delete both consume it as id.

None of this is hard to handle. It is hard to handle repeatedly, in eight call sites, in a codebase where three different people wrote the integration over two years. The fix is a single adapter module that speaks one internal vocabulary and translates at the wire boundary, in exactly the way we recommend for the identifier fields in the outbound message table.

# One place that knows the wire vocabulary. Nothing else in your codebase
# should ever type the string "groupid" or "cId".

import requests

BASE = "https://unify.smsgateway.center/SMSApi"

class Groups:
    def __init__(self, session, userid, password):
        self.s = session
        self.auth = {"userid": userid, "password": password, "output": "json"}

    def _post(self, path, **fields):
        # Never rely on a default. requests has no default timeout at all.
        r = self.s.post(f"{BASE}/{path}", data={**self.auth, **fields}, timeout=(5, 30))
        r.raise_for_status()
        return r.json()["response"]

    def create(self, name):
        # Returns no id. Success only.
        return self._post("group/create", groupname=name)

    def rename(self, group_id, new_name):
        return self._post("group/update", id=group_id, groupname=new_name)

    def delete(self, group_id):
        return self._post("group/delete", id=group_id)

    def list_all(self):
        body = self._post("group/read")
        # Double wrapped: response.groupnameList[i].groupname
        return [
            {
                "group_id": g["groupname"]["groupId"],       # keep as TEXT
                "name": g["groupname"]["groupName"],
                "group_type": g["groupname"]["groupType"],   # open enumeration
                "member_count": int(g["groupname"]["count"]),
                "created_at_ms": int(g["groupname"]["creationTime"]),
            }
            for g in body.get("groupnameList", [])
        ]

Two details in that snippet are deliberate. group_id stays a string, because identifiers on this platform run to eighteen and nineteen digits elsewhere in the API and the habit of storing every provider identifier as text is cheaper than deciding case by case which ones are safe. And the timeout is explicit, because the requests library ships with no default timeout whatsoever, a trap covered at length in the Python integration tutorial.


Success Is Not Code 200

This is the finding in this article most likely to be sitting in your codebase right now as a live bug.

Seven of the eight endpoints in this family return "code": "200" on success. The eighth does not. Here is the documented success response for Delete Group, verbatim:

{
    "response": {
        "api": "group",
        "action": "delete",
        "status": "success",
        "msg": "Group deleted successfully",
        "code": "224"
    }
}

status is "success". msg says the group was deleted. And code is "224".

If your client wraps every call in a check like if body["response"]["code"] == "200", then a perfectly successful group deletion is classified as a failure. What happens next depends on how your code handles failures, and none of the options are good. If it retries, it will retry a delete against a group that is already gone. If it raises, your sync job aborts halfway through a reconciliation pass. If it marks the local record as still-present, your local state and the platform state diverge permanently and every subsequent run tries the same delete again.

The rule that follows generalises across the whole API, and it is the same rule that came out of the campaign family, where campaign/update and campaign/delete both return schedule wording in msg because the two families share a code path:

Branch on api plus action plus status. Treat code as a detail to log, not a condition to test. Never parse msg.

class ApiError(Exception):
    def __init__(self, body):
        self.body = body
        super().__init__(
            f"{body.get('api')}/{body.get('action')} failed: "
            f"code={body.get('code')} msg={body.get('msg')!r}"
        )

def check(body, expect_api, expect_action):
    # Correct across the whole family, including group/delete returning 224.
    if body.get("api") != expect_api or body.get("action") != expect_action:
        raise ApiError(body)          # you parsed the wrong response entirely
    if body.get("status") != "success":
        raise ApiError(body)
    return body

Write a contract test that asserts this. The response code catalogues at the API error code list endpoint and the delivery error code list endpoint are read-only and free, which makes them the cheapest live contract surface on the platform. Diff them nightly and you will see a new code the day it appears rather than the day it breaks something, a practice we argue for in detail in testing code that sends messages.


One Contact, One Group

Here is the documented contact/read response, from the Read Contact page:

{
    "response": {
        "api": "contact",
        "action": "read",
        "status": "success",
        "msg": "success",
        "code": "200",
        "count": 2,
        "contactList": [
            {
                "contact": {
                    "cId": "144441",
                    "contactName": "abcd",
                    "contactStatus": "1",
                    "groupId": "63",
                    "mobileNo": "919999999999",
                    "groupName": "TEST4567",
                    "groupType": "PHONEBOOK"
                }
            }
        ]
    }
}

Every contact row carries a single scalar groupId. Not an array of group ids. Not a nested membership list. One value. And the group’s name and type are denormalised onto the contact row alongside it.

Combine that with the capability gap from section four, that no endpoint adds an existing contact to a second group and contact/update requires groupid as a mandatory field, and the model resolves as follows. A contact record belongs to one group. A person who should receive messages for three different audiences is represented as three contact records, with three distinct cId values and the same mobileNo.

This has consequences that are easy to miss and expensive to discover late.

Editing one record does not edit the others. If a customer changes their phone number and you update the contact in your “Premium” group, the copies in “Mumbai” and “Newsletter” still carry the old number. There is nothing on the platform tying them together. The only thing they have in common is a string that you now changed on one of them.

Deleting one record does not suppress the person. contact/delete takes one id. If someone opts out and you delete their row from the group you happened to be looking at, they remain in the other two, and your next campaign to those groups reaches a person who withdrew consent. This is not a hypothetical compliance problem. Under the Indian regulatory framework, consent and preference are properties of the subscriber, not of your list, and the TRAI TCCCPR regulations are explicit that the obligation attaches to the sender.

Suppression must therefore be enforced before the send, on your side. If you cannot guarantee that a suppressed number is absent from every platform group it might be in, then you cannot safely use a group send for that campaign. That is not a limitation to work around with cleverness. It is a design constraint that should push consent-sensitive campaigns onto an explicit recipient list, which is exactly what section eighteen’s decision matrix is for.

contactStatus is undocumented. The sample shows "1". No meaning is given anywhere in the documentation, and no other value is shown. Do not build behaviour on it. Store it, log distinct values you observe, and ask your account manager what it means before you let it gate anything.

There is a wrinkle worth naming honestly. Product-facing material on the site describes contacts belonging to several groups at once and describes selecting multiple groups to define an audience. That is entirely consistent with what the API returns if a person appears as multiple contact records, one per group, which is also how a multi-group send would naturally behave. But the documentation does not say so in those words, so this article states only what the response schema shows and stops there. See section twenty-one.


Enumerating a Group With an Endpoint That Has No Filter

contact/read takes authentication and output. That is the complete parameter list. There is no groupid filter, no pageLimit, no cursor, no fromdate and todate.

So the answer to “who is in group 63” is: fetch every contact in the account and filter on groupId == "63" in your own process.

For a hundred contacts this is a non-issue. For a hundred thousand it is an operational problem with four distinct edges.

Response size. One unbounded JSON document containing every contact you have ever stored. Stream it rather than loading it into memory as a single string if your address book is large, and set a generous read timeout, because the server has to assemble the whole thing before the first byte arrives.

Consistency. The snapshot is not transactional with anything. If a campaign is running while you read, or an operator is editing the phonebook in the portal, you have a view that was true at some unspecified moment.

Cost of freshness. Because the only read is a full read, the natural implementation is a periodic full sync rather than an incremental one. Pick an interval you can defend and treat the local copy as a cache with a known staleness bound, not as truth.

No change detection. Contact rows carry no timestamp at all. group/read gives you creationTime for groups, but a contact row has cId, contactName, contactStatus, groupId, mobileNo, groupName and groupType, and not one of them tells you when the row last changed. You cannot do a delta sync. You cannot detect a concurrent edit. If two of your processes both read, both modify, and both write, the second silently wins and nothing anywhere records that the first ever happened.

That last point deserves emphasis because contact/update makes it worse. Every field is required: id, groupid, contactname and mobileno. There is no partial update. To change a contact’s name you must also resend its group and its mobile number. If the copy you are working from is ten minutes stale and someone moved that contact in the portal five minutes ago, your name change also moves them back. The API offers no If-Match, no version token and no lastUpdated field to compare against, so read-modify-write here is genuinely unsafe under concurrency and the mitigation has to be procedural: serialise all writes to the phonebook through one worker, and never let a human portal edit and an automated sync run against the same group.

def members_of(group_id, contacts_response):
    """The only way to enumerate a group: read everything, filter locally."""
    rows = contacts_response.get("contactList", [])
    return [
        {
            "contact_id": c["contact"]["cId"],          # TEXT
            "name": c["contact"]["contactName"],
            "mobile": c["contact"]["mobileNo"],         # TEXT, never int
            "group_id": c["contact"]["groupId"],
            "status_raw": c["contact"]["contactStatus"], # undocumented, do not branch
        }
        for c in rows
        if c["contact"]["groupId"] == str(group_id)
    ]

mobileNo stays a string for the same reason groupId does. A number like 919999999999 fits in a 64-bit integer today, but treating phone numbers as integers strips leading characters, breaks the moment a format changes, and invites a language runtime to reformat them. Store every identifier and every phone number as text.


The Group Send Blind Spot

Now the part that makes all of the above urgent rather than merely tidy.

Compare the two documented SMSApi/send responses. From Send SMS Batch, a quick send:

{
    "status": "success",
    "mobile": "919999999999",
    "invalidMobile": "",
    "transactionId": "6305583318236810379",
    "statusCode": "200",
    "reason": "success"
}

From Send SMS Group, a group send:

{
    "status": "success",
    "group": "63",
    "transactionId": "8359251506264886974",
    "statusCode": "200",
    "reason": "success"
}

The group response has no mobile and no invalidMobile. It does not report how many recipients were resolved, how many numbers were invalid, or how many were dropped as duplicates. It tells you the group identifier you already sent and a transaction identifier.

So at the moment your send call returns, the set of people you just contacted exists nowhere in your systems. Not in your database, not in your logs, not in the response you just parsed. It exists only inside the platform, and it will only become visible to you later, one row at a time, in the delivery report feed.

That is why the delivery report API stops being monitoring for a group send and becomes the audit trail. It is the sole source of mobileNo on a per-message basis, alongside msgId, uuId, status, cause, cost and length. If your delivery report ingestion drops rows, you have not lost a metric. You have lost the record of who you messaged.

Three engineering consequences follow directly.

Ingest delivery reports into durable storage, not into a dashboard. Reports that live only in a monitoring system with a thirty day retention are not an audit trail. The correlation and storage patterns are covered in real-time DLR webhooks and the schema side in the outbound message table.

Back the webhook with polling. A webhook you did not receive is indistinguishable from a message that generated no report. Poll SMSApi/reports/status with method=getDlr over a date range and reconcile, because a missed push for a quick send costs you a status update, while a missed push for a group send costs you a row that was your only evidence a person was contacted.

Never derive compliance evidence from the send call. If someone asks you to prove that a suppressed number was not messaged on a given date, the send response for a group campaign cannot answer the question. Only the delivery report rows can.

Flow diagram showing a group send returning only group and transactionId with no recipient fields, the resulting gap in which the recipient set exists nowhere in your systems, and the delivery report feed as the only source of per recipient rows.
For a group send, delivery report ingestion is not telemetry. It is the system of record.

The Send Response Union Is Now Complete

One endpoint, SMSApi/send, returns four different documented key sets depending on how you called it, and there is no discriminator field inside the response telling you which shape you received. This article completes the picture with the file upload variant, from Send SMS File:

{
    "status": "success",
    "transactionId": "9140733627001556338",
    "statusCode": "200",
    "reason": "success"
}

That is the whole body. No mobile, no invalidMobile, no group, no count of rows accepted from the uploaded file.

sendMethodDocumented keysRecipient information
quickstatus, mobile, invalidMobile, transactionId, statusCode, reasonFull, echoes valid and invalid numbers
groupstatus, group, transactionId, statusCode, reasonNone, only the group id you sent
bulkuploadstatus, transactionId, statusCode, reasonNone at all
Basic Auth sampleincludes msgId, eighteen-digit transactionIdVaries

Common to every documented shape: status, statusCode and transactionId. Nothing else is safe to assume.

Two practical notes. First, the file upload method’s sendMethod value is bulkupload, not file, which is easy to get wrong if you are working from the parameter list of a sibling page. Second, bulkupload accepts csv, xls, xlsx and zip, is posted as multipart/form-data, and carries the same optional parameters as the other methods, including duplicatecheck, scheduleTime, trackLink and testMessage.

The parsing rule is simple and it applies everywhere in your codebase: parse the response against the sendMethod you sent, not against a single struct. If you have language tutorials or internal client libraries that model the send response as one type with optional fields, they will silently produce empty strings for mobile on group and bulk sends, and somebody downstream will eventually treat that empty string as “no recipients” rather than “this shape does not carry recipients”.


Overlapping Groups and duplicatecheck

SMSApi/send accepts group as, verbatim, “group ids or names in comma separated formats”. So you can send to several groups in one call. And duplicatecheck is documented as “Enable to remove duplicate mobile numbers. Default is true.”

Put those together with the one-contact-one-group model and the arithmetic stops being obvious.

Suppose group 63 has 40,000 members and group 64 has 25,000, and 6,000 people appear in both, as separate contact records with the same mobileNo. You send to group=63,64.

  • The sum of the count fields from group/read is 65,000.
  • With duplicatecheck at its default of true, duplicate mobile numbers are removed, so the number actually submitted is lower.
  • The response tells you neither number. It returns group and transactionId.
  • Your billing is based on submission, so you are billed for what was actually submitted, not for 65,000 and not for whatever your spreadsheet said.

This is not a bug. Deduplication is the behaviour you want, and it is the same guard discussed in preventing duplicate sends, where the key point is that duplicatecheck is within-request recipient deduplication and not a cross-request idempotency window. But it means the only honest way to state your expected recipient count for a multi-group send is to compute the distinct union of mobile numbers yourself, from your synced copy of the phonebook, before you send. group/read counts double-count overlap by construction, because each contact record is counted once in its own group.

-- Expected distinct recipients for a multi-group send, computed from your
-- synced copy. group_read counts cannot answer this: they double count overlap.
SELECT count(DISTINCT mobile_no) AS expected_recipients
FROM platform_contact
WHERE group_id IN ('63', '64')
  AND mobile_no NOT IN (SELECT mobile_no FROM suppression_list);

Record that number against the campaign before you submit. It is the denominator you will reconcile the delivery report rows against, and without it the reconciliation in section fifteen has nothing to compare to. The general failure mode of picking the wrong denominator, where a ratio looks healthy precisely because reports stopped arriving, is covered in observability for messaging pipelines.


When Does Membership Resolve

Here is a question the documentation does not answer, and the honest thing to do is to name it rather than guess: at what moment does a group send resolve its membership?

For an immediate, unsplit send the question barely matters, because resolution and submission are effectively simultaneous. It starts to matter in two situations.

A scheduled send. SMSApi/send accepts scheduleTime in the format YYYY-MM-DD HH:MM:SS. If you schedule a group send for Friday and add two hundred contacts to that group on Thursday, do those two hundred receive the message? The documentation does not say. Composition-time resolution and fire-time resolution are both defensible engineering choices and both are common in messaging platforms.

A split campaign. The split campaign feature breaks one logical send into several batches delivered at intervals, and it explicitly supports group sends. If a campaign runs across several hours in batches, and the group changes between the first batch and the last, the same question applies with more at stake, because a person removed from the group after batch one might still be in batch four.

Because the answer is not documented, do not encode either assumption. Encode the defensive posture instead.

Freeze the audience before you submit. For any campaign where it matters who exactly receives it, snapshot the membership on your side, store the snapshot against the campaign, and either send to an explicit recipient list built from that snapshot or accept that the group send is best-effort with respect to timing.

Do not mutate a group that has a pending send against it. Make this a hard rule in your tooling. Lock the group, queue membership changes, and apply them after the campaign completes. This is cheap to implement and removes an entire class of question you cannot otherwise answer.

Ask before you rely on it. If your use case genuinely requires knowing, ask through support and get the answer in writing rather than inferring it from one observed campaign.


Keeping Two Contact Systems in Sync

If you use platform groups at all, you now have two contact databases: yours and theirs. They will diverge. The only question is whether you find out from a reconciliation job or from a customer.

The forces pushing them apart are specific.

The portal is a second writer. Operations staff add, edit and delete contacts in the user interface. Nothing notifies your application. Because contact rows carry no timestamp, you cannot even tell which rows changed since your last sync.

Renames break name-keyed calls. group/update renames a group by id. contact/create places contacts by groupname. So the moment someone renames “Premium Customers” to “Premium Customers 2026”, every integration that hardcodes the old name starts failing, or worse, starts creating a new group with the old name if the platform auto-creates on unknown names. The documentation does not state what contact/create does with an unrecognised groupname, so treat that as unknown and never let a name you did not just read from group/read reach a create call.

Deletes are asymmetric. group/delete removes a group by id. What happens to the contacts that were in it is not documented. They may be deleted, they may be orphaned, they may be reassigned. Do not find out in production on a group that mattered.

Your own dedupe and theirs disagree. You deduplicate people. The platform deduplicates mobile numbers within a request. Those are different operations on different keys and they will produce different counts.

The sync design that survives all four is a one-way projection with reconciliation, not a two-way merge.

Your system is authoritative. The platform group is a projection of it. Every membership decision, every consent record, every suppression is made and stored on your side. The platform group exists only to make group sends possible.

Write in one direction, through one worker. All contact/create, contact/update and contact/delete calls come from a single serialised process. No parallel workers, no ad hoc scripts, no simultaneous portal editing during a sync window.

Reconcile periodically by full read. Because that is the only read available. Compare the platform’s view against yours, and record the differences rather than silently correcting them, because a difference you did not expect is information.

Track the group by id and treat the name as a label. Store group_id as your key everywhere. Keep the name as a display field, refresh it on every sync, and alert when it changes rather than assuming it did not.


Indian messaging compliance is not a layer you add on top of a working sender. It is a property of who you are allowed to message, which makes it a property of your contact model.

Consent lives on your side, always. A platform group is a list of names and numbers. It carries no consent field, no timestamp of when consent was given, no record of the channel it was given through, and no record of withdrawal. contactStatus exists but is undocumented and must not be pressed into service as a consent flag. If you are asked to evidence consent for a specific recipient on a specific date, the answer has to come from your records.

Suppression must be enforced before submission, not after. Because a person can be present as several contact records across several groups, removing them from one group does not suppress them. The safe pattern is to remove them from every group you know about, and to stop using group sends for any campaign where a suppression failure is a compliance event. The decision matrix in section eighteen treats this as the deciding factor rather than an afterthought.

DLT template and entity binding is unaffected by grouping but not by segmentation. Your dltEntityId and dltTemplateId bind the message content, not the audience. But the category of message you are permitted to send does depend on the relationship you have with the recipient, which is an audience question. Sending a promotional message to a group assembled for transactional purposes is a template and consent problem, not a grouping problem, and it is the sort of mismatch covered in why DLT templates get rejected and in the full DLT registration guide.

Preference and DND handling is the operator’s job, and your evidence of it is the delivery report. When a message is not delivered because of subscriber preference, that appears as a cause in the delivery report row, not in the send response. For a group send this is the only visibility you get, which is another reason durable delivery report storage is not optional.

Retention has to be deliberate. You are storing phone numbers, and Indian data protection obligations under the Digital Personal Data Protection Act framework attach to that. Decide how long you keep contact rows, decide how long you keep delivery report rows, and make sure the second is at least as long as your longest plausible audit window, because the delivery report is your proof.


The Sync and Audit Playbook

Six steps. Run the first four on a schedule and the last two per campaign.

Step one: snapshot the platform. Call group/read and contact/read. Store both raw responses, unparsed, with a fetch timestamp, before you touch them. When a reconciliation disagrees three weeks later, the raw payload is what settles it.

Step two: normalise into a flat table. Unwrap the double wrapping, coerce the two count fields to their correct types, keep every identifier and every mobile number as text, convert creationTime from millisecond epoch, and store contactStatus and groupType as raw strings without interpreting either.

Step three: diff against your authoritative contact system. Produce four sets: present on the platform but not in yours, present in yours but not on the platform, present in both with a differing name or number, and present on the platform while appearing on your suppression list. The fourth set is the one that wakes people up. It should be empty, and if it is not, that is a compliance incident and not a data quality ticket.

Step four: apply corrections through the single writer. Serialised, one at a time, checking status rather than code on every response so that a successful group/delete returning 224 is not misread. Log every call with its full response body.

Step five: freeze and record the audience before a campaign. Compute the distinct union of mobile numbers across the groups you will send to, minus suppressions, and store that number and that list against the campaign record. Lock the groups against membership changes until the campaign completes.

Step six: reconcile the delivery report feed against the frozen audience. Poll SMSApi/reports/status with method=getDlr for the campaign window, join delivery report rows to your frozen list on mobile number, and produce three counts: recipients you expected and saw reported, recipients you expected and never saw reported, and reported recipients you did not expect.

-- Step six. The third bucket is the alarming one for a group send: someone
-- was messaged who was not in the audience you thought you froze.
WITH frozen AS (
    SELECT mobile_no FROM campaign_audience WHERE campaign_ref = :ref
),
reported AS (
    SELECT DISTINCT mobile_no FROM dlr_row
    WHERE submitted_at >= :window_start AND submitted_at < :window_end
)
SELECT
    (SELECT count(*) FROM frozen f JOIN reported r USING (mobile_no)) AS matched,
    (SELECT count(*) FROM frozen f LEFT JOIN reported r USING (mobile_no)
       WHERE r.mobile_no IS NULL)                                     AS expected_never_reported,
    (SELECT count(*) FROM reported r LEFT JOIN frozen f USING (mobile_no)
       WHERE f.mobile_no IS NULL)                                     AS reported_not_expected;

expected_never_reported will rarely be zero, because delivery reports lag and some never arrive, so alert on its trend rather than its absolute value. reported_not_expected should be zero. A non-zero value means either your snapshot was stale when you froze it, or someone edited a group during the campaign, or you sent to a group you did not intend.


A Schema That Survives All of This

Two tables plus a link to your existing message table. These are siblings of the outbound_message table and follow the same rules: every provider identifier is TEXT, every timestamp is stored with a timezone, and raw payloads are kept.

CREATE TABLE platform_group (
    group_id          TEXT PRIMARY KEY,        -- platform groupId, never integer
    group_name        TEXT NOT NULL,           -- display label, can change
    group_type        TEXT NOT NULL,           -- open enumeration, e.g. PHONEBOOK
    member_count      INTEGER,                 -- platform's count, quoted string on wire
    created_at        TIMESTAMPTZ,             -- from creationTime, ms epoch
    first_seen_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
    last_synced_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    name_changed_at   TIMESTAMPTZ,             -- set when a sync sees a rename
    locked_for_send   BOOLEAN NOT NULL DEFAULT false,
    raw               JSONB NOT NULL
);

CREATE TABLE platform_contact (
    contact_id        TEXT PRIMARY KEY,        -- platform cId
    group_id          TEXT NOT NULL REFERENCES platform_group(group_id),
    contact_name      TEXT,
    mobile_no         TEXT NOT NULL,           -- TEXT, always
    contact_status    TEXT,                    -- undocumented, stored not interpreted
    local_person_id   TEXT,                    -- your authoritative person, nullable
    last_synced_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    raw               JSONB NOT NULL
);

-- One person can hold several contact rows across groups. This is the index
-- that makes suppression checks and overlap arithmetic possible.
CREATE INDEX platform_contact_mobile_idx ON platform_contact (mobile_no);
CREATE INDEX platform_contact_group_idx  ON platform_contact (group_id);

CREATE TABLE campaign_audience (
    campaign_ref      TEXT NOT NULL,           -- your reference, not the platform's
    mobile_no         TEXT NOT NULL,
    group_id          TEXT NOT NULL,
    frozen_at         TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (campaign_ref, mobile_no)
);

Three deliberate choices. local_person_id is nullable, because a contact that exists on the platform but not in your system is a real state you need to be able to represent rather than a constraint violation that aborts your sync. name_changed_at exists so a rename is an event you noticed rather than a surprise. And campaign_audience is keyed on your own campaign reference, not on a platform identifier, because the send response for a group send gives you a transactionId and no campaign identifier, so your reference is the only key that exists at the moment you need to write the row.


Ten Mistakes That Cost Real Money

One. Testing code == "200" for success. group/delete returns "224". Branch on status.

Two. Assuming group/create returns the new group id. It returns success and nothing else. Read-after-write and match on name.

Three. Storing groupId, cId or mobileNo as integers. Identifiers on this platform reach nineteen digits, which exceeds the safe integer range in JavaScript, so a plain JSON.parse corrupts them silently. Everything is text.

Four. Hardcoding a group name in an integration. group/update renames groups. Key on the numeric id and refresh the name from group/read.

Five. Treating a contact as belonging to several groups. One groupId per row. A person in three audiences is three rows that do not know about each other.

Six. Suppressing an opt-out from one group only. Remove them everywhere, and prefer an explicit recipient list for consent-sensitive campaigns.

Seven. Parsing the group send response for recipients. There are none. The mobile and invalidMobile fields do not exist in that shape.

Eight. Treating delivery reports as monitoring. For group sends they are the recipient record. Store them durably and back the webhook with polling.

Nine. Estimating recipients by summing group/read counts. That double-counts overlap. Compute the distinct union of mobile numbers.

Ten. Read-modify-write on contact/update from a stale snapshot. Every field is required, there is no partial update, contact rows carry no timestamp, and there is no version token. Serialise your writes.


Decision Matrix: Platform Groups, Your Own Lists, or Both

Twelve dimensions, three approaches. “Platform groups” means storing your audience on the platform and using sendMethod=group. “Your own list” means keeping the audience entirely in your system and sending explicit comma-separated recipients with sendMethod=quick. “Both” means your system is authoritative and platform groups are a synced projection used only for portal-driven sends.

DimensionPlatform groupsYour own listBoth
Recipients known at submissionNoYesYes
Non-technical staff can send from the portalYesNoYes
Suppression guaranteed before sendNoYesYes
Consent evidence availableNo, must be externalYesYes
Enumeration costFull account readLocal queryLocal query
Change detectionNone, no timestampsFullFull
Overlap handled predictablyNo, silent dedupeYes, you control itYes
Per-recipient personalisationNot via group sendYesYes
Request volume for a large sendOne callMany batched callsDepends on path
Rate limiting exposureLowHigher, needs backpressureMedium
Sync burdenNoneNoneReal and ongoing
Suitable for regulated or consent-sensitive campaignsNoYesYes, via the explicit path

The pattern that falls out of that table is not subtle. Use platform groups where a human needs to pick an audience in the portal and the campaign is not consent-critical. Use your own explicit lists for anything where you must be able to say afterwards exactly who was messaged and why they were eligible. If you need both, make your system authoritative and accept the sync burden as the price. Do not run the platform as a second authoritative store. If you take the explicit-list path at volume, the batching and backpressure side of it is covered in rate limiting and backpressure in messaging systems.


Build Order

  1. Build the wire adapter first, the one module that knows groupname from groupid from id from group.
  2. Write the status-based success check and a contract test that asserts group/delete with code 224 is treated as success.
  3. Implement group/read and contact/read with raw payload capture before parsing.
  4. Build the normalisation layer: unwrap, coerce types, keep identifiers as text.
  5. Create the platform_group and platform_contact tables and load your first full snapshot.
  6. Add the diff job producing the four difference sets, and alert on the suppression set specifically.
  7. Build the single serialised writer for create, update and delete.
  8. Add rename detection and populate name_changed_at.
  9. Implement the audience freeze that writes campaign_audience before any group send.
  10. Add the group lock so membership cannot change during a live campaign.
  11. Wire delivery report ingestion into durable storage, with polling backfill alongside any webhook.
  12. Build the three-count reconciliation query and run it after every group campaign.
  13. Add a nightly diff of the two error code catalogues so a new response code surfaces before it breaks something.

The Checklist

Copy this into your pull request template.

  • All group and contact API calls go through one adapter module
  • Success is determined by status, never by code
  • group/delete returning 224 is covered by a test
  • api and action are verified on every response before use
  • msg is never parsed, only logged
  • groupId, cId, transactionId, msgId and mobileNo are all stored as text
  • groupnameList and contactList double wrapping is unwrapped in one place
  • Envelope count and per-group count are modelled as separate fields with correct types
  • groupType is stored as a raw string with unknown values logged
  • contactStatus is stored but never gates behaviour
  • creationTime is parsed as millisecond epoch and stored with a timezone
  • Groups are keyed on numeric id everywhere, never on name
  • Group renames are detected and recorded on sync
  • No group name is sent to a create call unless it was read from group/read in the same run
  • Raw API payloads are stored before parsing
  • All phonebook writes are serialised through one worker
  • Portal editing is prohibited during a sync window
  • The reconciliation diff produces all four difference sets
  • A platform contact matching the suppression list raises an incident, not a ticket
  • Expected recipients are computed as a distinct union of mobile numbers, not a sum of group counts
  • The audience is frozen and stored before every group send
  • Groups are locked against membership change during a live campaign
  • The send response is parsed against the sendMethod that was sent
  • bulkupload is used as the sendMethod value for file sends, not file
  • No code path reads mobile or invalidMobile from a group or bulk send response
  • Delivery reports are written to durable storage with a retention at least as long as the audit window
  • Delivery report polling backfills any missed webhook pushes
  • Post-campaign reconciliation produces matched, expected-never-reported and reported-not-expected counts
  • A non-zero reported-not-expected count pages someone
  • duplicatecheck behaviour is documented in your runbook as within-request deduplication
  • Consent records live in your system, with timestamp and channel, never on the platform
  • Consent-sensitive campaigns use explicit recipient lists rather than group sends
  • Contact retention and delivery report retention periods are set deliberately
  • The two error code catalogues are diffed on a schedule

Unspecified Behaviour and How to Code Around It

Ten behaviours in this family are not pinned down by anything you can read before you write code. Each one below pairs the uncertainty with the defensive choice that stays correct whichever way it resolves. None of these require you to wait for an answer. They require you not to bet on one.

One. A contact row carries one group, so treat a person as a set of rows. Each contact/read row has a single scalar groupId, and moving a contact with contact/update changes that field rather than adding to it. Whether the platform models a person across groups as several rows or something else, your code is safe if it keys people on mobile_no in your own system and never assumes a contact row is a person. Look up by number, expect several hits, act on all of them.

Two. Do not let contactStatus gate anything. The sample shows "1" and nothing states what other values mean. Store it, log values you have not seen, and keep every decision about whether to message someone in your own consent record where you control the semantics.

Three. Treat groupType as an open set. "PHONEBOOK" is the value you will see today. Store the raw string and let unknown values pass through rather than mapping onto an enum that throws, so a new group type shipped next quarter does not take your sync job down.

Four. Never send a group name to contact/create that you did not just read. An unrecognised groupname might error, might create a group, might drop the contact. Resolve the name against a fresh group/read in the same run and skip the contact if it does not match. That is correct under all three behaviours.

Five. Empty a group before you delete it. Whether group/delete cascades to its contacts, orphans them, or reassigns them is not something you want to discover on a group that mattered. Delete the members first with contact/delete, confirm the member count is zero, then delete the group. Slower, and it removes the question.

Six. Write your list reads as if they might be truncated. Neither read endpoint exposes a pagination parameter, so you cannot page even if you wanted to. Compare the envelope count against the number of elements you actually parsed on every call, and alert when they disagree. If a limit is ever introduced, that check catches it on the first run instead of silently shrinking your view of the account.

Seven. Freeze the audience yourself rather than relying on when the platform resolves it. For a scheduled or split send, membership might resolve at composition time or at each batch’s fire time. Snapshot the members into campaign_audience before you submit and lock the group against edits until the campaign finishes, and the distinction stops mattering to you.

Eight. Reconcile billing against delivery reports, not against your own arithmetic. Whether duplicatecheck deduplicates across a whole split campaign or within each batch changes how many messages you pay for. The cost field on delivery report rows is the figure that settles it, so build the reconciliation query and let it tell you rather than predicting.

Nine. Rate limit yourself on the phonebook endpoints. No throughput or size limits are published for this family. Since your sync already funnels writes through a single serialised worker, add a modest delay between calls and back off on any non-success response, which is the behaviour you would want anyway.

Ten. Keep your own history, because the platform keeps none you can read. contact/update requires every field, returns success only, and exposes no prior state or version token. Write the before and after values into your own audit table on every phonebook write. That is also what you will need if anyone ever asks why a number changed.


FAQs

What is a contact group on SMSGatewayCenter?
A server-side list of contacts with a numeric groupId, a display name, a groupType of PHONEBOOK in the documented sample, a creation timestamp and a member count, readable through SMSApi/group/read. It is an enumerated list, not a rule-based segment.

How do I get the id of a group I just created?
SMSApi/group/create returns success without an identifier. Call SMSApi/group/read afterwards and match on the name you supplied. Store the numeric id and stop using the name as a key from that point on.

Why did my group delete look like it failed?
Because SMSApi/group/delete returns "code": "224" on success while the rest of the family returns "200". Check status for the value success instead of testing the code.

How do I list everyone in one group?
Call SMSApi/contact/read, which returns every contact in the account with a denormalised groupId on each row, and filter locally. The endpoint accepts no group filter, no pagination and no date range.

Can one contact be in two groups?
Each contact row in the documented response carries exactly one groupId, and there is no endpoint that adds an existing contact to a second group. In practice a person who should be in three audiences is represented by three separate contact records sharing a mobile number.

How do I move a contact to another group?
SMSApi/contact/update takes groupid as a required parameter, so updating a contact with a different group id moves it. Note that the endpoint also requires contactname and mobileno, so you must resend the current values or you will overwrite them.

Why does my group send response not list the recipients?
Because that response shape does not carry them. A group send returns status, group, transactionId, statusCode and reason. There is no mobile field and no invalidMobile field. Recipients appear only in the delivery report feed.

How do I know who was actually messaged?
Poll SMSApi/reports/status with method=getDlr over the campaign window. Each row carries mobileNo, msgId, uuId, status, cause and cost. For a group send this feed is your only per-recipient record.

What happens when I send to two groups that share members?
duplicatecheck defaults to true and removes duplicate mobile numbers within the request, so the overlap is submitted once. The response does not tell you how many were removed, so compute the distinct union of mobile numbers yourself beforehand if you need an expected count.

Does adding someone to a group after scheduling a campaign include them?
The documentation does not say when membership resolves for a scheduled or split send. Freeze your audience before submitting and lock the group against changes while a campaign is running rather than relying on either behaviour.

Can I use the API to turn split campaigns on for a group send?
No. Splitting is configured in the portal through the split campaign settings, not through any documented send parameter. The API can read, reschedule and delete a split campaign but there is no documented parameter on SMSApi/send that creates one.

What sendMethod value do I use for a file upload?
bulkupload. The response for that method is the smallest of all the send shapes: status, transactionId, statusCode and reason, with no recipient information whatsoever.

Does the platform store consent for me?
No. A contact row has a name, a number, a group and an undocumented contactStatus. There is no consent field, no consent timestamp and no record of withdrawal. Consent evidence has to live in your system.

How often should I sync my contacts to the platform?
Whatever interval you choose, treat the local copy as a cache with a known staleness bound. Because contact rows carry no timestamps, incremental sync is impossible, so every sync is a full read and the interval is a cost decision rather than a correctness one. Do not sync while a campaign is running against the groups you are about to modify.


Ready to build against the contact and group API properly?

Start with the full endpoint reference at smsgatewaycenter.com/developer-api, or get in touch if you need the undocumented behaviours in section twenty-one confirmed for your account before you commit to a design.


Save this interesting page on your favorite Social Media

Blog Author logo

SMS Gateway Center Desk

SMS Gateway Center is one of the largest and leading SMS Provider in India. It is run by a large professional team to cater small companies to large corporate companies. SMS Gateway Center is associated with the best operators in India covering the entire states in India. SMS Gateway Center has been serving through its SMS Resellers in more than 20 states in India. To become our SMS Reseller, kindly contact us

Looking for the best business communication solutions, get in touch!