{"id":3067,"date":"2026-10-07T14:00:38","date_gmt":"2026-10-07T08:30:38","guid":{"rendered":"https:\/\/www.smsgatewaycenter.com\/blog\/?p=3067"},"modified":"2026-10-09T12:18:25","modified_gmt":"2026-10-09T06:48:25","slug":"two-way-sms-keyword-routing-auto-reply-design","status":"publish","type":"post","link":"https:\/\/www.smsgatewaycenter.com\/blog\/two-way-sms-keyword-routing-auto-reply-design\/","title":{"rendered":"Two-Way SMS Keyword Design: Routing, Auto-Replies and Conversation State on a Long Code"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-admin\/edit.php?post_type=post\"><\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A customer texts &#8220;ACME BAL&#8221; to your long code. What happens next is your code&#8217;s job: matching the keyword, remembering where the conversation was, sending a reply that passes DLT, and not answering the same message twice. This guide shows how to build that router on <strong>SMSGatewayCenter<\/strong>&#8216;s <a href=\"https:\/\/www.smsgatewaycenter.com\/long-code-sms-services\/\" class=\"text-blue\">long code push<\/a>, with working samples in Python, Node.js, Java and PHP.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/two-way-sms-keyword-routing-auto-reply-design.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"584\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/two-way-sms-keyword-routing-auto-reply-design-1024x584.webp\" alt=\"Abstract illustration of incoming messages flowing into one teal routing node and branching into three paths, with one orange path curving back as a reply\" class=\"wp-image-3068\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/two-way-sms-keyword-routing-auto-reply-design-1024x584.webp 1024w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/two-way-sms-keyword-routing-auto-reply-design-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/two-way-sms-keyword-routing-auto-reply-design-768x438.webp 768w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/two-way-sms-keyword-routing-auto-reply-design.webp 1200w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><figcaption class=\"wp-element-caption\">Every inbound text goes through one router. What it decides determines which reply goes back.<\/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=\"#inbound-path\">What Happens When Someone Texts Your Long Code<\/a><\/li>\n\n\n\n<li><a href=\"#shared-vs-dedicated\">Shared or Dedicated: Who Owns the First Word<\/a><\/li>\n\n\n\n<li><a href=\"#callback-template\">The Callback Template Is Yours to Shape<\/a><\/li>\n\n\n\n<li><a href=\"#keyword-namespace\">Designing the Keyword Namespace<\/a><\/li>\n\n\n\n<li><a href=\"#normalising-input\">Normalising What People Actually Type<\/a><\/li>\n\n\n\n<li><a href=\"#keyword-router\">The Router: From Raw Text to an Intent<\/a><\/li>\n\n\n\n<li><a href=\"#conversation-state\">Conversation State That Survives Real Traffic<\/a><\/li>\n\n\n\n<li><a href=\"#auto-replies-dlt\">Auto-Replies Are Outbound SMS, With All the Rules<\/a><\/li>\n\n\n\n<li><a href=\"#one-reply-owner\">Portal Reply or Your Own Reply: Pick One Owner<\/a><\/li>\n\n\n\n<li><a href=\"#dedupe-inbound\">Deduplicating a Push With No Message ID<\/a><\/li>\n\n\n\n<li><a href=\"#reply-loops\">Stopping Reply Loops and Reply Storms<\/a><\/li>\n\n\n\n<li><a href=\"#reply-priority\">Keeping Replies Fast While a Campaign Is Sending<\/a><\/li>\n\n\n\n<li><a href=\"#securing-callback\">Locking Down the Callback URL<\/a><\/li>\n\n\n\n<li><a href=\"#code-samples\">Four Code Samples, Four Traps<\/a><\/li>\n\n\n\n<li><a href=\"#testing-keywords\">Testing a Keyword Flow Before Launch<\/a><\/li>\n\n\n\n<li><a href=\"#decision-matrix\">Decision Matrix<\/a><\/li>\n\n\n\n<li><a href=\"#launch-checklist\">Launch Checklist<\/a><\/li>\n\n\n\n<li><a href=\"#unspecified-behaviour\">Unspecified Behaviour and How to Code Around It<\/a><\/li>\n\n\n\n<li><a href=\"#faqs\">FAQs<\/a><\/li>\n<\/ol>\n\n\n\n<h2 id=\"the-short-answer\" class=\"wp-block-heading\">The Short Answer<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">On <strong>SMSGatewayCenter<\/strong>, the platform does one job for you when someone texts your long code: it matches the approved keyword and forwards the message to your URL as an HTTP GET. Everything after that is yours. Your code decides what the text means, remembers where that person is in a conversation, picks a reply, and sends it back through <code>SMSApi\/send<\/code> like any other outbound SMS, which means a registered sender ID and a DLT-approved template.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So build it as four small pieces. An endpoint that saves the raw message, dedupes it and answers 200 straight away. A router that normalises the text and maps it to an intent. A state table, one row per phone number and long code, with a version column so two quick texts can&#8217;t trample each other. And a reply sender with a budget per number, so a misbehaving auto-responder on the other end can&#8217;t drag you into a loop.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two decisions matter more than the code. First, decide who owns the reply: the portal&#8217;s Default Response or your webhook, never both, or people get two answers. Second, design your keywords as a namespace: one approved primary keyword that the platform routes on, and your own sub-commands after it (<code>ACME BAL<\/code>, <code>ACME HELP<\/code>), which you can change any day without waiting for approval.<\/p>\n\n\n\n<h2 id=\"tldr\" class=\"wp-block-heading\">TL;DR<\/h2>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Question<\/th><th>Answer<\/th><\/tr><\/thead><tbody><tr><td>What does the platform do with an inbound text?<\/td><td>Matches the approved keyword on the long code and forwards the message to your Callback URL (HTTP GET). It can also send one fixed Default Response and copy the message to an SMS number, an email address or Telegram.<\/td><\/tr><tr><td>What&#8217;s in the forwarded request?<\/td><td>Whatever your callback template says. The placeholders are <code>$vmn<\/code>, <code>$location<\/code>, <code>$mobile<\/code>, <code>$message<\/code>, <code>$operator<\/code>, <code>$timestamp<\/code> and <code>$keyword<\/code>. The parameter names are yours to choose.<\/td><\/tr><tr><td>Is there a message ID?<\/td><td>No. Build your own dedupe key from the long code, the sender&#8217;s number, <code>$timestamp<\/code> if you include it, and the normalised text.<\/td><\/tr><tr><td>Shared or dedicated long code?<\/td><td>On a shared code, the primary keyword is what separates your traffic from everyone else&#8217;s. On a dedicated code, every text to the number is yours and you get unlimited keywords.<\/td><\/tr><tr><td>How should you name keywords?<\/td><td>One short approved primary keyword, then your own sub-commands after it. Keep the sub-commands in your router so you can change them without re-approval.<\/td><\/tr><tr><td>Can an auto-reply say anything?<\/td><td>No. It&#8217;s an outbound SMS, so in India it needs an approved sender ID and a DLT template. Build a reply catalogue that maps each intent to a <code>dltTemplateId<\/code>.<\/td><\/tr><tr><td>Portal Default Response and a webhook reply together?<\/td><td>Pick one. With both switched on, the customer gets two replies.<\/td><\/tr><tr><td>How do you stop reply loops?<\/td><td>Cap replies per number per hour, never auto-reply to your own long code or sender, and stop replying after a run of unrecognised messages.<\/td><\/tr><tr><td>What about STOP?<\/td><td>Handle it in your router before anything else, and write it to your suppression table. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/opt-out-suppression-sms-whatsapp-rcs-telegram\/\">opt-out and suppression guide<\/a> covers that in depth.<\/td><\/tr><tr><td>How do you test without spamming real people?<\/td><td>Replay captured callback URLs against a staging endpoint and use a <a href=\"https:\/\/www.smsgatewaycenter.com\/demo\/\">sandbox account<\/a> for the reply leg.<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"inbound-path\" class=\"wp-block-heading\">What Happens When Someone Texts Your Long Code<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">It helps to picture the whole trip before writing any code, because each hop has a different owner and fails in a different way.<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/diagram-keyword-inbound-path.svg\"><img decoding=\"async\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/diagram-keyword-inbound-path.svg\" alt=\"Three-panel flow from the operator and platform, to your endpoint, to your router and reply, with notes on reply ownership, deduplication and DLT\" class=\"wp-image-3070\"\/><\/a><figcaption class=\"wp-element-caption\">Three hops from a customer&#8217;s text to your reply. The platform owns the first; you own the other two.<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Hop one: operator to platform.<\/strong> The customer sends a text to your 10-digit long code (also called a VMN, a virtual mobile number). The platform receives it and checks it against the keywords you&#8217;ve set up on that number. Keywords are created in the portal under Incoming SMS, 2 Way SMS, Add 2 Way SMS Keyword, and each one goes to the SMSGatewayCenter team for moderation before it&#8217;s live. You also need Incoming SMS credits on the account for the keyword to be approved.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Hop two: platform to you.<\/strong> For a matched keyword, the platform can do up to five things, each switched on per keyword in that same form:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Send a fixed <strong>Default Response<\/strong> back to the customer from the sender ID you picked.<\/li>\n\n\n\n<li>Forward the message to your <strong>Callback URL (HTTP)<\/strong>, which is the webhook this guide is about.<\/li>\n\n\n\n<li>Forward it as an SMS to a <strong>Callback SMS<\/strong> number.<\/li>\n\n\n\n<li>Forward it to a <strong>Callback E-Mail<\/strong> address.<\/li>\n\n\n\n<li>Forward it to Telegram, using a token you get from the keyword info bot.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Hop three: you to the customer.<\/strong> If you want anything smarter than a fixed acknowledgement, your code reads the forwarded message, works out what the person wants, and sends a reply with <code>SMSApi\/send<\/code>. That reply is a normal outbound SMS. It&#8217;s billed, it&#8217;s DLT-checked, and it can fail like any other send.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The thing to notice is that hops two and three don&#8217;t know about each other. The platform doesn&#8217;t wait for your reply, and it doesn&#8217;t know whether you sent one. That&#8217;s why you need to decide who owns the reply (more on that in <a href=\"#one-reply-owner\">Portal Reply or Your Own Reply<\/a>).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you&#8217;ve already read the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/receiving-messages-four-channel-inbox-contracts\/\">four-channel inbox comparison<\/a>, you&#8217;ll know SMS is the only channel where inbound arrives as a push. WhatsApp, RCS and Telegram replies sit in inbox endpoints until you poll them. Everything in this guide is about that SMS push.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"shared-vs-dedicated\" class=\"wp-block-heading\">Shared or Dedicated: Who Owns the First Word<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">SMSGatewayCenter offers both <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/whats-the-distinction-between-dedicated-and-shared-long-codes\/\">shared and dedicated long codes<\/a>, and the difference changes how your router has to think.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">On a <strong>shared long code<\/strong>, many businesses use the same number. Replies are routed by keyword, so the first word of the text is what decides whether the message is yours at all. Your primary keyword is a scarce, shared resource: it has to be approved, and it has to be distinct from everyone else&#8217;s on that number.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">On a <strong>dedicated long code<\/strong>, the number belongs to you, so replies to it come only to you. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/what-are-the-key-features-included-in-each-dedicated-long-code-plan\/\">dedicated long code plans<\/a> include unlimited keywords, auto response SMS and real-time HTTP forwarding.<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th><\/th><th>Shared long code<\/th><th>Dedicated long code<\/th><\/tr><\/thead><tbody><tr><td>Who receives texts to the number<\/td><td>Whoever&#8217;s keyword matches<\/td><td>Only you<\/td><\/tr><tr><td>What the first word does<\/td><td>Decides whose message it is<\/td><td>Picks a keyword you configured<\/td><\/tr><tr><td>Keyword count<\/td><td>What you&#8217;ve had approved<\/td><td>Unlimited<\/td><\/tr><tr><td>Where your sub-commands should live<\/td><td>In your router, after your primary keyword<\/td><td>In your router, or as separate keywords<\/td><\/tr><tr><td>Cost of a typo in the first word<\/td><td>The message may not reach you at all<\/td><td>The message is still yours; handle it in code<\/td><\/tr><tr><td>Good for<\/td><td>Low volume, campaigns with a printed keyword<\/td><td>Support lines, multi-step flows, anything conversational<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Two practical consequences.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">On a shared code, you can&#8217;t fix a mistyped primary keyword in code, because the message never reaches your code. So pick a primary keyword that&#8217;s hard to mistype: short, no ambiguous letters, no punctuation. Print it in your campaign copy exactly as it was approved, in capitals, because people copy what they see.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">On a dedicated code, you&#8217;ll be tempted to register a separate platform keyword for every command. Don&#8217;t, at least not for anything that changes often. Every platform keyword goes through moderation. A sub-command in your own router changes when you deploy.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One more thing worth knowing before you choose: the <a href=\"https:\/\/www.smsgatewaycenter.com\/long-code-sms-services\/\">long code product page<\/a> describes long codes as lower throughput than short codes and better suited to conversational traffic than large promotional pushes. That matters later when we get to <a href=\"#reply-priority\">keeping replies fast<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"callback-template\" class=\"wp-block-heading\">The Callback Template Is Yours to Shape<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Most inbound webhooks hand you a fixed payload. This one doesn&#8217;t. When you switch on Callback URL (HTTP) for a keyword, the platform appends parameters to your URL using a template, and the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/create-new-keyword-two-way-sms-longcode\/\">keyword setup guide<\/a> shows the default:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>phonecode=$vmn&amp;location=$location&amp;phoneno=$mobile&amp;content=$message&amp;carrier=$operator&amp;time=$timestamp&amp;keyword=$keyword&amp;forwardMethod=GET<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>$<\/code> values are fixed placeholders. The names on the left are yours. If your framework prefers <code>from<\/code> and <code>body<\/code>, you can rename them, as long as the placeholder values stay the same.<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Placeholder<\/th><th>What it holds<\/th><th>Default parameter<\/th><\/tr><\/thead><tbody><tr><td><code>$vmn<\/code><\/td><td>The long code the message arrived at<\/td><td><code>phonecode<\/code><\/td><\/tr><tr><td><code>$mobile<\/code><\/td><td>The customer&#8217;s mobile number<\/td><td><code>phoneno<\/code><\/td><\/tr><tr><td><code>$message<\/code><\/td><td>The message content, including the keyword<\/td><td><code>content<\/code><\/td><\/tr><tr><td><code>$keyword<\/code><\/td><td>The keyword the conversation started with<\/td><td><code>keyword<\/code><\/td><\/tr><tr><td><code>$timestamp<\/code><\/td><td>Time in epoch milliseconds<\/td><td><code>time<\/code><\/td><\/tr><tr><td><code>$location<\/code><\/td><td>Telecom circle, worked out from the number series<\/td><td><code>location<\/code><\/td><\/tr><tr><td><code>$operator<\/code><\/td><td>Operator name, worked out from the number series<\/td><td><code>carrier<\/code><\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Three choices here save you trouble later.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Keep <code>time=$timestamp<\/code> in the template.<\/strong> The older <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/php-script-retrieve-shortcode-incoming-sms-data\/\">PHP handler example<\/a> reads only <code>phonecode<\/code>, <code>keyword<\/code>, <code>phoneno<\/code>, <code>content<\/code>, <code>location<\/code> and <code>carrier<\/code>, so plenty of live integrations never asked for the time. Without it, you have nothing but your own clock to dedupe on. With it, you get a zone-free epoch value from the platform side. Store it as <code>platform_time<\/code>, and still stamp your own <code>received_at<\/code>. They answer different questions.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Keep the names boring and stable.<\/strong> You&#8217;ll have this template configured on several keywords, maybe on several long codes. If one keyword sends <code>phoneno<\/code> and another sends <code>from<\/code>, your handler grows special cases. Pick one set of names and use it everywhere. The defaults are fine.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Treat <code>location<\/code> and <code>carrier<\/code> as hints.<\/strong> They&#8217;re worked out from the number series, and Indian numbers can be ported between operators. Use them for reporting, not for routing or anything that costs money.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"keyword-namespace\" class=\"wp-block-heading\">Designing the Keyword Namespace<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Think of your keywords as a tiny command language that customers type with their thumbs. Good command languages have a clear structure, and people&#8217;s thumbs are bad at everything except short words.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The structure that works best on SMSGatewayCenter has two layers.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Layer one: the primary keyword.<\/strong> This is the word the platform matches. It&#8217;s approved by the team, it&#8217;s attached to a long code, and it&#8217;s what triggers the forward to your URL. Keep it to one short word tied to your brand or campaign: <code>ACME<\/code>, not <code>ACMEOFFERS2026<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Layer two: sub-commands.<\/strong> These are the words after the primary keyword, and only your router knows about them. <code>ACME BAL<\/code>, <code>ACME HELP<\/code>, <code>ACME YES<\/code>, <code>ACME 2<\/code>. You can add, rename or retire them with a deploy, without moderation, and you can give each one aliases.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A small command table might look like this:<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Sub-command<\/th><th>Aliases<\/th><th>Intent<\/th><th>Needs state?<\/th><\/tr><\/thead><tbody><tr><td><code>BAL<\/code><\/td><td><code>BALANCE<\/code>, <code>BALNCE<\/code><\/td><td>Look up account balance<\/td><td>No<\/td><\/tr><tr><td><code>HELP<\/code><\/td><td><code>INFO<\/code>, <code>?<\/code><\/td><td>Send the help menu<\/td><td>No<\/td><\/tr><tr><td><code>YES<\/code><\/td><td><code>Y<\/code>, <code>OK<\/code>, <code>CONFIRM<\/code>, <code>1<\/code><\/td><td>Confirm the pending action<\/td><td>Yes<\/td><\/tr><tr><td><code>NO<\/code><\/td><td><code>N<\/code>, <code>CANCEL<\/code>, <code>2<\/code><\/td><td>Reject the pending action<\/td><td>Yes<\/td><\/tr><tr><td><code>STOP<\/code><\/td><td>see the opt-out guide<\/td><td>Opt out<\/td><td>No, and it always wins<\/td><\/tr><tr><td>(anything else)<\/td><td><\/td><td>Fallback<\/td><td>Maybe<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">A few rules keep this table from turning into a mess.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Reserve the compliance words first.<\/strong> Before you add a single command, decide that <code>STOP<\/code> and its common variants always mean opt-out, in every state, and that <code>HELP<\/code> always returns the menu. Nothing else may use those words. A survey that asks &#8220;reply STOP if you don&#8217;t want to continue the survey&#8221; is a compliance incident waiting to happen. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/opt-out-suppression-sms-whatsapp-rcs-telegram\/\">opt-out guide<\/a> lists the variants worth catching and how to record them.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Don&#8217;t let one word mean two things.<\/strong> <code>CANCEL<\/code> as &#8220;cancel my appointment&#8221; and <code>CANCEL<\/code> as &#8220;stop messaging me&#8221; can&#8217;t both exist. Decide which one it is, and if it&#8217;s the appointment, make sure your opt-out list doesn&#8217;t include it. Write the decision down in the command table.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Prefer words over numbers for anything that matters.<\/strong> Numbered menus (<code>reply 1 for X, 2 for Y<\/code>) are fine inside a short-lived conversation, because the state tells you what <code>1<\/code> means. As top-level commands they&#8217;re fragile: <code>1<\/code> means nothing a week later.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Plan for a missing sub-command.<\/strong> Lots of people will text just the primary keyword. Decide what that means. Usually it&#8217;s &#8220;send the menu&#8221;, which is also a safe default.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Version the table.<\/strong> Store it in code or config, with a version number, and log the version on every routed message. When someone asks why a customer got the wrong reply last Tuesday, you&#8217;ll want to know which table was live.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"normalising-input\" class=\"wp-block-heading\">Normalising What People Actually Type<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s what arrives when you ask people to reply <code>ACME BAL<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>ACME BAL\nacme bal\nAcme  bal\nACME BAL.\nACME-BAL\nACMEBAL\nacme balance pls\nACME BAL \nAcme Bal?<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">All of those mean the same thing. Your router should treat them the same way, and it should do that in one function that every message goes through, so you never compare raw text anywhere else.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A normaliser that works well in practice does these steps, in this order:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Decode once.<\/strong> The forward is a GET, so the text arrives URL-encoded. Spaces may come through as <code>+<\/code> (the PHP handler example&#8217;s test URL uses <code>please+call+back<\/code>). Let your framework decode the query string, and don&#8217;t decode it a second time yourself. Double decoding turns a literal <code>+<\/code> or <code>%<\/code> in the message into garbage.<\/li>\n\n\n\n<li><strong>Unicode-normalise.<\/strong> Apply NFKC. It folds full-width letters and digits, and other compatibility forms, into plain ASCII, which matters more than you&#8217;d think for numbered replies.<\/li>\n\n\n\n<li><strong>Trim and collapse whitespace.<\/strong> Leading, trailing and repeated spaces, tabs and newlines all become single spaces.<\/li>\n\n\n\n<li><strong>Case-fold.<\/strong> Lowercase everything for matching. Keep the original text for storage and for agents.<\/li>\n\n\n\n<li><strong>Strip trailing punctuation.<\/strong> <code>.<\/code>, <code>!<\/code>, <code>?<\/code> and <code>,<\/code> at the end of a word don&#8217;t change the meaning of a command.<\/li>\n\n\n\n<li><strong>Split the primary keyword off.<\/strong> The content includes the keyword (the setup guide describes <code>$message<\/code> as the message content along with the keyword), so <code>acme bal<\/code> should become primary <code>acme<\/code> and rest <code>bal<\/code>. Also handle <code>acme-bal<\/code> and <code>acmebal<\/code> by checking whether the text starts with the primary keyword followed by a separator or by nothing at all.<\/li>\n\n\n\n<li><strong>Take the first token of the rest as the command candidate,<\/strong> and keep the remaining tokens as arguments.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">Then match the command candidate against your table: exact match on the command, then exact match on aliases. That&#8217;s it. Resist fuzzy matching on the top-level command. <code>BALNCE<\/code> as an explicit alias is fine. A Levenshtein distance of 2 that turns <code>NO<\/code> into <code>ON<\/code> into <code>OK<\/code> is not.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Store three things for every message: the raw text exactly as received, the normalised text, and the intent you resolved. When the router gets something wrong, the raw text is what lets you work out why.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"keyword-router\" class=\"wp-block-heading\">The Router: From Raw Text to an Intent<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The router is a pure function. Given the normalised message and the person&#8217;s current conversation state, it returns an intent and the next state. It doesn&#8217;t send anything, it doesn&#8217;t write to the database, and it doesn&#8217;t call the network. That makes it the easiest part of the whole system to test, and the part you&#8217;ll change most often.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The order of checks is what makes it correct:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Compliance words first, in every state.<\/strong> If the command is an opt-out word, the intent is <code>opt_out<\/code>, full stop. It doesn&#8217;t matter that the person is halfway through a survey. Then <code>HELP<\/code>.<\/li>\n\n\n\n<li><strong>Then state-dependent answers.<\/strong> If the person has an open question (say, &#8220;confirm your appointment for Friday? Reply YES or NO&#8221;) and the command is a valid answer to it, resolve it against that state.<\/li>\n\n\n\n<li><strong>Then stateless commands.<\/strong> <code>BAL<\/code>, <code>MENU<\/code>, and so on.<\/li>\n\n\n\n<li><strong>Then the bare primary keyword.<\/strong> Send the menu.<\/li>\n\n\n\n<li><strong>Then the fallback.<\/strong> Anything else is unrecognised.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">The fallback deserves more thought than it usually gets. An unrecognised message is often a real customer asking a real question in their own words: &#8220;my balance is wrong&#8221;, &#8220;who is this&#8221;, &#8220;call me&#8221;. Answering every one of those with &#8220;Sorry, I didn&#8217;t understand. Reply HELP for options&#8221; is how you make people angry. A better fallback does three things: it saves the message to a queue a human can see, it sends one short acknowledgement the first time (not every time), and it counts. After two unrecognised messages in a row, stop auto-replying and hand the conversation to a person. That same counter is one of your <a href=\"#reply-loops\">reply loop<\/a> defences.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you also run WhatsApp, the platform&#8217;s <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/whatsapp-workflow-builder\/\">Workflow Builder<\/a> works the same way in principle: a <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/how-do-i-route-support-messages-by-keyword\/\">Keyword Match condition<\/a> sends matching messages down one path and everything else down a default path, and the workflow config has <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/what-are-reset-cancel-and-clean-localcache-keywords\/\">Reset, Cancel and Clean localcache keywords<\/a> that restart the journey, exit it, or clear the user&#8217;s local variables. Those are good ideas to copy into your SMS router. A <code>RESET<\/code> or <code>MENU<\/code> command that always drops the person back to the start, from any state, will save you a lot of support tickets.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"conversation-state\" class=\"wp-block-heading\">Conversation State That Survives Real Traffic<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Single-command flows like <code>ACME BAL<\/code> don&#8217;t need state. Anything with a question and an answer does. &#8220;Reply YES to confirm&#8221; only works if you remember what YES is confirming.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Keep one row per conversation, keyed on the long code and the customer&#8217;s number. Not the number alone: the same person can be in a conversation with your support code and your appointments code at the same time.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>CREATE TABLE sms_conversation (\n    long_code        TEXT        NOT NULL,   -- $vmn\n    msisdn           TEXT        NOT NULL,   -- $mobile, stored as text\n    state            TEXT        NOT NULL,   -- e.g. 'idle', 'awaiting_confirm'\n    state_data       JSONB       NOT NULL DEFAULT '{}',  -- e.g. {\"appointment_id\": \"A-1042\"}\n    expires_at       TIMESTAMPTZ NULL,       -- when the open question lapses\n    unknown_streak   INT         NOT NULL DEFAULT 0,\n    version          BIGINT      NOT NULL DEFAULT 0,\n    updated_at       TIMESTAMPTZ NOT NULL,\n    PRIMARY KEY (long_code, msisdn)\n);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Three things in there are doing real work.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>expires_at<\/code>.<\/strong> Every open question needs an expiry. If you ask &#8220;confirm Friday&#8217;s appointment?&#8221; and the person replies YES three days later, after the appointment was moved, a stateless YES must not confirm the wrong thing. When the router sees an expired state, it treats the reply as if the state were <code>idle<\/code>, and usually sends the menu. Thirty minutes is a reasonable window for a quick confirmation. A day is reasonable for a reminder sent the evening before. Pick per question, not globally.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>version<\/code>.<\/strong> People send two texts in quick succession all the time, and two webhook calls can be processed at the same moment on two workers. Without protection, both read <code>awaiting_confirm<\/code>, both act, and you confirm twice or confirm and reject at once. The fix is optimistic locking: read the row with its version, compute the new state, and write it back with <code>WHERE version = :old_version<\/code>. If zero rows changed, someone else got there first. Reload and run the router again with the fresh state. A per-conversation row lock (<code>SELECT ... FOR UPDATE<\/code>) works too, as long as you keep the transaction short and never hold it across the reply send.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>unknown_streak<\/code>.<\/strong> It counts unrecognised messages in a row and resets on any recognised one. The router reads it to decide when to stop auto-replying.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">What doesn&#8217;t belong in this table: the message history (that goes in your inbound message table, as described in the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/receiving-messages-four-channel-inbox-contracts\/\">inbox contracts guide<\/a>), the opt-out record (that goes in the suppression table), and anything you&#8217;d need for billing.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One more rule. <strong>The state change and the reply are two separate steps, and the state change goes first.<\/strong> Write &#8220;confirmed&#8221; to your database, commit, then queue the &#8220;Thanks, you&#8217;re confirmed for Friday&#8221; reply. If the reply fails, you retry the reply. You never want a world where the customer was told &#8220;confirmed&#8221; and your database says otherwise.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"auto-replies-dlt\" class=\"wp-block-heading\">Auto-Replies Are Outbound SMS, With All the Rules<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">It&#8217;s easy to think of a reply as part of the inbound flow. It isn&#8217;t. As far as the network is concerned, your reply is a fresh outbound SMS, and in India that means it needs an approved sender ID and a DLT-registered template, the same as a campaign message.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That has a big consequence for design: <strong>you can&#8217;t reply with arbitrary text.<\/strong> Every reply your router can send has to match a template you&#8217;ve already registered. So build a reply catalogue before you build the router:<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Intent<\/th><th>Reply text (as registered)<\/th><th>Variables<\/th><th><code>dltTemplateId<\/code><\/th><\/tr><\/thead><tbody><tr><td><code>menu<\/code><\/td><td><code>Reply BAL for balance, HELP for help. -ACME<\/code><\/td><td>none<\/td><td>your ID<\/td><\/tr><tr><td><code>balance<\/code><\/td><td><code>Your balance is {#numeric#} points as of {#alphanumeric#}. -ACME<\/code><\/td><td>points, date<\/td><td>your ID<\/td><\/tr><tr><td><code>confirm_ok<\/code><\/td><td><code>Your appointment on {#alphanumeric#} is confirmed. -ACME<\/code><\/td><td>date label<\/td><td>your ID<\/td><\/tr><tr><td><code>confirm_no<\/code><\/td><td><code>Your appointment on {#alphanumeric#} is cancelled. -ACME<\/code><\/td><td>date label<\/td><td>your ID<\/td><\/tr><tr><td><code>ack_unknown<\/code><\/td><td><code>Thanks, we've got your message. Our team will reply soon. -ACME<\/code><\/td><td>none<\/td><td>your ID<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Since 14 January 2026, variables in DLT templates use typed tags such as <code>{#numeric#}<\/code>, <code>{#alphanumeric#}<\/code>, <code>{#url#}<\/code> and <code>{#cbn#}<\/code>. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/message-template-management-four-channels\/\">template management guide<\/a> covers how they work. For replies, the practical rules are:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Never put customer text into a variable.<\/strong> Echoing &#8220;You said: {#alphanumeric#}&#8221; back with whatever they typed is how you end up sending a URL, a phone number or an insult from your brand&#8217;s sender ID. Variables carry values you computed: balances, dates, reference numbers.<\/li>\n\n\n\n<li><strong>Every intent needs a reply, including the fallback.<\/strong> If <code>ack_unknown<\/code> isn&#8217;t registered, your fallback can&#8217;t say anything.<\/li>\n\n\n\n<li><strong>Use the sender ID the template is registered with.<\/strong> A mismatch fails with <code>SENDERID_MISMATCH<\/code>. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sender-identity-sms-whatsapp-rcs-telegram\/\">sender identity guide<\/a> explains the mapping.<\/li>\n\n\n\n<li><strong>Watch the length.<\/strong> A reply that tips from one SMS part to two doubles its cost. Check your longest realistic variable values with the <a href=\"https:\/\/www.smsgatewaycenter.com\/sms-length-calculator\/\">SMS length calculator<\/a>.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The send itself is an ordinary <code>SMSApi\/send<\/code> call with <code>sendMethod=quick<\/code>, one <code>mobile<\/code>, the rendered <code>msg<\/code>, the <code>senderid<\/code>, <code>msgType<\/code> (<code>text<\/code> for English, <code>unicode<\/code> for regional scripts) and the <code>dltTemplateId<\/code>. Send the <code>apikey<\/code> header alongside <code>userid<\/code>. Save the <code>transactionId<\/code> from the response as text on the reply row, because it&#8217;s an eighteen or nineteen digit number that loses precision if you parse it as a double.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One flag to set deliberately: <code>duplicatecheck<\/code>. The send page describes it as removing duplicate mobile numbers, default <code>true<\/code>. For a single-recipient reply it doesn&#8217;t hurt, but it isn&#8217;t idempotency. If your worker retries a reply after a timeout, the platform will happily send it twice. Your own reply table, keyed on the inbound message that caused it, is what stops a double send. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/message-idempotency-preventing-duplicate-sends\/\">idempotency guide<\/a> covers the pattern.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"one-reply-owner\" class=\"wp-block-heading\">Portal Reply or Your Own Reply: Pick One Owner<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The keyword form has an <strong>Add Default Response<\/strong> switch. Turn it on, type a message, and the platform answers every matched text with it. That&#8217;s genuinely useful when it&#8217;s the whole flow: a contest entry, a &#8220;thanks, we&#8217;ll call you back&#8221; for a lead form, a feedback line.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The trouble starts when you also add a Callback URL and your webhook sends its own reply. Now every message gets two answers, the fixed one from the platform and the smart one from you, and they&#8217;ll often contradict each other. &#8220;Thanks, our team will get back to you&#8221; followed two seconds later by &#8220;Your balance is 1,240 points&#8221; looks broken, because it is.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So pick one owner per keyword:<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Flow<\/th><th>Default Response<\/th><th>Callback URL<\/th><th>Who replies<\/th><\/tr><\/thead><tbody><tr><td>Fixed acknowledgement, no logic<\/td><td>On<\/td><td>Optional, for logging<\/td><td>Platform<\/td><\/tr><tr><td>Anything with commands, state or lookups<\/td><td>Off<\/td><td>On<\/td><td>Your code<\/td><\/tr><tr><td>Human-handled support line<\/td><td>Off, or a one-line acknowledgement<\/td><td>On, or Callback E-Mail<\/td><td>Platform for the ack, people for the rest<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">If you&#8217;re migrating an existing keyword from a fixed Default Response to your own router, switch them in a deliberate order: deploy the router with replies disabled, confirm the forwards are arriving and routing correctly for a day, turn the Default Response off, then enable your replies. If you do it the other way round, there&#8217;s a window where people get nothing at all.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">WhatsApp works differently, and it&#8217;s worth knowing if you run both. There, the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/whatsapp-default-response-configuration-introduction\/\">Default Response Configuration<\/a> is mandatory per WABA number. When a message comes in, the platform checks your Conversation Response keywords first and sends the default only if nothing matched. So on WhatsApp the platform does keyword-then-default for you, and on SMS you choose between a fixed reply and your own logic.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"dedupe-inbound\" class=\"wp-block-heading\">Deduplicating a Push With No Message ID<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The SMS forward carries no message identifier. If the same message reaches your endpoint twice, nothing in the request says so. You have to decide for yourself whether two requests are the same message.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Nobody can promise you each message arrives exactly once, so design as if duplicates will happen, because the cost of deduping is one unique index, and the cost of not deduping is answering one question twice, or worse, confirming one thing twice.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Build the key from what the request does carry:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>dedupe_key = sha256( phonecode | phoneno | time | normalised_content )<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">With <code>time=$timestamp<\/code> in your template, that&#8217;s a strong key. Two identical requests produce the same key. Two genuinely different messages, even with the same text a minute apart, have different timestamps and different keys. For example, a message from <code>919812345678<\/code> to <code>919223344556<\/code> at 12:40:00 IST on 7 October 2026 carries <code>time=1791357000000<\/code>, and with the normalised text <code>acme bal<\/code> the first 24 hex characters of the key are <code>b1670f034647a802e604b25a<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Without the timestamp, you&#8217;re stuck with your own clock, which means bucketing:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>dedupe_key = sha256( phonecode | phoneno | floor(received_at_ms \/ 60000) | normalised_content )<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That&#8217;s weaker in both directions. A retry that lands in the next minute (a message at 12:40:59 and a retry at 12:41:01 fall in buckets 29855950 and 29855951) slips through as a new message. And a person who really does send <code>ACME BAL<\/code> twice inside the same minute gets one reply instead of two, which for a balance check is fine and for a vote may not be. This is the best argument for adding <code>$timestamp<\/code> to the template today.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Put a unique index on <code>dedupe_key<\/code> in your inbound table. Insert first. If the insert hits the unique constraint, answer 200 and do nothing else. That one rule makes every downstream step safe to run once.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"reply-loops\" class=\"wp-block-heading\">Stopping Reply Loops and Reply Storms<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The worst two-way SMS incident isn&#8217;t a missed reply. It&#8217;s a loop. Your auto-reply goes to a number that also auto-replies (another company&#8217;s long code, an out-of-office responder, a misconfigured test rig), its reply comes back to your long code, you answer that, and the two systems talk to each other until somebody notices the bill.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The defences are cheap, and you want all of them:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Never reply to yourself.<\/strong> Keep a list of your own long codes and sender numbers, and drop any inbound message whose <code>phoneno<\/code> is on it. It sounds silly until a test harness forwards to the wrong place.<\/li>\n\n\n\n<li><strong>Cap replies per number.<\/strong> For example, no more than five auto-replies to one number per hour and twenty per day. When the cap is hit, stop replying, keep saving the messages, and raise an alert. The exact numbers depend on your flows. The cap existing is what matters.<\/li>\n\n\n\n<li><strong>Stop after unrecognised streaks.<\/strong> The <code>unknown_streak<\/code> counter from the state table: after two unrecognised messages in a row, the router stops auto-replying and hands off to a person. An auto-responder on the other end never sends a valid command, so this alone breaks most loops.<\/li>\n\n\n\n<li><strong>Don&#8217;t reply to empty or near-empty messages.<\/strong> A blank body, or just the primary keyword sent ten times, gets the menu once per expiry window, not ten times.<\/li>\n\n\n\n<li><strong>Respect suppression before replying.<\/strong> If the number is on your opt-out list, the only reply you may send is the opt-out confirmation, and only in response to the opt-out itself. Run the same send gate you use for campaigns.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">The cap also protects you from a different problem: someone deliberately hammering your long code to burn your SMS credits. Every auto-reply costs money. A reply budget per number turns an open-ended cost into a bounded one.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"reply-priority\" class=\"wp-block-heading\">Keeping Replies Fast While a Campaign Is Sending<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s a timing problem that catches teams on launch day. You send a campaign that says &#8220;reply YES to book&#8221;, and the campaign goes out through the same send pipeline your replies use. The first people to read it reply within seconds. Their YES gets routed, and their confirmation is queued behind the remaining campaign messages. They wait minutes for an answer to a question you asked them seconds ago.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The fix is to treat replies as a separate, higher-priority lane in your own outbound queue:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Two queues, or one queue with priority.<\/strong> Replies to inbound messages always go ahead of campaign sends.<\/li>\n\n\n\n<li><strong>Throttle campaigns so replies have room.<\/strong> If your client-side rate limiter allows N sends per second, reserve a slice of that for replies, and let the campaign use the rest. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/rate-limiting-backpressure-messaging-systems\/\">rate limiting and backpressure guide<\/a> shows how to build that limiter.<\/li>\n\n\n\n<li><strong>Measure reply latency on its own.<\/strong> Time from <code>received_at<\/code> to the reply&#8217;s <code>transactionId<\/code> coming back. If that number climbs during campaigns, the lanes aren&#8217;t separated properly. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/observability-for-messaging-pipelines\/\">observability guide<\/a> has the rest of the metrics worth tracking.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Keep the webhook itself fast too. The endpoint should validate, insert, enqueue and return 200. Routing, lookups and sending happen in a worker. A slow balance lookup should never be the reason the platform&#8217;s forward times out.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"securing-callback\" class=\"wp-block-heading\">Locking Down the Callback URL<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The forwarded request has no signature, token or shared secret in it. Anyone who learns your callback URL can send a request that looks exactly like a real inbound SMS, and your router will act on it: confirm appointments, look up balances, trigger replies that cost you money.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">You can&#8217;t add a signature to the platform&#8217;s request, but you can make the URL itself hard to guess and cheap to rotate:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Put a long random secret in the path,<\/strong> not just in a query parameter: <code>https:\/\/&lt;your-host>\/inbound\/sms\/7f3c9a...<\/code>. The platform appends its own parameters to your URL, so a path segment keeps your secret clear of the query string the template builds.<\/li>\n\n\n\n<li><strong>Use a different secret per keyword or per long code.<\/strong> If one leaks, you rotate one.<\/li>\n\n\n\n<li><strong>Rotate by running two.<\/strong> Accept the old and new secret for a short overlap, update the keyword&#8217;s Callback URL in the portal, confirm traffic has moved to the new one, then retire the old one.<\/li>\n\n\n\n<li><strong>Serve HTTPS only.<\/strong> The message content and the customer&#8217;s number are personal data.<\/li>\n\n\n\n<li><strong>Never log the full URL.<\/strong> Your access logs will happily record the secret. Mask the path segment.<\/li>\n\n\n\n<li><strong>Check what you can.<\/strong> <code>phonecode<\/code> should be one of your long codes. <code>keyword<\/code> should be one of your keywords. <code>phoneno<\/code> should look like a mobile number. Reject anything else with a 200 and no action, and count it.<\/li>\n\n\n\n<li><strong>Make actions that matter need more than one text.<\/strong> A YES that confirms a booking is low risk. A text that changes a delivery address shouldn&#8217;t be accepted on SMS alone at all.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">If your security team wants more, add a network-level allowlist at your load balancer once you&#8217;ve watched the forward&#8217;s source addresses over a few weeks of real traffic. Treat it as an extra layer, never the only one. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/ip-based-api-security-sms-gateway-unauthorized-access-protection\/\">IP-based API security post<\/a> covers the outbound side of the same idea.<\/p>\n\n\n\n<h2 id=\"code-samples\" class=\"wp-block-heading\">Four Code Samples, Four Traps<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Each sample is short on purpose and shows one trap. Together they make up the core of a working keyword service. They assume the default callback template with <code>time=$timestamp<\/code> included.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Python: the endpoint (trap: doing work before you&#8217;ve deduped)<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The endpoint does as little as possible: check the secret, decode once, build the dedupe key, insert, enqueue, return 200. The trap is doing routing or replying here, before the insert. Then a duplicate request does all the work twice.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import hashlib, hmac, os, time, unicodedata\nfrom flask import Flask, request, abort\n\napp = Flask(__name__)\nSECRETS = set(os.environ&#91;\"SMS_HOOK_SECRETS\"].split(\",\"))   # old and new during rotation\nOUR_CODES = set(os.environ&#91;\"OUR_LONG_CODES\"].split(\",\"))\n\ndef norm(text: str) -&gt; str:\n    t = unicodedata.normalize(\"NFKC\", text or \"\")\n    return \" \".join(t.split()).lower().rstrip(\".!?,\")\n\n@app.get(\"\/inbound\/sms\/&lt;secret&gt;\")\ndef inbound(secret):\n    if not any(hmac.compare_digest(secret, s) for s in SECRETS):\n        abort(404)\n    q = request.args                      # Flask has already decoded the query once\n    code, msisdn = q.get(\"phonecode\", \"\"), q.get(\"phoneno\", \"\")\n    content, keyword = q.get(\"content\", \"\"), q.get(\"keyword\")\n    platform_time = q.get(\"time\")         # epoch ms if your template includes it\n    if code not in OUR_CODES or not msisdn or msisdn in OUR_CODES:\n        return \"\", 200                    # drop quietly, but count it in metrics\n    received_ms = int(time.time() * 1000)\n    when = platform_time if platform_time and platform_time.isdigit() else str(received_ms \/\/ 60000)\n    key = hashlib.sha256(\"|\".join(&#91;code, msisdn, when, norm(content)]).encode()).hexdigest()\n    inserted = db_insert_inbound(          # INSERT ... ON CONFLICT (dedupe_key) DO NOTHING\n        dedupe_key=key, long_code=code, msisdn=msisdn, keyword=keyword,\n        raw_content=content, platform_time=platform_time, received_ms=received_ms)\n    if inserted:\n        enqueue_route(key)                 # routing and replies happen in a worker\n    return \"\", 200<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>db_insert_inbound<\/code> and <code>enqueue_route<\/code> are your own persistence and queue calls. The important bits are the order (insert, then enqueue) and that both branches return 200, so the platform has no reason to send the request again.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Node.js: the router (trap: letting state outrank compliance words)<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The router is a pure function: text and state in, intent and new state out. The trap is checking the state first, so a person halfway through a survey types STOP and gets &#8220;Sorry, please reply 1 to 5&#8221;.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>'use strict';\n\nconst OPT_OUT = new Set(&#91;'stop', 'stopall', 'unsubscribe', 'optout', 'end', 'quit']);\nconst COMMANDS = {\n  bal: 'balance', balance: 'balance',\n  help: 'menu', info: 'menu', menu: 'menu', reset: 'menu',\n};\nconst ANSWERS = {\n  awaiting_confirm: {\n    yes: 'confirm_ok', y: 'confirm_ok', ok: 'confirm_ok', '1': 'confirm_ok',\n    no: 'confirm_no', n: 'confirm_no', '2': 'confirm_no',\n  },\n};\n\nfunction splitPrimary(text, primary) {\n  \/\/ \"acme bal\", \"acme-bal\" and \"acmebal\" all become \"bal\"\n  if (!text.startsWith(primary)) return text;\n  return text.slice(primary.length).replace(\/^&#91;\\s\\-_:.]+\/, '');\n}\n\nfunction route(normText, primary, conv, nowMs) {\n  const rest = splitPrimary(normText, primary);\n  const cmd = rest.split(' ')&#91;0] || '';\n  const live = conv.expiresAt &amp;&amp; conv.expiresAt &gt; nowMs ? conv.state : 'idle';\n\n  if (OPT_OUT.has(cmd) || OPT_OUT.has(normText)) return { intent: 'opt_out', state: 'idle' };\n  if (cmd === 'help' || cmd === 'reset') return { intent: 'menu', state: 'idle' };\n\n  const answer = (ANSWERS&#91;live] || {})&#91;cmd];\n  if (answer) return { intent: answer, state: 'idle' };\n\n  if (COMMANDS&#91;cmd]) return { intent: COMMANDS&#91;cmd], state: live };\n  if (cmd === '') return { intent: 'menu', state: live };\n\n  const streak = (conv.unknownStreak || 0) + 1;\n  return { intent: streak &gt; 2 ? 'handoff' : 'ack_unknown', state: live, unknownStreak: streak };\n}\n\nmodule.exports = { route, splitPrimary };<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Notice that <code>reset<\/code> drops the person back to <code>idle<\/code> from any state, the same idea as the WhatsApp workflow&#8217;s Reset keyword. And notice that <code>handoff<\/code> isn&#8217;t a reply: it means &#8220;stop auto-replying and put this in front of a person&#8221;. Any intent other than <code>ack_unknown<\/code> and <code>handoff<\/code> resets the streak to zero, and the worker does that when it saves the state.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Java: the state write (trap: two texts, two workers, one conversation)<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The trap is read-modify-write without a guard. Two YES messages a second apart both see <code>awaiting_confirm<\/code>, and both confirm. The version column makes the second write fail, so the second worker reloads and routes against the new state.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import java.sql.*;\n\npublic final class ConversationStore {\n\n    \/** Returns true if the state was written; false means someone else changed it first. *\/\n    public static boolean saveState(Connection c, String longCode, String msisdn,\n                                    String newState, String stateDataJson,\n                                    Timestamp expiresAt, int unknownStreak,\n                                    long expectedVersion) throws SQLException {\n        String sql = \"UPDATE sms_conversation SET state = ?, state_data = ?::jsonb, \"\n                   + \"expires_at = ?, unknown_streak = ?, version = version + 1, updated_at = now() \"\n                   + \"WHERE long_code = ? AND msisdn = ? AND version = ?\";\n        try (PreparedStatement ps = c.prepareStatement(sql)) {\n            ps.setString(1, newState);\n            ps.setString(2, stateDataJson);\n            ps.setTimestamp(3, expiresAt);\n            ps.setInt(4, unknownStreak);\n            ps.setString(5, longCode);\n            ps.setString(6, msisdn);\n            ps.setLong(7, expectedVersion);\n            return ps.executeUpdate() == 1;\n        }\n    }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The worker loop around it is simple: load the row and its version, call the router, try <code>saveState<\/code>, and if it returns false, load again and repeat (three attempts is plenty). Only after a successful save does the worker queue the reply. Store <code>msisdn<\/code> as a string. It&#8217;s an identifier, not a number, and keeping it as text means you never lose a leading digit or a country code.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">PHP: the reply (trap: a retry that sends twice, and no budget)<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The reply sender claims the reply row first, checks the per-number budget, then calls <code>SMSApi\/send<\/code>. The trap is calling the API and then writing the row. If the process dies in between, the retry sends the same reply again.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;?php\n\/\/ $pdo: PDO connection. $reply: &#91;'inbound_key', 'msisdn', 'text', 'template_id', 'sender', 'msg_type']\nfunction send_reply(PDO $pdo, array $reply): void {\n    \/\/ 1. Claim: one reply per inbound message, enforced by a unique index on inbound_key.\n    $claim = $pdo-&gt;prepare(\n        \"INSERT INTO sms_reply (inbound_key, msisdn, status, created_at)\n         VALUES (?, ?, 'claimed', now()) ON CONFLICT (inbound_key) DO NOTHING\");\n    $claim-&gt;execute(&#91;$reply&#91;'inbound_key'], $reply&#91;'msisdn']]);\n    if ($claim-&gt;rowCount() === 0) { return; }            \/\/ already handled\n\n    \/\/ 2. Budget: at most 5 auto-replies per number per hour.\n    $count = $pdo-&gt;prepare(\n        \"SELECT count(*) FROM sms_reply WHERE msisdn = ? AND status = 'sent'\n         AND created_at &gt; now() - interval '1 hour'\");\n    $count-&gt;execute(&#91;$reply&#91;'msisdn']]);\n    if ((int) $count-&gt;fetchColumn() &gt;= 5) {\n        $pdo-&gt;prepare(\"UPDATE sms_reply SET status = 'over_budget' WHERE inbound_key = ?\")\n            -&gt;execute(&#91;$reply&#91;'inbound_key']]);\n        return;                                           \/\/ alert from your metrics\n    }\n\n    \/\/ 3. Send.\n    $fields = http_build_query(&#91;\n        'userid'        =&gt; getenv('SGC_USERID'),\n        'sendMethod'    =&gt; 'quick',\n        'mobile'        =&gt; $reply&#91;'msisdn'],\n        'msg'           =&gt; $reply&#91;'text'],\n        'senderid'      =&gt; $reply&#91;'sender'],\n        'msgType'       =&gt; $reply&#91;'msg_type'],            \/\/ 'text' or 'unicode'\n        'dltTemplateId' =&gt; $reply&#91;'template_id'],\n        'output'        =&gt; 'json',\n    ]);\n    $ch = curl_init('https:\/\/unify.smsgateway.center\/SMSApi\/send');\n    curl_setopt_array($ch, &#91;\n        CURLOPT_POST           =&gt; true,\n        CURLOPT_POSTFIELDS     =&gt; $fields,\n        CURLOPT_HTTPHEADER     =&gt; &#91;'apikey: ' . getenv('SGC_APIKEY')],\n        CURLOPT_RETURNTRANSFER =&gt; true,\n        CURLOPT_TIMEOUT        =&gt; 15,\n    ]);\n    $body = curl_exec($ch);\n    curl_close($ch);\n\n    \/\/ 4. Record. JSON_BIGINT_AS_STRING keeps an unquoted long id from turning into a float.\n    $res = json_decode((string) $body, true, 512, JSON_BIGINT_AS_STRING);\n    $ok  = is_array($res) &amp;&amp; strtolower((string) ($res&#91;'status'] ?? '')) === 'success';\n    $pdo-&gt;prepare(\"UPDATE sms_reply SET status = ?, transaction_id = ?, raw_response = ?\n                   WHERE inbound_key = ?\")\n        -&gt;execute(&#91;$ok ? 'sent' : 'failed', $ok ? (string) ($res&#91;'transactionId'] ?? '') : null,\n                   (string) $body, $reply&#91;'inbound_key']]);\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Read only <code>status<\/code> to decide success. Don&#8217;t branch on <code>statusCode<\/code> or parse <code>reason<\/code>; keep the raw body for when you need to look. If the call times out, the row stays <code>claimed<\/code>, and a sweeper can decide later whether to retry, after checking the delivery reports for that number. That&#8217;s slower than blind retrying, and it never sends twice.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"testing-keywords\" class=\"wp-block-heading\">Testing a Keyword Flow Before Launch<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The forward is a plain GET with query parameters, which makes it unusually easy to test. You don&#8217;t need the platform to send you anything. You can build the exact request yourself.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Capture a real one first.<\/strong> Before you write the router, point a keyword&#8217;s Callback URL at a staging endpoint that only logs, text it from your own phone a few times, and save the raw query strings. Send a plain English message, one in a regional script, one with an emoji, one with a <code>+<\/code> and a <code>%<\/code> in it, and one that&#8217;s just the keyword. Those five captures tell you more about encoding than any amount of reading.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Replay them in tests.<\/strong> Turn the captured query strings into fixtures and replay them against your endpoint. Check that each one produces exactly one inbound row, and that replaying the same one twice still produces one row.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Unit-test the router as a table.<\/strong> Because it&#8217;s a pure function, the tests are just rows: text, starting state, expected intent, expected new state. Include every alias, every compliance word in every state, an expired state, and a few messages that should hit the fallback. When someone adds a command, they add rows.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Test the race on purpose.<\/strong> Fire two different answers (<code>YES<\/code> and <code>NO<\/code>) for the same conversation at the same moment from two threads, and check that exactly one wins and the other is routed against the updated state.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Test the loop defences.<\/strong> Simulate an auto-responder: a fake number that answers every reply with &#8220;I am out of office&#8221;. Your system should send at most two replies and then hand off.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Use a sandbox for the reply leg.<\/strong> A <a href=\"https:\/\/www.smsgatewaycenter.com\/demo\/\">sandbox account<\/a> lets you exercise <code>SMSApi\/send<\/code> without real sends. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/testing-code-that-sends-messages\/\">testing guide<\/a> covers mocks and safe smoke tests, and the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/contract-testing-harness-messaging-api\/\">contract testing guide<\/a> shows how to notice when a response shape changes underneath you.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Do one end-to-end run on a real phone before launch.<\/strong> Text every command, wait for every reply, and read them on the handset. Check the sender ID shown, the length (did anything split into two parts?) and that nothing arrived twice.<\/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-image size-full\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/diagram-keyword-reply-options.svg\"><img decoding=\"async\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/diagram-keyword-reply-options.svg\" alt=\"Comparison table of five ways to answer an inbound keyword: portal default reply, forward to SMS or email, your webhook router, the WhatsApp keyword node and the WhatsApp default reply\" class=\"wp-image-3069\"\/><\/a><figcaption class=\"wp-element-caption\">Five ways to answer an inbound keyword. Only your own router can hold multi-step state on SMS.<\/figcaption><\/figure>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>If you need&#8230;<\/th><th>Use<\/th><th>Why<\/th><\/tr><\/thead><tbody><tr><td>A fixed thank-you for every entry<\/td><td>Portal Default Response, no webhook<\/td><td>No code, nothing to run<\/td><\/tr><tr><td>Leads sent straight to a sales inbox<\/td><td>Callback E-Mail or Callback SMS<\/td><td>No code, a person handles it<\/td><\/tr><tr><td>Balance checks, lookups, any reply that depends on data<\/td><td>Callback URL plus your router<\/td><td>Only code can look things up<\/td><\/tr><tr><td>Confirm or cancel flows (&#8220;reply YES&#8221;)<\/td><td>Callback URL, router and state table<\/td><td>You must remember what YES means<\/td><\/tr><tr><td>A shared code with a printed campaign keyword<\/td><td>One primary keyword, sub-commands in code<\/td><td>Sub-commands change without moderation<\/td><\/tr><tr><td>A busy support line<\/td><td>Dedicated long code, router with handoff<\/td><td>Every text is yours; people take over after two misses<\/td><\/tr><tr><td>The same journey on WhatsApp and SMS<\/td><td>Workflow Builder on WhatsApp, your router on SMS<\/td><td>WhatsApp has keyword nodes built in; SMS keyword logic lives in your code<\/td><\/tr><tr><td>Replies that never queue behind campaigns<\/td><td>A separate reply lane in your send queue<\/td><td>Campaign volume shouldn&#8217;t delay answers<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"launch-checklist\" class=\"wp-block-heading\">Launch Checklist<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Work through these in order. Each step depends on the one before it.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Pick the long code type.<\/strong> Shared for low volume with a printed keyword; dedicated for support and conversations.<\/li>\n\n\n\n<li><strong>Write the command table.<\/strong> Primary keyword, sub-commands, aliases, intents, and which ones need state. Reserve STOP and HELP first.<\/li>\n\n\n\n<li><strong>Register the reply catalogue.<\/strong> One DLT template per intent, typed variables only, linked to the sender ID you&#8217;ll reply from. Include the fallback acknowledgement.<\/li>\n\n\n\n<li><strong>Create the keyword in the portal.<\/strong> Incoming SMS, 2 Way SMS, Add 2 Way SMS Keyword. Pick the long code, keyword and sender ID. Make sure the account has Incoming SMS credits.<\/li>\n\n\n\n<li><strong>Decide the reply owner.<\/strong> Default Response on for a fixed reply, or off when your code replies. Never both.<\/li>\n\n\n\n<li><strong>Set the callback template.<\/strong> Keep <code>time=$timestamp<\/code> in it. Use the same parameter names on every keyword.<\/li>\n\n\n\n<li><strong>Put a random secret in the Callback URL path.<\/strong> One per keyword or long code. HTTPS only.<\/li>\n\n\n\n<li><strong>Build the endpoint.<\/strong> Check the secret, decode once, dedupe key, insert, enqueue, return 200.<\/li>\n\n\n\n<li><strong>Build the router.<\/strong> Normalise, split the primary keyword, compliance words first, then state, then commands, then fallback.<\/li>\n\n\n\n<li><strong>Add the state table.<\/strong> Keyed on long code and number, with expiry, unknown streak and version.<\/li>\n\n\n\n<li><strong>Add the reply sender.<\/strong> Claim the reply row first, check the per-number budget, send, record <code>transactionId<\/code> as text.<\/li>\n\n\n\n<li><strong>Separate the reply lane.<\/strong> Replies go ahead of campaign sends.<\/li>\n\n\n\n<li><strong>Wire up opt-out.<\/strong> STOP writes to your suppression table and the send gate checks it before every reply and every campaign send.<\/li>\n\n\n\n<li><strong>Test.<\/strong> Captured fixtures, router table tests, the race, the loop, the sandbox, one real phone.<\/li>\n\n\n\n<li><strong>Watch it for a week.<\/strong> Unrecognised rate, handoff count, reply latency, over-budget events, and any request that failed the <code>phonecode<\/code> or <code>keyword<\/code> check.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"unspecified-behaviour\" class=\"wp-block-heading\">Unspecified Behaviour and How to Code Around It<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Some of how the long code forward behaves isn&#8217;t pinned down by anything you can read. Each item below gives you the choice that stays safe either way, and none of them needs you to wait for an answer before you ship.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>One. Assume the forward can arrive twice, or not at all, and make both harmless.<\/strong> Nothing promises exactly-once delivery. The dedupe key and unique index make a duplicate a no-op. For the &#8220;not at all&#8221; case, keep the portal&#8217;s message reports as your reconciliation source and compare a daily count of inbound messages against your own table.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Two. Strip the primary keyword from the content if it&#8217;s there, and don&#8217;t fail if it isn&#8217;t.<\/strong> The setup guide describes the message as including the keyword, while the PHP example&#8217;s test URL has content without it. <code>splitPrimary<\/code> handles both: if the text starts with the keyword, it&#8217;s removed; if it doesn&#8217;t, the text is used as it is.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Three. Don&#8217;t depend on the platform&#8217;s keyword matching being case-insensitive or punctuation-tolerant.<\/strong> On a dedicated code, your router normalises everything anyway. On a shared code, approve the exact form you print in your campaign, and test the variations (<code>acme<\/code>, <code>Acme<\/code>, <code>ACME.<\/code>) from a real phone before launch.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Four. Treat a missing <code>keyword<\/code> parameter as a real case.<\/strong> The PHP handler marks it optional. Route a message with no keyword using the content alone, and on a shared code log it, because it shouldn&#8217;t happen.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Five. Store <code>$timestamp<\/code> as platform time and keep your own <code>received_at<\/code> beside it.<\/strong> Whether the epoch value is the moment the operator received the message or the moment the platform forwarded it isn&#8217;t spelled out. Epoch milliseconds carry no time zone, so the value is safe to store. Use your own clock for latency and for session expiry, and use the platform time only for dedupe and ordering.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Six. Decode the query string exactly once, and test regional scripts with real captures.<\/strong> How <code>$message<\/code> is encoded for Unicode text isn&#8217;t pinned down. Capture a Hindi or Marathi message during testing, check that it decodes to the right characters, and keep the raw query string on the inbound row so you can re-decode later if you got it wrong.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Seven. Answer 200 for everything you accept, including duplicates and requests you drop.<\/strong> What the platform does when your endpoint returns an error or times out isn&#8217;t stated. If it retries, a 200 stops the retries. If it doesn&#8217;t, a slow or failing endpoint loses messages. Either way, a fast 200 after a durable insert is the right answer.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Eight. Put your secret in the URL path, not the query string.<\/strong> How the platform joins its parameters onto a Callback URL that already has a query string isn&#8217;t shown. A path segment avoids the question.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Nine. Keep the portal Default Response off on any keyword your code replies to, and check it after every portal change.<\/strong> Whether the Default Response is sent before, after or regardless of the forward isn&#8217;t stated. Off means it can&#8217;t happen at all.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Ten. Measure throughput on your own long code before you promise reply times.<\/strong> No per-number send or receive rate is published for long codes, beyond the general statement that they&#8217;re slower than short codes. Run a load test in staging against your reply lane with realistic campaign traffic beside it, and set your reply-latency alert from what you measure.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Eleven. Treat <code>location<\/code> and <code>carrier<\/code> as reporting hints only.<\/strong> They&#8217;re worked out from the number series, which can be wrong for ported numbers. Never route, price or authorise anything on them.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Twelve. Keep keyword changes behind a version in your own config.<\/strong> Moderation means you can&#8217;t predict when a new platform keyword goes live. Ship router support for the new keyword first, with the old one still working, and switch your campaign copy only after you&#8217;ve seen real traffic arrive on the new one.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"faqs\" class=\"wp-block-heading\">FAQs<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What is a two-way SMS keyword?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">It&#8217;s a word customers text to your long code, such as <code>ACME<\/code>, that tells the platform which business and which campaign the message belongs to. On SMSGatewayCenter you create it in the portal under Incoming SMS, 2 Way SMS, and the team approves it before it goes live.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Do I need a dedicated long code for keywords?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">No. Shared long codes route by keyword too. The difference is that on a shared code your primary keyword is what separates your messages from other businesses&#8217; messages, while on a dedicated code every text to the number is yours and you get unlimited keywords.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Can I add new commands without waiting for keyword approval?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Yes, if they&#8217;re sub-commands. Register one primary keyword with the platform and handle everything after it (<code>ACME BAL<\/code>, <code>ACME HELP<\/code>) in your own router. Those you can change with a deploy.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What parameters does the inbound webhook send?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Whatever your callback template asks for. The available placeholders are <code>$vmn<\/code> (your long code), <code>$mobile<\/code> (the customer&#8217;s number), <code>$message<\/code> (the content), <code>$keyword<\/code>, <code>$timestamp<\/code> (epoch milliseconds), <code>$location<\/code> and <code>$operator<\/code>. The default parameter names are <code>phonecode<\/code>, <code>phoneno<\/code>, <code>content<\/code>, <code>keyword<\/code>, <code>time<\/code>, <code>location<\/code> and <code>carrier<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Is the inbound webhook a GET or a POST?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A GET. The default template ends with <code>forwardMethod=GET<\/code>, and the parameters arrive in the query string.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Does the forward include a message ID?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">No. Build your own dedupe key from the long code, the customer&#8217;s number, the <code>time<\/code> value and the normalised text, and put a unique index on it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Can my auto-reply say anything I want?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">No. In India it&#8217;s an outbound SMS like any other, so it has to match a DLT template registered with the sender ID you reply from. Build a catalogue of approved replies, one per intent, and fill only typed variables with values you computed.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Should I use the portal&#8217;s Default Response or reply from my own code?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One or the other, per keyword. Use the Default Response when a fixed reply is the whole flow. Turn it off when your code replies, or every customer gets two answers.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I stop two auto-responders talking to each other forever?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Cap auto-replies per number per hour, never reply to your own numbers, and stop replying after two unrecognised messages in a row. An auto-responder never sends a valid command, so the streak rule breaks most loops on its own.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How long should a conversation state stay open?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">As long as the question makes sense, set per question. Thirty minutes suits a quick confirmation; a day suits a reminder sent the evening before. After it expires, treat the next message as a fresh start.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What happens to STOP in the middle of a flow?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">It wins. Your router should check opt-out words before anything else, in every state, record the opt-out, and send only the confirmation. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/opt-out-suppression-sms-whatsapp-rcs-telegram\/\">opt-out and suppression guide<\/a> covers the rest.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Will a big campaign slow down my replies?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">It can if they share one queue. Give replies their own higher-priority lane and reserve part of your send rate for them.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How is this different on WhatsApp?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">On WhatsApp the platform does more for you. The Default Response Configuration is mandatory and checks your Conversation Response keywords before sending the default, and the Workflow Builder has Keyword Match conditions and Reset and Cancel keywords. On SMS that logic lives in your code.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Can I receive inbound SMS over SMPP instead?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That&#8217;s a separate setup. The knowledge base answer on <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/can-i-use-smpp-for-two-way-messaging\/\">using SMPP for two-way messaging<\/a> is the place to start. This guide is about the HTTP forward.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you&#8217;d like help planning a keyword flow or choosing between a shared and a dedicated long code, <a href=\"https:\/\/www.smsgatewaycenter.com\/contact\/\">get in touch with the SMSGatewayCenter team<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">Put a keyword on your long code this week<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Start with one primary keyword, three commands and a fixed fallback. <strong>SMSGatewayCenter<\/strong> gives you shared and dedicated long codes, keyword forwarding to your own URL, and the SMS API for <a href=\"https:\/\/www.smsgatewaycenter.com\/dlt-sms\/\">DLT-compliant<\/a> replies. Try the API in a sandbox first, then go live when your router passes its tests.<\/p>\n\n\n\n<div class=\"wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex\">\n<div class=\"wp-block-button\"><a class=\"wp-block-button__link wp-element-button\" href=\"https:\/\/www.smsgatewaycenter.com\/long-code-sms-services\/\">Explore Long Code SMS<\/a><\/div>\n\n\n\n<div class=\"wp-block-button\"><a class=\"wp-block-button__link wp-element-button\" href=\"https:\/\/www.smsgatewaycenter.com\/demo\/\">Try the Sandbox<\/a><\/div>\n\n\n\n<div class=\"wp-block-button\"><a class=\"wp-block-button__link wp-element-button\" href=\"https:\/\/www.smsgatewaycenter.com\/contact\/\">Talk to Us<\/a><\/div>\n<\/div>\n\n\n\n<p class=\"wp-block-paragraph\"><\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">Checkout our other posts<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/opt-out-suppression-sms-whatsapp-rcs-telegram\/\">Opt-Out and Suppression Across SMS, WhatsApp, RCS and Telegram: Building the List That Actually Stops Sends<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/scheduling-messages-sms-whatsapp-rcs-telegram\/\">How to Schedule Messages on SMS, WhatsApp, RCS and Telegram (and Why You Still Need Your Own Scheduler)<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/multi-brand-messaging-architecture-accounts-sub-users\/\">Multi-Brand Messaging Architecture: Accounts, Sub-Users and Reseller Child Accounts for SMS, WhatsApp, RCS and Telegram<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/contract-testing-harness-messaging-api\/\">Contract Testing a Messaging API: A Nightly Drift Harness That Never Sends a Message<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sender-identity-sms-whatsapp-rcs-telegram\/\">Sender Identity Across SMS, WhatsApp, RCS and Telegram: Sender IDs, WABA Numbers, Bots and Long Codes<\/a><\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n","protected":false},"excerpt":{"rendered":"<p>A customer texts &#8220;ACME BAL&#8221; to your long code. What happens next is your code&#8217;s job: matching the keyword, remembering where the conversation was, sending a reply that passes DLT, and not answering the same message twice. This guide shows how to build that router on SMSGatewayCenter&#8217;s long code push, with working samples in Python, Node.js, Java and PHP.<\/p>\n","protected":false},"author":118,"featured_media":3068,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[2010],"tags":[2312,2311,447,2313,2309,2284,481,2310,1302,2314],"class_list":["post-3067","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-developer-guides","tag-auto-reply-sms","tag-conversation-state","tag-dlt-templates","tag-inbound-sms-webhook","tag-keyword-routing","tag-long-code","tag-sms-api","tag-sms-keywords","tag-two-way-sms-2","tag-vmn"],"_links":{"self":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/3067","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=3067"}],"version-history":[{"count":0,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/3067\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media\/3068"}],"wp:attachment":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media?parent=3067"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/categories?post=3067"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/tags?post=3067"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}