{"id":3079,"date":"2026-10-14T15:31:50","date_gmt":"2026-10-14T10:01:50","guid":{"rendered":"https:\/\/www.smsgatewaycenter.com\/blog\/?p=3079"},"modified":"2026-10-14T15:31:53","modified_gmt":"2026-10-14T10:01:53","slug":"sms-message-encryption-aes-wire-format-key-handling","status":"publish","type":"post","link":"https:\/\/www.smsgatewaycenter.com\/blog\/sms-message-encryption-aes-wire-format-key-handling\/","title":{"rendered":"SMS Message Encryption: What It Hides, What It Doesn&#8217;t, and How to Get the Wire Format Right"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\"><strong>SMS message encryption<\/strong> keeps your message text out of the platform&#8217;s database, logs and reports, but the operator and the handset still see plaintext. Here&#8217;s the exact AES-CBC wire format, working code in four languages, the form-encoding and key-length traps that break interop, and how to rotate keys without losing your history.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/sms-message-encryption-aes-wire-format.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"584\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/sms-message-encryption-aes-wire-format-1024x584.webp\" alt=\"Abstract geometric illustration of ordered teal blocks passing through a blue lattice and emerging as a scrambled orange and teal mosaic\" class=\"wp-image-3080\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/sms-message-encryption-aes-wire-format-1024x584.webp 1024w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/sms-message-encryption-aes-wire-format-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/sms-message-encryption-aes-wire-format-768x438.webp 768w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/sms-message-encryption-aes-wire-format.webp 1200w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><figcaption class=\"wp-element-caption\">Encryption changes what the platform stores, not what the handset reads.<\/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=\"#threat-model\">What Encryption Actually Hides<\/a><\/li>\n\n\n\n<li><a href=\"#portal-switches\">The Three Switches in the Portal<\/a><\/li>\n\n\n\n<li><a href=\"#wire-format\">The Wire Format, Byte by Byte<\/a><\/li>\n\n\n\n<li><a href=\"#two-endpoints\">The Two Endpoints: SMSApi\/encrypt and SMSApi\/decrypt<\/a><\/li>\n\n\n\n<li><a href=\"#encrypt-locally\">Encrypt Locally, Not Over the API<\/a><\/li>\n\n\n\n<li><a href=\"#sending-encrypted\">Sending an Encrypted Message<\/a><\/li>\n\n\n\n<li><a href=\"#form-encoding\">The Plus Sign That Breaks Everything<\/a><\/li>\n\n\n\n<li><a href=\"#key-length\">Key Length and Which Cipher You Get<\/a><\/li>\n\n\n\n<li><a href=\"#four-languages\">Four Implementations, Four Traps<\/a><\/li>\n\n\n\n<li><a href=\"#canary\">Proving Interop With a Canary<\/a><\/li>\n\n\n\n<li><a href=\"#length-cost\">Length, Segments and Cost<\/a><\/li>\n\n\n\n<li><a href=\"#reading-reports\">Reading Encrypted Reports Back<\/a><\/li>\n\n\n\n<li><a href=\"#key-ring\">Key Storage and the Key Ring<\/a><\/li>\n\n\n\n<li><a href=\"#rotation\">Rotating a Key Without Losing History<\/a><\/li>\n\n\n\n<li><a href=\"#integrity\">What the Format Doesn&#8217;t Protect<\/a><\/li>\n\n\n\n<li><a href=\"#pipeline\">Encryption and the Rest of Your Pipeline<\/a><\/li>\n\n\n\n<li><a href=\"#decision-matrix\">Decision Matrix<\/a><\/li>\n\n\n\n<li><a href=\"#checklist\">Pre-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\"><strong>SMS message encryption<\/strong> on <strong>SMSGatewayCenter<\/strong> keeps the text of your messages out of the platform&#8217;s database, system logs, portal screens and downloaded reports. It doesn&#8217;t hide the text from the mobile operator or the person reading the SMS, and it can&#8217;t: the platform decrypts each message just before it hands it to the operator, because a handset can&#8217;t do anything with ciphertext.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The format is simple once you see it. Take 16 ASCII hex characters as the IV, AES-CBC encrypt the UTF-8 text with PKCS#7 padding and your secret token as the key, glue the IV to the front of the ciphertext, and base64 the lot. Do that in your own code, send it as <code>msg<\/code> with <code>encrypt=true<\/code>, and form-encode the body properly, because a base64 <code>+<\/code> that turns into a space is the single most common way this breaks.<\/p>\n\n\n\n<h2 id=\"tldr\" class=\"wp-block-heading\">TL;DR<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>What it protects:<\/strong> message text at rest on the platform (database, logs, reports, exports). TLS already protects it on the wire.<\/li>\n\n\n\n<li><strong>What it doesn&#8217;t:<\/strong> the operator network and the handset get plaintext. Encryption doesn&#8217;t make SMS end-to-end private.<\/li>\n\n\n\n<li><strong>Format:<\/strong> <code>base64( IV as 16 ASCII hex chars || AES-CBC-PKCS7(UTF-8 text) )<\/code>. The IV is random per message, so the same text never encrypts the same way twice.<\/li>\n\n\n\n<li><strong>Key:<\/strong> the secret token you generate under My Account, Security, Text Encryption. It&#8217;s separate from your <code>apikey<\/code> and must stay separate.<\/li>\n\n\n\n<li><strong>Key length:<\/strong> with a 16-character token everything agrees on AES-128. With 24 or 32 characters, the PHP and Java samples silently disagree. Pin the behaviour with a canary.<\/li>\n\n\n\n<li><strong>Endpoints:<\/strong> <code>SMSApi\/encrypt<\/code> and <code>SMSApi\/decrypt<\/code>, POST only, key in an <code>encryptKey<\/code> header. Use them to prove interop, not as your production encryptor.<\/li>\n\n\n\n<li><strong>Sending:<\/strong> encrypted <code>msg<\/code> plus <code>encrypt=true<\/code> on <code>SMSApi\/send<\/code>. URL-encode the body so <code>+<\/code>, <code>\/<\/code> and <code>=<\/code> survive.<\/li>\n\n\n\n<li><strong>Length:<\/strong> ciphertext is far longer than the text (a 160-character message becomes 256 characters of base64). Estimate segments and cost on the plaintext.<\/li>\n\n\n\n<li><strong>Rotation:<\/strong> old messages stay encrypted under the old key forever, even if you switch encryption off. Keep every key you&#8217;ve ever used, tagged with an id.<\/li>\n\n\n\n<li><strong>Integrity:<\/strong> CBC without a MAC is malleable. TLS covers transport; don&#8217;t build anything that reports padding errors to strangers.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"threat-model\" class=\"wp-block-heading\">What Encryption Actually Hides<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Start with the threat model, because it decides whether this feature is worth turning on at all.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A message you send goes through six places. Your own application and database. The HTTPS request to the platform. The platform&#8217;s send pipeline. The platform&#8217;s storage: database rows, system logs, the portal&#8217;s delivery report screens and any report you download. The operator network, including DLT scrubbing, which matches the content against your registered template. And finally the handset.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s where the plaintext is visible with encryption off and on:<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Place<\/th><th>Encryption off<\/th><th>Encryption on (System Message Encryption)<\/th><\/tr><\/thead><tbody><tr><td>Your app and your database<\/td><td>Plaintext, unless you encrypt it yourself<\/td><td>Your choice; you hold the plaintext before you encrypt<\/td><\/tr><tr><td>HTTPS request to the platform<\/td><td>Protected by TLS<\/td><td>Protected by TLS, and the body carries ciphertext<\/td><\/tr><tr><td>Platform send pipeline<\/td><td>Plaintext<\/td><td>Decrypted just before submission to the operator<\/td><\/tr><tr><td>Platform database and system logs<\/td><td>Plaintext<\/td><td>Ciphertext<\/td><\/tr><tr><td>Portal screens and downloaded reports<\/td><td>Plaintext<\/td><td>Ciphertext; you need the secret token to read them<\/td><\/tr><tr><td>Operator network and DLT scrubbing<\/td><td>Plaintext<\/td><td>Plaintext<\/td><\/tr><tr><td>Recipient&#8217;s handset<\/td><td>Plaintext<\/td><td>Plaintext<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Put plainly: encryption shrinks the number of places where your message text sits around readable. It&#8217;s a data-at-rest control on the provider side. If your worry is a database dump, a log shipped somewhere it shouldn&#8217;t go, or a portal user exporting a report full of account balances, it helps. If your worry is the operator, or SMS interception, it doesn&#8217;t change anything, and nothing an SMS provider does can.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That&#8217;s also why the right comparison isn&#8217;t &#8220;encrypted SMS versus WhatsApp&#8217;s end-to-end encryption&#8221;. It&#8217;s &#8220;my message bodies stored readable at my provider versus stored as ciphertext&#8221;. For banks, lenders, insurers and healthcare providers sending balances, loan amounts or appointment details, the second framing is usually the one your auditor cares about.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The provider says the secret token itself is stored encrypted and only decrypted when you log in, and that its team can&#8217;t view the actual key (that&#8217;s from the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/how-to-enable-text-message-encryption-feature-for-your-sms-account\/\">portal setup guide<\/a>). Keep in mind what that does and doesn&#8217;t mean. The send pipeline has to decrypt your messages, so the platform&#8217;s systems can use the key. What you&#8217;re buying is that message text isn&#8217;t sitting in plaintext in storage and logs.<\/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-encryption-plaintext-path.svg\"><img decoding=\"async\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/diagram-encryption-plaintext-path.svg\" alt=\"Three-panel flow showing message text encrypted in your app, stored as ciphertext on the platform and decrypted at submission, then delivered as plaintext through the operator to the handset\" class=\"wp-image-3082\"\/><\/a><figcaption class=\"wp-element-caption\">Ciphertext lives in the middle. Both ends see plaintext.<\/figcaption><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"portal-switches\" class=\"wp-block-heading\">The Three Switches in the Portal<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Encryption is turned on in the portal, not over the API. The path is My Account, then Security, then the <strong>Text Encryption<\/strong> tab. There are three settings and a button:<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Setting<\/th><th>What it does<\/th><\/tr><\/thead><tbody><tr><td>Enable Message Encryption<\/td><td>The master switch. It has to be Yes before you can generate a secret token.<\/td><\/tr><tr><td>System Message Encryption<\/td><td>Encodes message content throughout the system, including the database and the delivery report screens. If this is on, you don&#8217;t need the next one.<\/td><\/tr><tr><td>DLR Message Encryption<\/td><td>Leaves the database alone but scrambles message text when you view or download a report. Use it only if System Message Encryption is off.<\/td><\/tr><tr><td>Generate<\/td><td>Creates your secret token (the encryption key).<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Two things about this setup matter for your code later.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">First, the token you get from Generate is a different secret from your API key. You&#8217;ll send both on the same request, but they do different jobs: the API key authenticates you, the encryption token encrypts and decrypts text. The KB entry on <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/same-api-key-secret-token-message-encryption\/\">using the same API key for encryption<\/a> is explicit that they must stay separate. Store them as two secrets, rotate them separately, and never derive one from the other.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Second, turning encryption off is a support request, and it isn&#8217;t retroactive. According to the KB entry on <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/disable-message-encryption-at-any-time\/\">disabling message encryption<\/a>, messages stored while encryption was on stay encrypted; to read them you still need the token. So the day you enable this, you&#8217;ve signed up to keep that key for as long as you might ever want to read those reports.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"wire-format\" class=\"wp-block-heading\">The Wire Format, Byte by Byte<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Every encrypted message, whether you produce it or the platform does, has the same shape:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>base64( IV || ciphertext )\n\nIV          16 bytes: 16 ASCII hex characters, e.g. \"A1B2C3D4E5F60718\"\nciphertext  AES-CBC over the UTF-8 bytes of the text, PKCS#7 padded to a multiple of 16\nkey         the secret token's characters as raw bytes (16 characters = AES-128)<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The odd part is the IV. A normal AES-CBC implementation uses 16 random bytes. This format uses 8 random bytes, writes them as 16 hex characters, and uses those 16 ASCII characters as the IV bytes. You can see it directly in the platform&#8217;s sample output. Decode the first 16 bytes of the sample response on the encrypt endpoint page and you get the ASCII string <code>B475FF9471F853CC<\/code>. The decrypt page&#8217;s sample input starts with <code>C3002CA07C54FA56<\/code>. Both samples carry the same 149-byte message, and both decode to 176 bytes: a 16-byte IV plus 160 bytes of ciphertext, which is exactly 149 bytes padded up to the next multiple of 16.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That tells you three practical things:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>The IV is in the clear, at the front.<\/strong> You don&#8217;t store or send it separately. To decrypt, you slice off the first 16 bytes.<\/li>\n\n\n\n<li><strong>The IV is random per message.<\/strong> The two samples encrypt the same text and produce completely different output. You can&#8217;t search, deduplicate or join on ciphertext. If you need to find &#8220;the same message&#8221; later, store a keyed hash of the plaintext alongside it.<\/li>\n\n\n\n<li><strong>Hex case doesn&#8217;t matter for decryption.<\/strong> The platform&#8217;s IVs are uppercase hex; the PHP helper on the developer script page writes lowercase. Either way the decryptor just takes the first 16 bytes as they are.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s a worked example you can check your own code against. All values were computed, not typed by hand. The key is a made-up example; never use it for real.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>key        k7Rq2mX9pL4vT8wZ\nIV         A1B2C3D4E5F60718   (fixed here only so the output is reproducible)\ntext       Your login code is 482913. It expires in 10 minutes.\noutput     QTFCMkMzRDRFNUY2MDcxONuZdEP5royv1vW1pAoR+xsWd9aO\/I59U5BCpaNNMSM+mXfQKA5g1fBjmgYkI0ma2qsTZ\/l5BSUj+OWfIKi3bX8=<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The text is 52 bytes, which pads to 64. Add the 16-byte IV and you have 80 bytes, which base64 turns into 108 characters. Notice the output starts with <code>QTFCMkMz<\/code>: that&#8217;s just the base64 of the ASCII IV <code>A1B2C3<\/code>. Every ciphertext from this format starts with base64 of hex digits, which is handy when you need to recognise one (more on that in <a href=\"#reading-reports\">Reading Encrypted Reports Back<\/a>).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Also notice the output contains three <code>+<\/code> and two <code>\/<\/code> characters. Hold that thought.<\/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-encryption-wire-format.svg\"><img decoding=\"async\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/diagram-encryption-wire-format.svg\" alt=\"Four-column diagram of the encryption wire format: a 16-character ASCII hex IV, AES-CBC ciphertext with PKCS#7 padding, standard base64 wrapping, and URL form encoding of plus, slash and equals\" class=\"wp-image-3081\"\/><\/a><figcaption class=\"wp-element-caption\">Four layers, and each one has a way to go wrong.<\/figcaption><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"two-endpoints\" class=\"wp-block-heading\">The Two Endpoints: SMSApi\/encrypt and SMSApi\/decrypt<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The platform gives you two helper endpoints. Both are POST only, and both take the encryption token in a request header called <code>encryptKey<\/code>.<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th><\/th><th><code>SMSApi\/encrypt<\/code><\/th><th><code>SMSApi\/decrypt<\/code><\/th><\/tr><\/thead><tbody><tr><td>URL<\/td><td><code>https:\/\/unify.smsgateway.center\/SMSApi\/encrypt<\/code><\/td><td><code>https:\/\/unify.smsgateway.center\/SMSApi\/decrypt<\/code><\/td><\/tr><tr><td>Method<\/td><td>POST<\/td><td>POST<\/td><\/tr><tr><td>Auth<\/td><td><code>userid<\/code> and <code>password<\/code>, or the <code>apiKey<\/code> header<\/td><td>same<\/td><\/tr><tr><td>Key<\/td><td><code>encryptKey<\/code> header<\/td><td><code>encryptKey<\/code> header<\/td><\/tr><tr><td>Body<\/td><td><code>msg<\/code> (plain text), <code>output<\/code> (<code>json<\/code> or <code>plain<\/code>, default plain)<\/td><td><code>msg<\/code> (ciphertext), <code>output<\/code><\/td><\/tr><tr><td>Returns in <code>msg<\/code><\/td><td>ciphertext<\/td><td>plain text<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">A successful response looks like this (from the encrypt page, ciphertext shortened):<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n    \"status\": \"success\",\n    \"msg\": \"QjQ3NUZGOTQ3MUY4NTNDQ+poe2RJgCv6vDI7275TwWB7u6EpTIEBk4mRfsVx...\",\n    \"transactionId\": \"5274122124458018037\",\n    \"statusCode\": \"900\",\n    \"reason\": \"success\"\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Read it the same way you read every other response on this API. Check <code>status<\/code>, take <code>msg<\/code> if it says success, and stop there. Don&#8217;t branch on <code>statusCode<\/code> (it&#8217;s <code>\"900\"<\/code> here, not the <code>\"200\"<\/code> you see on sends), and don&#8217;t parse <code>reason<\/code>. The <code>transactionId<\/code> is a quoted 19-digit number; nothing is sent by this call, so you don&#8217;t need to keep it, but if you log it, keep it as text. A JavaScript <code>Number<\/code> can&#8217;t hold 19 digits exactly.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Send your API key as a header like everywhere else, with <code>userid<\/code> alongside it. And send the request with a proper form body, because the encrypt sample on the page puts the message straight into a hand-built <code>application\/x-www-form-urlencoded<\/code> string, while the decrypt sample switches to a multipart array. Both work in PHP&#8217;s cURL, but the moment you port the urlencoded version to another language and paste ciphertext into it, you&#8217;ll hit the bug in <a href=\"#form-encoding\">The Plus Sign That Breaks Everything<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"encrypt-locally\" class=\"wp-block-heading\">Encrypt Locally, Not Over the API<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">It&#8217;s tempting to call <code>SMSApi\/encrypt<\/code> before every send. Don&#8217;t. Think about what that call does: it ships the plain text to the platform over HTTPS so the platform can hand back ciphertext, which you then ship back to the platform. The plain text has already crossed the wire and been processed by the platform once, which is precisely the exposure you turned encryption on to reduce. You&#8217;ve also doubled your request count and added a dependency to every send.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Encrypt in your own process instead. It&#8217;s a dozen lines in any language (see <a href=\"#four-languages\">Four Implementations, Four Traps<\/a>), it costs microseconds, and your plain text only ever leaves your system inside the encrypted <code>msg<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So what are the two endpoints for? Proving that your code and the platform agree. Call each one once with a known string when you set up a key, compare the results with your local implementation, and you&#8217;ve tested the one thing that can silently go wrong: whether both sides interpret the key and the format the same way. That&#8217;s the canary in <a href=\"#canary\">Proving Interop With a Canary<\/a>. The decrypt endpoint is also handy as an occasional manual check when you&#8217;re staring at a report and want to know whether a string is yours.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"sending-encrypted\" class=\"wp-block-heading\">Sending an Encrypted Message<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">With encryption enabled and a token generated, a send looks like any other <code>SMSApi\/send<\/code> call, with two differences: <code>msg<\/code> carries ciphertext, and you add <code>encrypt=true<\/code>. The <code>encrypt=true<\/code> flag comes from the portal setup guide; it&#8217;s how the platform knows to decrypt <code>msg<\/code> before submission rather than send your base64 to a customer&#8217;s phone.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Everything else stays as you know it from the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/send-sms-api-guide\/\">send API guide<\/a>: <code>sendMethod<\/code>, <code>mobile<\/code>, <code>senderid<\/code>, <code>msgType<\/code>, <code>output<\/code>, and your DLT parameters if you pass them. Set <code>msgType<\/code> for the plaintext, not the ciphertext. The ciphertext is pure ASCII, but if the message you encrypted is in Hindi, it&#8217;s still a <code>unicode<\/code> message once the platform decrypts it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s a complete send in Python. It uses the helper module from <a href=\"#four-languages\">Four Implementations, Four Traps<\/a>.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import os\nimport requests\n\nfrom sgc_crypto import encrypt\n\nSEND_URL = \"https:\/\/unify.smsgateway.center\/SMSApi\/send\"\n\n\ndef send_encrypted(mobile: str, text: str, senderid: str, msg_type: str = \"text\") -&gt; dict:\n    token = os.environ&#91;\"SGC_ENCRYPT_TOKEN\"]          # the Text Encryption secret\n    body = {\n        \"userid\": os.environ&#91;\"SGC_USERID\"],\n        \"sendMethod\": \"quick\",\n        \"mobile\": mobile,\n        \"msg\": encrypt(token, text),                  # ciphertext, never the text\n        \"encrypt\": \"true\",\n        \"senderid\": senderid,\n        \"msgType\": msg_type,                          # describes the plaintext\n        \"output\": \"json\",\n    }\n    # data=dict makes requests form-encode every value: + -&gt; %2B, \/ -&gt; %2F, = -&gt; %3D\n    r = requests.post(SEND_URL, data=body,\n                      headers={\"apikey\": os.environ&#91;\"SGC_APIKEY\"]}, timeout=15)\n    reply = r.json()\n    if reply.get(\"status\") != \"success\":\n        raise RuntimeError(\"send rejected\")\n    return reply<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The response is the normal send response, with <code>transactionId<\/code> as a quoted 18 or 19 digit string. Save it as text, exactly as you would for an unencrypted send.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Before you send anything real with this, send one message to a phone you own and look at it. If you see readable text, the platform decrypted it. If you see a wall of base64, something about the flag or the key didn&#8217;t line up, and you&#8217;ve just found out on your own phone rather than your customer&#8217;s. That check is part of the canary below.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"form-encoding\" class=\"wp-block-heading\">The Plus Sign That Breaks Everything<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Base64 uses 64 characters, and two of them are <code>+<\/code> and <code>\/<\/code>, with <code>=<\/code> for padding. The SMS API takes form-encoded bodies. In <code>application\/x-www-form-urlencoded<\/code>, a literal <code>+<\/code> means a space.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So if you build the body by hand, like this:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>userid=you&amp;msg=QTFCMkMzRDRFNUY2MDcxONuZdEP5royv1vW1pAoR+xsWd9aO\/I59...&amp;encrypt=true<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">the server reads <code>pAoR xsWd9aO<\/code> where you wrote <code>pAoR+xsWd9aO<\/code>. The ciphertext is now corrupt, decryption fails, and the error you get back (if you get a clear one) won&#8217;t say &#8220;you forgot to URL-encode&#8221;. Our example token contains three <code>+<\/code> characters, and that&#8217;s normal. In a simulation of 40,000 random encryptions, about three in four ciphertexts of a 52-character OTP message contained at least one <code>+<\/code>, and about 97 in 100 ciphertexts of a full 160-character message did. This isn&#8217;t an edge case. It&#8217;s nearly every message.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two rules fix it:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Never concatenate a body string.<\/strong> Let your HTTP library encode a dictionary or form object: <code>requests<\/code> with <code>data=<\/code>, <code>URLSearchParams<\/code> in Node, <code>http_build_query<\/code> in PHP, <code>FormBody<\/code> or <code>UrlEncodedFormEntity<\/code> in Java. They all turn <code>+<\/code> into <code>%2B<\/code>, <code>\/<\/code> into <code>%2F<\/code> and <code>=<\/code> into <code>%3D<\/code>.<\/li>\n\n\n\n<li><strong>Use standard base64, not base64url.<\/strong> The URL-safe alphabet swaps <code>+<\/code> and <code>\/<\/code> for <code>-<\/code> and <code>_<\/code> (<a href=\"https:\/\/www.rfc-editor.org\/info\/rfc4648\" target=\"_blank\" rel=\"noopener nofollow\">RFC 4648<\/a> section 5). The platform&#8217;s own output uses the standard alphabet, so a URL-safe token won&#8217;t decode on the other side.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">And while you&#8217;re in RFC 4648: section 3.1 says encoders must not insert line breaks unless the spec asks for them. Some encoders do anyway, which matters for Java in a moment.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"key-length\" class=\"wp-block-heading\">Key Length and Which Cipher You Get<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">AES has three key sizes: 16, 24 and 32 bytes. The sample helpers on the developer script page accept a secret token of any of those lengths. Here&#8217;s the problem: the two languages don&#8217;t do the same thing with them.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The PHP helper always calls OpenSSL with <code>AES-128-CBC<\/code>. PHP&#8217;s <code>openssl_encrypt<\/code> documents that a passphrase longer than the cipher expects is silently truncated (<a href=\"https:\/\/www.php.net\/manual\/en\/function.openssl-encrypt.php\" target=\"_blank\" rel=\"noopener nofollow\">PHP manual<\/a>). So a 32-character token in PHP quietly becomes AES-128 with the first 16 characters. The Java helper builds a <code>SecretKeySpec<\/code> from all the bytes, so the same 32-character token becomes AES-256.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Same token, same text, same IV, different output (computed):<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>token      k7Rq2mX9pL4vT8wZc3Hn6Yb1Df5Gj0Ks   (32 characters, example only)\ntext       Hello\nPHP-style  QTFCMkMzRDRFNUY2MDcxOBYNyRklv9qlcgKj1xlL2Is=\nJava-style QTFCMkMzRDRFNUY2MDcxOND0B4vUR+QSQG8fc9DdmAE=<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">With a 16-character token, there&#8217;s only one possible interpretation and everything agrees. With a longer token, you have to find out which behaviour the platform uses before you encrypt anything you care about, and the only reliable way is to ask the platform itself, using the two endpoints. The Python helper below takes a <code>mode<\/code> argument for exactly this: it refuses a 24 or 32 character token until you&#8217;ve pinned <code>truncate16<\/code> or <code>full<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One more thing to check while you&#8217;re looking at your token: what characters it uses. If it&#8217;s made only of hex digits (the example in the script page is <code>256c6edda5e945d5<\/code>), each character carries 4 bits, so 16 hex characters give you 64 bits of key, not 128. That&#8217;s still far beyond casual guessing, but it&#8217;s worth knowing when someone asks you what key strength you&#8217;re running.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"four-languages\" class=\"wp-block-heading\">Four Implementations, Four Traps<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Each sample below implements the same format and round-trips the worked example from <a href=\"#wire-format\">The Wire Format, Byte by Byte<\/a>. Each one is there to show a different way the format goes wrong in that language. The Python, Node.js and Java versions were run against the worked example; the PHP version mirrors the platform&#8217;s own helper with its traps removed.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Python: the reference, and strict decoding<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The trap in Python is being too forgiving. <code>base64.b64decode<\/code> without <code>validate=True<\/code> silently skips characters that aren&#8217;t in the alphabet, including the spaces a broken form body leaves behind. Validate, and check the length before you touch the cipher.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import base64\nimport secrets\n\nfrom cryptography.hazmat.primitives import padding\nfrom cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes\n\n\ndef cipher_key(secret: str, mode: str = \"pinned\") -&gt; bytes:\n    \"\"\"Turn the secret token into AES key bytes.\n\n    16 characters: AES-128, no ambiguity.\n    24 or 32 characters: the PHP helper truncates to 16 (AES-128), the Java\n    helper uses all of them (AES-192\/256). Pin the mode with the canary.\n    \"\"\"\n    k = secret.encode(\"utf-8\")\n    if len(k) == 16:\n        return k\n    if len(k) not in (24, 32):\n        raise ValueError(f\"token must be 16, 24 or 32 bytes, got {len(k)}\")\n    if mode == \"truncate16\":\n        return k&#91;:16]\n    if mode == \"full\":\n        return k\n    raise ValueError(\"run the canary and pin mode='truncate16' or mode='full'\")\n\n\ndef encrypt(secret: str, text: str, mode: str = \"pinned\") -&gt; str:\n    iv = secrets.token_hex(8).upper().encode(\"ascii\")  # 16 ASCII hex chars\n    padder = padding.PKCS7(128).padder()\n    data = padder.update(text.encode(\"utf-8\")) + padder.finalize()\n    enc = Cipher(algorithms.AES(cipher_key(secret, mode)), modes.CBC(iv)).encryptor()\n    return base64.b64encode(iv + enc.update(data) + enc.finalize()).decode(\"ascii\")\n\n\ndef decrypt(secret: str, token: str, mode: str = \"pinned\") -&gt; str:\n    raw = base64.b64decode(\"\".join(token.split()), validate=True)\n    if len(raw) &lt; 32 or (len(raw) - 16) % 16:\n        raise ValueError(\"not a ciphertext in this format\")\n    dec = Cipher(algorithms.AES(cipher_key(secret, mode)), modes.CBC(raw&#91;:16])).decryptor()\n    unpadder = padding.PKCS7(128).unpadder()\n    data = unpadder.update(dec.update(raw&#91;16:]) + dec.finalize()) + unpadder.finalize()\n    return data.decode(\"utf-8\")<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Save it as <code>sgc_crypto.py<\/code>; the send example above imports it. It needs the <code>cryptography<\/code> package.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Node.js: the IV is 16 characters, not 16 random bytes<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The Node trap is muscle memory. Everyone writes <code>crypto.randomBytes(16)<\/code> for an IV. Here that produces ciphertext the platform can&#8217;t read, because the platform expects the 16 IV bytes to be ASCII hex characters. Generate 8 random bytes, hex them, and use the 16 characters as the IV. The other trap: <code>aes-128-cbc<\/code> throws if the key isn&#8217;t exactly 16 bytes, which is good, so don&#8217;t &#8220;fix&#8221; it by padding the key.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import crypto from \"node:crypto\";\n\nfunction keyFor(secret) {\n  const key = Buffer.from(secret, \"utf8\");\n  if (key.length !== 16) throw new Error(`use a 16 character token, or pin the mode first`);\n  return key;\n}\n\nexport function encrypt(secret, text) {\n  const iv = Buffer.from(crypto.randomBytes(8).toString(\"hex\").toUpperCase(), \"ascii\");\n  const c = crypto.createCipheriv(\"aes-128-cbc\", keyFor(secret), iv);\n  return Buffer.concat(&#91;iv, c.update(text, \"utf8\"), c.final()]).toString(\"base64\");\n}\n\nexport function decrypt(secret, token) {\n  const raw = Buffer.from(token.replace(\/\\s+\/g, \"\"), \"base64\");\n  if (raw.length &lt; 32 || (raw.length - 16) % 16 !== 0) throw new Error(\"not a ciphertext in this format\");\n  const d = crypto.createDecipheriv(\"aes-128-cbc\", keyFor(secret), raw.subarray(0, 16));\n  return Buffer.concat(&#91;d.update(raw.subarray(16)), d.final()]).toString(\"utf8\");\n}\n\n\/\/ Sending: URLSearchParams encodes + \/ = for you\n\/\/ const body = new URLSearchParams({ userid, sendMethod: \"quick\", mobile, msg: encrypt(tok, text),\n\/\/   encrypt: \"true\", senderid, msgType: \"text\", output: \"json\" });\n\/\/ await fetch(\"https:\/\/unify.smsgateway.center\/SMSApi\/send\", { method: \"POST\", body, headers: { apikey } });<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>Buffer.from(x, \"base64\")<\/code> is lenient too: it skips characters it doesn&#8217;t recognise. The length check after decoding is what catches a mangled token.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Java: the wrong Base64 class, and the platform charset<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The Java helper on the developer script page calls <code>Base64.encodeToString(bytes, Base64.DEFAULT)<\/code>. That&#8217;s Android&#8217;s <code>android.util.Base64<\/code>, which won&#8217;t compile on a server JVM, and its <code>DEFAULT<\/code> flag inserts a line break every 76 characters. The helper&#8217;s <code>.trim()<\/code> only removes the trailing one. It also decodes with <code>new String(bytes)<\/code>, which uses the platform&#8217;s default charset, so a Hindi message decrypted on a server whose default isn&#8217;t UTF-8 comes back as mojibake. Use <code>java.util.Base64<\/code> and say UTF-8 every time.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import java.nio.ByteBuffer;\nimport java.nio.charset.StandardCharsets;\nimport java.security.SecureRandom;\nimport java.util.Base64;\nimport javax.crypto.Cipher;\nimport javax.crypto.spec.IvParameterSpec;\nimport javax.crypto.spec.SecretKeySpec;\n\npublic final class SmsCipher {\n    private static final SecureRandom RNG = new SecureRandom();\n\n    private static SecretKeySpec key(String secret) {\n        byte&#91;] k = secret.getBytes(StandardCharsets.UTF_8);\n        if (k.length != 16) throw new IllegalArgumentException(\"use a 16 character token, or pin the mode first\");\n        return new SecretKeySpec(k, \"AES\");\n    }\n\n    public static String encrypt(String secret, String text) throws Exception {\n        byte&#91;] r = new byte&#91;8];\n        RNG.nextBytes(r);\n        StringBuilder hex = new StringBuilder();\n        for (byte b : r) hex.append(String.format(\"%02X\", b));\n        byte&#91;] iv = hex.toString().getBytes(StandardCharsets.US_ASCII);\n        Cipher c = Cipher.getInstance(\"AES\/CBC\/PKCS5Padding\");\n        c.init(Cipher.ENCRYPT_MODE, key(secret), new IvParameterSpec(iv));\n        byte&#91;] body = c.doFinal(text.getBytes(StandardCharsets.UTF_8));\n        \/\/ java.util.Base64.getEncoder() never inserts line breaks\n        return Base64.getEncoder().encodeToString(ByteBuffer.allocate(16 + body.length).put(iv).put(body).array());\n    }\n\n    public static String decrypt(String secret, String token) throws Exception {\n        byte&#91;] raw = Base64.getMimeDecoder().decode(token);   \/\/ tolerates line breaks from old encoders\n        if (raw.length &lt; 32 || (raw.length - 16) % 16 != 0) throw new IllegalArgumentException(\"not a ciphertext in this format\");\n        Cipher c = Cipher.getInstance(\"AES\/CBC\/PKCS5Padding\");\n        c.init(Cipher.DECRYPT_MODE, key(secret), new IvParameterSpec(raw, 0, 16));\n        return new String(c.doFinal(raw, 16, raw.length - 16), StandardCharsets.UTF_8);  \/\/ never the platform charset\n    }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>PKCS5Padding<\/code> in Java&#8217;s naming is the same PKCS#7 padding everyone else uses on 16-byte blocks. The decoder is deliberately the MIME one, so tokens produced by an older Android-style encoder still decode.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">PHP: silent key truncation, and errors that hide<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The PHP trap is the one from <a href=\"#key-length\">Key Length and Which Cipher You Get<\/a>: <code>openssl_encrypt<\/code> with <code>AES-128-CBC<\/code> quietly truncates a longer key instead of failing. The helper on the developer script page also catches exceptions and returns an object whose error you have to remember to check, and its error path passes <code>isset($initVector)<\/code> (a boolean) where the IV belongs. Fail loudly instead.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;?php\n\nfunction sgc_key(string $secret): string\n{\n    if (strlen($secret) !== 16) {\n        throw new InvalidArgumentException('use a 16 character token, or pin the mode first');\n    }\n    return $secret;\n}\n\nfunction sgc_encrypt(string $secret, string $text): string\n{\n    $iv = strtoupper(bin2hex(random_bytes(8)));          \/\/ 16 ASCII hex characters\n    $body = openssl_encrypt($text, 'AES-128-CBC', sgc_key($secret), OPENSSL_RAW_DATA, $iv);\n    if ($body === false) {\n        throw new RuntimeException('encryption failed');\n    }\n    return base64_encode($iv . $body);\n}\n\nfunction sgc_decrypt(string $secret, string $token): string\n{\n    $raw = base64_decode(preg_replace('\/\\s+\/', '', $token), true);   \/\/ strict mode\n    if ($raw === false || strlen($raw) &lt; 32 || (strlen($raw) - 16) % 16 !== 0) {\n        throw new InvalidArgumentException('not a ciphertext in this format');\n    }\n    $text = openssl_decrypt(substr($raw, 16), 'AES-128-CBC', sgc_key($secret), OPENSSL_RAW_DATA, substr($raw, 0, 16));\n    if ($text === false) {\n        throw new RuntimeException('decryption failed');\n    }\n    return $text;\n}\n\n\/\/ Sending: http_build_query encodes + \/ = correctly\n\/\/ $body = http_build_query(&#91;'userid' =&gt; $user, 'sendMethod' =&gt; 'quick', 'mobile' =&gt; $mobile,\n\/\/     'msg' =&gt; sgc_encrypt($token, $text), 'encrypt' =&gt; 'true', 'senderid' =&gt; $sender,\n\/\/     'msgType' =&gt; 'text', 'output' =&gt; 'json']);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>OPENSSL_RAW_DATA<\/code> matters. Without it, <code>openssl_encrypt<\/code> returns its own base64, and you&#8217;d base64 the IV plus a base64 string, which is a different format entirely.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"canary\" class=\"wp-block-heading\">Proving Interop With a Canary<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Your local code and the platform have to agree on four things: how the token becomes a key, how the IV is laid out, the padding, and the base64 alphabet. If any of them differ, nothing tells you until a customer gets gibberish or a report won&#8217;t open. So test it, once per token, before the token goes near production traffic. It takes four checks.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import os\nimport requests\n\nfrom sgc_crypto import decrypt, encrypt\n\nBASE = \"https:\/\/unify.smsgateway.center\/SMSApi\/\"\nPROBE = \"Canary 7f3a: \\u0928\\u092e\\u0938\\u094d\\u0924\\u0947 + \/ = &amp; %\"   # ASCII, Devanagari and the risky characters\n\n\ndef call(endpoint: str, msg: str) -&gt; str:\n    r = requests.post(BASE + endpoint,\n                      data={\"userid\": os.environ&#91;\"SGC_USERID\"], \"msg\": msg, \"output\": \"json\"},\n                      headers={\"apikey\": os.environ&#91;\"SGC_APIKEY\"],\n                               \"encryptKey\": os.environ&#91;\"SGC_ENCRYPT_TOKEN\"]},\n                      timeout=15)\n    reply = r.json()\n    if reply.get(\"status\") != \"success\":\n        raise RuntimeError(f\"{endpoint} refused the canary\")\n    return reply&#91;\"msg\"]\n\n\ndef run_canary(token: str, mode: str = \"pinned\") -&gt; None:\n    mine = encrypt(token, PROBE, mode)\n    assert call(\"decrypt\", mine) == PROBE, \"platform can't read my ciphertext\"\n    theirs = call(\"encrypt\", PROBE)\n    assert decrypt(token, theirs, mode) == PROBE, \"I can't read the platform's ciphertext\"\n    assert theirs != call(\"encrypt\", PROBE), \"platform IV is not random\"\n    print(\"canary passed; now send one encrypted SMS to your own phone and read it\")<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">What each check proves:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Local encrypt, platform decrypt.<\/strong> The platform reads what you&#8217;ll send.<\/li>\n\n\n\n<li><strong>Platform encrypt, local decrypt.<\/strong> You can read what the platform stores and puts in reports.<\/li>\n\n\n\n<li><strong>Two platform encryptions differ.<\/strong> The IV really is random per call, so you shouldn&#8217;t expect to match ciphertexts anywhere.<\/li>\n\n\n\n<li><strong>One real SMS to a phone you own,<\/strong> sent with <code>encrypt=true<\/code>. This is the only check that covers the send path itself: the flag, the decrypt-before-submit step, and the DLT template match on the decrypted text.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">If your token is 24 or 32 characters, run the first two checks with <code>mode=\"truncate16\"<\/code> and then with <code>mode=\"full\"<\/code>. Exactly one should pass. Write that mode down next to the key (see <a href=\"#key-ring\">Key Storage and the Key Ring<\/a>) and never guess it again.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The probe string deliberately includes Devanagari and every character that tends to break things: <code>+<\/code>, <code>\/<\/code>, <code>=<\/code>, <code>&amp;<\/code> and <code>%<\/code>. If the canary survives those, real messages will too.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Run the canary again whenever you generate a new token, change HTTP libraries, or move the code to a new runtime. It&#8217;s cheap, and it&#8217;s the difference between knowing and hoping.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"length-cost\" class=\"wp-block-heading\">Length, Segments and Cost<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Ciphertext is much longer than the text it carries. The IV adds 16 bytes, padding adds 1 to 16 more, and base64 inflates everything by a third. All of these were computed with the formula <code>4 * ceil((16 + 16 * (floor(bytes \/ 16) + 1)) \/ 3)<\/code>:<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Plain text<\/th><th>UTF-8 bytes<\/th><th>Ciphertext length (characters)<\/th><\/tr><\/thead><tbody><tr><td>52-character OTP message<\/td><td>52<\/td><td>108<\/td><\/tr><tr><td>160 GSM characters (one full segment)<\/td><td>160<\/td><td>256<\/td><\/tr><tr><td>306 GSM characters (two segments)<\/td><td>306<\/td><td>448<\/td><\/tr><tr><td>70 Devanagari characters (one Unicode segment)<\/td><td>210<\/td><td>320<\/td><\/tr><tr><td>1,000 characters<\/td><td>1,000<\/td><td>1,368<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The Devanagari row is the surprise. UTF-8 spends three bytes per Devanagari character, so a 70-character Hindi message that fits in a single Unicode segment becomes 320 characters of ciphertext.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">None of this should change what you&#8217;re billed for, because the operator receives the decrypted text and segments are counted on what&#8217;s actually delivered. But your own code has to count the right string. Run your <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/message-preview-cost-estimation-before-sending\/\">preview and cost estimate<\/a> on the plain text, before you encrypt it. If you run a length check or a segment counter on <code>msg<\/code> after encryption, you&#8217;ll reject valid messages and quote customers two or three times the real cost. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-length-calculation-credits-counted\/\">character limits explainer<\/a> covers how GSM-7 and Unicode segments are counted.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Then confirm it. After your first few encrypted sends, compare your estimated segment count with the part count the delivery report gives you for those messages. If they match, your estimator is counting the right thing. If the report shows more parts than the plain text needs, stop and look before you scale up.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"reading-reports\" class=\"wp-block-heading\">Reading Encrypted Reports Back<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">With System Message Encryption or DLR Message Encryption on, message text in the portal&#8217;s report screens and in downloaded reports comes out as ciphertext. You&#8217;ll need your decrypt function wherever a person or a job reads those reports.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Don&#8217;t assume every text field is encrypted, though. Messages sent before you switched encryption on are plaintext. Messages sent after you switch it off are plaintext. If you pull message text back over an API and keep it in your own <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/delivery-report-ingestion-system-of-record\/\">delivery report store<\/a>, you can end up with a mix. So decrypt on shape, not on assumption.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The format has a very recognisable shape: strict base64, a decoded length of 16 plus a multiple of 16, and a first 16 bytes made entirely of hex digits. Plain text practically never has all three.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import base64\nimport binascii\nimport string\n\nHEX = set(string.hexdigits.encode())\n\n\ndef looks_encrypted(value: str) -&gt; bool:\n    \"\"\"True if value has the exact shape of this encryption format.\"\"\"\n    compact = \"\".join(value.split())\n    if len(compact) &lt; 44 or len(compact) % 4:\n        return False\n    try:\n        raw = base64.b64decode(compact, validate=True)\n    except (binascii.Error, ValueError):\n        return False\n    return (len(raw) - 16) % 16 == 0 and len(raw) &gt;= 32 and all(b in HEX for b in raw&#91;:16])<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Use it as a router: if <code>looks_encrypted<\/code> is true, decrypt with the key that was active when the message was sent; if it&#8217;s false, treat the value as plain text. If it looks encrypted but won&#8217;t decrypt with that key, flag the row. Don&#8217;t loop through other keys to &#8220;find one that works&#8221; (the next two sections explain why).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">When you decrypt for display, do it at the last moment, in memory, for the person who&#8217;s allowed to see it. Writing the decrypted text back into a column next to the ciphertext undoes the reason you encrypted.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"key-ring\" class=\"wp-block-heading\">Key Storage and the Key Ring<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">You&#8217;ll end up with more than one encryption token over the life of an account. You rotate, someone leaks one into a log, a contractor leaves. Every old token is still the only way to read the messages and reports produced while it was active. So treat tokens as a key ring from day one, not as a single environment variable you overwrite.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Keep the token values in your secret manager, never in source control and never in the same secret as your API key. Then keep a small table of metadata that points at them:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>CREATE TABLE message_encryption_key (\n    key_id        text PRIMARY KEY,          -- your own label, e.g. 'k2026-10'\n    secret_ref    text NOT NULL,             -- where the token lives in your secret manager\n    cipher_mode   text NOT NULL,             -- 'aes128' for 16 characters, or the pinned 'truncate16' \/ 'full'\n    status        text NOT NULL,             -- 'pending', 'active', 'retired'\n    canary_passed timestamptz,\n    activated_at  timestamptz,\n    retired_at    timestamptz\n);\n\nALTER TABLE outbound_message\n    ADD COLUMN encryption_key_id text REFERENCES message_encryption_key (key_id);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The column on your outbound table is the important part. If you followed the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/outbound-message-table-schema-design\/\">outbound message table design<\/a>, you already store <code>transactionId<\/code> as text for every send. Add the key id beside it, set at the moment you encrypt. Later, when you&#8217;re reading a report row or a stored ciphertext, you look up the key by id instead of guessing.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Why not guess? Because the format has no key identifier and no authentication tag, and CBC with PKCS#7 padding gives you a false &#8220;success&#8221; surprisingly often. Decrypt with the wrong key and the last block is random bytes; the chance those happen to form valid padding is about 1 in 255 (computed as the sum of 256 to the power of minus k for k from 1 to 16). Try three wrong keys on a few thousand rows and you&#8217;ll &#8220;successfully&#8221; decrypt dozens of them into garbage, and nothing will tell you which.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Keep the label in your own system. Don&#8217;t store a hash of the token as its id: a token that&#8217;s only 16 hex characters is weaker than it looks, and a hash in a widely readable table gives an attacker something to grind against. A label like <code>k2026-10<\/code> carries no information about the key.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"rotation\" class=\"wp-block-heading\">Rotating a Key Without Losing History<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Rotation on this platform means generating a new token in the portal. Two facts shape how you do it. Old ciphertext stays readable only with the old token. And nothing tells you exactly when, between your click and your next send, the platform starts expecting the new one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So rotate in a way that doesn&#8217;t depend on that moment:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Pick a quiet window.<\/strong> Pause encrypted sends, or let them queue in your own system.<\/li>\n\n\n\n<li><strong>Drain or rebook scheduled encrypted messages.<\/strong> Anything you booked on the platform with a future <code>scheduleTime<\/code> was encrypted under the current token. Read the pending list with <code>SMSApi\/schedule\/read<\/code> and either let it finish or cancel with <code>SMSApi\/schedule\/delete<\/code> and rebook after rotation. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/scheduling-messages-sms-whatsapp-rcs-telegram\/\">scheduling article<\/a> covers that endpoint family.<\/li>\n\n\n\n<li><strong>Save the old token under its key id and mark it retired<\/strong>, before you touch the portal. If you generate first and save second, a crash between the two loses history.<\/li>\n\n\n\n<li><strong>Generate the new token<\/strong>, store it under a new key id as <code>pending<\/code>, and run the canary against it.<\/li>\n\n\n\n<li><strong>Flip it to active<\/strong> only when the canary and the handset check pass, then resume sends.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">Retired tokens stay in the ring for as long as you keep any report or record that might contain their ciphertext, and that includes reports someone downloaded to a laptop two years ago. If you ever decide to switch encryption off entirely, the same rule applies: switching off stops new encryption, it doesn&#8217;t decrypt the past.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"integrity\" class=\"wp-block-heading\">What the Format Doesn&#8217;t Protect<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">This format gives you confidentiality. It doesn&#8217;t give you integrity. There&#8217;s no MAC, no signature and no authentication tag, and CBC mode is malleable: someone who can change the ciphertext can flip chosen bits in the decrypted text (by flipping bits in the IV or the previous block) without knowing the key. They can&#8217;t read your message, but they can tamper with it in ways you won&#8217;t detect from the ciphertext alone.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In practice, here&#8217;s what that means for you:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>TLS does the integrity work in transit.<\/strong> Always call the API over HTTPS, keep your TLS stack current (the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/tls13-upgrade-guide\/\">TLS 1.3 upgrade guide<\/a> covers the client side), and don&#8217;t disable certificate checks to &#8220;get it working&#8221;.<\/li>\n\n\n\n<li><strong>Don&#8217;t build a padding oracle.<\/strong> If you expose anything that accepts ciphertext from outside and decrypts it, such as an internal report viewer or a support tool, return the same generic error for every failure. A tool that says &#8220;bad padding&#8221; for one input and &#8220;bad UTF-8&#8221; for another lets an attacker decrypt messages one byte at a time without the key.<\/li>\n\n\n\n<li><strong>Protect what you store with your own controls.<\/strong> If you keep ciphertext in your own database, the platform format isn&#8217;t what&#8217;s protecting it from tampering; your database permissions and audit logging are. If you need tamper evidence on your side, wrap the stored value in your own authenticated encryption (AES-GCM, for example) with a key the messaging platform never sees. That&#8217;s separate from, and in addition to, the platform format.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">And one property of the IV worth knowing: it carries 64 bits of randomness, because it&#8217;s 8 random bytes written as hex. With a 64-bit random IV, the chance of any repeat across n messages under one key is about n squared over 2 to the 65th. That&#8217;s about 0.03 percent at 100 million messages and about 2.7 percent at a billion (computed). A repeated IV in CBC reveals whether two messages share the same opening block, nothing more. If you&#8217;re anywhere near those volumes on one token, that&#8217;s one more reason to rotate on a schedule.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"pipeline\" class=\"wp-block-heading\">Encryption and the Rest of Your Pipeline<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Encryption touches more of your sending code than the one line that calls <code>encrypt<\/code>. Go through these before you switch it on.<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Part of your pipeline<\/th><th>What changes<\/th><th>What to do<\/th><\/tr><\/thead><tbody><tr><td>Idempotency and duplicate detection<\/td><td>The same text never produces the same ciphertext<\/td><td>Build your <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/message-idempotency-preventing-duplicate-sends\/\">idempotency key<\/a> from the plain text (or a keyed hash of it) before encrypting<\/td><\/tr><tr><td>Preview, segment count, cost<\/td><td>Ciphertext is 2 to 3 times longer<\/td><td>Count and price the plain text; see <a href=\"#length-cost\">Length, Segments and Cost<\/a><\/td><\/tr><tr><td>DLT template matching<\/td><td>The match happens on the decrypted text<\/td><td>Your template rules don&#8217;t change; test with the handset check<\/td><\/tr><tr><td>Smart links and click tracking<\/td><td>The platform can&#8217;t rewrite a link inside ciphertext unless it decrypts first<\/td><td>Create the short link first and put it in the plain text before you encrypt; see the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-click-tracking-attribution-smart-links\/\">click tracking article<\/a><\/td><\/tr><tr><td>Logging<\/td><td>Your own logs are now the leakiest place<\/td><td>Log the key id and <code>transactionId<\/code>, never the plain text or the token<\/td><\/tr><tr><td>Search and support tools<\/td><td>You can&#8217;t search ciphertext<\/td><td>Search on recipient, time and <code>transactionId<\/code>; decrypt the hits for authorised staff<\/td><\/tr><tr><td>Contract tests<\/td><td>The send body changes shape<\/td><td>Add the canary to your <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/contract-testing-harness-messaging-api\/\">contract test suite<\/a> so a library upgrade can&#8217;t silently break encoding<\/td><\/tr><tr><td>Other channels<\/td><td>The encrypt and decrypt endpoints sit under <code>SMSApi\/<\/code><\/td><td>Plan this as an SMS feature; the WhatsApp, RCS and Telegram send calls take no encrypt flag<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The logging row deserves a second look. Teams switch on provider-side encryption, then keep logging full request bodies at debug level in their own services. Now the platform holds ciphertext and your log cluster holds plaintext. Check what your HTTP client middleware writes before you claim the benefit.<\/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><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Your situation<\/th><th>Turn on encryption?<\/th><th>Which setting<\/th><th>Notes<\/th><\/tr><\/thead><tbody><tr><td>Bank, lender, insurer or healthcare provider sending balances, amounts or appointment details<\/td><td>Yes<\/td><td>System Message Encryption<\/td><td>Keeps message text out of provider storage and logs<\/td><\/tr><tr><td>You mainly worry about people exporting reports from the portal<\/td><td>Yes<\/td><td>DLR Message Encryption, or System<\/td><td>DLR mode leaves the database as is but scrambles views and downloads<\/td><\/tr><tr><td>OTP only, codes expire in minutes<\/td><td>Optional<\/td><td>System Message Encryption if your auditors ask<\/td><td>Low value at rest, but it costs little once the code exists<\/td><\/tr><tr><td>Marketing and promotional broadcasts<\/td><td>Usually no<\/td><td>None<\/td><td>The text is public by design; encryption adds failure modes for no gain<\/td><\/tr><tr><td>You need privacy from the operator or interception<\/td><td>Encryption won&#8217;t do it<\/td><td>None<\/td><td>SMS can&#8217;t give you that; use an end-to-end encrypted channel<\/td><\/tr><tr><td>You can&#8217;t commit to keeping old keys for years<\/td><td>Not yet<\/td><td>None<\/td><td>Old reports become unreadable without the old token<\/td><\/tr><tr><td>Your stack can&#8217;t run the canary or a handset check<\/td><td>Not yet<\/td><td>None<\/td><td>Fix the test path first<\/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=\"checklist\" class=\"wp-block-heading\">Pre-Launch Checklist<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Encryption enabled under My Account, Security, Text Encryption, with System or DLR Message Encryption chosen deliberately<\/li>\n\n\n\n<li>Token generated and stored in your secret manager, separate from the API key<\/li>\n\n\n\n<li>Token length checked; for 24 or 32 characters, cipher mode pinned by the canary<\/li>\n\n\n\n<li>Local encrypt and decrypt implemented with the 16-character ASCII hex IV, PKCS#7 padding and standard base64<\/li>\n\n\n\n<li>Request bodies built by your HTTP library, never by string concatenation<\/li>\n\n\n\n<li>Canary passed: local to platform, platform to local, two platform encryptions differ<\/li>\n\n\n\n<li>One encrypted SMS with <code>encrypt=true<\/code> received as readable text on a phone you own<\/li>\n\n\n\n<li>Segment counting and cost estimates run on the plain text<\/li>\n\n\n\n<li><code>encryption_key_id<\/code> stored on every outbound row; no trial decryption anywhere<\/li>\n\n\n\n<li>Report readers decrypt on shape and fall back to plain text for older rows<\/li>\n\n\n\n<li>Debug logs checked for plain text bodies and tokens<\/li>\n\n\n\n<li>Rotation runbook written, including draining scheduled encrypted messages<\/li>\n\n\n\n<li>Retention for retired tokens agreed with whoever owns your report archive<\/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 behaviours of the encrypted send path aren&#8217;t pinned down by anything you can read ahead of time. Each item below gives you the choice that stays safe whichever way the platform behaves, 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. Let the canary pick the cipher for any token longer than 16 characters.<\/strong> The two sample helpers disagree on what a 24 or 32 character token means. Running your ciphertext through <code>SMSApi\/decrypt<\/code> in both modes tells you which one the platform uses, and you only need to ask once per token.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Two. Send <code>encrypt=true<\/code> on every encrypted send, and treat ciphertext on a handset as an incident.<\/strong> The flag is what tells the platform to decrypt. Your handset check proves the send path works for your account today. Keep a second test number in your monitoring that receives one encrypted message a day; if it ever shows base64, stop encrypted sends.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Three. Count segments and cost on the plain text, then confirm against the delivery report.<\/strong> Whatever the platform counts internally, what reaches the operator is the decrypted text, and that&#8217;s what a segment is. Your estimate matches it by construction. The reconciliation tells you within a day if anything differs.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Four. Put short links in the plain text before you encrypt.<\/strong> Whether link tracking can rewrite a URL inside an encrypted <code>msg<\/code> depends on whether that step runs before or after decryption. Creating the short link yourself first makes the order irrelevant.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Five. Never regenerate a token while encrypted bookings are pending.<\/strong> Whether a scheduled message encrypted under the old token still decrypts after rotation isn&#8217;t something you want to discover at send time. Drain or rebook first, and the question never comes up.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Six. Decrypt report text on shape, not on a flag.<\/strong> Whether a given report field or API field carries ciphertext can depend on when the message was sent and which setting was on. The shape test in <a href=\"#reading-reports\">Reading Encrypted Reports Back<\/a> works for both, and falls back to plain text safely.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Seven. Store the key id with every ciphertext you keep, and never trial-decrypt.<\/strong> The format carries no key identifier. Your own column does, and with it a wrong-key decryption can&#8217;t happen by accident.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Eight. Keep every retired token for as long as any report from its period might exist.<\/strong> Switching encryption off doesn&#8217;t decrypt old data, and you can&#8217;t get a retired token back from anywhere else. Over-retaining a key costs a row in your secret manager; under-retaining one costs the history.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Nine. Read only <code>status<\/code> from the encrypt and decrypt endpoints.<\/strong> Their success <code>statusCode<\/code> is <code>\"900\"<\/code>, not the <code>\"200\"<\/code> used elsewhere, and error shapes aren&#8217;t something to bet on. <code>status<\/code> plus <code>msg<\/code> on success is all your code needs.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Ten. Build every request body with a form encoder, including in tests and scripts.<\/strong> Whether the server would tolerate a raw <code>+<\/code> in some code path doesn&#8217;t matter if your client never sends one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Eleven. Return one generic error from anything that decrypts input you didn&#8217;t produce.<\/strong> Whether a padding failure and a decoding failure would look different to an outsider is a question you avoid by making them look identical.<\/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 SMS encryption make my messages end-to-end encrypted?<\/strong><br>No. The platform decrypts each message right before handing it to the operator, and the operator and the recipient&#8217;s phone see plain text. What encryption gives you is ciphertext in the provider&#8217;s database, logs, portal screens and reports.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Should I call SMSApi\/encrypt before every send?<\/strong><br>No. That sends your plain text to the platform just to get ciphertext back. Encrypt in your own code and use the encrypt and decrypt endpoints once per token to prove your implementation matches.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Is the encryption token the same as my API key?<\/strong><br>No. They&#8217;re separate secrets with separate jobs. The API key authenticates requests; the encryption token encrypts and decrypts message text. Store and rotate them separately.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Why does decryption fail on some messages but not others?<\/strong><br>Almost always the <code>+<\/code> problem. If your request body is built by hand, every <code>+<\/code> in the base64 becomes a space on the server. Let your HTTP library form-encode the body and it goes away.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Does encryption increase my SMS cost?<\/strong><br>It shouldn&#8217;t. Segments are counted on the text that&#8217;s delivered, which is the decrypted text. Just make sure your own estimator counts the plain text, not the much longer ciphertext, and check the first few delivery reports.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What happens to old messages if I turn encryption off?<\/strong><br>They stay encrypted. Switching off only affects new messages, so keep the old token if you&#8217;ll ever need to read those reports.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Can I search my message history once it&#8217;s encrypted?<\/strong><br>Not by content. Each message uses a random IV, so the same text never produces the same ciphertext. Search on recipient, time and transaction id, then decrypt the matches for the people allowed to read them.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Does this work for WhatsApp, RCS or Telegram?<\/strong><br>It&#8217;s an SMS feature. The encrypt and decrypt endpoints live under <code>SMSApi\/<\/code>, and the other channels&#8217; send calls don&#8217;t take an encrypt flag.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Which AES key size does it use?<\/strong><br>With a 16-character token, AES-128. With a 24 or 32 character token, the two published helpers disagree, so run the canary in both modes and use whichever one the platform accepts.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">Sending Sensitive Data by SMS? Encrypt It First<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Handling account balances, loan amounts or patient details over SMS? <a href=\"https:\/\/unify.smsgateway.center\/signup\/\">Create a free account<\/a>, switch on Text Encryption in the portal, and run the canary from this guide against your own token before your first production send. If you&#8217;d like a second pair of eyes on your threat model or rotation plan, <a href=\"https:\/\/www.smsgatewaycenter.com\/contact\/\">get in touch with our 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\">More from Blog<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-click-tracking-attribution-smart-links\/\">SMS Click Tracking and Attribution: Joining Smart Link Clicks to Messages and Recipients<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/consent-capture-evidence-sms-whatsapp-rcs-telegram\/\">Consent Capture and Evidence for SMS, WhatsApp, RCS and Telegram: Building an Opt-In Record You Can Prove<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/two-way-sms-keyword-routing-auto-reply-design\/\">Two-Way SMS Keyword Design: Routing, Auto-Replies and Conversation State on a Long Code<\/a><\/p>\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<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n","protected":false},"excerpt":{"rendered":"<p>SMS message encryption keeps your message text out of the platform&#8217;s database, logs and reports, but the operator and the handset still see plaintext. Here&#8217;s the exact AES-CBC wire format, working code in four languages, the form-encoding and key-length traps that break interop, and how to rotate keys without losing your history.<\/p>\n","protected":false},"author":118,"featured_media":3080,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[2010],"tags":[2332,2333,2334,2337,2331,2330,481,2329,2335,2336],"class_list":["post-3079","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-developer-guides","tag-aes-cbc","tag-base64","tag-data-protection","tag-dlr-encryption","tag-key-rotation","tag-message-security","tag-sms-api","tag-sms-encryption","tag-smsapi-decrypt","tag-smsapi-encrypt"],"_links":{"self":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/3079","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=3079"}],"version-history":[{"count":0,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/3079\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media\/3080"}],"wp:attachment":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media?parent=3079"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/categories?post=3079"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/tags?post=3079"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}