{"id":2949,"date":"2026-09-07T11:27:10","date_gmt":"2026-09-07T05:57:10","guid":{"rendered":"https:\/\/www.smsgatewaycenter.com\/blog\/?p=2949"},"modified":"2026-09-07T11:27:12","modified_gmt":"2026-09-07T05:57:12","slug":"contact-groups-segments-data-model-group-send","status":"publish","type":"post","link":"https:\/\/www.smsgatewaycenter.com\/blog\/contact-groups-segments-data-model-group-send\/","title":{"rendered":"Contact Groups and Segments: The Data Model Behind a Group Send"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/contact-groups-segments-data-model-group-send.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"584\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/contact-groups-segments-data-model-group-send-1024x584.webp\" alt=\"Geometric illustration of overlapping audience clusters feeding into a single opaque container, with individual delivery records fanning out from the other side.\" class=\"wp-image-2950\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/contact-groups-segments-data-model-group-send-1024x584.webp 1024w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/contact-groups-segments-data-model-group-send-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/contact-groups-segments-data-model-group-send-768x438.webp 768w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/contact-groups-segments-data-model-group-send.webp 1200w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><figcaption class=\"wp-element-caption\">A group send addresses a container, not the people inside it. The individual records only appear afterwards, in the delivery report feed.<\/figcaption><\/figure>\n\n\n\n<h1 class=\"wp-block-heading\">Table of Contents<\/h1>\n\n\n\n<ol class=\"wp-block-list\">\n<li><a href=\"#the-short-answer\">The Short Answer<\/a><\/li>\n\n\n\n<li><a href=\"#tldr\">TL;DR<\/a><\/li>\n\n\n\n<li><a href=\"#what-a-group-is\">What a Group Actually Is on This Platform<\/a><\/li>\n\n\n\n<li><a href=\"#the-eight-endpoints\">The Eight Endpoints and What Each One Can Address<\/a><\/li>\n\n\n\n<li><a href=\"#five-spellings\">One Group, Five Spellings of Its Identifier<\/a><\/li>\n\n\n\n<li><a href=\"#not-code-200\">Success Is Not Code 200<\/a><\/li>\n\n\n\n<li><a href=\"#one-contact-one-group\">One Contact, One Group<\/a><\/li>\n\n\n\n<li><a href=\"#enumerating-a-group\">Enumerating a Group With an Endpoint That Has No Filter<\/a><\/li>\n\n\n\n<li><a href=\"#group-send-blind-spot\">The Group Send Blind Spot<\/a><\/li>\n\n\n\n<li><a href=\"#send-response-union\">The Send Response Union Is Now Complete<\/a><\/li>\n\n\n\n<li><a href=\"#overlapping-groups\">Overlapping Groups and duplicatecheck<\/a><\/li>\n\n\n\n<li><a href=\"#when-membership-resolves\">When Does Membership Resolve<\/a><\/li>\n\n\n\n<li><a href=\"#keeping-in-sync\">Keeping Two Contact Systems in Sync<\/a><\/li>\n\n\n\n<li><a href=\"#consent-and-dlt\">Consent, DND and DLT for a List You Cannot Enumerate<\/a><\/li>\n\n\n\n<li><a href=\"#sync-and-audit-playbook\">The Sync and Audit Playbook<\/a><\/li>\n\n\n\n<li><a href=\"#schema\">A Schema That Survives All of This<\/a><\/li>\n\n\n\n<li><a href=\"#ten-mistakes\">Ten Mistakes That Cost Real Money<\/a><\/li>\n\n\n\n<li><a href=\"#decision-matrix\">Decision Matrix: Platform Groups, Your Own Lists, or Both<\/a><\/li>\n\n\n\n<li><a href=\"#build-order\">Build Order<\/a><\/li>\n\n\n\n<li><a href=\"#checklist\">The Checklist<\/a><\/li>\n\n\n\n<li><a href=\"#unspecified-behaviour\">Unspecified Behaviour and How to Code Around It<\/a><\/li>\n\n\n\n<li><a href=\"#faqs\">FAQs<\/a><\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\"><\/p>\n\n\n\n<h2 id=\"the-short-answer\" class=\"wp-block-heading\">The Short Answer<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A contact group on SMSGatewayCenter is a server-side list identified by a numeric <code>groupId<\/code>, and when you send to it with <code>sendMethod=group<\/code> the API returns the group identifier and a transaction identifier but no recipient information at all. There is no <code>mobile<\/code> field and no <code>invalidMobile<\/code> 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.<\/p>\n\n\n\n<h2 id=\"tldr\" class=\"wp-block-heading\">TL;DR<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>A group is addressed by <code>groupId<\/code>, but the endpoint that creates a group does not return the id it just generated. You call <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/read-group\/\"><code>SMSApi\/group\/read<\/code><\/a> afterwards and match on the name you chose.<\/li>\n\n\n\n<li>The identifier is spelled five different ways across the family: <code>groupname<\/code>, <code>id<\/code>, <code>groupid<\/code>, <code>group<\/code> and <code>groupId<\/code>, depending on which endpoint you are calling. Write a mapping layer once.<\/li>\n\n\n\n<li><code>SMSApi\/group\/delete<\/code> returns <code>\"code\": \"224\"<\/code> on success, not <code>\"200\"<\/code>. Any success check written as <code>code == \"200\"<\/code> reports a successful delete as a failure. Branch on <code>status<\/code> instead.<\/li>\n\n\n\n<li>In the documented <code>contact\/read<\/code> response every contact row carries exactly one <code>groupId<\/code>, never an array. A person in three audiences is three contact records with three different <code>cId<\/code> values and the same <code>mobileNo<\/code>.<\/li>\n\n\n\n<li><code>contact\/create<\/code> places a contact by <code>groupname<\/code>, a mutable human-typed string. <code>contact\/update<\/code> places it by <code>groupid<\/code>. The same relationship is name-keyed on one endpoint and id-keyed on the other.<\/li>\n\n\n\n<li><code>group\/update<\/code> renames a group. Nothing warns you that any cached name, any hardcoded <code>groupname<\/code> in an integration, or any operator runbook now points at a group that no longer answers to that name.<\/li>\n\n\n\n<li><code>contact\/read<\/code> accepts no group filter, no pagination and no date range. To enumerate one group you download the entire address book and filter client side.<\/li>\n\n\n\n<li><code>duplicatecheck<\/code> defaults to <code>true<\/code>, 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.<\/li>\n\n\n\n<li>The <code>SMSApi\/send<\/code> response is a union keyed on <code>sendMethod<\/code> with no discriminator inside the body. The <code>bulkupload<\/code> variant returns the smallest shape of all: <code>status<\/code>, <code>transactionId<\/code>, <code>statusCode<\/code> and <code>reason<\/code> only.<\/li>\n\n\n\n<li>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.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"what-a-group-is\" class=\"wp-block-heading\">What a Group Actually Is on This Platform<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Start from what the API actually returns rather than from what the word &#8220;group&#8221; suggests. Here is the documented sample response from <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/read-group\/\">the Read Group endpoint<\/a>, verbatim in shape:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n    \"response\": {\n        \"api\": \"group\",\n        \"action\": \"read\",\n        \"status\": \"success\",\n        \"msg\": \"success\",\n        \"code\": \"200\",\n        \"count\": 2,\n        \"groupnameList\": &#91;\n            {\n                \"groupname\": {\n                    \"groupId\": \"16322\",\n                    \"count\": \"1\",\n                    \"groupName\": \"test12\",\n                    \"groupType\": \"PHONEBOOK\",\n                    \"creationTime\": \"1562969551262\"\n                }\n            },\n            {\n                \"groupname\": {\n                    \"groupId\": \"18373\",\n                    \"count\": \"0\",\n                    \"groupName\": \"TEST1234\",\n                    \"groupType\": \"PHONEBOOK\",\n                    \"creationTime\": \"1563027427155\"\n                }\n            }\n        ]\n    }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Five things in that payload matter more than they look.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The list is double wrapped.<\/strong> The array is <code>response.groupnameList<\/code>, and each element is an object whose single key is <code>groupname<\/code>, and the group itself sits one level below that. So the path to the first group is <code>response.groupnameList[0].groupname.groupId<\/code>. This is the same double-wrapping convention used by <code>campaign\/read<\/code> and <code>template\/read<\/code>, and it is worth writing one unwrapping helper rather than repeating the path in every call site.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>There are two fields called <code>count<\/code> at different levels with different JSON types.<\/strong> The envelope <code>count<\/code> is the unquoted integer <code>2<\/code>, meaning two groups were returned. The per-group <code>count<\/code> is the quoted string <code>\"1\"<\/code>, 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 <code>groups_returned<\/code> and <code>member_count<\/code>, and coerce the inner one to an integer at the boundary.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The word &#8220;group&#8221; appears in three casings in one response.<\/strong> The wrapper is <code>groupnameList<\/code>, the element key is <code>groupname<\/code>, and the field is <code>groupName<\/code>. 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>groupType<\/code> is <code>\"PHONEBOOK\"<\/code> and only that one value is documented.<\/strong> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>creationTime<\/code> is a quoted millisecond epoch with no timezone.<\/strong> Same convention as <code>creationTime<\/code> and <code>lastupdatedTimestamp<\/code> elsewhere in this API. Parse it as UTC milliseconds, store it as a proper timestamp, and never display it without converting.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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 &#8220;segment&#8221; 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.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"the-eight-endpoints\" class=\"wp-block-heading\">The Eight Endpoints and What Each One Can Address<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The group and contact families are eight endpoints, all under <code>https:\/\/unify.smsgateway.center\/SMSApi\/<\/code>, 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 <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/authentication\/\">the authentication page<\/a> and in our guide to <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/oauth-messaging-connect-customer-sms-account\/\">connecting a customer SMS account with OAuth<\/a>.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Endpoint<\/th><th>Path<\/th><th>Required parameters beyond auth and output<\/th><th>What it addresses<\/th><th>Returns the object?<\/th><\/tr><\/thead><tbody><tr><td>Create group<\/td><td><code>SMSApi\/group\/create<\/code><\/td><td><code>groupname<\/code><\/td><td>A new group, by name<\/td><td>No<\/td><\/tr><tr><td>Read groups<\/td><td><code>SMSApi\/group\/read<\/code><\/td><td>none<\/td><td>All groups in the account<\/td><td>Yes, full list<\/td><\/tr><tr><td>Update group<\/td><td><code>SMSApi\/group\/update<\/code><\/td><td><code>id<\/code>, <code>groupname<\/code><\/td><td>One group, by numeric id<\/td><td>No<\/td><\/tr><tr><td>Delete group<\/td><td><code>SMSApi\/group\/delete<\/code><\/td><td><code>id<\/code><\/td><td>One group, by numeric id<\/td><td>No<\/td><\/tr><tr><td>Create contact<\/td><td><code>SMSApi\/contact\/create<\/code><\/td><td><code>groupname<\/code>, <code>contactname<\/code>, <code>mobileno<\/code><\/td><td>A new contact, placed by group name<\/td><td>No<\/td><\/tr><tr><td>Read contacts<\/td><td><code>SMSApi\/contact\/read<\/code><\/td><td>none<\/td><td>All contacts in the account<\/td><td>Yes, full list<\/td><\/tr><tr><td>Update contact<\/td><td><code>SMSApi\/contact\/update<\/code><\/td><td><code>id<\/code>, <code>groupid<\/code>, <code>contactname<\/code>, <code>mobileno<\/code><\/td><td>One contact, by numeric id<\/td><td>No<\/td><\/tr><tr><td>Delete contact<\/td><td><code>SMSApi\/contact\/delete<\/code><\/td><td><code>id<\/code><\/td><td>One contact, by numeric id<\/td><td>No<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Read that table as a capability statement rather than a reference and three gaps show up immediately.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>No create endpoint returns the identifier it created.<\/strong> <code>group\/create<\/code> responds with <code>api<\/code>, <code>action<\/code>, <code>status<\/code>, <code>msg<\/code> and <code>code<\/code>, and nothing else. So does <code>contact\/create<\/code>. This is the same behaviour as <code>apikey\/create<\/code>, 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 <code>cId<\/code> means downloading two hundred thousand rows.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>No read endpoint takes a filter.<\/strong> Both <code>group\/read<\/code> and <code>contact\/read<\/code> take authentication and <code>output<\/code> and nothing else. No id lookup, no name search, no group filter on contacts, no pagination, no cursor, no date range. This mirrors <code>schedule\/read<\/code>, which has the same shape. It is the single biggest constraint in the family and section eight is about living with it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>No endpoint adds an existing contact to a second group.<\/strong> <code>contact\/create<\/code> takes a name and a number and a group and makes a new row. <code>contact\/update<\/code> takes <code>groupid<\/code> as a required field and moves the row. There is no documented join or membership endpoint. The consequence is section seven.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-group-identifier-spellings.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"598\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-group-identifier-spellings-1024x598.webp\" alt=\"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.\" class=\"wp-image-2951\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-group-identifier-spellings-1024x598.webp 1024w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-group-identifier-spellings-300x175.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-group-identifier-spellings-768x448.webp 768w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-group-identifier-spellings.webp 1050w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><figcaption class=\"wp-element-caption\">The identifier does not keep its name between the endpoint that returns it and the endpoint that consumes it.<\/figcaption><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"five-spellings\" class=\"wp-block-heading\">One Group, Five Spellings of Its Identifier<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Line up the parameter names and the problem is obvious.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Where<\/th><th>Parameter or field<\/th><th>Type of value<\/th><\/tr><\/thead><tbody><tr><td><code>group\/create<\/code><\/td><td><code>groupname<\/code><\/td><td>Human-typed name<\/td><\/tr><tr><td><code>contact\/create<\/code><\/td><td><code>groupname<\/code><\/td><td>Human-typed name<\/td><\/tr><tr><td><code>group\/update<\/code><\/td><td><code>id<\/code><\/td><td>Numeric group id<\/td><\/tr><tr><td><code>group\/delete<\/code><\/td><td><code>id<\/code><\/td><td>Numeric group id<\/td><\/tr><tr><td><code>contact\/update<\/code><\/td><td><code>groupid<\/code><\/td><td>Numeric group id<\/td><\/tr><tr><td><code>SMSApi\/send<\/code><\/td><td><code>group<\/code><\/td><td>Ids or names, comma separated<\/td><\/tr><tr><td><code>group\/read<\/code> response<\/td><td><code>groupId<\/code><\/td><td>Numeric group id, quoted string<\/td><\/tr><tr><td><code>contact\/read<\/code> response<\/td><td><code>groupId<\/code><\/td><td>Numeric group id, quoted string<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Contacts have the same problem in miniature: <code>contact\/read<\/code> returns the contact key as <code>cId<\/code>, while <code>contact\/update<\/code> and <code>contact\/delete<\/code> both consume it as <code>id<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">None of this is hard to handle. It is hard to handle <em>repeatedly<\/em>, 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 <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/outbound-message-table-schema-design\/\">the outbound message table<\/a>.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># One place that knows the wire vocabulary. Nothing else in your codebase\n# should ever type the string \"groupid\" or \"cId\".\n\nimport requests\n\nBASE = \"https:\/\/unify.smsgateway.center\/SMSApi\"\n\nclass Groups:\n    def __init__(self, session, userid, password):\n        self.s = session\n        self.auth = {\"userid\": userid, \"password\": password, \"output\": \"json\"}\n\n    def _post(self, path, **fields):\n        # Never rely on a default. requests has no default timeout at all.\n        r = self.s.post(f\"{BASE}\/{path}\", data={**self.auth, **fields}, timeout=(5, 30))\n        r.raise_for_status()\n        return r.json()&#91;\"response\"]\n\n    def create(self, name):\n        # Returns no id. Success only.\n        return self._post(\"group\/create\", groupname=name)\n\n    def rename(self, group_id, new_name):\n        return self._post(\"group\/update\", id=group_id, groupname=new_name)\n\n    def delete(self, group_id):\n        return self._post(\"group\/delete\", id=group_id)\n\n    def list_all(self):\n        body = self._post(\"group\/read\")\n        # Double wrapped: response.groupnameList&#91;i].groupname\n        return &#91;\n            {\n                \"group_id\": g&#91;\"groupname\"]&#91;\"groupId\"],       # keep as TEXT\n                \"name\": g&#91;\"groupname\"]&#91;\"groupName\"],\n                \"group_type\": g&#91;\"groupname\"]&#91;\"groupType\"],   # open enumeration\n                \"member_count\": int(g&#91;\"groupname\"]&#91;\"count\"]),\n                \"created_at_ms\": int(g&#91;\"groupname\"]&#91;\"creationTime\"]),\n            }\n            for g in body.get(\"groupnameList\", &#91;])\n        ]<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two details in that snippet are deliberate. <code>group_id<\/code> 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 <code>requests<\/code> library ships with no default timeout whatsoever, a trap covered at length in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-python-integration-tutorial\/\">the Python integration tutorial<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"not-code-200\" class=\"wp-block-heading\">Success Is Not Code 200<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">This is the finding in this article most likely to be sitting in your codebase right now as a live bug.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Seven of the eight endpoints in this family return <code>\"code\": \"200\"<\/code> on success. The eighth does not. Here is the documented success response for <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/delete-group\/\">Delete Group<\/a>, verbatim:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n    \"response\": {\n        \"api\": \"group\",\n        \"action\": \"delete\",\n        \"status\": \"success\",\n        \"msg\": \"Group deleted successfully\",\n        \"code\": \"224\"\n    }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>status<\/code> is <code>\"success\"<\/code>. <code>msg<\/code> says the group was deleted. And <code>code<\/code> is <code>\"224\"<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If your client wraps every call in a check like <code>if body[\"response\"][\"code\"] == \"200\"<\/code>, 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The rule that follows generalises across the whole API, and it is the same rule that came out of the campaign family, where <code>campaign\/update<\/code> and <code>campaign\/delete<\/code> both return schedule wording in <code>msg<\/code> because the two families share a code path:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Branch on <code>api<\/code> plus <code>action<\/code> plus <code>status<\/code>. Treat <code>code<\/code> as a detail to log, not a condition to test. Never parse <code>msg<\/code>.<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>class ApiError(Exception):\n    def __init__(self, body):\n        self.body = body\n        super().__init__(\n            f\"{body.get('api')}\/{body.get('action')} failed: \"\n            f\"code={body.get('code')} msg={body.get('msg')!r}\"\n        )\n\ndef check(body, expect_api, expect_action):\n    # Correct across the whole family, including group\/delete returning 224.\n    if body.get(\"api\") != expect_api or body.get(\"action\") != expect_action:\n        raise ApiError(body)          # you parsed the wrong response entirely\n    if body.get(\"status\") != \"success\":\n        raise ApiError(body)\n    return body<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Write a contract test that asserts this. The response code catalogues at <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/get-api-response-error-code-list\/\">the API error code list endpoint<\/a> and <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/get-delivery-error-code-list\/\">the delivery error code list endpoint<\/a> 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 <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/testing-code-that-sends-messages\/\">testing code that sends messages<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"one-contact-one-group\" class=\"wp-block-heading\">One Contact, One Group<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Here is the documented <code>contact\/read<\/code> response, from <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/read-contact\/\">the Read Contact page<\/a>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n    \"response\": {\n        \"api\": \"contact\",\n        \"action\": \"read\",\n        \"status\": \"success\",\n        \"msg\": \"success\",\n        \"code\": \"200\",\n        \"count\": 2,\n        \"contactList\": &#91;\n            {\n                \"contact\": {\n                    \"cId\": \"144441\",\n                    \"contactName\": \"abcd\",\n                    \"contactStatus\": \"1\",\n                    \"groupId\": \"63\",\n                    \"mobileNo\": \"919999999999\",\n                    \"groupName\": \"TEST4567\",\n                    \"groupType\": \"PHONEBOOK\"\n                }\n            }\n        ]\n    }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Every contact row carries a single scalar <code>groupId<\/code>. Not an array of group ids. Not a nested membership list. One value. And the group&#8217;s name and type are denormalised onto the contact row alongside it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Combine that with the capability gap from section four, that no endpoint adds an existing contact to a second group and <code>contact\/update<\/code> requires <code>groupid<\/code> as a mandatory field, and the model resolves as follows. <strong>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 <code>cId<\/code> values and the same <code>mobileNo<\/code>.<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This has consequences that are easy to miss and expensive to discover late.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Editing one record does not edit the others.<\/strong> If a customer changes their phone number and you update the contact in your &#8220;Premium&#8221; group, the copies in &#8220;Mumbai&#8221; and &#8220;Newsletter&#8221; 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Deleting one record does not suppress the person.<\/strong> <code>contact\/delete<\/code> takes one <code>id<\/code>. 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 <a href=\"https:\/\/trai.gov.in\/tcccpr\">TRAI TCCCPR regulations<\/a> are explicit that the obligation attaches to the sender.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Suppression must therefore be enforced before the send, on your side.<\/strong> 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&#8217;s decision matrix is for.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>contactStatus<\/code> is undocumented.<\/strong> The sample shows <code>\"1\"<\/code>. 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"enumerating-a-group\" class=\"wp-block-heading\">Enumerating a Group With an Endpoint That Has No Filter<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><code>contact\/read<\/code> takes authentication and <code>output<\/code>. That is the complete parameter list. There is no <code>groupid<\/code> filter, no <code>pageLimit<\/code>, no cursor, no <code>fromdate<\/code> and <code>todate<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So the answer to &#8220;who is in group 63&#8221; is: fetch every contact in the account and filter on <code>groupId == \"63\"<\/code> in your own process.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For a hundred contacts this is a non-issue. For a hundred thousand it is an operational problem with four distinct edges.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Response size.<\/strong> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Consistency.<\/strong> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Cost of freshness.<\/strong> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>No change detection.<\/strong> Contact rows carry no timestamp at all. <code>group\/read<\/code> gives you <code>creationTime<\/code> for groups, but a contact row has <code>cId<\/code>, <code>contactName<\/code>, <code>contactStatus<\/code>, <code>groupId<\/code>, <code>mobileNo<\/code>, <code>groupName<\/code> and <code>groupType<\/code>, 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That last point deserves emphasis because <code>contact\/update<\/code> makes it worse. Every field is required: <code>id<\/code>, <code>groupid<\/code>, <code>contactname<\/code> and <code>mobileno<\/code>. There is no partial update. To change a contact&#8217;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 <code>If-Match<\/code>, no version token and no <code>lastUpdated<\/code> 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.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def members_of(group_id, contacts_response):\n    \"\"\"The only way to enumerate a group: read everything, filter locally.\"\"\"\n    rows = contacts_response.get(\"contactList\", &#91;])\n    return &#91;\n        {\n            \"contact_id\": c&#91;\"contact\"]&#91;\"cId\"],          # TEXT\n            \"name\": c&#91;\"contact\"]&#91;\"contactName\"],\n            \"mobile\": c&#91;\"contact\"]&#91;\"mobileNo\"],         # TEXT, never int\n            \"group_id\": c&#91;\"contact\"]&#91;\"groupId\"],\n            \"status_raw\": c&#91;\"contact\"]&#91;\"contactStatus\"], # undocumented, do not branch\n        }\n        for c in rows\n        if c&#91;\"contact\"]&#91;\"groupId\"] == str(group_id)\n    ]<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>mobileNo<\/code> stays a string for the same reason <code>groupId<\/code> does. A number like <code>919999999999<\/code> 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.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"group-send-blind-spot\" class=\"wp-block-heading\">The Group Send Blind Spot<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Now the part that makes all of the above urgent rather than merely tidy.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Compare the two documented <code>SMSApi\/send<\/code> responses. From <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/send-sms-batch\/\">Send SMS Batch<\/a>, a quick send:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n    \"status\": \"success\",\n    \"mobile\": \"919999999999\",\n    \"invalidMobile\": \"\",\n    \"transactionId\": \"6305583318236810379\",\n    \"statusCode\": \"200\",\n    \"reason\": \"success\"\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">From <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/send-sms-group\/\">Send SMS Group<\/a>, a group send:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n    \"status\": \"success\",\n    \"group\": \"63\",\n    \"transactionId\": \"8359251506264886974\",\n    \"statusCode\": \"200\",\n    \"reason\": \"success\"\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The group response has no <code>mobile<\/code> and no <code>invalidMobile<\/code>. 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So at the moment your send call returns, the set of people you just contacted <strong>exists nowhere in your systems<\/strong>. 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That is why <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/sms-delivery-report\/\">the delivery report API<\/a> stops being monitoring for a group send and becomes the audit trail. It is the sole source of <code>mobileNo<\/code> on a per-message basis, alongside <code>msgId<\/code>, <code>uuId<\/code>, <code>status<\/code>, <code>cause<\/code>, <code>cost<\/code> and <code>length<\/code>. If your delivery report ingestion drops rows, you have not lost a metric. You have lost the record of who you messaged.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Three engineering consequences follow directly.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Ingest delivery reports into durable storage, not into a dashboard.<\/strong> 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 <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/real-time-dlr-webhooks\/\">real-time DLR webhooks<\/a> and the schema side in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/outbound-message-table-schema-design\/\">the outbound message table<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Back the webhook with polling.<\/strong> A webhook you did not receive is indistinguishable from a message that generated no report. Poll <code>SMSApi\/reports\/status<\/code> with <code>method=getDlr<\/code> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Never derive compliance evidence from the send call.<\/strong> 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.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-group-send-audit-trail.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"598\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-group-send-audit-trail-1024x598.webp\" alt=\"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.\" class=\"wp-image-2952\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-group-send-audit-trail-1024x598.webp 1024w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-group-send-audit-trail-300x175.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-group-send-audit-trail-768x448.webp 768w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-group-send-audit-trail.webp 1050w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><figcaption class=\"wp-element-caption\">For a group send, delivery report ingestion is not telemetry. It is the system of record.<\/figcaption><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"send-response-union\" class=\"wp-block-heading\">The Send Response Union Is Now Complete<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">One endpoint, <code>SMSApi\/send<\/code>, 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 <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/send-sms-file\/\">Send SMS File<\/a>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n    \"status\": \"success\",\n    \"transactionId\": \"9140733627001556338\",\n    \"statusCode\": \"200\",\n    \"reason\": \"success\"\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That is the whole body. No <code>mobile<\/code>, no <code>invalidMobile<\/code>, no <code>group<\/code>, no count of rows accepted from the uploaded file.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th><code>sendMethod<\/code><\/th><th>Documented keys<\/th><th>Recipient information<\/th><\/tr><\/thead><tbody><tr><td><code>quick<\/code><\/td><td><code>status<\/code>, <code>mobile<\/code>, <code>invalidMobile<\/code>, <code>transactionId<\/code>, <code>statusCode<\/code>, <code>reason<\/code><\/td><td>Full, echoes valid and invalid numbers<\/td><\/tr><tr><td><code>group<\/code><\/td><td><code>status<\/code>, <code>group<\/code>, <code>transactionId<\/code>, <code>statusCode<\/code>, <code>reason<\/code><\/td><td>None, only the group id you sent<\/td><\/tr><tr><td><code>bulkupload<\/code><\/td><td><code>status<\/code>, <code>transactionId<\/code>, <code>statusCode<\/code>, <code>reason<\/code><\/td><td>None at all<\/td><\/tr><tr><td>Basic Auth sample<\/td><td>includes <code>msgId<\/code>, eighteen-digit <code>transactionId<\/code><\/td><td>Varies<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Common to every documented shape: <code>status<\/code>, <code>statusCode<\/code> and <code>transactionId<\/code>. Nothing else is safe to assume.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two practical notes. First, the file upload method&#8217;s <code>sendMethod<\/code> value is <code>bulkupload<\/code>, not <code>file<\/code>, which is easy to get wrong if you are working from the parameter list of a sibling page. Second, <code>bulkupload<\/code> accepts csv, xls, xlsx and zip, is posted as <code>multipart\/form-data<\/code>, and carries the same optional parameters as the other methods, including <code>duplicatecheck<\/code>, <code>scheduleTime<\/code>, <code>trackLink<\/code> and <code>testMessage<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The parsing rule is simple and it applies everywhere in your codebase: <strong>parse the response against the <code>sendMethod<\/code> you sent, not against a single struct.<\/strong> 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 <code>mobile<\/code> on group and bulk sends, and somebody downstream will eventually treat that empty string as &#8220;no recipients&#8221; rather than &#8220;this shape does not carry recipients&#8221;.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"overlapping-groups\" class=\"wp-block-heading\">Overlapping Groups and duplicatecheck<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><code>SMSApi\/send<\/code> accepts <code>group<\/code> as, verbatim, &#8220;group ids or names in comma separated formats&#8221;. So you can send to several groups in one call. And <code>duplicatecheck<\/code> is documented as &#8220;Enable to remove duplicate mobile numbers. Default is true.&#8221;<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Put those together with the one-contact-one-group model and the arithmetic stops being obvious.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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 <code>mobileNo<\/code>. You send to <code>group=63,64<\/code>.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>The sum of the <code>count<\/code> fields from <code>group\/read<\/code> is 65,000.<\/li>\n\n\n\n<li>With <code>duplicatecheck<\/code> at its default of <code>true<\/code>, duplicate mobile numbers are removed, so the number actually submitted is lower.<\/li>\n\n\n\n<li>The response tells you neither number. It returns <code>group<\/code> and <code>transactionId<\/code>.<\/li>\n\n\n\n<li>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.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">This is not a bug. Deduplication is the behaviour you want, and it is the same guard discussed in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/message-idempotency-preventing-duplicate-sends\/\">preventing duplicate sends<\/a>, where the key point is that <code>duplicatecheck<\/code> 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. <code>group\/read<\/code> counts double-count overlap by construction, because each contact record is counted once in its own group.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>-- Expected distinct recipients for a multi-group send, computed from your\n-- synced copy. group_read counts cannot answer this: they double count overlap.\nSELECT count(DISTINCT mobile_no) AS expected_recipients\nFROM platform_contact\nWHERE group_id IN ('63', '64')\n  AND mobile_no NOT IN (SELECT mobile_no FROM suppression_list);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">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 <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/observability-for-messaging-pipelines\/\">observability for messaging pipelines<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"when-membership-resolves\" class=\"wp-block-heading\">When Does Membership Resolve<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Here is a question the documentation does not answer, and the honest thing to do is to name it rather than guess: <strong>at what moment does a group send resolve its membership?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For an immediate, unsplit send the question barely matters, because resolution and submission are effectively simultaneous. It starts to matter in two situations.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>A scheduled send.<\/strong> <code>SMSApi\/send<\/code> accepts <code>scheduleTime<\/code> in the format <code>YYYY-MM-DD HH:MM:SS<\/code>. 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>A split campaign.<\/strong> The <a href=\"https:\/\/www.smsgatewaycenter.com\/split-sms-campaigns\/\">split campaign feature<\/a> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Because the answer is not documented, do not encode either assumption. Encode the defensive posture instead.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Freeze the audience before you submit.<\/strong> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Do not mutate a group that has a pending send against it.<\/strong> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Ask before you rely on it.<\/strong> If your use case genuinely requires knowing, ask through <a href=\"https:\/\/www.smsgatewaycenter.com\/contact\/\">support<\/a> and get the answer in writing rather than inferring it from one observed campaign.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"keeping-in-sync\" class=\"wp-block-heading\">Keeping Two Contact Systems in Sync<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The forces pushing them apart are specific.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The portal is a second writer.<\/strong> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Renames break name-keyed calls.<\/strong> <code>group\/update<\/code> renames a group by id. <code>contact\/create<\/code> places contacts by <code>groupname<\/code>. So the moment someone renames &#8220;Premium Customers&#8221; to &#8220;Premium Customers 2026&#8221;, 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 <code>contact\/create<\/code> does with an unrecognised <code>groupname<\/code>, so treat that as unknown and never let a name you did not just read from <code>group\/read<\/code> reach a create call.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Deletes are asymmetric.<\/strong> <code>group\/delete<\/code> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Your own dedupe and theirs disagree.<\/strong> You deduplicate people. The platform deduplicates mobile numbers within a request. Those are different operations on different keys and they will produce different counts.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The sync design that survives all four is a one-way projection with reconciliation, not a two-way merge.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Your system is authoritative. The platform group is a projection of it.<\/strong> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Write in one direction, through one worker.<\/strong> All <code>contact\/create<\/code>, <code>contact\/update<\/code> and <code>contact\/delete<\/code> calls come from a single serialised process. No parallel workers, no ad hoc scripts, no simultaneous portal editing during a sync window.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Reconcile periodically by full read.<\/strong> Because that is the only read available. Compare the platform&#8217;s view against yours, and record the differences rather than silently correcting them, because a difference you did not expect is information.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Track the group by id and treat the name as a label.<\/strong> Store <code>group_id<\/code> 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.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"consent-and-dlt\" class=\"wp-block-heading\">Consent, DND and DLT for a List You Cannot Enumerate<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Consent lives on your side, always.<\/strong> 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. <code>contactStatus<\/code> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Suppression must be enforced before submission, not after.<\/strong> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>DLT template and entity binding is unaffected by grouping but not by segmentation.<\/strong> Your <code>dltEntityId<\/code> and <code>dltTemplateId<\/code> 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 <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/dlt-template-rejected-reasons-and-fixes\/\">why DLT templates get rejected<\/a> and in the full <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/dlt-registration-step-by-step-guide-india\/\">DLT registration guide<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Preference and DND handling is the operator&#8217;s job, and your evidence of it is the delivery report.<\/strong> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Retention has to be deliberate.<\/strong> You are storing phone numbers, and Indian data protection obligations under the <a href=\"https:\/\/www.meity.gov.in\/data-protection-framework\" target=\"_blank\" rel=\"noopener nofollow\">Digital Personal Data Protection Act framework<\/a> 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.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"sync-and-audit-playbook\" class=\"wp-block-heading\">The Sync and Audit Playbook<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Six steps. Run the first four on a schedule and the last two per campaign.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step one: snapshot the platform.<\/strong> Call <code>group\/read<\/code> and <code>contact\/read<\/code>. 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step two: normalise into a flat table.<\/strong> Unwrap the double wrapping, coerce the two <code>count<\/code> fields to their correct types, keep every identifier and every mobile number as text, convert <code>creationTime<\/code> from millisecond epoch, and store <code>contactStatus<\/code> and <code>groupType<\/code> as raw strings without interpreting either.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step three: diff against your authoritative contact system.<\/strong> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step four: apply corrections through the single writer.<\/strong> Serialised, one at a time, checking <code>status<\/code> rather than <code>code<\/code> on every response so that a successful <code>group\/delete<\/code> returning <code>224<\/code> is not misread. Log every call with its full response body.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step five: freeze and record the audience before a campaign.<\/strong> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step six: reconcile the delivery report feed against the frozen audience.<\/strong> Poll <code>SMSApi\/reports\/status<\/code> with <code>method=getDlr<\/code> 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.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>-- Step six. The third bucket is the alarming one for a group send: someone\n-- was messaged who was not in the audience you thought you froze.\nWITH frozen AS (\n    SELECT mobile_no FROM campaign_audience WHERE campaign_ref = :ref\n),\nreported AS (\n    SELECT DISTINCT mobile_no FROM dlr_row\n    WHERE submitted_at &gt;= :window_start AND submitted_at &lt; :window_end\n)\nSELECT\n    (SELECT count(*) FROM frozen f JOIN reported r USING (mobile_no)) AS matched,\n    (SELECT count(*) FROM frozen f LEFT JOIN reported r USING (mobile_no)\n       WHERE r.mobile_no IS NULL)                                     AS expected_never_reported,\n    (SELECT count(*) FROM reported r LEFT JOIN frozen f USING (mobile_no)\n       WHERE f.mobile_no IS NULL)                                     AS reported_not_expected;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>expected_never_reported<\/code> will rarely be zero, because delivery reports lag and some never arrive, so alert on its trend rather than its absolute value. <code>reported_not_expected<\/code> 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.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"schema\" class=\"wp-block-heading\">A Schema That Survives All of This<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Two tables plus a link to your existing message table. These are siblings of the <code>outbound_message<\/code> table and follow the same rules: every provider identifier is <code>TEXT<\/code>, every timestamp is stored with a timezone, and raw payloads are kept.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>CREATE TABLE platform_group (\n    group_id          TEXT PRIMARY KEY,        -- platform groupId, never integer\n    group_name        TEXT NOT NULL,           -- display label, can change\n    group_type        TEXT NOT NULL,           -- open enumeration, e.g. PHONEBOOK\n    member_count      INTEGER,                 -- platform's count, quoted string on wire\n    created_at        TIMESTAMPTZ,             -- from creationTime, ms epoch\n    first_seen_at     TIMESTAMPTZ NOT NULL DEFAULT now(),\n    last_synced_at    TIMESTAMPTZ NOT NULL DEFAULT now(),\n    name_changed_at   TIMESTAMPTZ,             -- set when a sync sees a rename\n    locked_for_send   BOOLEAN NOT NULL DEFAULT false,\n    raw               JSONB NOT NULL\n);\n\nCREATE TABLE platform_contact (\n    contact_id        TEXT PRIMARY KEY,        -- platform cId\n    group_id          TEXT NOT NULL REFERENCES platform_group(group_id),\n    contact_name      TEXT,\n    mobile_no         TEXT NOT NULL,           -- TEXT, always\n    contact_status    TEXT,                    -- undocumented, stored not interpreted\n    local_person_id   TEXT,                    -- your authoritative person, nullable\n    last_synced_at    TIMESTAMPTZ NOT NULL DEFAULT now(),\n    raw               JSONB NOT NULL\n);\n\n-- One person can hold several contact rows across groups. This is the index\n-- that makes suppression checks and overlap arithmetic possible.\nCREATE INDEX platform_contact_mobile_idx ON platform_contact (mobile_no);\nCREATE INDEX platform_contact_group_idx  ON platform_contact (group_id);\n\nCREATE TABLE campaign_audience (\n    campaign_ref      TEXT NOT NULL,           -- your reference, not the platform's\n    mobile_no         TEXT NOT NULL,\n    group_id          TEXT NOT NULL,\n    frozen_at         TIMESTAMPTZ NOT NULL DEFAULT now(),\n    PRIMARY KEY (campaign_ref, mobile_no)\n);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Three deliberate choices. <code>local_person_id<\/code> 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. <code>name_changed_at<\/code> exists so a rename is an event you noticed rather than a surprise. And <code>campaign_audience<\/code> is keyed on your own campaign reference, not on a platform identifier, because the send response for a group send gives you a <code>transactionId<\/code> and no campaign identifier, so your reference is the only key that exists at the moment you need to write the row.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"ten-mistakes\" class=\"wp-block-heading\">Ten Mistakes That Cost Real Money<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>One. Testing <code>code == \"200\"<\/code> for success.<\/strong> <code>group\/delete<\/code> returns <code>\"224\"<\/code>. Branch on <code>status<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Two. Assuming <code>group\/create<\/code> returns the new group id.<\/strong> It returns success and nothing else. Read-after-write and match on name.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Three. Storing <code>groupId<\/code>, <code>cId<\/code> or <code>mobileNo<\/code> as integers.<\/strong> Identifiers on this platform reach nineteen digits, which exceeds the safe integer range in JavaScript, so a plain <code>JSON.parse<\/code> corrupts them silently. Everything is text.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Four. Hardcoding a group name in an integration.<\/strong> <code>group\/update<\/code> renames groups. Key on the numeric id and refresh the name from <code>group\/read<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Five. Treating a contact as belonging to several groups.<\/strong> One <code>groupId<\/code> per row. A person in three audiences is three rows that do not know about each other.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Six. Suppressing an opt-out from one group only.<\/strong> Remove them everywhere, and prefer an explicit recipient list for consent-sensitive campaigns.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Seven. Parsing the group send response for recipients.<\/strong> There are none. The <code>mobile<\/code> and <code>invalidMobile<\/code> fields do not exist in that shape.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Eight. Treating delivery reports as monitoring.<\/strong> For group sends they are the recipient record. Store them durably and back the webhook with polling.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Nine. Estimating recipients by summing <code>group\/read<\/code> counts.<\/strong> That double-counts overlap. Compute the distinct union of mobile numbers.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Ten. Read-modify-write on <code>contact\/update<\/code> from a stale snapshot.<\/strong> Every field is required, there is no partial update, contact rows carry no timestamp, and there is no version token. Serialise your writes.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"decision-matrix\" class=\"wp-block-heading\">Decision Matrix: Platform Groups, Your Own Lists, or Both<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Twelve dimensions, three approaches. &#8220;Platform groups&#8221; means storing your audience on the platform and using <code>sendMethod=group<\/code>. &#8220;Your own list&#8221; means keeping the audience entirely in your system and sending explicit comma-separated recipients with <code>sendMethod=quick<\/code>. &#8220;Both&#8221; means your system is authoritative and platform groups are a synced projection used only for portal-driven sends.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Dimension<\/th><th>Platform groups<\/th><th>Your own list<\/th><th>Both<\/th><\/tr><\/thead><tbody><tr><td>Recipients known at submission<\/td><td>No<\/td><td>Yes<\/td><td>Yes<\/td><\/tr><tr><td>Non-technical staff can send from the portal<\/td><td>Yes<\/td><td>No<\/td><td>Yes<\/td><\/tr><tr><td>Suppression guaranteed before send<\/td><td>No<\/td><td>Yes<\/td><td>Yes<\/td><\/tr><tr><td>Consent evidence available<\/td><td>No, must be external<\/td><td>Yes<\/td><td>Yes<\/td><\/tr><tr><td>Enumeration cost<\/td><td>Full account read<\/td><td>Local query<\/td><td>Local query<\/td><\/tr><tr><td>Change detection<\/td><td>None, no timestamps<\/td><td>Full<\/td><td>Full<\/td><\/tr><tr><td>Overlap handled predictably<\/td><td>No, silent dedupe<\/td><td>Yes, you control it<\/td><td>Yes<\/td><\/tr><tr><td>Per-recipient personalisation<\/td><td>Not via group send<\/td><td>Yes<\/td><td>Yes<\/td><\/tr><tr><td>Request volume for a large send<\/td><td>One call<\/td><td>Many batched calls<\/td><td>Depends on path<\/td><\/tr><tr><td>Rate limiting exposure<\/td><td>Low<\/td><td>Higher, needs backpressure<\/td><td>Medium<\/td><\/tr><tr><td>Sync burden<\/td><td>None<\/td><td>None<\/td><td>Real and ongoing<\/td><\/tr><tr><td>Suitable for regulated or consent-sensitive campaigns<\/td><td>No<\/td><td>Yes<\/td><td>Yes, via the explicit path<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">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 <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/rate-limiting-backpressure-messaging-systems\/\">rate limiting and backpressure in messaging systems<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"build-order\" class=\"wp-block-heading\">Build Order<\/h2>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Build the wire adapter first, the one module that knows <code>groupname<\/code> from <code>groupid<\/code> from <code>id<\/code> from <code>group<\/code>.<\/li>\n\n\n\n<li>Write the <code>status<\/code>-based success check and a contract test that asserts <code>group\/delete<\/code> with code <code>224<\/code> is treated as success.<\/li>\n\n\n\n<li>Implement <code>group\/read<\/code> and <code>contact\/read<\/code> with raw payload capture before parsing.<\/li>\n\n\n\n<li>Build the normalisation layer: unwrap, coerce types, keep identifiers as text.<\/li>\n\n\n\n<li>Create the <code>platform_group<\/code> and <code>platform_contact<\/code> tables and load your first full snapshot.<\/li>\n\n\n\n<li>Add the diff job producing the four difference sets, and alert on the suppression set specifically.<\/li>\n\n\n\n<li>Build the single serialised writer for create, update and delete.<\/li>\n\n\n\n<li>Add rename detection and populate <code>name_changed_at<\/code>.<\/li>\n\n\n\n<li>Implement the audience freeze that writes <code>campaign_audience<\/code> before any group send.<\/li>\n\n\n\n<li>Add the group lock so membership cannot change during a live campaign.<\/li>\n\n\n\n<li>Wire delivery report ingestion into durable storage, with polling backfill alongside any webhook.<\/li>\n\n\n\n<li>Build the three-count reconciliation query and run it after every group campaign.<\/li>\n\n\n\n<li>Add a nightly diff of the two error code catalogues so a new response code surfaces before it breaks something.<\/li>\n<\/ol>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"checklist\" class=\"wp-block-heading\">The Checklist<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Copy this into your pull request template.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>All group and contact API calls go through one adapter module<\/li>\n\n\n\n<li>Success is determined by <code>status<\/code>, never by <code>code<\/code><\/li>\n\n\n\n<li><code>group\/delete<\/code> returning <code>224<\/code> is covered by a test<\/li>\n\n\n\n<li><code>api<\/code> and <code>action<\/code> are verified on every response before use<\/li>\n\n\n\n<li><code>msg<\/code> is never parsed, only logged<\/li>\n\n\n\n<li><code>groupId<\/code>, <code>cId<\/code>, <code>transactionId<\/code>, <code>msgId<\/code> and <code>mobileNo<\/code> are all stored as text<\/li>\n\n\n\n<li><code>groupnameList<\/code> and <code>contactList<\/code> double wrapping is unwrapped in one place<\/li>\n\n\n\n<li>Envelope <code>count<\/code> and per-group <code>count<\/code> are modelled as separate fields with correct types<\/li>\n\n\n\n<li><code>groupType<\/code> is stored as a raw string with unknown values logged<\/li>\n\n\n\n<li><code>contactStatus<\/code> is stored but never gates behaviour<\/li>\n\n\n\n<li><code>creationTime<\/code> is parsed as millisecond epoch and stored with a timezone<\/li>\n\n\n\n<li>Groups are keyed on numeric id everywhere, never on name<\/li>\n\n\n\n<li>Group renames are detected and recorded on sync<\/li>\n\n\n\n<li>No group name is sent to a create call unless it was read from <code>group\/read<\/code> in the same run<\/li>\n\n\n\n<li>Raw API payloads are stored before parsing<\/li>\n\n\n\n<li>All phonebook writes are serialised through one worker<\/li>\n\n\n\n<li>Portal editing is prohibited during a sync window<\/li>\n\n\n\n<li>The reconciliation diff produces all four difference sets<\/li>\n\n\n\n<li>A platform contact matching the suppression list raises an incident, not a ticket<\/li>\n\n\n\n<li>Expected recipients are computed as a distinct union of mobile numbers, not a sum of group counts<\/li>\n\n\n\n<li>The audience is frozen and stored before every group send<\/li>\n\n\n\n<li>Groups are locked against membership change during a live campaign<\/li>\n\n\n\n<li>The send response is parsed against the <code>sendMethod<\/code> that was sent<\/li>\n\n\n\n<li><code>bulkupload<\/code> is used as the <code>sendMethod<\/code> value for file sends, not <code>file<\/code><\/li>\n\n\n\n<li>No code path reads <code>mobile<\/code> or <code>invalidMobile<\/code> from a group or bulk send response<\/li>\n\n\n\n<li>Delivery reports are written to durable storage with a retention at least as long as the audit window<\/li>\n\n\n\n<li>Delivery report polling backfills any missed webhook pushes<\/li>\n\n\n\n<li>Post-campaign reconciliation produces matched, expected-never-reported and reported-not-expected counts<\/li>\n\n\n\n<li>A non-zero reported-not-expected count pages someone<\/li>\n\n\n\n<li><code>duplicatecheck<\/code> behaviour is documented in your runbook as within-request deduplication<\/li>\n\n\n\n<li>Consent records live in your system, with timestamp and channel, never on the platform<\/li>\n\n\n\n<li>Consent-sensitive campaigns use explicit recipient lists rather than group sends<\/li>\n\n\n\n<li>Contact retention and delivery report retention periods are set deliberately<\/li>\n\n\n\n<li>The two error code catalogues are diffed on a schedule<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Unspecified Behaviour and How to Code Around It<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>One. A contact row carries one group, so treat a person as a set of rows.<\/strong> Each <code>contact\/read<\/code> row has a single scalar <code>groupId<\/code>, and moving a contact with <code>contact\/update<\/code> 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 <code>mobile_no<\/code> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Two. Do not let <code>contactStatus<\/code> gate anything.<\/strong> The sample shows <code>\"1\"<\/code> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Three. Treat <code>groupType<\/code> as an open set.<\/strong> <code>\"PHONEBOOK\"<\/code> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Four. Never send a group name to <code>contact\/create<\/code> that you did not just read.<\/strong> An unrecognised <code>groupname<\/code> might error, might create a group, might drop the contact. Resolve the name against a fresh <code>group\/read<\/code> in the same run and skip the contact if it does not match. That is correct under all three behaviours.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Five. Empty a group before you delete it.<\/strong> Whether <code>group\/delete<\/code> 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 <code>contact\/delete<\/code>, confirm the member count is zero, then delete the group. Slower, and it removes the question.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Six. Write your list reads as if they might be truncated.<\/strong> Neither read endpoint exposes a pagination parameter, so you cannot page even if you wanted to. Compare the envelope <code>count<\/code> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Seven. Freeze the audience yourself rather than relying on when the platform resolves it.<\/strong> For a scheduled or split send, membership might resolve at composition time or at each batch&#8217;s fire time. Snapshot the members into <code>campaign_audience<\/code> before you submit and lock the group against edits until the campaign finishes, and the distinction stops mattering to you.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Eight. Reconcile billing against delivery reports, not against your own arithmetic.<\/strong> Whether <code>duplicatecheck<\/code> deduplicates across a whole split campaign or within each batch changes how many messages you pay for. The <code>cost<\/code> field on delivery report rows is the figure that settles it, so build the reconciliation query and let it tell you rather than predicting.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Nine. Rate limit yourself on the phonebook endpoints.<\/strong> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Ten. Keep your own history, because the platform keeps none you can read.<\/strong> <code>contact\/update<\/code> 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.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"faqs\" class=\"wp-block-heading\">FAQs<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What is a contact group on SMSGatewayCenter?<\/strong><br>A server-side list of contacts with a numeric <code>groupId<\/code>, a display name, a <code>groupType<\/code> of <code>PHONEBOOK<\/code> in the documented sample, a creation timestamp and a member count, readable through <code>SMSApi\/group\/read<\/code>. It is an enumerated list, not a rule-based segment.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I get the id of a group I just created?<\/strong><br><code>SMSApi\/group\/create<\/code> returns success without an identifier. Call <code>SMSApi\/group\/read<\/code> afterwards and match on the name you supplied. Store the numeric id and stop using the name as a key from that point on.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Why did my group delete look like it failed?<\/strong><br>Because <code>SMSApi\/group\/delete<\/code> returns <code>\"code\": \"224\"<\/code> on success while the rest of the family returns <code>\"200\"<\/code>. Check <code>status<\/code> for the value <code>success<\/code> instead of testing the code.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I list everyone in one group?<\/strong><br>Call <code>SMSApi\/contact\/read<\/code>, which returns every contact in the account with a denormalised <code>groupId<\/code> on each row, and filter locally. The endpoint accepts no group filter, no pagination and no date range.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Can one contact be in two groups?<\/strong><br>Each contact row in the documented response carries exactly one <code>groupId<\/code>, 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I move a contact to another group?<\/strong><br><code>SMSApi\/contact\/update<\/code> takes <code>groupid<\/code> as a required parameter, so updating a contact with a different group id moves it. Note that the endpoint also requires <code>contactname<\/code> and <code>mobileno<\/code>, so you must resend the current values or you will overwrite them.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Why does my group send response not list the recipients?<\/strong><br>Because that response shape does not carry them. A group send returns <code>status<\/code>, <code>group<\/code>, <code>transactionId<\/code>, <code>statusCode<\/code> and <code>reason<\/code>. There is no <code>mobile<\/code> field and no <code>invalidMobile<\/code> field. Recipients appear only in the delivery report feed.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I know who was actually messaged?<\/strong><br>Poll <code>SMSApi\/reports\/status<\/code> with <code>method=getDlr<\/code> over the campaign window. Each row carries <code>mobileNo<\/code>, <code>msgId<\/code>, <code>uuId<\/code>, <code>status<\/code>, <code>cause<\/code> and <code>cost<\/code>. For a group send this feed is your only per-recipient record.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What happens when I send to two groups that share members?<\/strong><br><code>duplicatecheck<\/code> defaults to <code>true<\/code> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Does adding someone to a group after scheduling a campaign include them?<\/strong><br>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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Can I use the API to turn split campaigns on for a group send?<\/strong><br>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 <code>SMSApi\/send<\/code> that creates one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What <code>sendMethod<\/code> value do I use for a file upload?<\/strong><br><code>bulkupload<\/code>. The response for that method is the smallest of all the send shapes: <code>status<\/code>, <code>transactionId<\/code>, <code>statusCode<\/code> and <code>reason<\/code>, with no recipient information whatsoever.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Does the platform store consent for me?<\/strong><br>No. A contact row has a name, a number, a group and an undocumented <code>contactStatus<\/code>. There is no consent field, no consent timestamp and no record of withdrawal. Consent evidence has to live in your system.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How often should I sync my contacts to the platform?<\/strong><br>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.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">Ready to build against the contact and group API properly?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Start with the full endpoint reference at <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/\">smsgatewaycenter.com\/developer-api<\/a>, or <a href=\"https:\/\/www.smsgatewaycenter.com\/contact\/\">get in touch<\/a> if you need the undocumented behaviours in section twenty-one confirmed for your account before you commit to a design.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n","protected":false},"excerpt":{"rendered":"<p>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.<\/p>\n","protected":false},"author":118,"featured_media":2950,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[268],"tags":[2226,511,2222,2223,2224,2098,1277,263,2225,481],"class_list":["post-2949","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-tutorials","tag-audit-trail","tag-bulk-sms-api","tag-contact-groups","tag-contact-management-api","tag-data-model","tag-dlr-reconciliation","tag-dlt-compliance","tag-group-sms","tag-phonebook","tag-sms-api"],"_links":{"self":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/2949","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/users\/118"}],"replies":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/comments?post=2949"}],"version-history":[{"count":0,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/2949\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media\/2950"}],"wp:attachment":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media?parent=2949"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/categories?post=2949"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/tags?post=2949"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}