
Table of Contents
- The Short Answer
- TL;DR
- Four Tenancy Models, Not One
- What a Brand Owns on Each Channel
- Six Things the Account Boundary Decides
- Decision Matrix: Where Each Brand Should Live
- The Brand Registry Schema
- Resolving Credentials Per Brand at Send Time
- The Reseller User API at a Glance
- Creating a Child Account and Reading It Back
- The User Profile Row, Field by Field
- Epoch Milliseconds and the Midnight IST Expiry
- Moving Credits Without Double Spending
- Reconciling Transfers with the User Credit History
- Sender IDs, DLT Entities and Templates Per Brand
- WhatsApp Numbers, RCS Brands and Telegram Bots
- One Webhook Per Account: Routing Delivery Reports
- Passwords and Credential Rotation for Child Accounts
- Four Code Samples, Four Traps
- Migration Checklist
- Unspecified Behaviour and How to Code Around It
- FAQs
The Short Answer
When one system sends messages for several brands, the first decision is not the schema or the queue. It is where the account boundary goes, because on SMSGatewayCenter a few things exist exactly once per account: the SMS wallet, the SMS delivery report webhook and the Telegram bot. Everything that must be isolated per brand along one of those three axes (a separate budget, a separate delivery callback, a separate Telegram identity) needs its own account. Everything else (sender IDs, DLT templates, WhatsApp numbers, RCS brands and bots) can live side by side inside one account and be selected per request.
That gives four workable tenancy models:
- One account, many brands. Cheapest to run. Brands are rows in your own registry, selected by sender ID, WhatsApp number or RCS bot on every call.
- Team sub-users under one direct account. Separate logins for people, not separate tenants. Sub-users carry the main account’s products and payment type, cannot create further sub-users, and inherit pricing from the main account.
- Reseller child accounts. Real, separate accounts created and funded through eight
SMSApi/reseller/endpoints. Each child has its own balance, its own RCS brand and bots, and its own login. - Customer-owned accounts connected through OAuth. For SaaS products where each customer should own the sending relationship.
For most engineering teams the right answer is model 1 for brands that share a legal entity and a budget, and model 3 for brands that need a hard budget wall, their own Telegram bot or their own delivery callback. The rest of this guide shows how to decide, how to model it, and how to drive the reseller API without double-crediting an account on a retry.
TL;DR
- Three things are per account: the SMS wallet (billing fires at submission), the SMS delivery report webhook, and the Telegram bot. Isolating any of them per brand means separate accounts.
- Several things are per request: sender ID,
dltEntityIdanddltTemplateIdon SMS,wabaNumberon WhatsApp reports, RCS bot selection through templates. One account can carry many brands on these axes. - Team sub-users are logins, not tenants. They inherit products, payment type and pricing;
SMSApi/rateplan/readanswers a sub-user with error486, “Rate plans for sub-users are undefined.” - Reseller child accounts are tenants. Eight endpoints under
SMSApi/reseller/:readuser,createuser,updateuser,generateuserpassword,resetuserpassword,readcredithistory,addcredit,removecredit. Every response carries"api": "user", not"reseller". createuserreturns no identifier. Read the new account back withreaduserbyuserloginnameand store the quoteduserIdit returns.- Profile timestamps are epoch milliseconds in quoted strings. The sample
expDateof"1709231400000"is exactly midnight IST on 1 March 2024; formatted in UTC it shows 29 February. - Credit transfers are not idempotent and return no balance or transaction id. Put a unique reference in
comment, and on any timeout readreadcredithistoryfor the child before resending. - Credit products are
SMS | Voice | Miss Call. WhatsApp and RCS spend per brand has to be tracked in your own ledger. - Parameter spellings differ between tables and samples (
mobilenoandmobileNo,transactiontypeandtype,adjustmentsandadjustment). Send both key spellings with the same value and verify by read-back. - Always use a form encoder. Hand-built bodies are how
®ionturns into an HTML entity and how&=product=SMSsends a parameter with no name.
Four Tenancy Models, Not One
“Multi-brand” covers very different situations. A retail group with three store brands under one legal entity, a fintech that white-labels its OTP flow for partner banks, and a marketing agency sending for forty clients all have “several brands”, but they need different boundaries. The platform gives you four places to draw that boundary.
Model 1: one account, many brands
Every brand’s sender IDs, DLT templates, WhatsApp numbers and RCS bots are registered in a single account. Your application chooses the brand on every request by passing the right senderid, dltEntityId, dltTemplateId, wabaNumber or RCS template code. There is one set of credentials, one wallet and one SMS webhook.
This model is right when the brands share a legal entity, a budget owner and an operations team. It is the cheapest to run and the easiest to report on, because every delivery report and every credit movement lands in one place. It is wrong the moment one brand’s campaign must not be able to drain another brand’s balance, or two brands each need their own Telegram bot.
Model 2: team sub-users under a direct account
Sub-users are separate logins under a main direct-customer account, created in the panel under Sub-Users > Create. The platform generates the password and emails it to the sub-user. According to the sub-users knowledge base entry, a sub-user gets “the same products, payment type and login-verification (OTP) methods as your main account” and has “no ability to create further sub-users”. The feature has to be enabled on the account, and it is available on direct customer accounts, not on reseller accounts or on sub-users themselves.
The rate plan endpoint confirms the pricing side: sub-users are userType = 4, “cannot access rate plans”, and “inherit pricing from the main account”. A call to SMSApi/rateplan/read from a sub-user returns code 486 with the message “Rate plans for sub-users are undefined. Please review the rates in the main account”.
Treat sub-users as an access-control feature for people. They let a campaign manager for Brand A sign in without the main password, and they let you deactivate that login when the person leaves. They are not a budget wall or a data partition you should rely on in code.
Model 3: reseller child accounts
A reseller account can create full user accounts beneath it with SMSApi/reseller/createuser, choosing usertype of customer or reseller. Each child is its own account: its own login, its own smsBalance, its own status and expiry date, and on RCS its own brand, bots and templates. The reseller funds children by transferring credits with SMSApi/reseller/addcredit and claws them back with SMSApi/reseller/removecredit.
This is the model for hard isolation. A child account cannot spend what it was not given, it has its own webhook, and it can register its own Telegram bot. The cost is operational: every child is another set of credentials to store, another balance to monitor and another place to reconcile.
Model 4: customer-owned accounts through OAuth
If you are building a product that other businesses use to send their own messages, the cleanest boundary may be that each customer owns their SMSGatewayCenter account and connects it to your product. OAuth for messaging platforms covers that flow end to end: authorization code with PKCE S256, a 10-minute code, a 3600-second access token and a 30-day refresh token. In this model you never hold the customer’s balance, and DLT, sender ID and template registration stay with the business that owns them.
What a Brand Owns on Each Channel
Before choosing a model, list what “a brand” means on each channel. On this platform the answer is different for every channel, and the names do not line up. Sender identity across SMS, WhatsApp, RCS and Telegram covers the field names in depth; the summary that matters for tenancy is this:
| Channel | Brand identity unit | Cardinality per account | Selected per request by | Registered where |
|---|---|---|---|---|
| SMS (India) | Sender ID (header) tied to a DLT principal entity and templates | Many sender IDs; SMSApi/senderid/read lists them | senderid, plus optional dltEntityId and dltTemplateId on SMSApi/send | DLT portal, then the account |
| WABA phone number | Several numbers addressable; WAApi/report requires wabaNumber and analytics can group by waNumber | The number used on the send and the wabaNumber on reads | Meta onboarding through the account | |
| RCS | RCS brand, with one or more bots linked to it | Several brands and bots; every bot must be linked to a brand | The template, whose code belongs to a bot | Panel: RCS > My RCS Brands, then bots |
| Telegram | One bot | Exactly one per account | Implicit: the account’s bot | rest/tg/v1/setup with botName, botHandle, botToken |
| Inbound SMS | Keyword on a long code or short code | Many keywords | Keyword in the inbound push | Panel keyword setup |
Three consequences fall out of that table.
SMS brands are cheap to co-host. Several sender IDs, each mapped to its own templates, is the normal state of an Indian SMS account. The SENDERID_MISMATCH outcome is what you get when a brand’s sender ID is paired with another brand’s template, so the pairing must come from one registry row, never from two independent lookups.
RCS has a real brand object. The RCS brand stores business identity: brand name, official website, industry, logo, contact person and address, and carriers use these details to verify the sender. If your brands are distinct businesses to a carrier, they are distinct RCS brands, even inside one account.
Telegram forces the split. One bot per account means two Telegram identities need two accounts. If only one of your brands uses Telegram, that brand can still live in a shared account; if two do, at least one of them needs a child account.
Six Things the Account Boundary Decides
Everything in a multi-brand design either follows the account or follows the request. The six below follow the account, and each one is a reason to split.
1. The wallet
SMS billing fires at submission: the pricing terms state that “the applicable per-SMS rate will be deducted from your wallet while sending SMS” and that “credits are non-refundable once SMS is successfully submitted to the operator.” There is one wallet per account. If Brand A and Brand B share an account, a runaway campaign on Brand A spends Brand B’s money, and nothing on the platform stops it. SMSApi/account/readstatus returns one smsBalance, not one per sender ID.
You can enforce per-brand budgets in your own ledger inside a shared account, and many teams do. But that is a soft wall: it holds only if every path that sends goes through your ledger, including the panel, a colleague’s script and a scheduled campaign someone set up by hand. A child account is a hard wall.
2. The SMS delivery report webhook
There is one SMS delivery report webhook per account. Every brand in a shared account delivers its status callbacks to the same URL, and your receiver must work out which brand each report belongs to. That is solvable (see One Webhook Per Account), but if a brand’s delivery events must go to a different system owned by a different team, the clean answer is a separate account.
3. The Telegram bot
One bot per account, configured through rest/tg/v1/setup. rest/tg/v1/dlr rows carry no bot field, because there is only ever one bot to report on. See the Telegram messaging API for the full contract.
4. Pricing and the rate plan
A child account created through the reseller API has its own rate plan, which is what makes reseller margins possible. Team sub-users do not: they inherit the main account’s pricing, and the rate plan endpoint answers them with 486. If two brands must be billed at different per-message rates, they cannot both be sub-users of one account.
5. Credentials and blast radius
An API key belongs to an account. In a shared account, one leaked key can send as every brand. In a child-account layout, a leaked key for Brand A’s child sends only as Brand A and spends only Brand A’s balance. For a platform sending on behalf of clients, that difference is often the whole argument.
6. Feature enablement
Some channels are switched on per account. On RCS, the reseller knowledge base states that “RCS must be enabled on each user account before the RCS menu appears” for that user. Sub-users, by contrast, get “the same products” as the main account. If one brand should have RCS and another must not, only separate accounts express that.
Everything else (which sender ID, which template, which WhatsApp number, which RCS bot) is a per-request choice, and per-request choices belong in your brand registry, not in your account layout.
Decision Matrix: Where Each Brand Should Live
Use the strictest row that applies. If any single requirement lands in the child-account column, the brand needs a child account, even if every other requirement would be happy in a shared one.
| Requirement for the brand | One account, many brands | Team sub-user | Reseller child account | Customer-owned (OAuth) |
|---|---|---|---|---|
| Separate SMS budget that cannot be overspent | Soft wall only (your ledger) | No, shared payment type | Yes, own smsBalance | Yes, customer’s wallet |
| Different per-message SMS price | No | No, inherits main pricing | Yes, own rate plan | Yes, customer’s plan |
| Own Telegram bot | Only one brand per account | No | Yes | Yes |
| Own SMS delivery webhook URL | No, one per account | No | Yes | Yes |
| Own login for staff | Shared login | Yes | Yes | Customer’s own |
| Several sender IDs and templates | Yes | Treat as shared with the main account | Yes | Yes |
| Several WhatsApp numbers | Yes | Treat as shared with the main account | Yes | Yes |
| Own RCS brand and bots | Yes, several brands per account | Treat as shared with the main account | Yes | Yes |
| API key compromise limited to one brand | No | No | Yes | Yes |
| You hold and resell credits | Not applicable | Not applicable | Yes | No |
| Lowest operational overhead | Best | Good | Worst | Depends on your product |
Three common shapes come out of the matrix:
Group brands under one legal entity (a retailer with three store names, a bank with separate card and loan sender IDs): one account, many brands. Add sub-users if different people run different brands. Enforce per-brand budgets in your ledger and accept that it is a soft wall.
Agency or platform sending for clients who each have their own DLT registration and budget: a reseller account with one child account per client. The child holds the client’s sender IDs, templates and balance; your platform holds the parent credentials and the child credentials.
SaaS product where each customer is the sender of record: customer-owned accounts through OAuth. You never touch their balance, and your registry stores tokens instead of passwords.
Mixed layouts are normal. A platform might run its own marketing brands in one shared account, give each client a child account, and let its largest customers connect their own accounts. The registry in the next section handles all three with the same row shape.
The Brand Registry Schema
Whatever layout you choose, the application needs one place that answers “for brand X on channel Y, which account, which credentials and which identity do I use?” That is the brand registry. It is the multi-brand counterpart of the outbound message table: every outbound row should carry a brand_id, and every brand_id resolves through the registry.
-- One row per account you can send through, whatever tenancy model it uses.
CREATE TABLE messaging_account (
account_id BIGSERIAL PRIMARY KEY,
tenancy_model TEXT NOT NULL CHECK (tenancy_model IN
('shared','subuser','child','oauth')),
parent_account_id BIGINT REFERENCES messaging_account(account_id),
login_name TEXT NOT NULL, -- userid / userloginname
platform_user_id TEXT, -- quoted userId from readuser, kept as text
secret_ref TEXT NOT NULL, -- pointer into your secret store, never the key
webhook_path_key TEXT UNIQUE, -- random token in this account's DLR URL
status TEXT NOT NULL, -- last observed userStatus
expires_at TIMESTAMPTZ, -- from expDate, see the epoch section
last_synced_at TIMESTAMPTZ
);
-- One row per brand. A brand lives in exactly one account per channel.
CREATE TABLE brand (
brand_id BIGSERIAL PRIMARY KEY,
brand_key TEXT UNIQUE NOT NULL, -- 'acme-retail', stable, human readable
legal_entity TEXT NOT NULL,
budget_owner TEXT NOT NULL
);
-- Channel identity per brand. The pairing rules live here, nowhere else.
CREATE TABLE brand_channel (
brand_id BIGINT NOT NULL REFERENCES brand(brand_id),
channel TEXT NOT NULL CHECK (channel IN ('sms','whatsapp','rcs','telegram')),
account_id BIGINT NOT NULL REFERENCES messaging_account(account_id),
sender_key TEXT, -- SMS senderid, WhatsApp number, RCS bot name
dlt_entity_id TEXT, -- SMS only, text: long numeric
enabled BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (brand_id, channel)
);
-- Templates are owned by a brand and a sender, never by a channel alone.
CREATE TABLE brand_template (
brand_id BIGINT NOT NULL,
channel TEXT NOT NULL,
template_key TEXT NOT NULL, -- your name for it
platform_ref TEXT NOT NULL, -- dltTemplateId, WA name+language, RCS templateCode
sender_key TEXT, -- the sender this template is registered with
PRIMARY KEY (brand_id, channel, template_key),
FOREIGN KEY (brand_id, channel) REFERENCES brand_channel(brand_id, channel)
);
-- A second Telegram brand on the same account is a design error. Make it impossible.
CREATE UNIQUE INDEX one_telegram_brand_per_account
ON brand_channel(account_id) WHERE channel = 'telegram';
Five design choices in that schema matter more than the column names.
The primary key on brand_channel is (brand_id, channel). A brand lives in exactly one account per channel. If you allow a brand’s SMS to go out of two accounts, delivery reports, balances and DLT pairings split across two places and reconciliation never closes.
Templates carry a sender_key. On SMS a DLT template is registered against specific sender IDs, and one template can map to several sender IDs. Storing the sender next to the template is what lets the resolver refuse a mismatched pair before the platform does.
Secrets are references. The registry stores a pointer into a secret manager, not the API key. The registry is read on every send; the secret store is read only when a client is built.
Platform identifiers are text. userId arrives quoted ("6"), credit history uuId is sixteen digits in the sample, and SMS transaction identifiers run to eighteen or nineteen digits. Every identifier the platform hands back goes into a text column.
The partial unique index encodes the Telegram rule. The database, not a code review, stops a second brand from claiming an account’s only bot.
Resolving Credentials Per Brand at Send Time
The resolver turns (brand_key, channel, template_key) into everything one API call needs: base URL, account credentials, sender and template reference. It should be the only code in the system that knows which account a brand uses.
Four rules keep it safe.
Resolve once, pass explicitly. The resolver returns a complete, immutable send context. Downstream code never looks up “the default sender ID” or “the account’s DLT entity” on its own. Account-level defaults are exactly the thing that is wrong for the second brand in a shared account.
Send dltEntityId and dltTemplateId on every SMS. SMSApi/send accepts both as optional parameters. In a single-brand account you can often omit them; in a multi-brand account, passing them explicitly is what guarantees the brand’s own entity and template are used.
Fail closed when a brand has no identity on a channel. If Brand B has no brand_channel row for RCS, the resolver raises. It does not fall back to Brand A’s bot, and it does not fall back to the account’s first bot. A missing mapping is a configuration bug, and a message sent under the wrong brand is worse than one not sent.
Authenticate with the apikey header and still send userid. The apikey header is accepted on every endpoint. HTTP header names are case-insensitive, so the apiKey spelling in the reseller and SMSApi/ tables is the same header. Send the account’s userid alongside it. In a layout with child accounts, the resolver picks the child’s userid and key for sends and the parent’s for reseller calls, and those two credential sets must never be swapped.
A resolved context looks like this:
{
"brand_key": "acme-retail",
"channel": "sms",
"account": { "login_name": "acmechild01", "secret_ref": "vault:msg/acmechild01" },
"base_url": "https://unify.smsgateway.center/",
"sender_key": "ACMERT",
"dlt_entity_id": "1201159XXXXXXXXXXXX",
"template": { "template_key": "order-shipped", "platform_ref": "1207161XXXXXXXXXXXX" }
}
The two DLT values are placeholders; real ones are long numerics and are stored as text.
The Reseller User API at a Glance
Child accounts are created and funded through eight endpoints under https://unify.smsgateway.center/SMSApi/reseller/. All of them take the parent’s credentials plus a userloginname naming the child, and all of them return the familiar response envelope with "api": "user" and a quoted "code": "200" on success.
| Endpoint | Method on its page | Purpose | Required parameters beyond credentials | Success msg | Returns an identifier? |
|---|---|---|---|---|---|
SMSApi/reseller/readuser | POST and GET | Read a child’s profile | userloginname, output | “success” | Yes, userId inside userList[i].user |
SMSApi/reseller/createuser | POST only | Create a child account | userloginname, usertype, email, mobileno, fullname, address, region, expirydate, output | “User created successfully.” | No |
SMSApi/reseller/updateuser | POST only | Update a child’s details | Same set as create | “User update successfully.” | No |
SMSApi/reseller/generateuserpassword | POST only | Email the child a reset link | userloginname, output | “Password changed successfully.” | No |
SMSApi/reseller/resetuserpassword | POST only | Set the child’s password directly | userloginname, newPassword, output | “Password changed successfully.” | No |
SMSApi/reseller/readcredithistory | POST and GET | Read a child’s credit movements | userloginname, fromdate, todate, output | “success” | Yes, uuId per history row |
SMSApi/reseller/addcredit | POST and GET | Transfer credits to a child | userloginname, product, transactiontype, credits, comment, output | “Credit transferred successfully.” | No |
SMSApi/reseller/removecredit | POST and GET | Take credits back from a child | Same set as add | “Credit removed successfully.” | No |
Five things in that table shape every client you write against it.
Use POST for everything. Four of the eight accept GET, including both credit writes. A GET that moves money ends up in proxy logs, browser history and link prefetchers. POST with a form-encoded body is accepted by all eight, so a single code path covers the family.
The api field says user, the path says reseller. If your client asserts that response.api matches the path segment, it will reject every successful reply. Check response.status and nothing else to decide success, the same rule that applies across the rest of the API.
Writes return no identifier. createuser does not return the new userId, and the credit writes return neither a transaction id nor the resulting balance. Every write must be confirmed by a read. The next three sections show how.
Two success messages lie about what happened. generateuserpassword sends the child an email with a reset link, yet its success msg reads “Password changed successfully.” Treat it as “reset email dispatched” and never tell an operator that the password has changed. Never parse msg at all; branch on status.
Read the child with userloginname, even for a listing. The readuser page describes retrieving “a list of all users”, but userloginname sits among the required parameters and the sample returns one row with count 1. Keep your own list of children in messaging_account and read each one by name.
Creating a Child Account and Reading It Back
Provisioning a child account for a new brand is a three-call sequence: create, read back, and verify that what landed matches what you sent.
Step 1: create
The create table and the sample on the same page spell some parameters differently:
| Concept | Parameter table | Sample request body |
|---|---|---|
| Mobile number | mobileno (“Should be integer”) | mobileNo=919999xxxxxx |
| City | region, described as “City” | city=city name |
| State | not listed | region=state name |
| Country | not listed | country=country name |
| Expiry | expirydate, YYYY-MM-DD | expirydate=2019-10-01 |
| Login name | userloginname | userloginname=... |
The table and sample agree on userloginname, usertype, email, fullname, address and expirydate. For the rest, the safe approach is to send both spellings with the same value: mobileno and mobileNo carrying the same number. A server that reads one key ignores the other, so the request is correct under either reading.
region is the harder one, because the table uses it for the city and the sample uses it for the state. Send what the sample sends (city, region as the state, country) and then verify with the read-back in step 2. The profile row returns postalCity, postalRegion and postalCountry separately, so one read tells you which input landed where.
Build the body with a form encoder. The sample strings are concatenated by hand, and a hand-built string containing ®ion is exactly where an HTML renderer or a careless copy turns ® into a registered-trademark sign. A form encoder also handles spaces in fullname and address, which the sample sends unencoded.
Step 2: read back
POST https://unify.smsgateway.center/SMSApi/reseller/readuser
apikey: <parent key>
Content-Type: application/x-www-form-urlencoded
userid=<parent login>&userloginname=acmechild01&output=json
Take response.userList[0].user.userId, keep it as text, and write it into messaging_account.platform_user_id. From here on your registry knows the child by both its login name and its platform id.
Step 3: verify
Compare the read-back row against the create request field by field: userType against usertype (case-insensitively, since the sample returns "Reseller" for an input of reseller), emailId against email, mobileNo against the mobile number, postalCity, postalRegion and postalCountry against what you sent, and expDate against expirydate (after the conversion in the epoch section). A mismatch is a provisioning failure: alert, do not start sending.
If step 1 times out, do not resend it. Go straight to step 2. If readuser finds the login name, the create succeeded and you continue with step 3. If it does not, resend the create with the same userloginname. Every reseller call addresses the child by userloginname, so the name has to point at one account. If the resend is rejected, run the read-back once more before treating it as a failure: a rejection right after a timeout usually means the first create landed.
The User Profile Row, Field by Field
The readuser sample response is the most information-dense payload in the reseller family:
{
"response": {
"api": "user",
"action": "readuser",
"status": "success",
"msg": "success",
"code": "200",
"count": 1,
"userList": [
{
"user": {
"userId": "6",
"userName": "myuser",
"userType": "Reseller",
"emailId": "[email protected]",
"mobileNo": "919999999999",
"domainName": "smssssssssssss.com",
"expDate": "1709231400000",
"regDate": "1546740446000",
"userStatus": "Active",
"enableCMS": "Inactive",
"fullName": "",
"postalAddress": "",
"postalCity": "Mumbai",
"postalCountry": "India",
"postalRegion": "Maharashtra",
"smppEnabled": "1",
"accountType": "TRANSACTIONAL",
"smsBalance": "3900"
}
}
]
}
}
| Field | Wire type | Meaning and handling |
|---|---|---|
userId | Quoted digits | Platform id of the child. Store as text. |
userName | String | Login name; equals userloginname. |
userType | String, capitalised | "Reseller" in the sample; input values are lower case. Compare case-insensitively. |
emailId | String | Contact email. |
mobileNo | Quoted digits with country code | Store as text. Never parse as a number. |
domainName | String | The white-label domain associated with the user. |
expDate | Quoted epoch milliseconds | Account expiry. See the next section. |
regDate | Quoted epoch milliseconds | Registration time. "1546740446000" is 2019-01-06 07:37:26 IST. |
userStatus | String | "Active" in the sample. Treat any other value as not sendable. |
enableCMS | String | "Inactive" here. |
postalCity, postalRegion, postalCountry | String names | "Mumbai", "Maharashtra", "India". |
smppEnabled | Quoted "1" | Flag as a string. |
accountType | String | "TRANSACTIONAL" in the sample. |
smsBalance | Quoted integer | The child’s SMS credits. Parse with an integer parser that rejects decimals. |
Two fields in that row share a name with fields on the account’s own profile endpoint but not their encoding. On SMSApi/account/readprofile, postalCity comes back as "2707", postalCountry as "101" and postalRegion as "22": numeric identifiers in a name-like field. On readuser the same three fields carry names. enableCMS is "1" on readprofile and "Inactive" on readuser. If one model class maps both payloads, it will either fail to parse or silently store an id where a name belongs. Give the two endpoints separate types, or at least separate mappers.
For the same reason, never compare a child’s postalCity from readuser with the parent’s postalCity from readprofile. They are different kinds of value that happen to share a key.
Epoch Milliseconds and the Midnight IST Expiry
createuser and updateuser take expirydate as a calendar date, YYYY-MM-DD. readuser returns expDate as epoch milliseconds in a quoted string. The sample value is worth converting by hand:
| Value | As UTC | As IST (UTC+05:30) |
|---|---|---|
expDate "1709231400000" | 2024-02-29 18:30:00 | 2024-03-01 00:00:00 |
regDate "1546740446000" | 2019-01-06 02:07:26 | 2019-01-06 07:37:26 |
credit history timestamp "1570868345603" | 2019-10-12 08:19:05.603 | 2019-10-12 13:49:05.603 |
The expiry lands on exactly midnight in India. That strongly suggests the platform turns an input date into the start of that day in IST. The practical consequences:
Formatting in UTC shows the wrong day. A dashboard running in a UTC container that prints expDate as a date shows 29 February for an account you set to expire on 1 March. An operator who “corrects” it by setting 2 March has now given the account an extra day.
Round-trip checks must convert in IST. In the verify step, convert expDate to a date in Asia/Kolkata before comparing it with the expirydate you sent.
Decide what “expires on 1 March” means for your brand. If expiry is the start of that day in IST, the account stops at midnight going into 1 March, not at the end of it. If a contract says “valid through 1 March”, send 2 March.
Parse the string, then the number. The values are quoted. In JavaScript, Number("1709231400000") is exact because thirteen-digit millisecond values are far below 2^53, but code that feeds the raw string into a date constructor gets Invalid Date. Convert explicitly.
The same convention applies to every timestamp in the reseller family, including the credit history rows in the next two sections.
Moving Credits Without Double Spending
addcredit and removecredit move money, return only a success message, and carry no idempotency key. A retry after a timeout can credit a child twice, and nothing in the response tells you it happened. This section is the fix.
The parameters, and where the table and sample disagree
| Concept | Parameter table | Sample request body |
|---|---|---|
| Child account | userloginname | userloginname=YourUserLoginname |
| Product | product: SMS, Voice or Miss Call | product=SMS (on the add page, preceded by a stray &=) |
| Transaction type | transactiontype: purchase or adjustments | type=purchase on add, type=adjustment on remove |
| Amount | credits, integer | credits=10000 |
| Note | comment | comment=... |
Apply the same rule as on create: send transactiontype and type with the same value, so the request is right whichever key the server reads. The value spelling (adjustments or adjustment) cannot be hedged the same way, because you can only send one value per key. For top-ups, use purchase, where the table and both samples agree. Before your first adjustment in production, move one credit with the table spelling, read the child’s history, and check that the row’s method reflects an adjustment; pin whichever spelling produced it.
The stray &= in the add sample is a reminder to never build these bodies by concatenation. A form encoder never emits a parameter with an empty name.
The product list decides what you can allocate
product accepts SMS, Voice and Miss Call. WhatsApp and RCS are not in that list, even though the page intro mentions WhatsApp. For a multi-brand platform this means:
- SMS budgets per child can be enforced by the platform, through transfers.
- WhatsApp and RCS spend per brand has to be measured from delivery and analytics data and enforced in your own ledger. Reconciling a messaging invoice across four channels covers where per-message cost appears on each channel.
- Rate plans for a child’s RCS traffic are confirmed with the platform when RCS is enabled for that user, per the reseller RCS knowledge base.
The three-step transfer
Step 1. Record intent before calling. Write a row to your own credit_transfer table with a fresh reference such as ct-7f3a9c21, the child, product, type, amount and state PENDING. Commit before the HTTP call. If the process dies mid-call, the row survives and tells the recovery job what to look for.
Step 2. POST the transfer with the reference in comment. Put the reference at the start of the comment, for example comment=ct-7f3a9c21 monthly top-up acme-retail. The comment is the only free-text field that travels with the transfer, and credit history rows return a comments field.
Step 3. Confirm by reading the child’s credit history. Whether the POST returned success, failed or timed out, call readcredithistory for the child over a window covering the call, and look for a row whose comments contains the reference. If it is there, mark the transfer CONFIRMED and store the row’s uuId and balance. If it is absent and the POST clearly failed, mark it FAILED. If it is absent after a timeout, wait and read again before concluding anything.
Only one path leads to a resend: the reference is still absent after a second read, well after the call. Resend with the same reference. If both attempts eventually land, the two rows share one reference, which makes the duplicate visible and reversible with a single removecredit.
State machine
| State | Entered when | Next action |
|---|---|---|
PENDING | Intent row written | POST the transfer |
SENT | POST returned status: success | Read history, match reference |
UNKNOWN | POST timed out or the connection dropped | Wait, then read history; never resend from here directly |
CONFIRMED | History row with the reference found | Store uuId, balance; done |
FAILED | POST returned status: error and history shows no row | Alert; safe to create a new intent |
DUPLICATE | Two history rows carry the same reference | Reverse one with removecredit, reference it in the comment |
This is the same idempotency discipline as preventing duplicate sends, applied to money instead of messages: the client owns the key, because the server does not.
Reconciling Transfers with the User Credit History
SMSApi/reseller/readcredithistory is the read side of every transfer. Unlike the account’s own SMSApi/account/readcredithistory, which has no date range, the reseller version requires fromdate and todate in YYYY-MM-DD.
{
"response": {
"api": "user",
"action": "readcredithistory",
"status": "success",
"msg": "success",
"code": "200",
"count": 1,
"historyList": [
{
"history": {
"uuId": "3555847512901782",
"type": "CREDIT",
"method": "purchase",
"balance": "3800",
"amount": "100",
"comments": "testing",
"timestamp": "1570868345603"
}
}
]
}
}
| Field | Wire type | Handling |
|---|---|---|
uuId | Quoted, sixteen digits in the sample | The history row id. Store as text; sibling ids elsewhere in the API run to nineteen digits. |
type | String | "CREDIT" in the sample. Direction of the movement. |
method | String | "purchase" here; mirrors the transaction type you sent. |
balance | Quoted integer | Balance after the movement. |
amount | Quoted integer | Credits moved. |
comments | String | Your comment. The reference lives here. |
timestamp | Quoted epoch milliseconds | Convert in IST as in the previous section. |
The reseller history’s numbers are quoted strings; the account’s own credit history returns integer credits. A shared parser for “credit history rows” across the two endpoints must accept both, or better, the two endpoints get two row types.
Daily reconciliation per child
Once a day, for each child account:
- Read
readcredithistoryfor yesterday’s IST date, both bounds the same day. - Match every row with a reference in
commentsagainst yourcredit_transfertable. Rows with no matching reference are transfers someone made outside your system, most often in the panel. Record them, attribute them, and alert if they are unexpected. - Check continuity: consecutive rows should chain, each
balanceequal to the previous row’sbalanceplus or minusamount. A gap means sending consumed credits between movements, which is expected; a jump in the opposite direction is not. - Read
readuserand storesmsBalanceas the day’s closing snapshot. Keep it as history; the platform gives you the current balance, not yesterday’s.
Store the snapshots from the first day. A daily row costs almost nothing and turns “what was Brand A’s balance on the 14th?” from an unanswerable question into a query.
Checking the parent side
The success message for addcredit reads “Credit transferred successfully”, which describes a movement from the parent to the child. Read the parent’s own balance with SMSApi/account/readstatus before and after a batch of transfers and check that the parent’s smsBalance fell by the sum you moved. If it did not, your model of where the credits come from is wrong, and you want to learn that in week one, not at month end.
Sender IDs, DLT Entities and Templates Per Brand
On Indian SMS, a brand is a chain: a principal entity registered on DLT, one or more headers (sender IDs) registered to that entity, and content templates registered against those headers. The platform account holds the sender IDs and templates; your registry holds the chain.
In a shared account
Several brands’ sender IDs sit side by side in SMSApi/senderid/read, and several brands’ templates sit side by side in SMSApi/template/read. Nothing in either list says which brand a row belongs to. The sender list returns sId, senderName, isEnabled and addTime; the template list returns mtId, identifier, template, dltTemplateId, senderIds and status. The brand is your concept, so the mapping lives in brand_channel and brand_template.
Three practices keep shared accounts clean:
- Name templates with a brand prefix. Use
identifiervalues such asacme-retail.order-shipped. When someone reads the template list in the panel, the brand is obvious, and your sync job can flag rows with no known prefix. - Validate the sender and template pair before sending. The resolver checks that the chosen sender appears in the template’s
senderIds. That catches in your code what the platform would report as SENDERID_MISMATCH after the fact. - Pass
dltEntityIdanddltTemplateIdon every send. Brands under different legal entities have different principal entity ids. Passing both explicitly means no send depends on an account-level default that is only right for one of the brands.
Message template management across four channels covers the template lifecycle, typed DLT variable tags and read-back rules on every channel.
In a child account
The child holds its own sender IDs and templates, and its lists contain only its own brand. That is the main operational win of child accounts on SMS: a sync job reading the child’s lists can treat every row as belonging to the brand.
A reseller can add sender names on behalf of a user in the white-label panel, under “User Sender Names”, per the reseller sender names knowledge base entry. Through the API, sender IDs and templates are managed with the SMSApi/senderid/ and SMSApi/template/ endpoints called with the child’s own credentials. Your provisioning job therefore switches credential sets partway through: parent credentials for createuser and addcredit, child credentials for sender ID and template setup.
Moving a brand between accounts
Sender IDs and templates are DLT registrations first and platform rows second. Moving a brand from a shared account to a child account means re-adding its sender IDs and templates in the child, then switching the registry row. Keep the old account’s rows until delivery reports for traffic sent before the switch have settled, because those reports still arrive through the old account.
WhatsApp Numbers, RCS Brands and Telegram Bots
The WhatsApp surface is built to address more than one number per account. WAApi/report requires wabaNumber, and rest/wa/v1/analytics accepts groupBy=waNumber. In a shared account, each brand gets its own WhatsApp number, and your registry maps brand to number.
Two traps apply specifically to multi-brand WhatsApp.
The number changes type between endpoints. Report rows carry wabaNumber unquoted, analytics delivery rows carry waNumber quoted, and inbox rows carry a quoted wabaNumber. A brand lookup keyed on “the number” must normalise all three to one text form, digits only with country code, before comparing.
Media writes are number-scoped; media reads are account-scoped. Brand A’s uploaded media is visible when you list media for the account. Tag media in your own store with the brand, and never pick “the latest uploaded image” from the account list.
Template names are lower-cased and deleted by name and language, so prefix WhatsApp template names with the brand too. In a shared account, acme_order_shipped and zenith_order_shipped cannot collide.
RCS
RCS has a first-class brand object, and every bot must be linked to one. In a shared account you register one RCS brand per real business and one or more bots under each. Delivery rows from rest/rcs/v1/dlr carry botName, and inbox rows from rest/rcs/v1/inbox carry an unquoted botId, so routing an RCS event to a brand needs both mappings in your registry: bot name to brand and bot id to brand.
rest/rcs/v1/bots lists active bots only. A bot that is deactivated disappears from the list while its historical delivery rows still carry its name. Keep bots in your registry after they leave the platform list, marked inactive, so old events still resolve to a brand.
For child accounts, the reseller RCS knowledge base is explicit: each user creates their own brand, bot and templates, and RCS is enabled per user account. A reseller can share selected templates with sandbox users for testing and view per-user RCS reports. See the reseller RCS features entry.
Telegram
One bot per account, set with rest/tg/v1/setup. botToken is write-only: the setup read returns botName and botHandle, never the token. For a multi-brand layout:
- One Telegram brand per account, enforced by the partial unique index in the registry.
- A second Telegram brand means a second account. With a reseller parent, that is a child account; without one, it is a separate direct account.
- Store each bot’s token in your secret store keyed by account, because you cannot read it back from the platform if you lose it.
One Webhook Per Account: Routing Delivery Reports
Every account has one SMS delivery report webhook. In a shared account, reports for every brand arrive at one URL. In a child-account layout, each child has its own webhook, which you can point at a URL that identifies the child.
Give every account its own URL path
Even when all accounts deliver to the same service, register each with a distinct path containing a random token:
https://hooks.example.com/sms-dlr/k7Qm2xV9pLr4/ <- shared account
https://hooks.example.com/sms-dlr/Zp8wT3nB6yHc/ <- child for acme-retail
https://hooks.example.com/sms-dlr/Rf5jK1sD0qMu/ <- child for zenith-bank
The token is messaging_account.webhook_path_key. The receiver resolves the account from the path before it parses the body, so a report can never be attributed to the wrong account. A random token also means a stranger who learns one URL cannot guess another.
Resolve the brand from your own outbound row
Within an account, resolve the brand by joining the report to your outbound message row by transaction id. Your outbound row already carries brand_id. Do not resolve the brand from the sender name in the report: in a shared account two brands could legitimately share a header during a migration, and the join on transaction id is exact where the sender name is not.
Persist raw, then reconcile
The push payload’s field list is not something to build a strict parser around. Store the raw body with the resolved account and a receive timestamp, return quickly, and let a worker extract the transaction id. Then reconcile against SMSApi/reports/status with method=getDlr, which returns a stable row shape including uuId, mobileNo, submitTime and status. Delivery report ingestion as a system of record walks through that pipeline.
The other channels poll
WhatsApp, RCS and Telegram inbound traffic is read by polling rest/wa/v1/inbox, rest/rcs/v1/inbox and rest/tg/v1/inbox, and their delivery data by polling their report and dlr endpoints. Polling is per account and per credential set, so a layout with ten child accounts runs ten pollers per channel. Schedule them with jitter and a shared rate budget, not ten cron lines at the same minute. Receiving messages on four channels has the inbox contracts.
Passwords and Credential Rotation for Child Accounts
The reseller family offers two ways to deal with a child’s password.
generateuserpassword | resetuserpassword | |
|---|---|---|
| What happens | The child receives an email with a reset link | You set the new password directly |
| Extra parameter | None | newPassword |
| Who knows the new password | Only the child | You, and anything that logged the request |
Success msg | “Password changed successfully.” | “Password changed successfully.” |
| Right for | A human who signs in to the panel | A service account you operate entirely |
For children that a client’s staff use, prefer generateuserpassword. You never hold the password, and the email goes to the address on the child’s profile. Read the profile first and confirm emailId is the address you expect; a reset link sent to a stale address is an account handover.
For service children your platform operates, prefer API keys over passwords. A password is needed for panel access; your sending code should authenticate with the child’s API key in the apikey header plus userid. If you do set a password with resetuserpassword, generate it, write it straight to the secret store, and make sure the request body is excluded from every log, trace and error report. The body contains newPassword in clear text.
Rotate keys per account, never globally. In a child-account layout, rotating Brand A’s key touches one registry row and one secret. That is the blast-radius benefit of child accounts, and it only holds if keys are never shared across children.
For team sub-users, password resets are done from Sub-Users > Manage in the panel, alongside activate, deactivate and email and name updates.
Four Code Samples, Four Traps
Each sample shows one trap from this guide. They share the client shape used across the API: parse loosely, read only the top-level status, return on failure, and bind typed fields only on success.
Python: reading a child account without mis-dating its expiry
The trap: quoted epoch milliseconds formatted in UTC show the day before the expiry you set.
import requests
from dataclasses import dataclass
from datetime import datetime, timezone, timedelta
BASE = "https://unify.smsgateway.center/SMSApi/reseller/"
IST = timezone(timedelta(hours=5, minutes=30))
@dataclass(frozen=True)
class ChildAccount:
user_id: str # quoted digits, kept as text
login: str
user_type: str # normalised to lower case
status: str
expires_ist: datetime
sms_balance: int
postal_city: str # a NAME on readuser, unlike readprofile
def epoch_ms_ist(value: str) -> datetime:
return datetime.fromtimestamp(int(value) / 1000, tz=IST)
def read_child(parent_login: str, api_key: str, child_login: str) -> ChildAccount | None:
r = requests.post(
BASE + "readuser",
headers={"apikey": api_key},
data={"userid": parent_login, "userloginname": child_login, "output": "json"},
timeout=15,
)
body = r.json().get("response", {})
if body.get("status") != "success":
return None
rows = body.get("userList") or []
if not rows:
return None
u = rows[0].get("user", {})
return ChildAccount(
user_id=str(u["userId"]),
login=u["userName"],
user_type=u.get("userType", "").lower(),
status=u.get("userStatus", ""),
expires_ist=epoch_ms_ist(u["expDate"]),
sms_balance=int(u.get("smsBalance", "0")),
postal_city=u.get("postalCity", ""),
)
# "1709231400000" -> 2024-03-01 00:00 IST. In UTC it would print as 2024-02-29.
Node.js: an idempotent credit transfer
The trap: addcredit returns no id and no balance, so a blind retry can credit a child twice.
import crypto from "node:crypto";
const BASE = "https://unify.smsgateway.center/SMSApi/reseller/";
async function post(action, apiKey, fields) {
const body = new URLSearchParams(fields); // never hand-build the body
const res = await fetch(BASE + action, {
method: "POST",
headers: { apikey: apiKey, "Content-Type": "application/x-www-form-urlencoded" },
body,
signal: AbortSignal.timeout(15000),
});
return (await res.json()).response ?? {};
}
function istDate(d = new Date()) {
return new Date(d.getTime() + 330 * 60000).toISOString().slice(0, 10);
}
async function findTransfer(parent, apiKey, child, ref) {
const r = await post("readcredithistory", apiKey, {
userid: parent, userloginname: child,
fromdate: istDate(new Date(Date.now() - 86400000)), todate: istDate(), output: "json",
});
if (r.status !== "success") return null;
return (r.historyList ?? []).map(x => x.history ?? {})
.find(h => String(h.comments ?? "").startsWith(ref)) ?? null;
}
export async function transferCredits(db, parent, apiKey, child, credits, note) {
const ref = "ct-" + crypto.randomBytes(4).toString("hex");
await db.insertTransfer({ ref, child, credits, state: "PENDING" }); // commit first
let state = "UNKNOWN";
try {
const r = await post("addcredit", apiKey, {
userid: parent, userloginname: child, product: "SMS",
transactiontype: "purchase", type: "purchase", // both spellings, same value
credits: String(credits), comment: `${ref} ${note}`, output: "json",
});
state = r.status === "success" ? "SENT" : "ERROR";
} catch { /* timeout or network: state stays UNKNOWN */ }
const row = await findTransfer(parent, apiKey, child, ref);
if (row) {
await db.updateTransfer(ref, { state: "CONFIRMED", historyId: String(row.uuId), balance: row.balance });
} else {
await db.updateTransfer(ref, { state: state === "ERROR" ? "FAILED" : "UNKNOWN" });
// UNKNOWN is re-read later by a recovery job. It is never resent from here.
}
return ref;
}
Java: a resolver that fails closed
The trap: falling back to an account default sends Brand B’s message under Brand A’s sender, entity or bot.
public record SendContext(String brandKey, String channel, String loginName,
String secretRef, String senderKey, String dltEntityId,
String templateRef) {}
public final class BrandResolver {
private final BrandRegistry registry;
public BrandResolver(BrandRegistry registry) { this.registry = registry; }
public SendContext resolve(String brandKey, String channel, String templateKey) {
BrandChannel bc = registry.channel(brandKey, channel)
.filter(BrandChannel::enabled)
.orElseThrow(() -> new MisconfiguredBrand(brandKey + " has no enabled " + channel));
BrandTemplate t = registry.template(brandKey, channel, templateKey)
.orElseThrow(() -> new MisconfiguredBrand(brandKey + " has no template " + templateKey));
if (t.senderKey() != null && !t.senderKey().equals(bc.senderKey())) {
throw new MisconfiguredBrand("template " + templateKey
+ " is registered for " + t.senderKey() + ", brand sends as " + bc.senderKey());
}
if ("sms".equals(channel) && (bc.dltEntityId() == null || bc.dltEntityId().isBlank())) {
throw new MisconfiguredBrand(brandKey + " has no DLT entity id");
}
MessagingAccount acct = registry.account(bc.accountId());
if (!"active".equalsIgnoreCase(acct.status())) {
throw new MisconfiguredBrand("account " + acct.loginName() + " is " + acct.status());
}
return new SendContext(brandKey, channel, acct.loginName(), acct.secretRef(),
bc.senderKey(), bc.dltEntityId(), t.platformRef());
}
}
No method on BrandResolver returns a default. Every miss is an exception, and every exception is a configuration alert, not a retry.
PHP: a delivery report receiver that knows its account before it parses
The trap: one webhook per account means a shared URL cannot tell accounts apart, and a body-first parser guesses.
<?php
// Route: POST or GET /sms-dlr/{pathKey}/
function handleDlr(PDO $db, string $pathKey): void
{
$stmt = $db->prepare('SELECT account_id FROM messaging_account WHERE webhook_path_key = ?');
$stmt->execute([$pathKey]);
$accountId = $stmt->fetchColumn();
if ($accountId === false) {
http_response_code(404); // unknown token: reveal nothing
return;
}
$raw = file_get_contents('php://input');
if ($raw === '' || $raw === false) {
$raw = http_build_query($_GET); // accept either delivery style
}
$ins = $db->prepare(
'INSERT INTO dlr_inbox (account_id, received_at, raw_body) VALUES (?, NOW(), ?)'
);
$ins->execute([$accountId, $raw]); // persist raw, parse later
http_response_code(200);
echo 'OK';
}
// A worker extracts the transaction id as a STRING, joins to outbound_message
// for brand_id, and reconciles with SMSApi/reports/status method=getDlr.
Migration Checklist
Moving from “one account, one brand” to a multi-brand layout:
- List every brand, its legal entity, budget owner and channels.
- Mark every brand that needs a hard budget wall, its own price, its own Telegram bot or its own webhook. Those brands get child accounts.
- Create
messaging_account,brand,brand_channelandbrand_template, with the Telegram partial unique index. - Add
brand_idto the outbound message table and backfill existing rows. - Prefix template identifiers and WhatsApp template names with the brand key.
- Route every send through the resolver; delete code that reads account-level defaults.
- Pass
dltEntityIdanddltTemplateIdon every SMS send. - For each child:
createuserwith a form encoder,readuser, verify every field, storeuserIdas text. - Convert
expDate,regDateand historytimestampin IST everywhere they are displayed or compared. - Build the
credit_transfertable and the three-step transfer; never retry a transfer without reading history first. - Verify the adjustment spelling with a one-credit test before the first production adjustment.
- Snapshot every child’s
smsBalancedaily from the first day. - Register a distinct webhook path token per account.
- Map RCS bot names and bot ids, and normalised WhatsApp numbers, to brands; keep inactive bots in the registry.
- Store each Telegram
botTokenin the secret store keyed by account. - Use
generateuserpasswordfor human children; keepnewPasswordout of all logs. - Add a nightly drift check on the reseller endpoints, as described in the contract testing harness.
Unspecified Behaviour and How to Code Around It
The behaviours below are not pinned down by anything you can read before integrating. Each item gives the choice that stays correct whichever way the platform behaves, and none of them require waiting for an answer.
One. Treat a team sub-user’s sender IDs, templates, WhatsApp numbers and wallet as shared with the main account. Sub-users get the main account’s products and payment type. Whether their sender and template lists are separate is not something to build on. Design as if everything is shared: if they turn out to be partitioned, nothing breaks; if you assume partitioning and they are shared, one brand’s staff can use another brand’s sender.
Two. Confirm every credit transfer by reference, never by message. Whether addcredit can succeed after the client times out is unknown. The reference-plus-read-back flow is correct in both cases: a landed transfer is found and confirmed, a lost one is resent once with the same reference.
Three. Send both transactiontype and type, and both mobileno and mobileNo, with identical values. A server reads at most one key of each pair. Duplicated keys with the same value are correct under either spelling.
Four. Pin the adjustment spelling from observation. Move one credit with adjustments, read the history row’s method, and store whichever value the platform recorded. Until that check passes, route adjustments through a human.
Five. Send city, region and country, then verify with readuser. The meaning of region differs between the create table and its sample. The read-back fields postalCity, postalRegion and postalCountry tell you which input landed where, so one provisioning run settles it for good.
Six. Treat expirydate as the first instant of that day in IST. The sample expiry is exactly midnight IST. If a contract says “valid through” a date, send the following day. Being a day generous is recoverable; cutting off a client a day early is not.
Seven. Verify the parent’s balance moves on every batch of transfers. “Credit transferred” implies the parent is debited. Snapshot the parent’s smsBalance before and after; a mismatch is found on day one rather than at month end.
Eight. Assume the comment may be truncated, and put the reference first. A short reference at the start of comment survives any plausible length limit. Match with a prefix test, not equality.
Nine. Read each child by login name, and keep your own list of children. Whether readuser without a login name returns every child is unclear from its required parameters. Your messaging_account table is the list; the platform confirms each entry.
Ten. Treat any userStatus other than an observed “Active” as not sendable. Only "Active" appears in the sample. An allowlist of observed values fails closed: an unknown status pauses a brand instead of sending into a suspended account.
Eleven. Budget WhatsApp and RCS per brand in your own ledger. Credit transfers cover SMS, Voice and Miss Call. Whether a child’s WhatsApp or RCS traffic draws on a separate balance is a commercial arrangement confirmed per user. Metering in your ledger is correct under any arrangement.
Twelve. Keep a second Telegram brand out of any account that already has one. One bot per account is the rule. The unique index guarantees that a misconfiguration fails at write time instead of silently re-pointing an existing brand’s bot.
FAQs
Can one SMSGatewayCenter account send for several brands?
Yes. Sender IDs, DLT templates, WhatsApp numbers and RCS brands and bots can all coexist in one account and are selected per request. The limits are the single SMS wallet, the single SMS delivery webhook and the single Telegram bot per account.
What is the difference between a sub-user and a reseller child account?
A sub-user is a separate login under a direct customer account, with the same products, payment type and pricing as the main account. A reseller child account is a separate account created with SMSApi/reseller/createuser, with its own balance, rate plan, login and channel registrations.
Can a sub-user read the rate plan?
No. SMSApi/rateplan/read returns code 486, “Rate plans for sub-users are undefined. Please review the rates in the main account.” Sub-users inherit pricing from the main account.
Can a sub-user create more sub-users?
No. Sub-users cannot create further sub-users, and the sub-user feature is available on direct customer accounts, not reseller accounts.
Does createuser return the new user’s id?
No. The success response contains only status, msg and code. Call readuser with the same userloginname and store the quoted userId.
Why does readuser show an expiry one day before the date I set?
You are probably formatting it in UTC. expDate is epoch milliseconds for midnight IST on the chosen date; in UTC that is 18:30 on the previous day. Convert in Asia/Kolkata.
How do I avoid crediting a child account twice?
Write an intent row with a unique reference, put the reference at the start of comment, and confirm the transfer by finding that reference in readcredithistory before any retry.
Can I transfer WhatsApp or RCS credits to a child account?
The product values for addcredit and removecredit are SMS, Voice and Miss Call. Meter WhatsApp and RCS per brand in your own ledger and confirm a child’s RCS rate plan when RCS is enabled for that user.
Should reseller calls use GET or POST?
POST. All eight endpoints accept it, and credit movements should never travel in a URL.
Why does postalCity contain a number on one endpoint and a name on another?SMSApi/account/readprofile returns identifiers such as "2707" in postalCity, while SMSApi/reseller/readuser returns names such as "Mumbai". Map the two payloads with separate types.
Can two brands share one Telegram bot?
They can share a bot only by sharing an identity, which is rarely what a brand wants. Each account has one bot, so two Telegram identities need two accounts.
How do I route SMS delivery reports when several brands share an account?
Register a URL path token per account, persist the raw body, and resolve the brand by joining the transaction id to your outbound message row, which carries brand_id.
Should I give each client its own child account or let them connect their own?
If you hold and resell credits, child accounts. If each client should own its DLT registrations, balance and relationship with the platform, have them connect their own account through OAuth.
Can a reseller add sender IDs for a child account?
Yes, in the white-label panel under “User Sender Names”. Through the API, call the SMSApi/senderid/ endpoints with the child’s own credentials.
Lets Start Multi-Brand Messaging
Running several brands, clients or business units through one messaging stack? Start with a free sandbox account on the SMSGatewayCenter demo page to test sender IDs, templates and delivery reports, or look at the bulk SMS reseller programme if you need child accounts with their own balances. For a layout review with our team, get in touch.
Latest Posts
- Multi-Brand Messaging Architecture: Accounts, Sub-Users and Reseller Child Accounts for SMS, WhatsApp, RCS and Telegram
- Contract Testing a Messaging API: A Nightly Drift Harness That Never Sends a Message
- Sender Identity Across SMS, WhatsApp, RCS and Telegram: Sender IDs, WABA Numbers, Bots and Long Codes
- Message Template Management Across SMS, RCS, WhatsApp and Telegram: One API Comparison
- Reconciling a Messaging Invoice Across Four Channels: Credits, Currency and the Rate Plan API