{"id":2938,"date":"2026-09-02T10:06:08","date_gmt":"2026-09-02T04:36:08","guid":{"rendered":"https:\/\/www.smsgatewaycenter.com\/blog\/?p=2938"},"modified":"2026-09-02T10:12:55","modified_gmt":"2026-09-02T04:42:55","slug":"message-preview-cost-estimation-before-sending","status":"publish","type":"post","link":"https:\/\/www.smsgatewaycenter.com\/blog\/message-preview-cost-estimation-before-sending\/","title":{"rendered":"Message Preview and Cost Estimation: Showing Users What They Are About to Send"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">The Preview Message API returns encoding, length, parts, estimated cost per part and, when you supply a DLT entity ID, the registered template your body matches. It does not multiply by parts, it does not multiply by recipients, and it does not know what your variables will expand to. This is how to build a preview and cost estimator that is right instead of reassuring.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/message-preview-cost-estimation-featured.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"614\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/message-preview-cost-estimation-featured-1024x614.webp\" alt=\"Abstract geometric illustration showing a message body crossing a length boundary and expanding into multiple nested segment frames.\" class=\"wp-image-2939\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/message-preview-cost-estimation-featured-1024x614.webp 1024w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/message-preview-cost-estimation-featured-300x180.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/message-preview-cost-estimation-featured-768x461.webp 768w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/message-preview-cost-estimation-featured.webp 1200w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><figcaption class=\"wp-element-caption\">One character past the boundary is not one character more. It is a second billable segment.<\/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-preview-returns\">What the Preview API Actually Returns<\/a><\/li>\n\n\n\n<li><a href=\"#three-multiplications\">The Three Multiplications the API Does Not Do<\/a><\/li>\n\n\n\n<li><a href=\"#rendered-not-template\">Preview the Rendered Message, Not the Template<\/a><\/li>\n\n\n\n<li><a href=\"#reverse-template-lookup\">Reverse Template Lookup Is the Real Feature<\/a><\/li>\n\n\n\n<li><a href=\"#two-counters\">Two Counters, and Why They Disagree<\/a><\/li>\n\n\n\n<li><a href=\"#local-counter\">Building the Local Counter<\/a><\/li>\n\n\n\n<li><a href=\"#server-side-proxy\">Never Call Preview From the Browser<\/a><\/li>\n\n\n\n<li><a href=\"#debounce-and-cache\">Debouncing, Caching and the Request Budget<\/a><\/li>\n\n\n\n<li><a href=\"#the-preview-ui\">What the Preview UI Should Actually Show<\/a><\/li>\n\n\n\n<li><a href=\"#preview-in-ci\">Preview at Save Time and in CI, Not on the Send Path<\/a><\/li>\n\n\n\n<li><a href=\"#template-registry\">A Local Template Registry From template\/read<\/a><\/li>\n\n\n\n<li><a href=\"#decision-matrix\">Decision Matrix<\/a><\/li>\n\n\n\n<li><a href=\"#build-order\">Build Order<\/a><\/li>\n\n\n\n<li><a href=\"#checklist\">Implementation Checklist<\/a><\/li>\n\n\n\n<li><a href=\"#ten-mistakes\">Ten Mistakes<\/a><\/li>\n\n\n\n<li><a href=\"#not-claimed\">What This Article Deliberately Does Not Claim<\/a><\/li>\n\n\n\n<li><a href=\"#faqs\">FAQs<\/a><\/li>\n<\/ol>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\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\">The <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/previewMsg\/\">Preview Message API<\/a> accepts a message body and a sender ID, and returns <code>msgType<\/code> (text or unicode), <code>length<\/code>, <code>parts<\/code>, <code>amount<\/code> (the estimated cost of one part), and, when you also pass <code>dltEntityId<\/code>, the <code>dltTemplateId<\/code> of the registered template your body matches. That last field is the capability worth building around, because it inverts the usual relationship: instead of you asserting a template ID and the platform checking it, you supply a body and the platform tells you which template it is.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">What the endpoint does not do is finish the arithmetic. <code>amount<\/code> is documented as the cost per SMS, and the official Python sample on the documentation page multiplies it by <code>parts<\/code> and then by the recipient count to reach a campaign total. Those two multiplications are yours. So is a third one that nobody mentions: the body you preview is usually a template containing <code>{#var#}<\/code> placeholders, and the body you send is that template with real values substituted in. Seven characters of placeholder can become forty characters of customer name. Preview the template and you will quote a number that the actual send does not honour.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">An accurate estimator therefore does three things the naive one does not. It previews a rendered worst case rather than a template. It multiplies parts by recipients by cost per part, using the widest recipient in the list rather than the average. And it runs the preview call on your server, not in the browser, because the documented ways to authenticate that endpoint are an account password or an account API key, and both of them can send messages.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\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><code>SMSApi\/preview<\/code> returns <code>msgType<\/code>, <code>length<\/code>, <code>parts<\/code>, <code>amount<\/code>, <code>message<\/code>, and optionally <code>dltTemplateId<\/code> and <code>dltEntityId<\/code>. It is a read-only analysis endpoint and it does not send anything.<\/li>\n\n\n\n<li><code>amount<\/code> is the cost of <strong>one part<\/strong>. Campaign total is <code>parts x amount x recipients<\/code>. The API performs neither multiplication.<\/li>\n\n\n\n<li>The reverse template lookup, body plus <code>dltEntityId<\/code> returns which registered template matched, is the feature that distinguishes this endpoint from any client-side character counter.<\/li>\n\n\n\n<li>Preview a <strong>rendered<\/strong> body with realistic variable values. A template full of <code>{#var#}<\/code> placeholders is a different string from the one you will be billed for.<\/li>\n\n\n\n<li>Estimate with the <strong>worst-case recipient<\/strong>, not the mean. Personalised campaigns have a segment distribution, not a segment count.<\/li>\n\n\n\n<li>A local counter is required for keystroke-level feedback. The server call is the authority. Show the local number continuously and reconcile to the server number on a debounce.<\/li>\n\n\n\n<li>Never ship <code>userid<\/code> and <code>password<\/code>, or an <code>apiKey<\/code>, to a browser or a mobile app. Proxy the preview call through an endpoint you control that authenticates your own user.<\/li>\n\n\n\n<li>Cache preview results keyed on the exact body plus sender ID plus entity ID. Identical bodies produce identical results and the cache hit rate on a real composer is high.<\/li>\n\n\n\n<li>Move template previewing to save time and to CI. A body that fails to match a template should fail your build, not your campaign.<\/li>\n\n\n\n<li>The single highest-value warning to put in front of a user is not the character count. It is the moment the encoding flips from text to unicode, because that is where the cost changes by a factor of more than two.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"what-preview-returns\" class=\"wp-block-heading\">What the Preview API Actually Returns<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The endpoint is <code>https:\/\/unify.smsgateway.center\/SMSApi\/preview<\/code>. It accepts POST or GET, and for POST it requires <code>application\/x-www-form-urlencoded<\/code>. Authentication follows the same pattern as the rest of the SMS API: <code>userid<\/code> plus <code>password<\/code> as form fields, or an <code>apiKey<\/code> HTTP header.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The request parameters are short. <code>msg<\/code> and <code>senderid<\/code> are required. <code>dltEntityId<\/code> is optional and is described in the documentation as required for DLT template matching, which is the precise way of saying that without it you get length and cost but no template resolution. <code>format<\/code> accepts <code>json<\/code>, <code>xml<\/code> or <code>plain<\/code>, and <code>output<\/code> is documented on this page as an alias for <code>format<\/code>. That alias note matters beyond this endpoint, because it is the clearest statement anywhere in the documentation that the two parameter names are interchangeable across the API.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here is a minimal call.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>curl -X POST \"https:\/\/unify.smsgateway.center\/SMSApi\/preview\" \\\n  -H \"Content-Type: application\/x-www-form-urlencoded\" \\\n  --data-urlencode \"userid=YOUR_USERNAME\" \\\n  --data-urlencode \"password=YOUR_PASSWORD\" \\\n  --data-urlencode \"msg=Dear Customer, your order has been dispatched.\" \\\n  --data-urlencode \"senderid=YOUR_SENDER_ID\" \\\n  --data-urlencode \"dltEntityId=YOUR_ENTITY_ID\" \\\n  --data-urlencode \"format=json\"<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Note the <code>--data-urlencode<\/code> rather than <code>-d<\/code>. The documentation calls this out explicitly, and it is not pedantry. A message body containing an ampersand, a plus sign or a hash will be silently truncated or mangled by plain <code>-d<\/code>, and the failure looks like a wrong length rather than an error.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The success response is flat, not wrapped in the <code>response<\/code> envelope that the campaign, template and report endpoints use.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"status\": \"success\",\n  \"statusCode\": \"200\",\n  \"reason\": \"Preview generated successfully\",\n  \"msgType\": \"text\",\n  \"length\": 28,\n  \"parts\": 1,\n  \"dltTemplateId\": \"1234567890123456\",\n  \"dltEntityId\": \"YOUR_ENTITY_ID\",\n  \"amount\": 0.05,\n  \"message\": \"Hello, this is a test message\"\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Three details in that block are worth pinning down before you write a parser.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>statusCode<\/code> is a quoted string, <code>length<\/code> and <code>parts<\/code> are unquoted integers, and <code>amount<\/code> is an unquoted float.<\/strong> Three different JSON types in one small object. If you have read the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/outbound-message-table-schema-design\/\">outbound message table article<\/a>, you already know the house rule here: normalise at the boundary, coerce <code>statusCode<\/code> to a string on every path, and never compare it with a loose equality that will pass for both <code>200<\/code> and <code>\"200\"<\/code> on one runtime and fail on another.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>amount<\/code> is a float and it is money.<\/strong> Bind it to a decimal type the moment it crosses your boundary. Python&#8217;s <code>Decimal(str(value))<\/code>, PHP&#8217;s BCMath, Java&#8217;s <code>BigDecimal<\/code>. Multiplying an IEEE 754 double by a recipient count of five hundred thousand and showing the result to a customer as a rupee figure is how quotes end in a long tail of nines.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>message<\/code> echoes the original body as submitted.<\/strong> This is a meaningful contrast with <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/validateTemplate\/\">validateTemplate<\/a>, which returns a punctuation-normalised version of the template it matched. Preview hands your string back unchanged. So preview will not tell you that your curly apostrophe differs from the straight apostrophe in the registered template, whereas the template mismatch path will. The two endpoints are complementary and you want both.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The documented error codes are compact: <code>400<\/code> for invalid or missing parameters, <code>401<\/code> for authentication failure, <code>500<\/code> for a server error. Treat <code>401<\/code> on the preview path exactly as seriously as <code>401<\/code> on the send path, because it usually means the same credential is broken for both.<\/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-preview-response-to-estimate.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"529\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-preview-response-to-estimate-1024x529.webp\" alt=\"Flow diagram showing a message body entering the preview endpoint, the endpoint returning message type, length, parts and cost per part, and three separate multiplication steps outside the endpoint producing a campaign total.\" class=\"wp-image-2940\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-preview-response-to-estimate-1024x529.webp 1024w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-preview-response-to-estimate-300x155.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-preview-response-to-estimate-768x397.webp 768w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-preview-response-to-estimate.webp 1200w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><figcaption class=\"wp-element-caption\">The endpoint stops at cost per part. Everything to the right of that boundary is your arithmetic.<\/figcaption><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"three-multiplications\" class=\"wp-block-heading\">The Three Multiplications the API Does Not Do<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">This is where most cost estimators go wrong, and they go wrong quietly, in the direction of under-quoting.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Multiplication one: parts<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><code>amount<\/code> is documented as the estimated cost per SMS. The documentation&#8217;s own Python sample removes any ambiguity about what that means by computing <code>total_cost = parts * amount_per_sms<\/code>. A three-part message costs three times the single-part figure. This is consistent with how the platform bills generally: a multi-part message is delivered to the handset as one message but consumes credits per part, which is the same arithmetic the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-length-calculation-credits-counted\/\">SMS length calculation explainer<\/a> and the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/text-messages-chart\/\">text messages chart<\/a> describe.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you display <code>amount<\/code> directly next to a three-part message, you have understated the cost by two thirds.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Multiplication two: recipients<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The preview endpoint takes no recipient parameter. It has no idea whether you are sending to one number or to a hundred thousand. The campaign total is <code>parts x amount x recipient_count<\/code>, and again the documentation&#8217;s own sample demonstrates exactly this.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Multiplication three: the one nobody writes down<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Personalisation makes <code>parts<\/code> a distribution rather than a number.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Consider a template that renders to 152 characters with a short first name and 163 characters with a long one. Every recipient in the first group costs one part. Every recipient in the second group costs two. If eight percent of your list has a long name, your true cost is not <code>1 x amount x N<\/code> and it is not <code>2 x amount x N<\/code>. It is somewhere in between, and the naive estimate that previews the template with a placeholder name is wrong by whatever that eight percent is worth.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The honest way to handle this is to compute the distribution rather than a point estimate.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>from collections import Counter\nfrom decimal import Decimal\n\ndef campaign_cost(preview_fn, template, recipients, sender_id, entity_id=None):\n    \"\"\"\n    preview_fn(body) -&gt; dict with 'parts' (int) and 'amount' (str or float)\n\n    Returns the exact total plus the segment distribution, because a single\n    number hides the fact that personalisation splits a list into cost tiers.\n    \"\"\"\n    distribution = Counter()\n    cost_per_part = None\n    seen = {}\n\n    for r in recipients:\n        body = render(template, r)          # your own substitution function\n        if body not in seen:\n            result = preview_fn(body)\n            seen&#91;body] = int(result&#91;\"parts\"])\n            if cost_per_part is None:\n                cost_per_part = Decimal(str(result&#91;\"amount\"]))\n        distribution&#91;seen&#91;body]] += 1\n\n    total_parts = sum(parts * count for parts, count in distribution.items())\n    return {\n        \"total_cost\": cost_per_part * total_parts,\n        \"cost_per_part\": cost_per_part,\n        \"total_parts\": total_parts,\n        \"recipients\": len(recipients),\n        \"distribution\": dict(sorted(distribution.items())),\n    }<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>seen<\/code> dictionary matters more than it looks. On a list of a hundred thousand recipients, the number of <em>distinct rendered bodies<\/em> is often a few hundred, because most personalisation fields are short and many recipients share a segment-equivalent rendering. Deduplicating on the exact body before calling the endpoint turns an impossible number of API calls into a tractable one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For very large lists you should not call the endpoint per distinct body at all. Call it once to learn <code>amount<\/code> and to confirm the encoding, then use a local counter, described below, for the per-recipient part counts. The endpoint establishes the price and the encoding. Local arithmetic scales the rest.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A worked example, using round numbers rather than any published rate so that the arithmetic is the point:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Scenario<\/th><th>Parts<\/th><th>Recipients<\/th><th>Cost per part<\/th><th>Total<\/th><\/tr><\/thead><tbody><tr><td>Naive: preview the template, show <code>amount<\/code><\/td><td>1<\/td><td>not counted<\/td><td>0.05<\/td><td>0.05<\/td><\/tr><tr><td>Better: multiply by parts<\/td><td>2<\/td><td>not counted<\/td><td>0.05<\/td><td>0.10<\/td><\/tr><tr><td>Correct: multiply by parts and recipients<\/td><td>2<\/td><td>100,000<\/td><td>0.05<\/td><td>10,000<\/td><\/tr><tr><td>Honest: account for the distribution<\/td><td>92,000 at 1, 8,000 at 2<\/td><td>100,000<\/td><td>0.05<\/td><td>5,400<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The last two rows differ by a factor of nearly two, and both of them are defensible depending on whether you are quoting a ceiling or an expectation. What is not defensible is the first row, and the first row is what you get if you display the API response without thinking about it.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"rendered-not-template\" class=\"wp-block-heading\">Preview the Rendered Message, Not the Template<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>msg<\/code> parameter is documented as accepting DLT template variables in <code>{#var#}<\/code> form. That is genuinely useful for template matching, and it is a trap for cost estimation.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>{#var#}<\/code> is seven characters. The value that replaces it is whatever your data holds. A template that previews as 158 characters with three placeholders still intact will render to well over 160 for most real rows, and the user who saw &#8220;1 part&#8221; in your composer will be billed for two.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The rule follows directly:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>For template matching<\/strong>, send the body with <code>{#var#}<\/code> placeholders intact. That is what the matcher expects and it is how you get a <code>dltTemplateId<\/code> back.<\/li>\n\n\n\n<li><strong>For cost estimation<\/strong>, send a body with realistic values substituted in. Not the placeholders, and not a one-character dummy.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Those are two different calls with two different bodies, and a good composer makes both. The first runs when the template is saved. The second runs when the user picks an audience.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you cannot render real values at preview time, because the audience is not selected yet or the data is not available in the composer, then substitute a <strong>declared maximum<\/strong> for each variable and label the number as an upper bound. Do not substitute the empty string, and do not substitute the literal word &#8220;Customer&#8221;, because both of them produce an optimistic answer that will be wrong in the expensive direction.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># Field-level maxima declared once, alongside the template.\nFIELD_MAXIMA = {\n    \"first_name\": 30,\n    \"order_id\": 12,\n    \"amount\": 10,\n    \"tracking_url\": 23,   # after link shortening, if you shorten\n}\n\ndef worst_case_body(template, field_order):\n    \"\"\"Substitute each {#var#} with a run of 'X' at that field's declared maximum.\"\"\"\n    parts = template.split(\"{#var#}\")\n    if len(parts) - 1 != len(field_order):\n        raise ValueError(\n            f\"template has {len(parts) - 1} variables, field_order declares {len(field_order)}\"\n        )\n    out = &#91;parts&#91;0]]\n    for field, tail in zip(field_order, parts&#91;1:]):\n        out.append(\"X\" * FIELD_MAXIMA&#91;field])\n        out.append(tail)\n    return \"\".join(out)<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That arity check is doing real work. Templates on this platform can contain adjacent variables, and the documented sample response from the template read endpoint contains bodies like <code>{#var#}{#var#}\\nA\/c Name: {#var#}{#var#}<\/code>. Two placeholders with nothing between them are indistinguishable from one placeholder once rendered, so an off-by-one in your field ordering produces a body that is syntactically fine and semantically scrambled. Failing loudly at preview time is much cheaper than discovering it after a send. The same class of failure, and the same fix, is covered at length in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/dlt-template-rejected-reasons-and-fixes\/\">DLT Template Rejected: Every Reason and How to Fix It<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One more caution about the worst case. Padding with <code>X<\/code> keeps the body in the GSM-7 alphabet. If any of your real values can contain a non-GSM character, a customer name with an accent, a product title with a curly quote pasted from a word processor, then the worst case is not &#8220;the longest string&#8221;, it is &#8220;the string that flips the encoding&#8221;. Those are different worst cases and the encoding one is far more expensive. Compute both and show the larger.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"reverse-template-lookup\" class=\"wp-block-heading\">Reverse Template Lookup Is the Real Feature<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Every SMS platform has a character counter. Very few have this.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Pass <code>dltEntityId<\/code> alongside your body, and the response comes back with <code>dltTemplateId<\/code> populated: the registered template that your body matched. You did not have to know the template ID. You did not have to store a mapping. You gave the platform a string and it told you which of your registered templates that string is.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Compare that with <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/validateTemplate\/\">validateTemplate<\/a>, which is the forward direction: you supply a <code>dltTemplateId<\/code> and a body, and it tells you whether they agree, returning <code>statusCode<\/code> 188 with reason <code>message template mismatch<\/code> when they do not. Both are useful and they answer different questions.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Question<\/th><th>Endpoint<\/th><th>You supply<\/th><th>You learn<\/th><\/tr><\/thead><tbody><tr><td>Does this body match the template I think it does?<\/td><td><code>SMSApi\/validateTemplate<\/code><\/td><td>body plus <code>dltTemplateId<\/code><\/td><td>agreement, or a 188 mismatch<\/td><\/tr><tr><td>Which of my templates does this body match?<\/td><td><code>SMSApi\/preview<\/code><\/td><td>body plus <code>dltEntityId<\/code><\/td><td>the matching <code>dltTemplateId<\/code>, or nothing<\/td><\/tr><tr><td>What are all my registered templates?<\/td><td><code>SMSApi\/template\/read<\/code><\/td><td>credentials<\/td><td>the full list with identifiers and sender ID bindings<\/td><\/tr><tr><td>How long is this body and what will it cost?<\/td><td><code>SMSApi\/preview<\/code> or <code>SMSApi\/info\/msg<\/code><\/td><td>body<\/td><td>encoding, length, parts, cost<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The reverse direction is what makes a genuinely helpful composer possible. A user types a message. Before they have selected a template, before they have thought about DLT at all, you can tell them one of three things:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>&#8220;This matches your registered template <em>Order Dispatch Alert<\/em>.&#8221; Good. Proceed.<\/li>\n\n\n\n<li>&#8220;This does not match any registered template.&#8221; That is a send that will be rejected, and you now know it while the user is still typing rather than after they have committed a campaign.<\/li>\n\n\n\n<li>Nothing came back because you did not pass an entity ID. Fix your integration.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">Case two is the valuable one. A rejected send on the India DLT path is not a soft failure, and catching it in the composer converts a support ticket into an inline warning.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two honest limitations. First, the documentation says <code>dltTemplateId<\/code> is returned &#8220;if DLT entity ID provided and template found&#8221;, which means an absent field is ambiguous between &#8220;no entity ID&#8221; and &#8220;no match&#8221;. Your client should distinguish those two cases itself, by knowing whether it sent an entity ID, rather than inferring it from the response. Second, the documentation does not state what happens when a body could match more than one registered template, and I am not going to guess. Design your registry so that near-identical templates are avoided, and treat a surprising match as a reason to check rather than as a guarantee.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"two-counters\" class=\"wp-block-heading\">Two Counters, and Why They Disagree<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">You will end up with two counters, and they will not always agree. Understanding why is the difference between a composer that feels solid and one that flickers.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The <strong>server counter<\/strong> is the preview endpoint. It is authoritative for your account, it knows your rate, and it can resolve templates. It costs a network round trip and it requires credentials.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The <strong>local counter<\/strong> runs in the user&#8217;s browser on every keystroke. It is instant and free, and it is an approximation.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The documentation&#8217;s message type table describes <code>text<\/code> as &#8220;Standard ASCII&#8221; and <code>unicode<\/code> as &#8220;Extended Unicode&#8221;. That is a reasonable simplification for a docs page and a poor specification for a counter, because the GSM 7-bit alphabet is not ASCII. It includes characters ASCII does not have, such as \u00a3, \u00a7, \u00d1, \u00e0 and \u00e9, and it treats a small set of characters, including <code>[<\/code>, <code>]<\/code>, <code>{<\/code>, <code>}<\/code>, <code>\\<\/code>, <code>~<\/code>, <code>^<\/code> and <code>|<\/code>, as extension-table characters that consume **two** of the 160 units rather than one. A local counter that tests <code>charCodeAt(i) &lt; 128<\/code> will therefore disagree with the server at both edges: it will call a body with \u00e9 unicode when the server may not, and it will undercount a body full of curly braces.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The vendor&#8217;s own <a href=\"https:\/\/www.smsgatewaycenter.com\/sms-length-calculator\/\">SMS Length Calculator<\/a> page states plainly that its result &#8220;may have difference with different SMS Providers&#8221;. That candour is the right posture and you should adopt it in your own UI. The local number is a live indicator. The server number is the quote.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Property<\/th><th>Local counter<\/th><th>Preview endpoint<\/th><\/tr><\/thead><tbody><tr><td>Latency<\/td><td>Zero<\/td><td>One network round trip<\/td><\/tr><tr><td>Cost<\/td><td>None<\/td><td>An API call<\/td><\/tr><tr><td>Runs on every keystroke<\/td><td>Yes<\/td><td>No, and it should not<\/td><\/tr><tr><td>Knows your account rate<\/td><td>No<\/td><td>Yes, via <code>amount<\/code><\/td><\/tr><tr><td>Resolves DLT templates<\/td><td>No<\/td><td>Yes, via <code>dltTemplateId<\/code><\/td><\/tr><tr><td>Handles the GSM-7 extension table<\/td><td>Only if you implement it<\/td><td>Yes<\/td><\/tr><tr><td>Correct at the encoding boundary<\/td><td>Approximately<\/td><td>Authoritatively<\/td><\/tr><tr><td>Safe to expose to a browser<\/td><td>Yes<\/td><td>No<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The reconciliation rule that works: show the local count continuously in a neutral colour, and when the debounced server response arrives, replace it. If the two disagree by a part, do not flash a warning, just update. If they disagree by more than a part, that is a bug in your local counter and it belongs in your logs, not in the user&#8217;s face.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"local-counter\" class=\"wp-block-heading\">Building the Local Counter<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Here is a counter that implements the GSM 03.38 basic and extension sets properly, in the language a composer is actually written in.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ GSM 03.38 basic alphabet, one unit each.\nconst GSM_BASIC = new Set(\n  \"@\u00a3$\u00a5\u00e8\u00e9\u00f9\u00ec\u00f2\u00c7\\n\u00d8\u00f8\\r\u00c5\u00e5\u0394_\u03a6\u0393\u039b\u03a9\u03a0\u03a8\u03a3\u0398\u039e\u00c6\u00e6\u00df\u00c9 !\\\"#\u00a4%&amp;'()*+,-.\/0123456789:;&lt;=&gt;?\" +\n  \"\u00a1ABCDEFGHIJKLMNOPQRSTUVWXYZ\u00c4\u00d6\u00d1\u00dc\u00a7\u00bfabcdefghijklmnopqrstuvwxyz\u00e4\u00f6\u00f1\u00fc\u00e0\"\n);\n\n\/\/ GSM 03.38 extension table, two units each (escape byte plus the character).\nconst GSM_EXTENDED = new Set(\"^{}\\\\&#91;~]|\u20ac\");\n\nexport function countSegments(text) {\n  let units = 0;\n  let unicode = false;\n\n  \/\/ Iterate by code point, not by UTF-16 code unit. A single emoji is one\n  \/\/ code point but two code units, and counting units double-charges it.\n  for (const ch of text) {\n    if (GSM_BASIC.has(ch)) {\n      units += 1;\n    } else if (GSM_EXTENDED.has(ch)) {\n      units += 2;\n    } else {\n      unicode = true;\n      break;\n    }\n  }\n\n  if (unicode) {\n    \/\/ UCS-2 counts UTF-16 code units, so a character outside the Basic\n    \/\/ Multilingual Plane genuinely costs two. Here the code-unit count\n    \/\/ is the correct one, which is the opposite of the GSM branch.\n    units = text.length;\n    const single = 70;\n    const multi = 67;\n    const parts = units &lt;= single ? 1 : Math.ceil(units \/ multi);\n    return { encoding: \"unicode\", units, parts, perPart: units &lt;= single ? single : multi };\n  }\n\n  const single = 160;\n  const multi = 153;\n  const parts = units &lt;= single ? 1 : Math.ceil(units \/ multi);\n  return { encoding: \"text\", units, parts, perPart: units &lt;= single ? single : multi };\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The comment on the two branches is the part that gets skipped and then costs someone a day. In the GSM branch you iterate by <strong>code point<\/strong>, because the alphabet is defined over characters. In the Unicode branch you count <strong>UTF-16 code units<\/strong>, because UCS-2 segmentation is defined over 16-bit units and an astral-plane character such as an emoji really does occupy two of them. Using the same counting mode for both branches is wrong in one of them, always. The <a href=\"https:\/\/www.unicode.org\/faq\/utf_bom.html\" target=\"_blank\" rel=\"noopener nofollow\">Unicode FAQ on encoding forms<\/a> is the reference if you want to convince a reviewer.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The empty-string case deserves an explicit test. Zero characters should report zero units and one part, not zero parts, because a composer that shows &#8220;0 parts&#8221; invites the user to believe an empty send is free rather than impossible.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Boundary tests you should have, and which are the same boundaries the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/testing-code-that-sends-messages\/\">testing article<\/a> argues belong in your fastest test layer:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Input<\/th><th>Expected encoding<\/th><th>Expected units<\/th><th>Expected parts<\/th><\/tr><\/thead><tbody><tr><td><code>\"\"<\/code><\/td><td>text<\/td><td>0<\/td><td>1<\/td><\/tr><tr><td>160 plain ASCII characters<\/td><td>text<\/td><td>160<\/td><td>1<\/td><\/tr><tr><td>161 plain ASCII characters<\/td><td>text<\/td><td>161<\/td><td>2<\/td><\/tr><tr><td>306 plain ASCII characters<\/td><td>text<\/td><td>306<\/td><td>2<\/td><\/tr><tr><td>307 plain ASCII characters<\/td><td>text<\/td><td>307<\/td><td>3<\/td><\/tr><tr><td>159 ASCII plus one <code>{<\/code><\/td><td>text<\/td><td>161<\/td><td>2<\/td><\/tr><tr><td>70 Devanagari characters<\/td><td>unicode<\/td><td>70<\/td><td>1<\/td><\/tr><tr><td>71 Devanagari characters<\/td><td>unicode<\/td><td>71<\/td><td>2<\/td><\/tr><tr><td>69 ASCII plus one emoji<\/td><td>unicode<\/td><td>71<\/td><td>2<\/td><\/tr><tr><td>159 ASCII plus one curly apostrophe<\/td><td>unicode<\/td><td>160<\/td><td>3<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">That last row is the one to put on a poster. A body sitting comfortably inside one GSM segment, plus a single typographic apostrophe of the kind every word processor inserts automatically, becomes a <strong>three-part unicode message<\/strong>. The cost tripled and the visible text did not change. This is the single most expensive character in bulk messaging and it is invisible in every font.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"server-side-proxy\" class=\"wp-block-heading\">Never Call Preview From the Browser<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The integration examples on the documentation page include a React Native component that calls the preview endpoint directly with <code>userid<\/code> and <code>password<\/code> in the request body. Read as a demonstration of the response shape it is fine. Copied into a product it is a credential leak.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The reasoning is short and it does not depend on anyone being careless. The preview endpoint authenticates with the same credentials as the send endpoint. There is no preview-only scope, no read-only key, and nothing in the documentation describing a restricted credential. So a browser bundle or a mobile app binary that can call preview can also call <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/send-sms-batch\/\">send<\/a>, and can spend your balance.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">An API key is not a mitigation here. The key management endpoints make the position clear: the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/create-api-key\/\">read API key endpoint<\/a> returns the key itself in plaintext to anyone holding the account credentials, and nothing in the documentation describes an expiry. A credential that is recoverable and does not expire is exactly the credential you least want in a client bundle. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/oauth-messaging-connect-customer-sms-account\/\">OAuth article<\/a> develops this argument fully for the multi-tenant case, and its conclusion applies here in miniature.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The fix is a proxy, and it is about fifteen lines.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ Express. The browser authenticates to YOU. Only your server holds\n\/\/ the messaging credentials, and they never leave this process.\nimport express from \"express\";\n\nconst app = express();\napp.use(express.json());\n\nconst PREVIEW_URL = \"https:\/\/unify.smsgateway.center\/SMSApi\/preview\";\n\napp.post(\"\/api\/preview\", requireSession, rateLimitPerUser, async (req, res) =&gt; {\n  const { msg, senderId } = req.body ?? {};\n\n  if (typeof msg !== \"string\" || msg.length === 0 || msg.length &gt; 5000) {\n    return res.status(400).json({ error: \"invalid_message\" });\n  }\n  \/\/ The sender ID must come from what this user is allowed to use,\n  \/\/ never from whatever the client posted.\n  if (!(await userMaySend(req.session.userId, senderId))) {\n    return res.status(403).json({ error: \"sender_not_permitted\" });\n  }\n\n  const form = new URLSearchParams({\n    userid: process.env.SGC_USERID,\n    msg,\n    senderid: senderId,\n    dltEntityId: process.env.SGC_DLT_ENTITY_ID ?? \"\",\n    format: \"json\",\n  });\n\n  const upstream = await fetch(PREVIEW_URL, {\n    method: \"POST\",\n    headers: {\n      \"Content-Type\": \"application\/x-www-form-urlencoded\",\n      apiKey: process.env.SGC_API_KEY,\n    },\n    body: form.toString(),\n    signal: AbortSignal.timeout(5000),\n  });\n\n  const data = await upstream.json();\n\n  \/\/ Return only what the UI needs. Do not proxy the upstream body\n  \/\/ verbatim; it echoes parameters and may grow fields later.\n  res.json({\n    encoding: data.msgType,\n    length: data.length,\n    parts: data.parts,\n    costPerPart: data.amount,\n    templateId: data.dltTemplateId ?? null,\n  });\n});<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Four things in that handler are load-bearing. The session check means the caller is a logged-in user of yours. The sender ID authorisation means a user cannot preview against a sender ID that is not theirs, which in a multi-tenant product is a small information leak about other tenants&#8217; registered headers. The <code>AbortSignal.timeout<\/code> is there because <code>fetch<\/code> has no default timeout, a point the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-nodejs-integration-tutorial\/\">Node.js integration tutorial<\/a> makes at length. And the narrowed response body means a future field added upstream does not silently start flowing to browsers.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Add a per-user rate limit on this route. The preview endpoint is cheap for you but it is not free for the platform, and an unthrottled proxy attached to a keystroke handler is a load generator pointed at your own vendor. The mechanics of doing that correctly, including why a per-process limiter is not a global limiter, are 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<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-preview-proxy-and-cache.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"546\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-preview-proxy-and-cache-1024x546.webp\" alt=\"Architecture diagram showing a browser running a local segment counter on every keystroke, a debounced request to a server-side proxy, a cache keyed on body and sender ID, and a single upstream call to the preview endpoint.\" class=\"wp-image-2941\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-preview-proxy-and-cache-1024x546.webp 1024w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-preview-proxy-and-cache-300x160.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-preview-proxy-and-cache-768x410.webp 768w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-preview-proxy-and-cache.webp 1200w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><figcaption class=\"wp-element-caption\">Two counters, one credential. The keystroke path never leaves the browser and the credential never leaves the server.<\/figcaption><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"debounce-and-cache\" class=\"wp-block-heading\">Debouncing, Caching and the Request Budget<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The documentation&#8217;s best-practice list includes &#8220;Call preview API as user types to show live character count and cost&#8221; and, two lines later, &#8220;Cache preview results for identical messages to reduce API calls&#8221;. The second recommendation is the one to build on. Taken literally, the first one produces an authenticated network request per keystroke, and a user typing a 300-character message generates 300 requests to compute a number that changes meaningfully perhaps four times.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The workable pattern is a division of labour.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Local, on every keystroke:<\/strong> character count, encoding, parts, and the encoding-flip warning. Zero requests.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Server, on a trailing debounce of roughly 400 to 600 milliseconds after typing stops:<\/strong> the authoritative parts, the cost per part, and the template match. One request per pause.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Server, immediately and without debounce:<\/strong> when the user changes the sender ID, selects a template, or clicks anything that looks like &#8220;check&#8221; or &#8220;estimate&#8221;. These are deliberate acts and they deserve a synchronous answer.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Caching sits underneath all of it. The cache key is the full tuple, because every element of it can change the answer:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sha256(msg) + \":\" + senderid + \":\" + (dltEntityId or \"\") + \":\" + account_id<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Include the account ID. Two tenants can submit the identical body and receive different <code>amount<\/code> values, and a cache that omits the account will quote one customer another customer&#8217;s rate. That is a correctness bug and a commercial one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A short time to live is right. Five to fifteen minutes covers a composer session comfortably. Do not cache for a day, because <code>amount<\/code> reflects the account rate and rates change. Do not cache error responses at all, and in particular do not cache a <code>401<\/code>, because the natural user response to a credential problem is to fix the credential and try again, and a cached failure makes that look broken.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import hashlib\nfrom decimal import Decimal\n\nCACHE_TTL_SECONDS = 600\n\ndef preview_key(account_id, msg, senderid, dlt_entity_id=\"\"):\n    digest = hashlib.sha256(msg.encode(\"utf-8\")).hexdigest()\n    return f\"preview:{account_id}:{digest}:{senderid}:{dlt_entity_id}\"\n\ndef cached_preview(cache, client, account_id, msg, senderid, dlt_entity_id=\"\"):\n    key = preview_key(account_id, msg, senderid, dlt_entity_id)\n    hit = cache.get(key)\n    if hit is not None:\n        return hit\n\n    result = client.preview(msg=msg, senderid=senderid, dltEntityId=dlt_entity_id)\n\n    if str(result.get(\"statusCode\")) != \"200\":\n        # Never cache a failure. Surface it and let the caller retry.\n        raise PreviewError(result.get(\"reason\", \"preview failed\"))\n\n    normalised = {\n        \"encoding\": result&#91;\"msgType\"],\n        \"length\": int(result&#91;\"length\"]),\n        \"parts\": int(result&#91;\"parts\"]),\n        \"cost_per_part\": Decimal(str(result&#91;\"amount\"])),\n        \"template_id\": result.get(\"dltTemplateId\") or None,\n    }\n    cache.set(key, normalised, ttl=CACHE_TTL_SECONDS)\n    return normalised<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Hash the message rather than using it as the key directly. Message bodies contain customer names, order numbers and amounts, and a cache key is one of the least controlled surfaces in a system: it lands in slow-log output, in monitoring dashboards, in memory dumps. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/observability-for-messaging-pipelines\/\">observability article<\/a> makes the same argument about metric labels, and the reasoning transfers unchanged.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"the-preview-ui\" class=\"wp-block-heading\">What the Preview UI Should Actually Show<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Most SMS composers show a character countdown. Character countdowns are the least useful thing on the screen, because the user cannot act on &#8220;97 characters remaining&#8221; and can act on almost everything else.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Ranked by how much a user&#8217;s behaviour changes when they see it:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>1. The encoding flip.<\/strong> The moment the body goes from text to unicode, say so, loudly, and say what caused it. &#8220;This message now uses Unicode encoding, which reduces the limit from 160 to 70 characters per part. Cause: a curly apostrophe at position 84.&#8221; Offer a one-click fix that replaces the offending character with its GSM equivalent. This single feature saves more money than every other element of the composer combined, and it is the direct application of the point that the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/the-curious-case-of-sms-character-limits-why-your-text-messages-have-a-cap\/\">SMS character limits explainer<\/a> makes conceptually.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>2. The part count, with the boundary in sight.<\/strong> Not &#8220;2 parts&#8221; but &#8220;2 parts, 9 characters over the 1-part limit&#8221;. A user who is nine characters over will trim nine characters. A user told only &#8220;2 parts&#8221; will shrug.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>3. The template match status.<\/strong> Matched, with the template&#8217;s human-readable identifier. Or not matched, with a clear statement that the send will be rejected.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>4. The campaign total, once an audience exists.<\/strong> Parts times recipients times cost per part, with the distribution shown if it is not uniform. Before an audience is selected, show cost per message and label it as such.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>5. The character count.<\/strong> Last. It is a diagnostic, not a decision input.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two things not to do. Do not show a live cost figure that updates on every keystroke; watching a number tick upward while composing is anxiety-inducing and it is also, on the local counter, an approximation. Settle the cost figure on the debounced server response. And do not block the send button on a preview call failing. A preview outage is not a reason to prevent a legitimate send; degrade to the local counter, mark the estimate as unverified, and let the user proceed.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"preview-in-ci\" class=\"wp-block-heading\">Preview at Save Time and in CI, Not on the Send Path<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">There is a tempting design where you call preview immediately before every send, as a guard. Resist it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Adding a synchronous network call in front of every send doubles the latency of the send path, doubles the number of things that can fail, and introduces a new ambiguous-outcome case at exactly the point where ambiguity is most expensive. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/message-idempotency-preventing-duplicate-sends\/\">idempotency article<\/a> covers what an ambiguous outcome costs on the send path, and there is no reason to add a second one for information you could have obtained hours earlier.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Template bodies are not user input in the general case. They are configuration. They change when someone edits them, which is rare, and they are known long before the send. So preview them when they change.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>At template save time<\/strong>, run a preview, store the result alongside the template, and refuse the save if the body does not match a registered template. The author is right there, with context, able to fix it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>In CI<\/strong>, assert the same thing for every template in your repository, so a template edited in a pull request cannot reach production unmatched. This mirrors the <code>validateTemplate<\/code> CI check that the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/dlt-template-rejected-reasons-and-fixes\/\">DLT rejection article<\/a> recommends, with preview answering the complementary question.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>#!\/usr\/bin\/env python3\n\"\"\"\nci_preview_templates.py\nFails the build when a template does not match a registered DLT template,\nor when its worst-case rendering costs more parts than declared.\nRun against a CI-only account. This endpoint does not send and does not bill,\nbut it does authenticate, so use a credential scoped to CI.\n\"\"\"\nimport os, sys, yaml\n\nfrom sgc import PreviewClient          # your own thin wrapper\n\nclient = PreviewClient(\n    userid=os.environ&#91;\"SGC_CI_USERID\"],\n    api_key=os.environ&#91;\"SGC_CI_API_KEY\"],\n    entity_id=os.environ&#91;\"SGC_DLT_ENTITY_ID\"],\n)\n\nfailures = &#91;]\n\nwith open(\"templates\/registry.yaml\") as fh:\n    registry = yaml.safe_load(fh)\n\nfor name, spec in registry.items():\n    body = spec&#91;\"body\"]\n    declared_max_parts = spec&#91;\"max_parts\"]\n    field_order = spec.get(\"fields\", &#91;])\n\n    # 1. Template matching uses the placeholder form.\n    match = client.preview(msg=body, senderid=spec&#91;\"sender_id\"])\n    if not match.get(\"dltTemplateId\"):\n        failures.append(f\"{name}: no registered DLT template matched this body\")\n        continue\n    if spec.get(\"expected_template_id\") and match&#91;\"dltTemplateId\"] != spec&#91;\"expected_template_id\"]:\n        failures.append(\n            f\"{name}: matched {match&#91;'dltTemplateId']}, \"\n            f\"expected {spec&#91;'expected_template_id']}\"\n        )\n\n    # 2. Cost checking uses the worst-case rendered form.\n    rendered = worst_case_body(body, field_order)\n    cost = client.preview(msg=rendered, senderid=spec&#91;\"sender_id\"])\n    if int(cost&#91;\"parts\"]) &gt; declared_max_parts:\n        failures.append(\n            f\"{name}: worst case is {cost&#91;'parts']} parts, \"\n            f\"declared maximum is {declared_max_parts}\"\n        )\n    if cost&#91;\"msgType\"] == \"unicode\" and not spec.get(\"unicode_expected\"):\n        failures.append(\n            f\"{name}: worst case renders as unicode but the template \"\n            f\"is not declared as a unicode template\"\n        )\n\nif failures:\n    print(\"Template preview checks failed:\\n  \" + \"\\n  \".join(failures))\n    sys.exit(1)\n\nprint(f\"All {len(registry)} templates matched and are within their declared part budgets.\")<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>declared_max_parts<\/code> field is the part that changes team behaviour. It forces someone to write down, in the repository, how expensive a template is allowed to be. After that, a well-meaning copy edit that pushes a one-part template to two parts fails the build with a message that says exactly what happened, instead of appearing a month later as an unexplained bump in the bill. This is the same idea as a performance budget, applied to a cost that most teams never budget at all.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"template-registry\" class=\"wp-block-heading\">A Local Template Registry From template\/read<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The preview endpoint hands you a <code>dltTemplateId<\/code>, which is a nineteen-digit-class opaque number. Users do not recognise it. To turn a match into something a human can read, you need the other side of the mapping, and <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/read-message-template\/\">SMSApi\/template\/read<\/a> provides it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The documented response is worth reading carefully, because its shape has two traps.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n    \"response\": {\n        \"api\": \"template\",\n        \"action\": \"read\",\n        \"status\": \"success\",\n        \"msg\": \"success\",\n        \"code\": \"200\",\n        \"count\": 46,\n        \"templateList\": &#91;\n            {\n                \"template\": {\n                    \"mtId\": \"39\",\n                    \"identifier\": \"bank details without statement\",\n                    \"template\": \"{#var#}{#var#}\\nA\/c Name: {#var#}{#var#}\\nA\/c No: {#var#}...\",\n                    \"msgType\": \"text\",\n                    \"dltTemplateId\": \"10070142034...\",\n                    \"dltTemplateType\": \"Service Implicit\",\n                    \"senderIds\": \"SENDER\",\n                    \"status\": \"ENABLED\",\n                    \"lastUpdated\": \"1687764018447\"\n                }\n            }\n        ]\n    }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Trap one: the name <code>template<\/code> appears at three levels.<\/strong> <code>templateList<\/code> contains wrapper objects whose single key is <code>template<\/code>, and the object inside that has a field also called <code>template<\/code> holding the body. So the body is at <code>response.templateList[i].template.template<\/code>. Every typed language will need an explicit wrapper type here, exactly as the by-transaction report needs one, and a naive automatic binding will produce something unreadable.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Trap two: <code>senderIds<\/code> is plural but it is a string, not an array.<\/strong> One template can be bound to more than one sender ID. If you model it as a single value you will build a registry that silently drops bindings, and a template that works from one header will appear unavailable from another.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Other fields worth noting: <code>mtId<\/code> is a small local identifier, distinct from the long <code>dltTemplateId<\/code>, and it is the one to use in your own URLs. <code>dltTemplateType<\/code> carries the DLT classification, <code>Service Implicit<\/code> in the sample. <code>status<\/code> is an enumerated string, <code>ENABLED<\/code> in the sample, and you should treat any value other than the one you expect as &#8220;do not offer this template&#8221; rather than assuming a fixed set. <code>lastUpdated<\/code> is a millisecond epoch delivered as a quoted string, the same convention as the campaign and schedule endpoints, and the same reason to normalise epochs at the boundary rather than downstream.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Sync this list into your own table on a schedule, and store <code>dltTemplateId<\/code> as <code>TEXT<\/code>. Every provider identifier on this platform is long enough to lose precision if any layer of your stack decides it looks like a number, which is the rule established 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>CREATE TABLE message_template (\n    id                  BIGSERIAL   PRIMARY KEY,\n    account_id          BIGINT      NOT NULL,\n    provider_mt_id      TEXT        NOT NULL,\n    dlt_template_id     TEXT,\n    identifier          TEXT        NOT NULL,\n    body                TEXT        NOT NULL,\n    msg_type            TEXT        NOT NULL,\n    dlt_template_type   TEXT,\n    sender_ids          TEXT&#91;]      NOT NULL DEFAULT '{}',\n    provider_status     TEXT        NOT NULL,\n    variable_count      INT         NOT NULL,\n    declared_max_parts  INT,\n    last_updated_at     TIMESTAMPTZ,\n    synced_at           TIMESTAMPTZ NOT NULL DEFAULT now(),\n    UNIQUE (account_id, provider_mt_id)\n);\n\n-- Resolving a preview match back to something a human recognises.\nCREATE INDEX ON message_template (account_id, dlt_template_id)\n    WHERE dlt_template_id IS NOT NULL;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Note <code>sender_ids<\/code> as an array in your schema even though it arrives as a string. Split it on ingest. Modelling it correctly locally costs one line and saves you from re-deriving the plural later.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">With that table in place, a preview response becomes a sentence: &#8220;This matches <em>bank details without statement<\/em>, enabled, valid for sender ID SENDER.&#8221; That is a composer that helps.<\/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<\/h2>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Situation<\/th><th>Local counter<\/th><th>Preview endpoint<\/th><th>Notes<\/th><\/tr><\/thead><tbody><tr><td>Live feedback while typing<\/td><td>Yes<\/td><td>No<\/td><td>One request per keystroke is not a design, it is a load test<\/td><\/tr><tr><td>Settling the number after a typing pause<\/td><td>No<\/td><td>Yes, debounced 400 to 600 ms<\/td><td>Replace the local figure with the server figure<\/td><\/tr><tr><td>Quoting a campaign cost to a customer<\/td><td>No<\/td><td>Yes<\/td><td>Never quote from an approximation<\/td><\/tr><tr><td>Checking a template matches a registered DLT template<\/td><td>No<\/td><td>Yes, with <code>dltEntityId<\/code><\/td><td>The local counter cannot do this at all<\/td><\/tr><tr><td>Checking a body against a template ID you already hold<\/td><td>No<\/td><td>Use <code>validateTemplate<\/code> instead<\/td><td>Forward direction, returns 188 on mismatch<\/td><\/tr><tr><td>Computing per-recipient parts across a large list<\/td><td>Yes<\/td><td>No<\/td><td>Call once for <code>amount<\/code>, count locally per recipient<\/td><\/tr><tr><td>Validating templates in CI<\/td><td>No<\/td><td>Yes<\/td><td>Read-only, does not send, does not bill<\/td><\/tr><tr><td>Guarding an individual send at send time<\/td><td>No<\/td><td>No<\/td><td>Adds latency and a new failure mode; do it at save time<\/td><\/tr><tr><td>Inside a browser or mobile app<\/td><td>Yes<\/td><td>No, proxy it<\/td><td>The credential can also send<\/td><\/tr><tr><td>When the preview call fails<\/td><td>Yes, degrade to it<\/td><td>Retry once, then degrade<\/td><td>Never block the send button on a preview outage<\/td><\/tr><tr><td>Estimating with personalisation<\/td><td>Yes, per rendered body<\/td><td>Once, for <code>amount<\/code> and encoding<\/td><td>Distribution, not a point estimate<\/td><\/tr><tr><td>Confirming the encoding at the boundary<\/td><td>Approximately<\/td><td>Authoritatively<\/td><td>Local counter is right about 99 percent of bodies and wrong about the interesting one percent<\/td><\/tr><\/tbody><\/table><\/figure>\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><strong>Write the local segment counter first, with the boundary tests from the table above.<\/strong> It is pure, it is fast to test, and everything else in the composer depends on it being right. Do not move on until 159 ASCII plus a curly apostrophe reports three parts.<\/li>\n\n\n\n<li><strong>Stand up the server-side proxy route.<\/strong> Session check, sender ID authorisation, per-user rate limit, upstream timeout, narrowed response. No messaging credential ever reaches a client bundle.<\/li>\n\n\n\n<li><strong>Add the cache<\/strong>, keyed on account plus hashed body plus sender ID plus entity ID, with a ten-minute time to live and no caching of failures.<\/li>\n\n\n\n<li><strong>Wire the debounce.<\/strong> Local counter on keystroke, server call on a trailing 400 to 600 millisecond pause, immediate server call on sender ID or template change.<\/li>\n\n\n\n<li><strong>Sync the template registry<\/strong> from <code>SMSApi\/template\/read<\/code> into your own table, splitting <code>senderIds<\/code> into an array and storing every identifier as text.<\/li>\n\n\n\n<li><strong>Render the match.<\/strong> Turn a returned <code>dltTemplateId<\/code> into the template&#8217;s human-readable identifier, and turn a missing match into an explicit warning that the send will be rejected.<\/li>\n\n\n\n<li><strong>Implement the worst-case renderer<\/strong> with declared field maxima and an arity check that fails loudly when the placeholder count and the field list disagree.<\/li>\n\n\n\n<li><strong>Add the campaign estimator<\/strong> that deduplicates rendered bodies, counts locally, and reports a distribution rather than a single number.<\/li>\n\n\n\n<li><strong>Add the encoding-flip warning<\/strong> with the offending character identified by position, and a one-click replacement for the common typographic culprits.<\/li>\n\n\n\n<li><strong>Move template checking into CI<\/strong> with declared part budgets, so a copy edit that doubles a template&#8217;s cost fails the build rather than the bill.<\/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\">Implementation Checklist<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Counter correctness<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>GSM-7 basic alphabet implemented as a set, not as an ASCII range check<\/li>\n\n\n\n<li>GSM-7 extension characters counted as two units each<\/li>\n\n\n\n<li>GSM branch iterates by code point<\/li>\n\n\n\n<li>Unicode branch counts UTF-16 code units<\/li>\n\n\n\n<li>160 and 153 thresholds for text, 70 and 67 for unicode<\/li>\n\n\n\n<li>Empty string reports one part, not zero<\/li>\n\n\n\n<li>Boundary tests at 160, 161, 306, 307, 70 and 71 all present and passing<\/li>\n\n\n\n<li>Emoji test present, asserting unicode and the correct code-unit count<\/li>\n\n\n\n<li>Curly apostrophe test present, asserting the flip to unicode<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Preview integration<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>POST with <code>application\/x-www-form-urlencoded<\/code><\/li>\n\n\n\n<li><code>--data-urlencode<\/code> or an equivalent encoder, never naive string concatenation<\/li>\n\n\n\n<li><code>statusCode<\/code> coerced to string before comparison<\/li>\n\n\n\n<li><code>amount<\/code> bound to a decimal type, never a float, before any arithmetic<\/li>\n\n\n\n<li><code>length<\/code> and <code>parts<\/code> coerced to integers<\/li>\n\n\n\n<li>Missing <code>dltTemplateId<\/code> distinguished from &#8220;no entity ID sent&#8221; in your own code<\/li>\n\n\n\n<li>Unknown response fields ignored rather than causing a parse failure<\/li>\n\n\n\n<li>Upstream timeout set explicitly<\/li>\n\n\n\n<li><code>401<\/code> from preview alerts, because it usually means the send credential is broken too<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Security<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>No messaging credential in any client bundle, web or mobile<\/li>\n\n\n\n<li>Preview reached only through your own authenticated proxy route<\/li>\n\n\n\n<li>Sender ID validated against what the calling user is permitted to use<\/li>\n\n\n\n<li>Per-user rate limit on the proxy route<\/li>\n\n\n\n<li>Upstream response narrowed before being returned to the client<\/li>\n\n\n\n<li>Message bodies hashed, not stored raw, in cache keys and logs<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Cost estimation<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Total computed as parts times cost per part times recipients<\/li>\n\n\n\n<li>Estimates computed from rendered bodies, not from placeholder templates<\/li>\n\n\n\n<li>Field maxima declared alongside each template<\/li>\n\n\n\n<li>Arity check between placeholder count and declared field list<\/li>\n\n\n\n<li>Worst case considers encoding flip, not only length<\/li>\n\n\n\n<li>Distribution reported when personalisation produces more than one part count<\/li>\n\n\n\n<li>Rendered bodies deduplicated before any per-body API call<\/li>\n\n\n\n<li>Quote labelled as an estimate, with the basis stated<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Templates and CI<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Template registry synced from <code>SMSApi\/template\/read<\/code><\/li>\n\n\n\n<li>Wrapper object at <code>templateList[i].template<\/code> explicitly modelled<\/li>\n\n\n\n<li><code>senderIds<\/code> split into an array on ingest<\/li>\n\n\n\n<li><code>dltTemplateId<\/code> and <code>mtId<\/code> stored as text<\/li>\n\n\n\n<li><code>lastUpdated<\/code> normalised from a quoted millisecond epoch<\/li>\n\n\n\n<li><code>provider_status<\/code> treated as an open enumeration<\/li>\n\n\n\n<li>CI asserts every template matches a registered DLT template<\/li>\n\n\n\n<li>CI asserts every template is within its declared part budget<\/li>\n\n\n\n<li>CI runs against a credential scoped to CI<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>User interface<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Encoding flip warned about explicitly, with the causing character located<\/li>\n\n\n\n<li>Part count shown with distance to the next boundary<\/li>\n\n\n\n<li>Template match status shown in human-readable form<\/li>\n\n\n\n<li>Campaign total shown only once an audience exists<\/li>\n\n\n\n<li>Character count present but de-emphasised<\/li>\n\n\n\n<li>Cost figure settles on the server response rather than ticking per keystroke<\/li>\n\n\n\n<li>Send button not blocked by a preview failure<\/li>\n\n\n\n<li>Estimate visibly marked unverified when the server call failed<\/li>\n<\/ul>\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<\/h2>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Displaying <code>amount<\/code> as the message cost.<\/strong> It is the cost of one part. A three-part message costs three times that, and the documentation&#8217;s own sample multiplies it out.<\/li>\n\n\n\n<li><strong>Forgetting recipients entirely.<\/strong> The endpoint has no recipient parameter, so nothing in the response reflects list size. A per-message figure shown next to a hundred-thousand-recipient campaign is not an estimate, it is a misdirection.<\/li>\n\n\n\n<li><strong>Previewing the template instead of a rendered body.<\/strong> Placeholders are seven characters. Real values are not. This error always points the same way, toward under-quoting.<\/li>\n\n\n\n<li><strong>Testing for ASCII instead of implementing GSM-7.<\/strong> The two alphabets are different at both ends, and the differences are exactly the characters that appear in real Indian and European copy.<\/li>\n\n\n\n<li><strong>Using one counting mode for both encodings.<\/strong> Code points in the GSM branch, code units in the Unicode branch. Using either one for both is wrong in one of them.<\/li>\n\n\n\n<li><strong>Calling the preview endpoint from a browser or a mobile app.<\/strong> The credential that previews can also send, and there is no documented read-only scope.<\/li>\n\n\n\n<li><strong>Calling preview on every keystroke.<\/strong> The documentation suggests live preview and, separately, caching. Take the caching advice, debounce the calls, and let the local counter carry the keystroke path.<\/li>\n\n\n\n<li><strong>Caching without the account in the key.<\/strong> Two accounts, one body, two different rates. Omitting the account ID quotes one customer another customer&#8217;s price.<\/li>\n\n\n\n<li><strong>Modelling <code>senderIds<\/code> as a single value.<\/strong> It is plural, it is comma-joined, and templates really are bound to multiple headers.<\/li>\n\n\n\n<li><strong>Putting the preview call on the send path as a guard.<\/strong> It doubles the latency, adds a failure mode, and answers a question you could have answered at template save time.<\/li>\n<\/ol>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"not-claimed\" class=\"wp-block-heading\">What This Article Deliberately Does Not Claim<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The documentation is specific about what preview returns and silent about several things a complete treatment would want. Rather than guess, here is what is not established.<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Whether calls to the preview endpoint are rate limited, and at what rate.<\/strong> No limit is published. The recommendation to debounce and cache is based on general good practice and on the documentation&#8217;s own caching advice, not on a known ceiling.<\/li>\n\n\n\n<li><strong>Whether preview consumes credits.<\/strong> The endpoint does not send, and nothing in the documentation describes it as billable. That is not the same as a written statement that it is free, so verify against your own credit history before wiring it into a high-volume path.<\/li>\n\n\n\n<li><strong>What happens when a body could match more than one registered template.<\/strong> The documentation describes a single <code>dltTemplateId<\/code> in the response and does not address ambiguity.<\/li>\n\n\n\n<li><strong>How the matcher normalises whitespace, punctuation and case when comparing a body to a registered template.<\/strong> <code>validateTemplate<\/code> is documented as returning a punctuation-normalised template, but preview echoes your body unchanged and the matching rules themselves are not published.<\/li>\n\n\n\n<li><strong>Whether <code>amount<\/code> includes taxes, surcharges or route differentials.<\/strong> It is documented as the estimated cost per SMS in your account currency, and no breakdown is given.<\/li>\n\n\n\n<li><strong>Whether <code>amount<\/code> varies by destination country.<\/strong> The endpoint takes no recipient, so it cannot reflect a per-destination rate, and how that interacts with international sending is not documented.<\/li>\n\n\n\n<li><strong>Whether the 255-part maximum in the message type table is enforced by the send endpoint.<\/strong> It is documented on the preview page as an upper bound for the calculation.<\/li>\n\n\n\n<li><strong>How preview treats a body containing a shortened link when link tracking is enabled on the send.<\/strong> The send endpoint has <code>trackLink<\/code> and <code>appendHttp<\/code> parameters that can alter the final body, and whether preview accounts for that is not stated.<\/li>\n\n\n\n<li><strong>Whether the response field set is fixed.<\/strong> Response shapes elsewhere on this API differ between documented samples for the same endpoint family, so parse defensively and ignore unknown fields.<\/li>\n\n\n\n<li><strong>Any specific throughput, latency or availability figure for the preview endpoint.<\/strong> None is published and none is inferred here.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">Where a number would have helped, its absence is stated rather than filled in. If any of these are clarified, this article should be updated rather than quietly extended by assumption.<\/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>Does the preview API send an SMS?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">No. It analyses the body and returns encoding, length, parts, estimated cost per part and, when an entity ID is supplied, the matching DLT template. It is a read-only analysis endpoint. That is also why it is safe to run in CI, where a test that really sends really bills.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Is <code>amount<\/code> the cost of the whole message or of one part?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One part. The documentation describes it as the estimated cost per SMS, and the official Python sample on the documentation page computes the message total as <code>parts * amount<\/code>. Multiply before displaying.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I get a campaign total?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Parts times cost per part times recipient count. If your message is personalised, compute the parts for each rendered body rather than once for the template, and report the distribution. A list where eight percent of recipients cross the segment boundary has a materially different cost from one where none do.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What is the difference between the preview endpoint and validateTemplate?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Direction. <code>validateTemplate<\/code> takes a body plus a template ID you already hold and tells you whether they agree, returning status code 188 on a mismatch. Preview takes a body plus your entity ID and tells you which template it matched. Forward and reverse. Use both.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What is the difference between the preview endpoint and the SMS length and cost endpoint?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/get-sms-length-cost\/\">SMSApi\/info\/msg<\/a> returns encoding, length, remaining characters and an integer <code>credit<\/code> count. Preview returns encoding, length, <code>parts<\/code>, a float <code>amount<\/code> and the DLT template match. Preview is the richer of the two for a composer; the length endpoint is fine when all you need is a credit count.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Can I call the preview endpoint from my front-end?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">You should not. It authenticates with the same credentials as the send endpoint, and no read-only scope is documented. Proxy it through your own authenticated route so the messaging credential stays on your server.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Is an API key safer than a password for a browser call?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">No. The key management endpoints show that an account API key can be read back in plaintext by anyone holding the account credentials, and no expiry is documented. Recoverable and non-expiring is the worst combination to ship in a client bundle.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Why does my local counter disagree with the server?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Almost always the GSM-7 alphabet. It is not ASCII: it includes characters such as \u00e9 and \u00a3, and it charges two units for characters such as <code>{<\/code>, <code>}<\/code>, <code>[<\/code>, <code>]<\/code>, <code>\\<\/code>, <code>~<\/code>, <code>^<\/code> and <code>|<\/code>. A counter built on an ASCII range check will be wrong at both edges.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Which single character costs the most money?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The curly apostrophe, and its relatives: curly quotes, the en and em dash, the ellipsis character, and the rupee sign. Any one of them flips a body from GSM-7 to Unicode, cutting the per-part limit from 160 to 70. A 159-character message plus one curly apostrophe becomes a three-part message.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Should I preview before every send?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">No. Preview when the template changes and in CI. Adding a network call in front of every send doubles the latency and creates a second ambiguous-outcome path on the part of your system that can least afford one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How long should I cache a preview result?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Five to fifteen minutes, keyed on the account, a hash of the body, the sender ID and the entity ID. Do not cache longer, because <code>amount<\/code> reflects your account rate. Do not cache failures at all.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What if the preview call fails while a user is composing?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Degrade to the local counter, mark the estimate as unverified, and let the send proceed. A preview outage is not a reason to block a legitimate campaign. Log the failure and alert on the rate, particularly on <code>401<\/code>, which usually means the send credential is broken too.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Can preview tell me the cost for an international destination?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">It takes no recipient parameter, so it cannot reflect a per-destination rate. For India-only rate context see <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/bulk-sms-pricing-in-india-what-actually-drives-cost\/\">Bulk SMS Pricing in India: What Actually Drives Cost<\/a>, and treat international estimation as a separate exercise.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Does the preview number match what I am actually billed?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Treat it as an estimate and reconcile against delivery reports, where <code>cost<\/code> is reported per message. Billing on this platform fires at submission rather than delivery, so the reconciliation you care about is submitted parts against estimated parts. The mechanics of that reconciliation are covered in the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/observability-for-messaging-pipelines\/\">observability article<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\"><strong>Build a composer your users can trust<\/strong><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Sign up for an account, generate an API key from the control panel, and call <code>SMSApi\/preview<\/code> against your own registered templates to see the reverse lookup working with your real entity ID. The full parameter reference is in the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/previewMsg\/\">Preview Message API documentation<\/a>, and the <a href=\"https:\/\/www.smsgatewaycenter.com\/contact\/\">contact team<\/a> can help with DLT entity registration if you have not completed it yet.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n","protected":false},"excerpt":{"rendered":"<p>The Preview Message API returns encoding, length, parts, estimated cost per part and, when you supply a DLT entity ID, the registered template your body matches. It does not multiply by parts, it does not multiply by recipients, and it does not know what your variables will expand to. This is how to build a preview and cost estimator that is right instead of reassuring.<\/p>\n","protected":false},"author":118,"featured_media":2942,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[268],"tags":[2215,2211,2216,2210,2212,2213,40,2214,2217,262],"class_list":["post-2938","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-tutorials","tag-dlt-template-matching","tag-gsm-7","tag-message-preview-ui","tag-sms-character-counter","tag-sms-cost-estimation","tag-sms-preview-api","tag-sms-pricing-calculator","tag-sms-segments","tag-smsgatewaycenter-api","tag-unicode-sms"],"_links":{"self":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/2938","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=2938"}],"version-history":[{"count":0,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/2938\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media\/2942"}],"wp:attachment":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media?parent=2938"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/categories?post=2938"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/tags?post=2938"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}