{"id":3128,"date":"2026-10-30T08:41:51","date_gmt":"2026-10-30T03:11:51","guid":{"rendered":"https:\/\/www.smsgatewaycenter.com\/blog\/?p=3128"},"modified":"2026-10-30T08:41:54","modified_gmt":"2026-10-30T03:11:54","slug":"rcs-rich-cards-carousels-content-model-validation","status":"publish","type":"post","link":"https:\/\/www.smsgatewaycenter.com\/blog\/rcs-rich-cards-carousels-content-model-validation\/","title":{"rendered":"RCS Rich Cards and Carousels as a Content Model: Validate, Version and Send Cards Without Surprises"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">RCS rich cards and carousels look like design work, but they behave like a data model with hard limits, an approval clock and no update call. Here&#8217;s how to model cards in your own system, validate them the way the device counts, version them by template name, read them back from <code>RCSApi\/template\/list<\/code>, and match button taps to the exact send.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/rcs-rich-cards-carousels-content-model.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"584\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/rcs-rich-cards-carousels-content-model-1024x584.webp\" alt=\"Abstract fanned row of teal and blue cards with orange button shapes and a return line, representing RCS carousel cards and tap postbacks\" class=\"wp-image-3129\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/rcs-rich-cards-carousels-content-model-1024x584.webp 1024w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/rcs-rich-cards-carousels-content-model-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/rcs-rich-cards-carousels-content-model-768x438.webp 768w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/rcs-rich-cards-carousels-content-model.webp 1200w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><figcaption class=\"wp-element-caption\">A carousel is a list of cards with hard limits, and every button tap is a message coming 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=\"#four-types\">Four Template Types and What Each One Is For<\/a><\/li>\n\n\n\n<li><a href=\"#limits-table\">The Limits That Actually Bind<\/a><\/li>\n\n\n\n<li><a href=\"#read-back\">What the Platform Hands Back<\/a><\/li>\n\n\n\n<li><a href=\"#content-model\">Your Own Card Model: Store Intent, Not Wire JSON<\/a><\/li>\n\n\n\n<li><a href=\"#counting\">Counting Characters the Way the Limit Counts<\/a><\/li>\n\n\n\n<li><a href=\"#versioning\">Template Names Are Your Version Numbers<\/a><\/li>\n\n\n\n<li><a href=\"#variables\">Variables Across Titles, Buttons and URLs<\/a><\/li>\n\n\n\n<li><a href=\"#postbacks\">Postbacks as a Tiny Grammar<\/a><\/li>\n\n\n\n<li><a href=\"#media\">The Media Pipeline: Ratios, Sizes and Thumbnails<\/a><\/li>\n\n\n\n<li><a href=\"#carousels\">Carousels: Order, Height and Truncation<\/a><\/li>\n\n\n\n<li><a href=\"#lifecycle\">The Approval Lifecycle and the Integer Status<\/a><\/li>\n\n\n\n<li><a href=\"#fallback-text\">One Model, One Fallback Text<\/a><\/li>\n\n\n\n<li><a href=\"#python-code\">Working Code: Validator, Read-Back Parser and Postback Matcher<\/a><\/li>\n\n\n\n<li><a href=\"#four-languages\">Four Traps in Four Languages<\/a><\/li>\n\n\n\n<li><a href=\"#decision-matrix\">Decision Matrix: Card, Carousel, Media or Text<\/a><\/li>\n\n\n\n<li><a href=\"#seven-steps\">From Model to First Send in Seven Steps<\/a><\/li>\n\n\n\n<li><a href=\"#checklist\">Pre-Submit 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\">Treat every RCS rich card and carousel as a record in your own system, not as JSON you hand-craft for each campaign. Keep one card model (titles, descriptions, media, buttons, variables, postback prefixes), validate it against the platform&#8217;s limits before you submit, give each version its own template name, create it with <code>RCSApi\/template\/create<\/code>, read it back with <code>RCSApi\/template\/list<\/code> and diff what was stored against what you meant, and only send with <code>RCSApi\/send<\/code> once the template&#8217;s <code>status<\/code> reads <code>1<\/code>. When a button is tapped, the postback comes back as your prefix plus a <code>_[message_id]<\/code> suffix, so match it against the prefixes you issued and look the suffix up in your send ledger.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The limits that catch people are small and easy to check in code: a template name of up to 20 letters, digits or underscores; titles up to 200 characters; descriptions up to 2,000; up to 4 suggestions per card and 10 cards per carousel; button text up to 25 characters; and every variable costing 10 characters of the field it sits in. Count non-ASCII text by UTF-8 bytes and you&#8217;ll never be surprised by an emoji.<\/p>\n\n\n\n<h2 id=\"tldr\" class=\"wp-block-heading\">TL;DR<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"https:\/\/www.smsgatewaycenter.com\/rcs-messaging\/\">SMSGatewayCenter RCS<\/a> supports four template types: Text Message, Rich Card Stand Alone, Rich Card Carousel and Media. The API names three of them as <code>text_message<\/code>, <code>rich_card<\/code> and <code>carousel<\/code>.<\/li>\n\n\n\n<li>Every RCS send references an approved template by name. <code>RCSApi\/send<\/code> takes <code>contentMessage.templateMessage.templateCode<\/code>, optional <code>customParams<\/code> and <code>ttl<\/code>. The card content itself lives in the template.<\/li>\n\n\n\n<li><code>RCSApi\/template\/list<\/code> returns each template with a <code>data<\/code> field that&#8217;s a JSON <strong>string<\/strong>. Parse it a second time to get <code>templateModel<\/code>, which holds <code>standAlone<\/code> for a rich card or <code>carouselList<\/code> for a carousel.<\/li>\n\n\n\n<li>There&#8217;s no update endpoint, only create, list and delete. A content change is a new template with a new name, so put the version in the name and keep it under 20 characters.<\/li>\n\n\n\n<li>Create replies &#8220;Accepted for processing.&#8221; with the template name and no identifier. Confirm by reading it back.<\/li>\n\n\n\n<li><code>customParams<\/code> is a JSON string inside the <code>msg<\/code> JSON, not a nested object. Let a JSON library encode it.<\/li>\n\n\n\n<li>Postbacks always end in <code>_[message_id]<\/code>, filled in at send time. Design the editable part as a short code you issued (<code>P01<\/code>, <code>P02<\/code>) and keep the meaning in your database.<\/li>\n\n\n\n<li>Budget text by UTF-8 bytes and count each <code>[variable]<\/code> as at least 10 characters. Check again at send time with the real values.<\/li>\n\n\n\n<li>Render the fallback text from the same model, so the SMS version never drifts from the card.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"four-types\" class=\"wp-block-heading\">Four Template Types and What Each One Is For<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Every RCS message on SMSGatewayCenter goes out from a template, and every template belongs to one bot. The template also has to match that bot&#8217;s message type: Promotional, OTP, Transactional or Multi-Use. You pick one of four content types when you create it, as the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/what-types-of-rcs-templates-can-i-create\/\">RCS template types guide<\/a> lists:<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Template type<\/th><th>What it holds<\/th><th>Limits<\/th><th>API <code>type<\/code><\/th><\/tr><\/thead><tbody><tr><td>Text Message<\/td><td>Plain text with optional suggested replies or action buttons<\/td><td>Up to 2,500 characters, up to 11 suggestions<\/td><td><code>text_message<\/code><\/td><\/tr><tr><td>Rich Card Stand Alone<\/td><td>One card: image, video or PDF, a title, a description and buttons<\/td><td>Title 200, description 2,000, up to 4 suggestions<\/td><td><code>rich_card<\/code><\/td><\/tr><tr><td>Rich Card Carousel<\/td><td>Several cards the recipient swipes through, each with its own media, title, description and buttons<\/td><td>Up to 10 cards, each with the card limits<\/td><td><code>carousel<\/code><\/td><\/tr><tr><td>Media<\/td><td>One file (image, audio, video or PDF) with body text above or below it<\/td><td>Body 2,000 characters, file up to 100 MB<\/td><td>not named on the create page<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The last column matters more than it looks. The <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/create-rcs-template\/\">Create RCS Template API<\/a> names <code>text_message<\/code>, <code>rich_card<\/code> and <code>carousel<\/code> as supported <code>type<\/code> values, and shows a full sample only for <code>text_message<\/code>. So in practice you have two ways to make a card: build it in the portal under RCS, RCS Templates, or create it through the API using the field names you see when the platform reads a card back. Both end up in the same place, and both are read back the same way, which is what the rest of this guide leans on.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A practical way to think about the four:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Text Message<\/strong> is for anything that&#8217;s mostly words: OTPs, alerts, short confirmations with one or two reply buttons.<\/li>\n\n\n\n<li><strong>Rich Card<\/strong> is one thing with a picture and a call to action: an order confirmation with a &#8220;Track&#8221; button, a single offer, a boarding reminder.<\/li>\n\n\n\n<li><strong>Carousel<\/strong> is a short list of comparable things: three plans, five products, a trip with one card per leg.<\/li>\n\n\n\n<li><strong>Media<\/strong> is a file that matters on its own: an invoice PDF, a short video, an audio clip.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">If you&#8217;re coming from SMS, the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/rcs-messaging-api-reference-migration-from-sms\/\">RCS Messaging API reference<\/a> walks through bots, send methods, delivery reports and the inbox. This guide picks up where that one stops: what to do with the card content itself once you have more than a handful of templates.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"limits-table\" class=\"wp-block-heading\">The Limits That Actually Bind<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s every limit that a validator can check before you submit, pulled from the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/what-are-the-media-and-layout-requirements-for-rcs-rich-cards\/\">rich card layout and media requirements<\/a>, the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/how-do-suggested-replies-and-action-buttons-work-in-rcs-templates\/\">suggestions and buttons guide<\/a> and the template creation guide.<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>What<\/th><th>Limit<\/th><th>Notes<\/th><\/tr><\/thead><tbody><tr><td>Template name<\/td><td>Up to 20 characters, letters, digits and underscores only<\/td><td>No spaces, no special characters<\/td><\/tr><tr><td>Text message body<\/td><td>2,500 characters<\/td><td><\/td><\/tr><tr><td>Card title<\/td><td>200 characters<\/td><td>Applies to every card in a carousel<\/td><\/tr><tr><td>Card description<\/td><td>2,000 characters<\/td><td>Same<\/td><\/tr><tr><td>Media template body<\/td><td>2,000 characters<\/td><td><\/td><\/tr><tr><td>Suggestions on a text message<\/td><td>11<\/td><td><\/td><\/tr><tr><td>Suggestions per rich card or carousel card<\/td><td>4<\/td><td><\/td><\/tr><tr><td>Button text<\/td><td>25 characters<\/td><td><\/td><\/tr><tr><td>URL in a URL button<\/td><td>2,048 characters<\/td><td><\/td><\/tr><tr><td>Cards in a carousel<\/td><td>Up to 10<\/td><td><\/td><\/tr><tr><td>Each variable<\/td><td>Uses 10 characters of the field&#8217;s limit<\/td><td>Applies in text, buttons, postbacks and URLs<\/td><\/tr><tr><td>Card image<\/td><td>JPEG, JPG, PNG, GIF, up to 2 MB<\/td><td><\/td><\/tr><tr><td>Card video<\/td><td>Video up to 10 MB, plus a thumbnail (JPEG, JPG or PNG) up to 100 KB, about 40 KB recommended<\/td><td><\/td><\/tr><tr><td>Card PDF<\/td><td>PDF only, up to 100 MB<\/td><td><\/td><\/tr><tr><td>Media template file<\/td><td>JPEG, JPG, PNG, GIF, MP3, MP4, PDF, MPEG, up to 100 MB<\/td><td><\/td><\/tr><tr><td>RCS Media Library upload<\/td><td>Images, video, audio, PDF and Word, up to 10 MB each<\/td><td>Stricter than the card PDF and media template limits<\/td><\/tr><tr><td>Vertical card, short media<\/td><td>3:1 image, ideally 1440 x 480 px<\/td><td>The form shows the exact ratio for other combinations<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">One catch before you turn this into code: the Media Library cap is 10 MB while card PDFs and media template files allow 100 MB. If your pipeline uploads through the library, the 10 MB cap is the one you&#8217;ll hit, so use it. And Google&#8217;s own <a href=\"https:\/\/developers.google.com\/business-communications\/rcs-business-messaging\/guides\/learn\/rich-cards\">rich cards guide<\/a> recommends a 7:2 ratio for short vertical media, while the platform&#8217;s form asks for 3:1. Crop to what the form asks for, because that&#8217;s what accepts or rejects your upload.<\/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-rcs-template-limits-table.svg\"><img decoding=\"async\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/diagram-rcs-template-limits-table.svg\" alt=\"Table of RCS template limits by type: title, body or description, suggestions, button text, card count and media size for text message, rich card, carousel and media templates\" class=\"wp-image-3131\"\/><\/a><figcaption class=\"wp-element-caption\">The limits a validator can check before submit. Where two sources differ, validate against the stricter one.<\/figcaption><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"read-back\" class=\"wp-block-heading\">What the Platform Hands Back<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The fastest way to understand the card shape is to look at what <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/list-rcs-template\/\"><code>RCSApi\/template\/list<\/code><\/a> returns. It&#8217;s a GET with <code>userid<\/code>, credentials and <code>botId<\/code>, and each row looks like this (the carousel row from the published sample):<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"data\": \"{\\\"templateModel\\\":{\\\"name\\\":\\\"Flight_Booking\\\",\\\"type\\\":\\\"carousel\\\",\\\"height\\\":\\\"SHORT_HEIGHT\\\",\\\"width\\\":\\\"SMALL_WIDTH\\\",\\\"botId\\\":\\\"YOUR_BOT_ID\\\",\\\"carouselList\\\":&#91;{\\\"cardTitle\\\":\\\"Your Trip is Confirmed\\\",\\\"cardDescription\\\":\\\"Mumbai to Dubai\\\",\\\"mediaUrl\\\":\\\"https:\/\/example.com\/media\/flight.jpg\\\",\\\"suggestions\\\":&#91;{\\\"suggestionType\\\":\\\"reply\\\",\\\"displayText\\\":\\\"View QR\\\",\\\"postback\\\":\\\"View QR_&#91;message_id]\\\",\\\"preference\\\":1}]}],\\\"createdViaApi\\\":true}}\",\n  \"creationTime\": 1784978480698,\n  \"botName\": \"YourBot\",\n  \"name\": \"Flight_Booking\",\n  \"updationTime\": 1784978480698,\n  \"category\": \"Promotional\",\n  \"type\": \"carousel\",\n  \"status\": 1\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A few things jump out once you&#8217;ve stared at it for a minute.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>data<\/code> is a string, not an object. You parse the response, then parse <code>data<\/code> again, and only then do you reach <code>templateModel<\/code>. Forget the second parse and every field you look for is undefined.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The card fields are consistent across the two card types. A carousel keeps its cards in <code>carouselList<\/code>; a rich card keeps its single card in <code>standAlone<\/code>. Each card has <code>cardTitle<\/code>, <code>cardDescription<\/code>, <code>mediaUrl<\/code> and a <code>suggestions<\/code> array, and each suggestion has <code>suggestionType<\/code>, <code>displayText<\/code>, <code>postback<\/code> and <code>preference<\/code>. Layout sits on the model: <code>height<\/code> (<code>SHORT_HEIGHT<\/code>, <code>MEDIUM_HEIGHT<\/code> in the samples), <code>width<\/code> on carousels (<code>SMALL_WIDTH<\/code> in the sample) and <code>orientation<\/code> on rich cards (<code>VERTICAL<\/code> in the sample).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>creationTime<\/code> and <code>updationTime<\/code> are epoch milliseconds. <code>1784978480698<\/code> is 2026-07-25 11:21:20 UTC, which is 16:51:20 in India. Thirteen digits fit comfortably in a JavaScript number or a Java <code>long<\/code>, so no special handling is needed there.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>status<\/code> is an integer, and the list page says <code>1<\/code> means active. <code>category<\/code> comes back as <code>Promotional<\/code>, while the create sample sends <code>TRANSACTIONAL<\/code>. Compare categories case-insensitively.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>name<\/code> is the value you pass as <code>templateCode<\/code> when you send. That&#8217;s the only link between the template you designed and the message that goes out.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"content-model\" class=\"wp-block-heading\">Your Own Card Model: Store Intent, Not Wire JSON<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The tempting approach is to keep a folder of template JSON and copy it into each create call. It works for three templates. By thirty you&#8217;ve got near-duplicates with slightly different button labels, no idea which one is live, and a fallback SMS that says something the card no longer says.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The fix is to keep a small model in your own database and generate everything else from it: the create payload, the preview, the fallback text and the postback map. Here&#8217;s a shape that works:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>CREATE TABLE rcs_template (\n  template_name   TEXT PRIMARY KEY,          -- the platform name, includes the version\n  family          TEXT NOT NULL,             -- e.g. 'FLTCONF', shared by all versions\n  version         INTEGER NOT NULL,\n  bot_id          TEXT NOT NULL,\n  kind            TEXT NOT NULL,             -- 'text_message' | 'rich_card' | 'carousel' | 'media'\n  category        TEXT NOT NULL,             -- must match the bot's use case\n  layout          TEXT NOT NULL,             -- JSON: orientation, height, width, ratio\n  body            TEXT,                      -- text_message body, NULL for cards\n  fallback_text   TEXT,                      -- rendered from the model, stored for audit\n  state           TEXT NOT NULL,             -- 'draft' | 'submitted' | 'active' | 'retired'\n  platform_status INTEGER,                   -- last integer read from template\/list\n  submitted_at    TIMESTAMPTZ,\n  activated_at    TIMESTAMPTZ,\n  UNIQUE (family, version)\n);\n\nCREATE TABLE rcs_card (\n  template_name TEXT NOT NULL REFERENCES rcs_template(template_name),\n  position      INTEGER NOT NULL,            -- 0 for a rich card, 0..9 for a carousel\n  title         TEXT NOT NULL DEFAULT '',\n  description   TEXT NOT NULL DEFAULT '',\n  media_asset   TEXT,                        -- your asset id, not a URL you typed\n  PRIMARY KEY (template_name, position)\n);\n\nCREATE TABLE rcs_suggestion (\n  template_name TEXT NOT NULL,\n  position      INTEGER NOT NULL,            -- card position, or -1 for a text message\n  slot          INTEGER NOT NULL,            -- 0..3 on a card, 0..10 on a text message\n  kind          TEXT NOT NULL,               -- 'reply' | 'url' | 'dialer' | ...\n  label         TEXT NOT NULL,               -- max 25 after the variable rule\n  prefix        TEXT,                        -- postback prefix you issued, reply buttons\n  url           TEXT,\n  action        TEXT,                        -- what the tap means in your system\n  PRIMARY KEY (template_name, position, slot),\n  UNIQUE (prefix)\n);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>family<\/code> and <code>version<\/code> pair turns &#8220;which flight confirmation is live?&#8221; into a query. The platform only knows names; your table knows that <code>FLTCONF_V03<\/code> replaced <code>FLTCONF_V02<\/code> and when.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>rcs_suggestion.prefix<\/code> is globally unique. It&#8217;s the short code that comes back when someone taps a reply button, so it has to point at exactly one template, card and action. Keeping the meaning in <code>action<\/code> means the postback itself can stay short and boring.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>state<\/code> is yours and <code>platform_status<\/code> is theirs. You move a template to <code>active<\/code> only after a read-back shows status <code>1<\/code> and the stored content matches your model. Until then it&#8217;s <code>submitted<\/code>, and nothing sends it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you already keep templates for other channels, this sits next to them rather than replacing them, and the send itself still lands in your <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/outbound-message-table-schema-design\/\">outbound message table<\/a> with the template name and version on the row. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/message-template-management-four-channels\/\">template management comparison across four channels<\/a> covers the cross-channel side; this table is the RCS-specific detail that a generic template row doesn&#8217;t have room for.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"counting\" class=\"wp-block-heading\">Counting Characters the Way the Limit Counts<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">&#8220;200 characters&#8221; sounds unambiguous until someone puts an emoji in a title. Google&#8217;s rich cards guide is blunt about it: special characters, emoji and multi-byte character sets count as two to four characters or more. Your validator has to pick a counting rule, and the safe one is UTF-8 bytes.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s why that rule works. For plain English text, UTF-8 bytes and characters are the same number, so you lose nothing. For an emoji, UTF-8 uses four bytes, which matches the top of Google&#8217;s range. For Hindi, each Devanagari code point is three bytes, so a six-letter greeting like &#8220;namaste&#8221; written in Devanagari costs 18. If the platform actually counts code points, you&#8217;ve been stricter than you needed to be and the card still goes through. If it counts something closer to bytes, you were right. Either way nothing gets rejected at submit time.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The numbers are worth seeing once, because every language gets them different:<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Text<\/th><th>Code points<\/th><th>UTF-16 units (Java <code>length()<\/code>, JavaScript <code>.length<\/code>)<\/th><th>UTF-8 bytes<\/th><\/tr><\/thead><tbody><tr><td><code>View QR<\/code><\/td><td>7<\/td><td>7<\/td><td>7<\/td><\/tr><tr><td>Party popper emoji + &#8221; Offer&#8221;<\/td><td>7<\/td><td>8<\/td><td>10<\/td><\/tr><tr><td>&#8220;namaste&#8221; in Devanagari<\/td><td>6<\/td><td>6<\/td><td>18<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Then add the variable rule. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/how-do-i-use-variables-to-personalise-rcs-templates\/\">variables guide<\/a> and the buttons guide both say each variable uses 10 characters of the field&#8217;s limit. A short placeholder like <code>[name]<\/code> is six characters on the page but costs 10. A long one like <code>[custom_param_1]<\/code> is 16 on the page. Count it as 16, not 10, and you&#8217;re safe whichever way the platform reads it. So the template-time cost of a field is: UTF-8 bytes of the literal text, plus the larger of the placeholder&#8217;s length and 10 for each variable.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That&#8217;s only half the check. A button labelled <code>Hi [name]<\/code> costs 13 at template time and passes. At send time, a 20-character name turns it into a 23-character label, still fine, but a 30-character company name won&#8217;t be. The variables guide warns that a long value can make the message fail validation. So validate twice: once when the template is created, with the variable rule, and once per send, with the real values substituted.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"versioning\" class=\"wp-block-heading\">Template Names Are Your Version Numbers<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The RCS template family has three endpoints: create, list and delete. There&#8217;s no update. The portal has an Edit action, but your code can&#8217;t call it. So from your system&#8217;s point of view, a template&#8217;s content is fixed once you submit it. Changing a word means a new template.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That makes the name do two jobs: it&#8217;s the <code>templateCode<\/code> you send with, and it&#8217;s your version number. A naming scheme that fits the 20-character rule:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>FAMILY_Vnn\nFLTCONF_V03      (11 characters)\nDIWALI26_V01     (12 characters)\nPLANCMP_V12      (11 characters)<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Keep the family to about 14 characters and you&#8217;ve got room for <code>_V<\/code> and two digits. Letters, digits and underscores only, so no hyphens, dots or spaces. Name it after the use case, which is also what the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/how-do-i-create-an-rcs-template\/\">template creation guide<\/a> suggests.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A rollout then looks like this:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Create <code>FLTCONF_V04<\/code> while <code>FLTCONF_V03<\/code> keeps sending.<\/li>\n\n\n\n<li>Poll <code>RCSApi\/template\/list<\/code> until <code>FLTCONF_V04<\/code> shows status <code>1<\/code> and its <code>templateModel<\/code> matches your model.<\/li>\n\n\n\n<li>Flip a pointer in your database from V03 to V04. Sends pick up the new <code>templateCode<\/code> from that pointer, never from a hard-coded string.<\/li>\n\n\n\n<li>Leave V03 alone for as long as taps on old messages might still arrive, then retire it.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">Step 4 is easy to skip. A recipient can tap a button on yesterday&#8217;s carousel today. If you deleted V03 and reused its postback prefixes in V04, that tap now means the wrong thing. Keep prefixes unique across versions (the <code>UNIQUE (prefix)<\/code> constraint above does this) and retire old versions on a schedule rather than on deploy.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Deleting is permanent. <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/delete-rcs-template\/\"><code>RCSApi\/template\/delete<\/code><\/a> takes <code>botId<\/code> and <code>templateName<\/code>, and the statuses guide says deleted templates can&#8217;t be recovered. Run deletes from a reviewed list, not from a loop that guesses.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"variables\" class=\"wp-block-heading\">Variables Across Titles, Buttons and URLs<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Variables work in text messages, card titles, card descriptions, suggestion text, the editable part of postbacks, and URLs. The portal inserts named ones like <code>[name]<\/code>, <code>[otp]<\/code>, <code>[orderid]<\/code>, <code>[amount]<\/code>, <code>[date]<\/code> and <code>[tracking_id]<\/code>, plus numbered custom fields such as <code>[custom_param_1]<\/code>. The create API sample uses the same square-bracket style: <code>\"Hello [name], your OTP is [otp].\"<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">At send time you fill them through <code>customParams<\/code>, and this is where people trip. Per the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/can-i-send-rcs-messages-through-the-api\/\">RCS API guide<\/a>, <code>customParams<\/code> is a JSON <strong>string<\/strong> inside the <code>msg<\/code> JSON, not a nested object:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"contentMessage\": {\n    \"templateMessage\": {\n      \"templateCode\": \"WELCOME_TEMPLATE\",\n      \"customParams\": \"{\\\"custom_var\\\":\\\"John\\\",\\\"amount\\\":\\\"999\\\"}\"\n    }\n  },\n  \"ttl\": \"86400s\"\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Build it with a JSON library: serialise the parameter map to a string, put that string in the <code>customParams<\/code> field, then serialise the whole <code>msg<\/code>. Hand-escaping quotes in a shell script is how a stray apostrophe in someone&#8217;s name takes down a campaign.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Treat the variable set as part of the template. Your model can extract every <code>[variable]<\/code> from every field, so it can also refuse a send that&#8217;s missing one. That one check catches the most common card bug there is: a carousel goes out with a literal <code>[orderid]<\/code> in the third card&#8217;s URL because somebody added the variable in the portal and not in the code.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For URL buttons, keep the base fixed and put the variable at the end, as in <code>https:\/\/yourdomain.com\/offers\/[custom_param_1]<\/code>. That keeps the domain inspectable at review time and makes the click easy to attribute. If you track clicks through short links, the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-click-tracking-attribution-smart-links\/\">SMS click tracking guide<\/a> covers the attribution side.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"postbacks\" class=\"wp-block-heading\">Postbacks as a Tiny Grammar<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Every suggestion has a postback, and the platform appends a fixed <code>_[message_id]<\/code> suffix to whatever you type, filling it in at send time. You only edit the first part. The published samples show human-readable first parts, <code>View QR_[message_id]<\/code> and <code>Learn More_[message_id]<\/code>, and they&#8217;ll work, but they&#8217;re a poor key. &#8220;Learn More&#8221; tells you nothing about which template or which card it came from.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Make the editable part a short code you issued, and keep its meaning in your database:<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Prefix<\/th><th>Template<\/th><th>Card<\/th><th>Action<\/th><\/tr><\/thead><tbody><tr><td><code>P01<\/code><\/td><td><code>FLTCONF_V03<\/code><\/td><td>0<\/td><td>show boarding QR<\/td><\/tr><tr><td><code>P02<\/code><\/td><td><code>FLTCONF_V03<\/code><\/td><td>1<\/td><td>offer seat upgrade<\/td><\/tr><tr><td><code>P03<\/code><\/td><td><code>PLANCMP_V12<\/code><\/td><td>2<\/td><td>choose the annual plan<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">When a tap comes back as <code>P02_<\/code> followed by the message identifier, you look up <code>P02<\/code> and know the template, card and action, and you look up the suffix and know which send it was. Then stick to two rules:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Match against prefixes you issued, longest first, followed by an underscore.<\/strong> Don&#8217;t split on the last underscore. You don&#8217;t control the format of whatever fills <code>[message_id]<\/code>, so treating it as opaque is the only parse that can&#8217;t break.<\/li>\n\n\n\n<li><strong>Never reuse a prefix.<\/strong> Not across cards, not across versions. A tap on an old message has to keep its old meaning.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Short uppercase codes also stay readable in a log line, which you&#8217;ll appreciate the first time you debug a tap.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Taps and replies show up in RCS Bot Incoming in the portal, and the API side is the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/rcs-inbox\/\">RCS inbox endpoint<\/a>, <code>rest\/rcs\/v1\/inbox<\/code>, which you poll with <code>botId<\/code>, a date range, <code>page<\/code> and <code>limit<\/code> up to 200. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/receiving-messages-four-channel-inbox-contracts\/\">four-channel inbox guide<\/a> covers polling and paging. For cards, the extra step is simple: run every inbound <code>message<\/code> through the prefix matcher. A match is a tap; anything else is free text from a person.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"media\" class=\"wp-block-heading\">The Media Pipeline: Ratios, Sizes and Thumbnails<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Most rejected cards are image problems, and almost all of them are preventable before upload.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Card layout has three settings in the portal: orientation (vertical or horizontal), media height (short or medium) and alignment (which side the image sits on in a horizontal card). The required aspect ratio depends on the combination, and the form shows the exact size for your selection. The one combination spelled out in writing is vertical plus short, which needs a 3:1 image, ideally 1440 x 480 pixels.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So build the pipeline around a ratio table you fill in from the form, not from memory:<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Orientation<\/th><th>Height<\/th><th>Ratio<\/th><th>Ideal size<\/th><th>Where it came from<\/th><\/tr><\/thead><tbody><tr><td>Vertical<\/td><td>Short<\/td><td>3:1<\/td><td>1440 x 480<\/td><td>Rich card layout guide<\/td><\/tr><tr><td>Vertical<\/td><td>Medium<\/td><td>read from the form<\/td><td>read from the form<\/td><td>Portal template form<\/td><\/tr><tr><td>Horizontal<\/td><td>Short or Medium<\/td><td>read from the form<\/td><td>read from the form<\/td><td>Portal template form<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The pipeline itself is short:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Crop to the exact ratio, then resize.<\/strong> Check it with integer arithmetic (<code>width * h == height * w<\/code>), never with a float comparison. If the media doesn&#8217;t match the slot, Google&#8217;s guide says the client zooms and crops centrally, which is how a product photo loses its product.<\/li>\n\n\n\n<li><strong>Re-encode to the allowed type.<\/strong> Card images are JPEG, JPG, PNG or GIF. A WebP that a designer dropped in a folder needs converting, not renaming.<\/li>\n\n\n\n<li><strong>Enforce the size cap after encoding.<\/strong> 2 MB for a card image, 10 MB for a card video, 100 KB for the video thumbnail with about 40 KB recommended. Measure the encoded file, not the source.<\/li>\n\n\n\n<li><strong>Generate the thumbnail yourself for every video.<\/strong> It&#8217;s what shows while the video loads, and it&#8217;s what a failed download falls back to.<\/li>\n\n\n\n<li><strong>Store the asset by your own id.<\/strong> The <code>rcs_card.media_asset<\/code> column holds your id; the URL or Media Library identifier the platform uses is a property of that asset. If you re-host an image, one row changes instead of every template that uses it.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">PDFs deserve a caution. The platform accepts PDFs on cards (up to 100 MB) and in media templates, but Google&#8217;s guide notes that PDF in rich cards is currently available only in India on the Google Messages client. If your audience is all in India and on Google Messages, fine. If not, put the PDF behind a URL button on an image card instead.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/how-do-i-use-the-rcs-media-library\/\">RCS Media Library<\/a> lets you upload once and reuse across templates and campaigns, identified by a media ID or an identifier you choose. Use your asset id as that identifier and the mapping stays obvious. Remember its 10 MB per-file cap.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"carousels\" class=\"wp-block-heading\">Carousels: Order, Height and Truncation<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A carousel is the card type where the content model pays off most, because it&#8217;s the one where small inconsistencies are visible.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Cards are scaled to the tallest one.<\/strong> Google&#8217;s guide says every card in a carousel is scaled to match the height of the tallest card, up to a maximum (542 DP for small width, 592 DP for medium). One card with a long description makes every other card taller and emptier. Write carousel cards to a shared length budget, not just a shared limit.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Overflow is truncated in a fixed order.<\/strong> When content exceeds the maximum height, the client cuts in this order: description to one line, title to one line, suggestions removed, description removed, title removed. Google also provides an expandable full-screen view for carousel cards, so the text isn&#8217;t lost, but the first impression is the truncated one. Put the deciding fact in the title.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Carousels are vertical cards only.<\/strong> Google&#8217;s guide is explicit about it. Don&#8217;t offer horizontal layout in your carousel editor.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The first card should be the best option.<\/strong> That&#8217;s Google&#8217;s advice too, and it&#8217;s easy to encode: order cards by a <code>position<\/code> column, and make the recommended plan or the next flight leg position 0.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Use two to ten cards.<\/strong> The platform allows up to 10. A one-card carousel is just a rich card with a swipe affordance that goes nowhere, so the validator refuses it and tells the author to pick Rich Card instead.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Keep each card self-contained.<\/strong> Each card has its own media, title, description and up to four buttons. A button on card 3 that only makes sense after reading card 1 will be tapped by someone who never saw card 1.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Width matters too. Google lists two fixed carousel widths, small at 180 DP and medium at 296 DP. The published platform sample shows <code>SMALL_WIDTH<\/code>. At 180 DP, a 25-character button label is already tight, so write carousel buttons shorter than the limit.<\/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-rcs-card-model-flow.svg\"><img decoding=\"async\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/diagram-rcs-card-model-flow.svg\" alt=\"Flow from your RCS card model through validation and naming to the platform round trip of create, list, diff and send, with postback taps returning as prefix plus message id\" class=\"wp-image-3132\"\/><\/a><figcaption class=\"wp-element-caption\">Your model is the source of truth. The platform stores a copy, and the read-back proves the copy matches.<\/figcaption><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"lifecycle\" class=\"wp-block-heading\">The Approval Lifecycle and the Integer Status<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A new template starts as Pending and is usable for live sends only once it&#8217;s Enabled. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/what-do-rcs-template-statuses-mean-and-how-do-i-manage-templates\/\">template statuses guide<\/a> lists six states: Pending, Enabled, Rejected, Paused, Disabled and Deleted. The list API reports <code>status<\/code> as an integer and says <code>1<\/code> means active.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That gives you a clean rule: a template is sendable when the list call shows <code>status<\/code> equal to the integer <code>1<\/code>, and nothing else counts. Not the string <code>\"1\"<\/code>, not a missing field, not a number you&#8217;ve never seen. When a status you don&#8217;t recognise shows up, the template waits and someone gets an alert. The cost of being wrong in that direction is a delayed campaign; the cost of being wrong in the other direction is a live send against a template that isn&#8217;t approved.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The create call doesn&#8217;t help you here. It replies like this:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"reason\": \"Accepted for processing.\",\n  \"templateName\": \"my_text_template\",\n  \"status\": \"success\",\n  \"statusCode\": \"200\"\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">&#8220;Accepted&#8221; means received, not approved, and there&#8217;s no identifier to poll. So your state machine moves <code>draft<\/code> to <code>submitted<\/code> on that reply, then polls <code>RCSApi\/template\/list<\/code> for the bot, finds the row by <code>name<\/code>, diffs its <code>templateModel<\/code> against your model, and only then moves to <code>active<\/code>. If the portal&#8217;s status looks stale, the statuses guide mentions a Sync templates action that refreshes approval status from the RCS network.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The diff step catches things a status never will: a title trimmed by an editor, a suggestion dropped, a postback prefix typed differently in the portal. If the diff isn&#8217;t empty, the template stays <code>submitted<\/code> and the difference goes to whoever owns the content. It&#8217;s the same idea as a <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/contract-testing-harness-messaging-api\/\">contract test against the messaging API<\/a>, pointed at your own content instead of the response shape.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Sandbox accounts have one extra wrinkle. Users on an RCS Sandbox Account can&#8217;t create templates; a reseller shares templates with them by setting Allow Sandbox on each one, as the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/how-do-i-share-rcs-templates-with-my-sandbox-users\/\">sandbox sharing guide<\/a> explains. Build your test suite on those shared templates and the same read-back code works in both places.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"fallback-text\" class=\"wp-block-heading\">One Model, One Fallback Text<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Not every phone that gets your carousel can show it, and not every send should wait for RCS. The create payload has a <code>fallbackText<\/code> field (empty in the published sample), and if you run fallback to SMS yourself, you need a text version of every card anyway.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Generate it from the same model, every time:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>For a rich card: title, then description, then any URL buttons as &#8220;label: url&#8221;.<\/li>\n\n\n\n<li>For a carousel: one numbered line per card in position order, each with its title and description, with URL buttons indented under the card they belong to.<\/li>\n\n\n\n<li>Reply buttons don&#8217;t translate. A reply chip needs a tap; SMS has no tap. Drop them, or turn the most important one into an instruction (&#8220;Reply UPGRADE to add a seat&#8221;) only if your SMS inbound flow handles that keyword.<\/li>\n\n\n\n<li>Substitute variables with the same values you&#8217;re sending in <code>customParams<\/code>.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Stored next to the template, that text is also the audit record of what the card said in words. When the card changes, the text changes, because both come from one row. If you&#8217;re designing the routing between channels, the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/multi-channel-fallback-sms-whatsapp-rcs-voice-otp-design\/\">multi-channel fallback design guide<\/a> covers when to fall back and how to keep one message from turning into three. And remember that an SMS fallback in India still needs its own DLT-approved template; the RCS card&#8217;s wording doesn&#8217;t carry over.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"python-code\" class=\"wp-block-heading\">Working Code: Validator, Read-Back Parser and Postback Matcher<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s the whole model in one Python module with no dependencies outside the standard library. It validates a template against the limits above, counts text the safe way, extracts variables, checks a send with real values, parses the list response (including the second parse of <code>data<\/code>), decides whether a template is sendable, diffs the stored copy against your model, builds the <code>msg<\/code> field for <code>RCSApi\/send<\/code>, matches postbacks and renders fallback text.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">It&#8217;s been run against the published <code>template\/list<\/code> sample and a set of good and bad templates, and every assertion in the usage block below passes.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\"\"\"A small RCS card model: validate before submit, parse the read-back,\ndiff it against what you meant, and build the send payload.\"\"\"\nimport json\nimport re\nfrom dataclasses import dataclass, field\nfrom typing import Optional\n\nNAME_RE = re.compile(r\"^&#91;A-Za-z0-9_]{1,20}$\")\nVAR_RE = re.compile(r\"\\&#91;&#91;A-Za-z0-9_]+\\]\")\nVAR_COST = 10  # each variable uses 10 characters of a field's limit\n\nLIMITS = {\n    \"text\": 2500, \"title\": 200, \"description\": 2000, \"media_body\": 2000,\n    \"button\": 25, \"url\": 2048,\n    \"suggestions_text\": 11, \"suggestions_card\": 4, \"cards\": 10,\n}\nMEDIA_MAX_BYTES = {\"image\": 2 * 1024 * 1024, \"video\": 10 * 1024 * 1024,\n                   \"thumbnail\": 100 * 1024, \"pdf\": 100 * 1024 * 1024}\nIMAGE_TYPES = {\"jpeg\", \"jpg\", \"png\", \"gif\"}\n\n\n@dataclass\nclass Suggestion:\n    kind: str                 # \"reply\", \"url\", \"dialer\", ...\n    text: str                 # button label, max 25\n    prefix: Optional&#91;str] = None   # postback prefix you issue, e.g. \"P07\"\n    url: Optional&#91;str] = None\n\n\n@dataclass\nclass Media:\n    kind: str                 # \"image\", \"video\", \"pdf\"\n    ext: str\n    size_bytes: int\n    width: int = 0\n    height: int = 0\n    thumb_bytes: int = 0\n\n\n@dataclass\nclass Card:\n    title: str\n    description: str\n    media: Optional&#91;Media] = None\n    suggestions: list = field(default_factory=list)\n\n\n@dataclass\nclass Template:\n    name: str\n    kind: str                 # \"text_message\", \"rich_card\", \"carousel\"\n    category: str             # must match the bot's use case\n    text: str = \"\"\n    cards: list = field(default_factory=list)\n    suggestions: list = field(default_factory=list)  # text_message only\n    ratio: Optional&#91;tuple] = None  # (w, h) the portal form asks for\n\n\ndef cost(s: str) -&gt; int:\n    \"\"\"Template-time cost: UTF-8 bytes, with every &#91;variable] counted as\n    at least VAR_COST. For plain ASCII this is just the character count.\"\"\"\n    total, pos = 0, 0\n    for m in VAR_RE.finditer(s):\n        total += len(s&#91;pos:m.start()].encode(\"utf-8\"))\n        total += max(len(m.group(0)), VAR_COST)\n        pos = m.end()\n    return total + len(s&#91;pos:].encode(\"utf-8\"))\n\n\ndef rendered_cost(s: str, values: dict) -&gt; int:\n    \"\"\"Send-time cost: the text after substitution, in UTF-8 bytes.\"\"\"\n    out = VAR_RE.sub(lambda m: str(values.get(m.group(0)&#91;1:-1], m.group(0))), s)\n    return len(out.encode(\"utf-8\"))\n\n\ndef _check_text(errs, where, s, limit):\n    if cost(s) &gt; limit:\n        errs.append(f\"{where}: cost {cost(s)} &gt; {limit}\")\n\n\ndef _check_suggestions(errs, where, sugs, limit, issued):\n    if len(sugs) &gt; limit:\n        errs.append(f\"{where}: {len(sugs)} suggestions &gt; {limit}\")\n    for i, sg in enumerate(sugs):\n        _check_text(errs, f\"{where}.s{i}.text\", sg.text, LIMITS&#91;\"button\"])\n        if sg.kind == \"reply\":\n            if not sg.prefix or not re.fullmatch(r\"&#91;A-Z]&#91;A-Z0-9]{1,7}\", sg.prefix):\n                errs.append(f\"{where}.s{i}: reply needs a prefix like P07\")\n            elif sg.prefix in issued:\n                errs.append(f\"{where}.s{i}: prefix {sg.prefix} reused\")\n            else:\n                issued.add(sg.prefix)\n        if sg.kind == \"url\" and (not sg.url or cost(sg.url) &gt; LIMITS&#91;\"url\"]):\n            errs.append(f\"{where}.s{i}: url missing or too long\")\n\n\ndef _check_media(errs, where, md, ratio):\n    if md is None:\n        return\n    cap = MEDIA_MAX_BYTES.get(md.kind)\n    if cap is None:\n        errs.append(f\"{where}: unknown media kind {md.kind}\")\n        return\n    if md.size_bytes &gt; cap:\n        errs.append(f\"{where}: {md.size_bytes} bytes &gt; {cap}\")\n    if md.kind == \"image\" and md.ext.lower() not in IMAGE_TYPES:\n        errs.append(f\"{where}: image type {md.ext} not allowed\")\n    if md.kind == \"video\" and not (0 &lt; md.thumb_bytes &lt;= MEDIA_MAX_BYTES&#91;\"thumbnail\"]):\n        errs.append(f\"{where}: video needs a thumbnail up to 100 KB\")\n    if md.kind == \"image\" and ratio:\n        w, h = ratio\n        if md.width * h != md.height * w:\n            errs.append(f\"{where}: {md.width}x{md.height} is not {w}:{h}\")\n\n\ndef validate(t: Template) -&gt; list:\n    errs, issued = &#91;], set()\n    if not NAME_RE.match(t.name):\n        errs.append(\"name: letters, digits, underscore, max 20\")\n    if t.kind == \"text_message\":\n        _check_text(errs, \"text\", t.text, LIMITS&#91;\"text\"])\n        _check_suggestions(errs, \"text\", t.suggestions, LIMITS&#91;\"suggestions_text\"], issued)\n    elif t.kind in (\"rich_card\", \"carousel\"):\n        want = 1 if t.kind == \"rich_card\" else None\n        n = len(t.cards)\n        if want and n != 1:\n            errs.append(\"rich_card: exactly one card\")\n        if t.kind == \"carousel\" and not (2 &lt;= n &lt;= LIMITS&#91;\"cards\"]):\n            errs.append(f\"carousel: {n} cards, use 2 to 10\")\n        for i, c in enumerate(t.cards):\n            w = f\"card{i}\"\n            if not (c.title or c.description or c.media):\n                errs.append(f\"{w}: needs media, a title or a description\")\n            _check_text(errs, f\"{w}.title\", c.title, LIMITS&#91;\"title\"])\n            _check_text(errs, f\"{w}.description\", c.description, LIMITS&#91;\"description\"])\n            _check_suggestions(errs, w, c.suggestions, LIMITS&#91;\"suggestions_card\"], issued)\n            _check_media(errs, w, c.media, t.ratio)\n    else:\n        errs.append(f\"kind {t.kind} not handled\")\n    return errs\n\n\ndef variables(t: Template) -&gt; set:\n    \"\"\"Every &#91;variable] the template uses, across all fields.\"\"\"\n    parts = &#91;t.text] + &#91;s.text for s in t.suggestions] + &#91;s.url or \"\" for s in t.suggestions]\n    for c in t.cards:\n        parts += &#91;c.title, c.description]\n        parts += &#91;s.text for s in c.suggestions] + &#91;s.url or \"\" for s in c.suggestions]\n    return {m.group(0)&#91;1:-1] for p in parts for m in VAR_RE.finditer(p)}\n\n\ndef check_send(t: Template, values: dict) -&gt; list:\n    \"\"\"Send-time checks: every variable filled, rendered fields in limits.\"\"\"\n    errs = &#91;f\"missing value for {v}\" for v in sorted(variables(t) - set(values))]\n    for i, c in enumerate(t.cards):\n        for fld, lim in ((\"title\", LIMITS&#91;\"title\"]), (\"description\", LIMITS&#91;\"description\"])):\n            if rendered_cost(getattr(c, fld), values) &gt; lim:\n                errs.append(f\"card{i}.{fld}: rendered value too long\")\n        for j, s in enumerate(c.suggestions):\n            if rendered_cost(s.text, values) &gt; LIMITS&#91;\"button\"]:\n                errs.append(f\"card{i}.s{j}: rendered button too long\")\n    if t.text and rendered_cost(t.text, values) &gt; LIMITS&#91;\"text\"]:\n        errs.append(\"text: rendered value too long\")\n    return errs\n\n\ndef parse_list(body: str) -&gt; dict:\n    \"\"\"RCSApi\/template\/list: `data` is a JSON string holding templateModel.\"\"\"\n    out = {}\n    for row in json.loads(body).get(\"templates\", &#91;]):\n        model = None\n        try:\n            model = json.loads(row.get(\"data\") or \"{}\").get(\"templateModel\")\n        except (TypeError, ValueError):\n            pass\n        out&#91;row.get(\"name\")] = {\n            \"status\": row.get(\"status\"),\n            \"type\": row.get(\"type\"),\n            \"category\": str(row.get(\"category\", \"\")).lower(),\n            \"created_ms\": row.get(\"creationTime\"),\n            \"model\": model,\n        }\n    return out\n\n\ndef sendable(row: Optional&#91;dict]) -&gt; bool:\n    \"\"\"Fail closed: only the observed active value counts.\"\"\"\n    return bool(row) and row&#91;\"status\"] == 1 and row&#91;\"model\"] is not None\n\n\ndef _strip_suffix(postback: str) -&gt; str:\n    return postback&#91;:-len(\"_&#91;message_id]\")] if postback.endswith(\"_&#91;message_id]\") else postback\n\n\ndef diff(t: Template, row: dict) -&gt; list:\n    \"\"\"Compare what the platform stored with what you meant to submit.\"\"\"\n    m, d = row.get(\"model\") or {}, &#91;]\n    if row.get(\"type\") != t.kind:\n        d.append(f\"type {row.get('type')} != {t.kind}\")\n    if row.get(\"category\") != t.category.lower():\n        d.append(f\"category {row.get('category')} != {t.category.lower()}\")\n    if t.kind == \"rich_card\":\n        stored = &#91;m.get(\"standAlone\") or {}]\n    elif t.kind == \"carousel\":\n        stored = m.get(\"carouselList\") or &#91;]\n    else:\n        stored = &#91;]\n    if len(stored) != len(t.cards):\n        d.append(f\"cards {len(stored)} != {len(t.cards)}\")\n    for i, (c, s) in enumerate(zip(t.cards, stored)):\n        if s.get(\"cardTitle\") != c.title:\n            d.append(f\"card{i}.title differs\")\n        if s.get(\"cardDescription\") != c.description:\n            d.append(f\"card{i}.description differs\")\n        sugs = s.get(\"suggestions\") or &#91;]\n        if len(sugs) != len(c.suggestions):\n            d.append(f\"card{i}: {len(sugs)} suggestions != {len(c.suggestions)}\")\n        for j, (mine, theirs) in enumerate(zip(c.suggestions, sugs)):\n            if theirs.get(\"displayText\") != mine.text:\n                d.append(f\"card{i}.s{j}.text differs\")\n            if mine.prefix and _strip_suffix(str(theirs.get(\"postback\", \"\"))) != mine.prefix:\n                d.append(f\"card{i}.s{j}.postback prefix differs\")\n    return d\n\n\ndef send_msg(template_code: str, values: dict, ttl_s: int = 86400) -&gt; str:\n    \"\"\"The `msg` field for RCSApi\/send. customParams is a JSON *string*.\"\"\"\n    tm = {\"templateCode\": template_code}\n    if values:\n        tm&#91;\"customParams\"] = json.dumps({k: str(v) for k, v in values.items()})\n    return json.dumps({\"contentMessage\": {\"templateMessage\": tm}, \"ttl\": f\"{ttl_s}s\"})\n\n\ndef match_postback(text: str, issued: dict):\n    \"\"\"issued maps prefix -&gt; (template, card, action). Returns\n    (prefix, suffix) only for a prefix you issued, else None.\"\"\"\n    for prefix in sorted(issued, key=len, reverse=True):\n        head = prefix + \"_\"\n        if text.startswith(head) and len(text) &gt; len(head):\n            return prefix, text&#91;len(head):]\n    return None\n\n\ndef fallback_text(t: Template, values: dict, limit: int = 1000) -&gt; str:\n    \"\"\"One plain-text rendering of the same model for fallback.\"\"\"\n    sub = lambda s: VAR_RE.sub(lambda m: str(values.get(m.group(0)&#91;1:-1], \"\")), s)\n    lines = &#91;sub(t.text)] if t.text else &#91;]\n    for i, c in enumerate(t.cards, 1):\n        head = f\"{i}. {sub(c.title)}\" if len(t.cards) &gt; 1 else sub(c.title)\n        lines.append(\" - \".join(x for x in (head, sub(c.description)) if x.strip()))\n        lines += &#91;f\"  {sub(s.text)}: {sub(s.url)}\" for s in c.suggestions if s.kind == \"url\" and s.url]\n    out = \"\\n\".join(l for l in lines if l.strip())\n    return out if len(out) &lt;= limit else out&#91;: limit - 3] + \"...\"<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Using it looks like this. These lines are lifted from the test run, so the values are real:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>img = Media(\"image\", \"jpg\", 180_000, 1440, 480)\nt = Template(\"FLTCONF_V03\", \"carousel\", \"Transactional\", ratio=(3, 1), cards=&#91;\n    Card(\"Hi &#91;name], your trip is confirmed\", \"Booking &#91;orderid]. Mumbai to Dubai.\", img,\n         &#91;Suggestion(\"reply\", \"View QR\", \"P01\"),\n          Suggestion(\"url\", \"Manage booking\", url=\"https:\/\/example.com\/b\/&#91;orderid]\")]),\n    Card(\"Add a seat upgrade\", \"Extra legroom from the next leg.\", img,\n         &#91;Suggestion(\"reply\", \"Upgrade\", \"P02\")]),\n])\nassert validate(t) == &#91;]\nassert variables(t) == {\"name\", \"orderid\"}\nassert check_send(t, {\"name\": \"Asha\"}) == &#91;\"missing value for orderid\"]\n\nassert cost(\"&#91;name]\") == 10              # a short variable still costs 10\nassert cost(\"&#91;custom_param_1]\") == 16    # a long one costs its own length\nassert cost(\"\\U0001F389 Offer\") == 10    # the emoji is 4 bytes\n\nrows = parse_list(list_body)             # body of RCSApi\/template\/list\nassert sendable(rows&#91;\"TestSoftware\"])    # status is the integer 1\nassert not sendable({\"status\": \"1\", \"model\": {}})   # the string \"1\" fails closed\n\nissued = {\"P01\": (\"FLTCONF_V03\", 0, \"view_qr\"), \"P02\": (\"FLTCONF_V03\", 1, \"upgrade\")}\nassert match_postback(\"P01_6305583318236810379\", issued) == (\"P01\", \"6305583318236810379\")\nassert match_postback(\"Hi, is my order shipped?\", issued) is None<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">And the fallback text it renders for that carousel with <code>{\"name\": \"Asha\", \"orderid\": \"ORD1042\"}<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>1. Hi Asha, your trip is confirmed - Booking ORD1042. Mumbai to Dubai.\n  Manage booking: https:\/\/example.com\/b\/ORD1042\n2. Add a seat upgrade - Extra legroom from the next leg.<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A bad template shows what the validator reports. A carousel named <code>Flight Booking v3!<\/code> with one card, a 201-character title, a 3 MB WebP at 1200 x 500, five reply buttons with 26-character labels and a lowercase prefix gets errors for the name, the card count, the title, the suggestion count, the button length, the prefix, the file size, the image type and the ratio. Nine problems, found before anything leaves your network.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Notice two things in the code. <code>sendable()<\/code> compares <code>status<\/code> to the integer <code>1<\/code> and nothing else. And <code>match_postback()<\/code> tries the longest issued prefix first, so <code>P012_...<\/code> never gets read as <code>P01<\/code> with a suffix of <code>2_...<\/code>.<\/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 Traps in Four Languages<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Each of these is short, runs as written, and shows one mistake that&#8217;s easy to make with RCS cards. Credentials go in the <code>apikey<\/code> header with <code>userid<\/code> alongside, which works on every SMSGatewayCenter endpoint.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">cURL and jq: <code>customParams<\/code> is a string inside a string<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>#!\/usr\/bin\/env bash\n# Trap: customParams is a JSON string inside the msg JSON. Let jq encode it.\nset -euo pipefail\nparams=$(jq -nc --arg name \"Asha\" --arg orderid \"ORD1042\" \\\n  '{name: $name, orderid: $orderid}')\nmsg=$(jq -nc --arg code \"FLTCONF_V03\" --arg params \"$params\" \\\n  '{contentMessage: {templateMessage: {templateCode: $code, customParams: $params}}, ttl: \"86400s\"}')\necho \"$msg\"\n&#91; \"${DRY_RUN:-1}\" = 1 ] &amp;&amp; exit 0\ncurl --silent --request POST 'https:\/\/unify.smsgateway.center\/RCSApi\/send' \\\n  --header \"apikey: ${SGC_APIKEY}\" \\\n  --data-urlencode \"userid=${SGC_USERID}\" \\\n  --data-urlencode 'sendMethod=quick' \\\n  --data-urlencode 'msgType=text' \\\n  --data-urlencode 'format=json' \\\n  --data-urlencode \"botId=${SGC_BOT_ID}\" \\\n  --data-urlencode 'mobile=919999999999' \\\n  --data-urlencode \"msg=${msg}\" \\\n  --data-urlencode 'identifier=fltconf_v03_2026_10'<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The script prints the <code>msg<\/code> it would send (set <code>DRY_RUN=0<\/code> and the three environment variables to send for real). Run it and the <code>customParams<\/code> value comes out as <code>\"{\\\"name\\\":\\\"Asha\\\",\\\"orderid\\\":\\\"ORD1042\\\"}\"<\/code>, a properly escaped string. Feed that value back through <code>jq<\/code> and you get the object <code>{\"name\":\"Asha\",\"orderid\":\"ORD1042\"}<\/code>. jq&#8217;s <code>--arg<\/code> does the escaping, so an apostrophe or a quote in a name can&#8217;t break the payload.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Node.js: parse <code>data<\/code> twice, and don&#8217;t trust <code>.length<\/code><\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ Trap: `data` is a JSON string. Parse it a second time, and count limits in\n\/\/ UTF-8 bytes, not String.length (which counts UTF-16 code units).\nexport function models(listBody) {\n  const out = new Map();\n  for (const row of JSON.parse(listBody).templates ?? &#91;]) {\n    let model = null;\n    try { model = JSON.parse(row.data ?? \"{}\").templateModel ?? null; } catch { \/* keep null *\/ }\n    out.set(row.name, { status: row.status, type: row.type, model });\n  }\n  return out;\n}\nexport const cost = (s) =&gt; Buffer.byteLength(s, \"utf8\");\n\nconst body = JSON.stringify({ templates: &#91;{ name: \"TestSoftware\", type: \"rich_card\", status: 1,\n  data: JSON.stringify({ templateModel: { standAlone: { cardTitle: \"Enterprise SMS Gateway Software\" } } }) }] });\nconst m = models(body).get(\"TestSoftware\");\nconsole.log(typeof JSON.parse(body).templates&#91;0].data);       \/\/ \"string\"\nconsole.log(m.model.standAlone.cardTitle);\nconsole.log(\"\\u{1F389}\".length, cost(\"\\u{1F389}\"));            \/\/ 2 4<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Output: <code>string<\/code>, then <code>Enterprise SMS Gateway Software<\/code>, then <code>2 4<\/code>. The first line is the reminder that <code>data<\/code> arrives as text. The last line is the one that bites: JavaScript&#8217;s <code>.length<\/code> says the party popper emoji is 2 long, while it&#8217;s 4 bytes in UTF-8. Validate with <code>Buffer.byteLength<\/code>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Java: <code>String.length()<\/code> counts UTF-16 units<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>import java.nio.charset.StandardCharsets;\n\n\/\/ Trap: String.length() counts UTF-16 code units. Budget by UTF-8 bytes,\n\/\/ and keep creationTime (epoch milliseconds) in a long.\npublic class CardCost {\n    static int cost(String s) {\n        return s.getBytes(StandardCharsets.UTF_8).length;\n    }\n\n    public static void main(String&#91;] args) {\n        String party = \"\\uD83C\\uDF89 Offer\";            \/\/ emoji + \" Offer\"\n        String hindi = \"\\u0928\\u092E\\u0938\\u094D\\u0924\\u0947\";\n        System.out.println(party.length() + \" \" + party.codePointCount(0, party.length()) + \" \" + cost(party));\n        System.out.println(hindi.length() + \" \" + cost(hindi));\n        long created = 1784978480698L;\n        System.out.println(java.time.Instant.ofEpochMilli(created));\n    }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Output: <code>8 7 10<\/code>, then <code>6 18<\/code>, then <code>2026-07-25T11:21:20.698Z<\/code>. Same text, three different answers depending on what you count. <code>length()<\/code> gives 8, code points give 7, UTF-8 gives 10. For the Devanagari word, <code>length()<\/code> says 6 and UTF-8 says 18. Use the UTF-8 count for limits, and keep <code>creationTime<\/code> in a <code>long<\/code>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Python: measure the value, not the serialised JSON<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>import json\n\ntitle = \"\\U0001F389 Diwali offer for &#91;name]\"\nwire = json.dumps({\"cardTitle\": title})          # ensure_ascii=True by default\nprint(len(title), len(title.encode(\"utf-8\")))    # what you meant to measure\nprint(len(json.dumps(title)))                    # what you measure by mistake\nprint(json.loads(wire)&#91;\"cardTitle\"] == title)    # the wire form is still correct<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Output: <code>25 28<\/code>, then <code>38<\/code>, then <code>True<\/code>. Python&#8217;s <code>json.dumps<\/code> escapes non-ASCII as <code>\\uXXXX<\/code> by default, so measuring the serialised title gives 38 for a 25-character title. The wire form is still correct; it&#8217;s only the measurement that&#8217;s wrong. Count limits on the string before you serialise it.<\/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: Card, Carousel, Media or Text<\/h2>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>You&#8217;re sending<\/th><th>Use<\/th><th>Why<\/th><th>Watch out for<\/th><\/tr><\/thead><tbody><tr><td>A one-time password<\/td><td>Text Message on an OTP bot<\/td><td>Words, not pictures; the bot&#8217;s use case must match<\/td><td>Keep the code in the text, not in an image<\/td><\/tr><tr><td>One order, booking or payment confirmation with one next step<\/td><td>Rich Card<\/td><td>One topic, one image, up to four buttons<\/td><td>A clear title; the deciding fact must survive truncation<\/td><\/tr><tr><td>Two to ten comparable options (plans, products, legs of a trip)<\/td><td>Carousel<\/td><td>Swipeable, each option self-contained<\/td><td>Equal-length cards; best option first; vertical only<\/td><\/tr><tr><td>A document that matters on its own (invoice, statement, ticket)<\/td><td>Media template, or a Rich Card with a URL button<\/td><td>The file is the message<\/td><td>PDF on cards is limited to India on Google Messages<\/td><\/tr><tr><td>Long policy or terms text<\/td><td>Text Message<\/td><td>Up to 2,500 characters<\/td><td>Link out for anything longer<\/td><\/tr><tr><td>A question with two or three short answers<\/td><td>Text Message with reply suggestions<\/td><td>Up to 11 chips, cheap to build<\/td><td>Reply chips need prefixes and an inbox reader<\/td><\/tr><tr><td>A promotion with one hero product<\/td><td>Rich Card on a Promotional bot<\/td><td>One image, one call to action<\/td><td>Consent and timing rules still apply<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">When in doubt between a rich card and a carousel, ask whether the recipient is choosing between things. If they aren&#8217;t, it&#8217;s a rich card.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"seven-steps\" class=\"wp-block-heading\">From Model to First Send in Seven Steps<\/h2>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Pick the bot and check its use case.<\/strong> List bots with the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/rcs-bots\/\">RCS Bots API<\/a> and note whether the bot is Promotional, OTP, Transactional or Multi-Use. The template&#8217;s category must match.<\/li>\n\n\n\n<li><strong>Write the model.<\/strong> One <code>rcs_template<\/code> row with family, version and layout, one <code>rcs_card<\/code> row per card, one <code>rcs_suggestion<\/code> row per button with a fresh prefix for every reply button.<\/li>\n\n\n\n<li><strong>Validate.<\/strong> Run the validator: name, lengths with the UTF-8 and variable rules, suggestion counts, card count, media type, size and ratio. Fix everything it reports before anything is submitted.<\/li>\n\n\n\n<li><strong>Create the template.<\/strong> Through the portal, or with <code>RCSApi\/template\/create<\/code> (<code>userid<\/code>, credentials, <code>botId<\/code>, <code>templateData<\/code>). Move the row to <code>submitted<\/code> when you get &#8220;Accepted for processing.&#8221;<\/li>\n\n\n\n<li><strong>Read it back and diff.<\/strong> Poll <code>RCSApi\/template\/list<\/code> for the bot, parse <code>data<\/code>, compare <code>templateModel<\/code> with your model, and wait for <code>status<\/code> to be <code>1<\/code>. Move to <code>active<\/code> only when both are true.<\/li>\n\n\n\n<li><strong>Send one message to a phone you control.<\/strong> Call <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/send-rcs-batch\/\"><code>RCSApi\/send<\/code><\/a> with <code>sendMethod=quick<\/code>, <code>msgType=text<\/code>, <code>format=json<\/code>, <code>botId<\/code>, <code>mobile<\/code> and a <code>msg<\/code> built by your code. Save the <code>transactionId<\/code> from the reply as text.<\/li>\n\n\n\n<li><strong>Tap every button and watch it come back.<\/strong> Poll the inbox, run each message through the prefix matcher, and confirm every reply prefix resolves to the right card and action and every suffix resolves to the send in step 6. Then check the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/rcs-delivery-report\/\">RCS delivery report<\/a> for the same message.<\/li>\n<\/ol>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"checklist\" class=\"wp-block-heading\">Pre-Submit Checklist<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Template name matches <code>^[A-Za-z0-9_]{1,20}$<\/code> and includes a version.<\/li>\n\n\n\n<li>Template category matches the bot&#8217;s use case.<\/li>\n\n\n\n<li>Every title is 200 or less, every description 2,000 or less, counted in UTF-8 bytes with each variable as at least 10.<\/li>\n\n\n\n<li>Text message body is 2,500 or less by the same rule.<\/li>\n\n\n\n<li>No card has more than 4 suggestions; no text message has more than 11.<\/li>\n\n\n\n<li>Every button label is 25 or less, and still 25 or less with realistic variable values.<\/li>\n\n\n\n<li>Every reply button has a unique prefix that&#8217;s never been used before, in any version.<\/li>\n\n\n\n<li>Every URL button has a fixed base and is 2,048 or less.<\/li>\n\n\n\n<li>Carousel has between 2 and 10 cards, all vertical, best option first, similar text lengths.<\/li>\n\n\n\n<li>Every card has at least one of media, title or description.<\/li>\n\n\n\n<li>Every image is JPEG, JPG, PNG or GIF, at the form&#8217;s exact ratio, 2 MB or less after encoding.<\/li>\n\n\n\n<li>Every video is 10 MB or less with a thumbnail of 100 KB or less.<\/li>\n\n\n\n<li>No PDF on a card unless the whole audience is in India on Google Messages.<\/li>\n\n\n\n<li>Every variable used in any field is supplied in <code>customParams<\/code> at send time.<\/li>\n\n\n\n<li>Fallback text is rendered from the model and stored with it.<\/li>\n\n\n\n<li>Read-back diff is empty and <code>status<\/code> is the integer <code>1<\/code> before the template is marked active.<\/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\">A handful of behaviours around RCS cards aren&#8217;t pinned down by anything you can read today. 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. Treat only the integer <code>1<\/code> as sendable.<\/strong> The list call reports <code>status<\/code> as a number and says <code>1<\/code> is active; the portal uses six named states. Map nothing else. A template whose status is anything other than the integer <code>1<\/code> waits, and an unfamiliar value raises an alert. Fail closed costs you a delay, never a bad send.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Two. Create one card of each layout in the portal, read it back, and use that as your API shape.<\/strong> The read-back gives you the exact field names and values the platform stores (<code>standAlone<\/code>, <code>carouselList<\/code>, <code>cardTitle<\/code>, <code>height<\/code>, <code>width<\/code>, <code>orientation<\/code>, <code>suggestionType<\/code>). Build API creates from that, then diff every new template&#8217;s read-back against your model. If the shapes ever drift, the diff tells you on the first template, not the fiftieth.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Three. Budget non-ASCII text by UTF-8 bytes.<\/strong> Whether the limit counts code points or something closer to bytes, a field that fits in UTF-8 bytes fits under both readings. You give up a little room in Hindi or emoji-heavy copy, and you never get a rejection for it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Four. Count each variable as the larger of its own length and 10, then check again with real values.<\/strong> That covers both readings of &#8220;uses 10 characters&#8221;, and the second check catches the long name that a template-time check can&#8217;t see.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Five. Match postbacks by the prefixes you issued, and treat the suffix as opaque.<\/strong> You don&#8217;t control what fills <code>[message_id]<\/code> or what characters it contains. A prefix lookup followed by an underscore works whatever the suffix looks like; splitting on the last underscore doesn&#8217;t.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Six. Look the suffix up against every identifier you store for a send.<\/strong> Save the <code>transactionId<\/code> from the send reply, and the message identifiers from delivery report rows as they arrive. When a tap comes in, search all of them for that mobile number. Whichever one the platform uses, you find the send.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Seven. Run every inbound message through the matcher, whatever its type label.<\/strong> The inbox row carries <code>type<\/code> and <code>typeLabel<\/code> (<code>text<\/code> and <code>FREE TEXT<\/code> in the sample). Don&#8217;t filter on them before matching. A tap that matches an issued prefix is a tap; anything else is a person typing, and both paths are correct whatever label the tap arrives with.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Eight. Name <code>customParams<\/code> keys exactly like the template&#8217;s variables, without the brackets, and prove it with one test send.<\/strong> The template uses <code>[name]<\/code>; the parameter map uses <code>name<\/code>. Send one message to your own phone and check the rendered card before any campaign uses a new template. If a placeholder shows up literally, the keys don&#8217;t match.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Nine. Use 10 MB as the upload cap when your pipeline goes through the Media Library.<\/strong> Card PDFs and media template files allow more, but the library caps each file at 10 MB. The stricter cap works on both paths.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Ten. Offer only the layout values you&#8217;ve seen read back.<\/strong> The samples show <code>VERTICAL<\/code>, <code>SHORT_HEIGHT<\/code>, <code>MEDIUM_HEIGHT<\/code> and <code>SMALL_WIDTH<\/code>. Before your editor offers horizontal cards, medium-width carousels or an alignment choice, create one in the portal and read its stored values. Your editor&#8217;s options then match the platform&#8217;s, by construction.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Eleven. Take the per-message cost from the delivery report, not from an assumption about card types.<\/strong> RCS delivery rows carry an <code>amount<\/code> for each message. Reconcile on that, so a carousel and a text message are each charged at whatever they actually cost. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/reconciling-messaging-invoice-four-channels\/\">invoice reconciliation guide<\/a> shows the ledger side.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Twelve. Save both inbox timestamps and sort by the epoch.<\/strong> Inbox rows carry <code>receivedAt<\/code> in epoch milliseconds and <code>receivedAtFormatted<\/code> as a string with no zone. Store both as received, order and window on the epoch, and compare the two on your own traffic. If they ever disagree, you still have the raw values to sort it out.<\/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<h3 class=\"wp-block-heading\">Can I update an RCS template through the API?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">No. The RCS template API has create, list and delete only. Changing content means creating a new template with a new name, which is why the version belongs in the name. The portal has an Edit action, but there&#8217;s no API call for it.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">What&#8217;s the maximum number of cards in an RCS carousel?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Ten. Each card can have its own media, a title of up to 200 characters, a description of up to 2,000 characters and up to four suggestions. Use at least two; a single card is better sent as a rich card.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How many buttons can a rich card have?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Up to four suggestions per rich card or carousel card, and up to 11 on a text message. Button text can be up to 25 characters, and a variable in the label costs 10 of those.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Why does <code>templateModel<\/code> come back undefined when I read my templates?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Because <code>data<\/code> is a JSON string. Parse the list response, then parse each row&#8217;s <code>data<\/code> field again, and <code>templateModel<\/code> is inside that second result.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How do I pass variable values when sending an RCS card?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Put them in <code>customParams<\/code> inside <code>msg.contentMessage.templateMessage<\/code>, as a JSON string that maps each variable name to its value, for example <code>\"{\\\"name\\\":\\\"Asha\\\",\\\"orderid\\\":\\\"ORD1042\\\"}\"<\/code>. Build it with a JSON library rather than by hand.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How do I know which message a button tap came from?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The postback ends with a <code>_[message_id]<\/code> suffix that the platform fills in at send time. Make the part you control a short unique code, look it up to get the template, card and action, and look the suffix up in your send records to get the exact message.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">What image size should I use for a vertical rich card with short media?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">A 3:1 image, ideally 1440 x 480 pixels, in JPEG, JPG, PNG or GIF, up to 2 MB. For other orientation and height combinations, the template form shows the exact ratio for your selection.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Can I send a PDF in a rich card?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Yes, the platform accepts PDFs on cards up to 100 MB. Google&#8217;s guide notes that PDF in rich cards currently works only in India on the Google Messages client, so for a wider audience, link to the PDF from a URL button instead.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">When is a new template ready to send?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">When <code>RCSApi\/template\/list<\/code> shows it with <code>status<\/code> equal to <code>1<\/code>, and its stored content matches what you submitted. &#8220;Accepted for processing.&#8221; from the create call only means it was received.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Do emoji count as one character in RCS limits?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Not safely. Google&#8217;s guidance says emoji and multi-byte characters count as two to four characters or more. Budget by UTF-8 bytes, where an emoji is 4, and you&#8217;ll stay inside the limit whichever way it&#8217;s counted.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">Building RCS cards into your own product?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/demo\/\">Open a sandbox account<\/a> and run the validator from this guide against your first carousel, or <a href=\"https:\/\/www.smsgatewaycenter.com\/contact\/\">talk to the SMSGatewayCenter team<\/a> about bots, templates and pricing for your use case. Current RCS rates are on the <a href=\"https:\/\/www.smsgatewaycenter.com\/rcs-pricing\/\">RCS pricing page<\/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 our blog<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/multi-channel-fallback-sms-whatsapp-rcs-voice-otp-design\/\">Multi-Channel Fallback Design: Platform Failover or Your Own Orchestrator for SMS, WhatsApp, RCS and Voice OTP<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/voice-call-api-wire-contract-obd-otp-delivery-reports\/\">Voice Call API Wire Contract: OBD Calls, Voice OTP, Audio Library, Schedules and Delivery Reports<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/messaging-data-retention-erasure-sms-whatsapp-rcs-telegram\/\">Data Retention and Erasure for SMS, WhatsApp, RCS and Telegram: Deleting a Person Without Breaking Opt-Outs<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-account-security-audit-trail-activity-logs-connected-devices\/\">SMS Account Security Audit Trail: Activity Logs, Login Alerts and Connected Devices via API<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-whatsapp-preference-centre-design-purposes-channels\/\">Building an SMS and WhatsApp Preference Centre: Purposes, Channels, Frequency and the Send Gate<\/a><\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n","protected":false},"excerpt":{"rendered":"<p>RCS rich cards and carousels look like design work, but they behave like a data model with hard limits, an approval clock and no update call. Here&#8217;s how to model cards in your own system, validate them the way the device counts, version them by template name, read them back from RCSApi\/template\/list, and match button taps to the exact send.<\/p>\n","protected":false},"author":118,"featured_media":3129,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[2010],"tags":[2379,2231,2376,2234,2232,2233,2378,2280,2377,2375],"class_list":["post-3128","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-developer-guides","tag-content-model","tag-messaging-api","tag-postback","tag-rcs","tag-rcs-api","tag-rcs-business-messaging","tag-rcs-carousel","tag-rcs-templates","tag-rich-cards","tag-suggested-replies"],"_links":{"self":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/3128","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=3128"}],"version-history":[{"count":0,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/3128\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media\/3129"}],"wp:attachment":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media?parent=3128"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/categories?post=3128"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/tags?post=3128"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}