{"id":3098,"date":"2026-10-19T17:40:59","date_gmt":"2026-10-19T12:10:59","guid":{"rendered":"https:\/\/www.smsgatewaycenter.com\/blog\/?p=3098"},"modified":"2026-10-19T17:41:02","modified_gmt":"2026-10-19T12:11:02","slug":"sms-whatsapp-preference-centre-design-purposes-channels","status":"publish","type":"post","link":"https:\/\/www.smsgatewaycenter.com\/blog\/sms-whatsapp-preference-centre-design-purposes-channels\/","title":{"rendered":"Building an SMS and WhatsApp Preference Centre: Purposes, Channels, Frequency and the Send Gate"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">A preference centre is the page where customers choose what you may send them, on which channel and how often. Built badly it becomes a third list that disagrees with your consent log and your suppression table. Here is how to build one that writes into those records instead, maps purposes to DLT categories, handles rows nobody can switch off, and feeds a send gate that fails closed across SMS, WhatsApp, RCS and Telegram.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/preference-centre-sms-whatsapp-rcs-telegram.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"584\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/preference-centre-sms-whatsapp-rcs-telegram-1024x584.webp\" alt=\"Abstract grid of toggles feeding a single gate that routes to four channel shapes\" class=\"wp-image-3099\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/preference-centre-sms-whatsapp-rcs-telegram-1024x584.webp 1024w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/preference-centre-sms-whatsapp-rcs-telegram-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/preference-centre-sms-whatsapp-rcs-telegram-768x438.webp 768w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/preference-centre-sms-whatsapp-rcs-telegram.webp 1200w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><figcaption class=\"wp-element-caption\">Every choice on the page ends up in one place: the gate in front of every send.<\/figcaption><\/figure>\n\n\n\n<h1 class=\"wp-block-heading\">Table of Contents<\/h1>\n\n\n\n<ol class=\"wp-block-list\">\n<li><a href=\"#the-short-answer\">The Short Answer<\/a><\/li>\n\n\n\n<li><a href=\"#tldr\">TL;DR<\/a><\/li>\n\n\n\n<li><a href=\"#what-it-controls\">What a Preference Centre Actually Controls<\/a><\/li>\n\n\n\n<li><a href=\"#three-layers\">Three Layers of &#8220;No&#8221; and Who Owns Each<\/a><\/li>\n\n\n\n<li><a href=\"#the-matrix\">Purposes, Channels and the Matrix<\/a><\/li>\n\n\n\n<li><a href=\"#dlt-mapping\">Mapping Purposes to DLT Categories<\/a><\/li>\n\n\n\n<li><a href=\"#locked-rows\">The Rows a Customer Can&#8217;t Switch Off<\/a><\/li>\n\n\n\n<li><a href=\"#data-model\">The Data Model: Preferences Write Events, Not Flags<\/a><\/li>\n\n\n\n<li><a href=\"#frequency-and-pause\">Frequency Caps, Pauses and Quiet Hours<\/a><\/li>\n\n\n\n<li><a href=\"#signed-links\">Getting the Customer There: Signed Links<\/a><\/li>\n\n\n\n<li><a href=\"#asymmetric-auth\">Easy Out, Careful In<\/a><\/li>\n\n\n\n<li><a href=\"#saving-changes\">Saving a Change: Diff, Version, Idempotency<\/a><\/li>\n\n\n\n<li><a href=\"#send-gate\">The Send Gate With Preferences In It<\/a><\/li>\n\n\n\n<li><a href=\"#queued-messages\">Changes That Hit Messages Already Queued<\/a><\/li>\n\n\n\n<li><a href=\"#channel-specifics\">Channel Specifics: SMS, WhatsApp, RCS, Telegram<\/a><\/li>\n\n\n\n<li><a href=\"#mirroring\">Mirroring to the Platform Where You Can<\/a><\/li>\n\n\n\n<li><a href=\"#code-samples\">Four Code Samples, Four Traps<\/a><\/li>\n\n\n\n<li><a href=\"#decision-matrix\">Decision Matrix<\/a><\/li>\n\n\n\n<li><a href=\"#checklist\">Launch Checklist<\/a><\/li>\n\n\n\n<li><a href=\"#unspecified-behaviour\">Unspecified Behaviour and How to Code Around It<\/a><\/li>\n\n\n\n<li><a href=\"#faqs\">FAQs<\/a><\/li>\n<\/ol>\n\n\n\n<h2 id=\"the-short-answer\" class=\"wp-block-heading\">The Short Answer<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A preference centre is a page where a customer says what you may send them, on which channel and how often. The trap is building it as a fresh table of on\/off flags. Do that and you now have three places that answer &#8220;may I message this person?&#8221;: your consent log, your suppression list and the new flags. Sooner or later they disagree, and a send goes out that shouldn&#8217;t.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Build it as a writer instead. Every toggle the customer changes becomes an event in the consent log (for &#8220;yes, send me offers on WhatsApp&#8221;) or the suppression log (for &#8220;stop everything&#8221;). Frequency caps, pauses and quiet hours are the only genuinely new records. Your send gate then reads all of it in one place and refuses anything it can&#8217;t prove is allowed.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">On <strong>SMSGatewayCenter<\/strong> the preference centre is something you host. The platform gives you a dashboard Blocked Numbers list and a dashboard WhatsApp opt-in list, plus whatever the operator&#8217;s <a href=\"https:\/\/www.smsgatewaycenter.com\/dlt-sms\/\">DND registry enforces on SMS<\/a>. None of those are written from your code, and none of them know about purposes like &#8220;offers&#8221; or &#8220;reminders&#8221;. So your own records are the authority, and the page is how customers change them.<\/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>Don&#8217;t add a third source of truth.<\/strong> The page writes consent events and suppression events. It reads back the folded state. Only frequency, pause and quiet hours get their own table.<\/li>\n\n\n\n<li><strong>Model a purpose by channel matrix<\/strong>, not one big &#8220;marketing&#8221; switch. A person can want delivery updates on WhatsApp and offers by SMS only.<\/li>\n\n\n\n<li><strong>Map every purpose to a DLT category.<\/strong> Service-implicit content can&#8217;t be switched off from the page, promotional and service-explicit content can, and the operator&#8217;s DND registry sits above both.<\/li>\n\n\n\n<li><strong>Show locked rows honestly.<\/strong> OTPs and transaction alerts appear as &#8220;always on, here&#8217;s why&#8221;, never as a toggle that does nothing.<\/li>\n\n\n\n<li><strong>Reach the page through a signed, expiring link<\/strong> sent in your messages. Opting out needs nothing more. Opting in to more needs a confirmation on the channel itself.<\/li>\n\n\n\n<li><strong>Save only what changed.<\/strong> Diff the submitted form against current state and write one event per changed cell, with a version check so two tabs can&#8217;t overwrite each other.<\/li>\n\n\n\n<li><strong>Gate every send on all of it:<\/strong> consent active, not suppressed, not paused, under the cap, inside the customer&#8217;s hours. Unknown values fail closed.<\/li>\n\n\n\n<li><strong>Purge what&#8217;s already queued<\/strong> when someone opts out, and know which channels can&#8217;t recall a booking.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"what-it-controls\" class=\"wp-block-heading\">What a Preference Centre Actually Controls<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Strip away the UI and there are four kinds of choice on the page.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Purpose.<\/strong> What the message is about: delivery updates, appointment reminders, offers, newsletters, surveys. This is the axis customers care most about, and it&#8217;s the one the operator and the messaging platform don&#8217;t know about at all.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Channel.<\/strong> <a href=\"https:\/\/www.smsgatewaycenter.com\/transactional-sms\/\">SMS<\/a>, <a href=\"https:\/\/www.smsgatewaycenter.com\/whatsapp-business-api\/\">WhatsApp<\/a>, <a href=\"https:\/\/www.smsgatewaycenter.com\/rcs-messaging\/\">RCS<\/a>, <a href=\"https:\/\/www.smsgatewaycenter.com\/telegram-bulk-messaging\/\">Telegram<\/a>. Same person, four different addresses, four different rulebooks.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Volume.<\/strong> How often. &#8220;At most two offers a week&#8221;, or &#8220;pause everything until the 30th&#8221;. This is the only part that isn&#8217;t a consent decision, which is why it gets its own table.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Timing.<\/strong> When. &#8220;Not before 9 in the morning my time.&#8221; On SMS the platform already holds promotional traffic outside its posting window if you&#8217;ve switched that on, but a customer&#8217;s own hours are usually narrower, and the platform doesn&#8217;t know what they are.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A preference centre that handles all four well is mostly plumbing. The visible page is maybe ten percent of the work. The rest is making sure each choice lands in the right record and that the send path reads it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you&#8217;ve already built the consent log from <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/consent-capture-evidence-sms-whatsapp-rcs-telegram\/\">Consent Capture and Evidence for SMS, WhatsApp, RCS and Telegram<\/a> and the suppression table from <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/opt-out-suppression-sms-whatsapp-rcs-telegram\/\">Opt-Out and Suppression Across SMS, WhatsApp, RCS and Telegram<\/a>, most of the storage exists already. What follows is the customer-facing surface on top and the gate underneath.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"three-layers\" class=\"wp-block-heading\">Three Layers of &#8220;No&#8221; and Who Owns Each<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Before you design a single toggle, be clear about which refusals you control and which you don&#8217;t. There are three layers, and the page only writes to one of them.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Layer one: the operator.<\/strong> In India, a subscriber can register with the national do-not-disturb registry, fully or for selected categories, through their operator (the old 1909 route is described in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/how-to-register-de-register-from-ncpr-database\/\">How to Register and De-Register from the NCPR Database<\/a>). That choice lives with the operator, not with you. You find out about it when a send comes back as <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/what-is-ncpr_fail\/\">NCPR_FAIL<\/a> or <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/what-does-dnd_preference_number-mean\/\">DND_PREFERENCE_NUMBER<\/a>. Your preference centre can&#8217;t override it, and a customer ticking &#8220;yes, send me offers&#8221; on your page doesn&#8217;t change what the operator lets through.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Layer two: the platform.<\/strong> On SMSGatewayCenter there&#8217;s a <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/what-is-the-blocked-numbers-feature\/\">Blocked Numbers<\/a> list (per number, per product: SMS, WhatsApp, Voice) and a per-WABA <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/whatsapp-messaging-opt-in-guide\/\">WhatsApp opt-in list<\/a>. Both are managed in the dashboard. They&#8217;re a useful backstop, but they hold numbers, not purposes.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Layer three: you.<\/strong> Your consent log, your suppression log, and the preference rows this page adds. This is the only layer that knows the difference between &#8220;no offers&#8221; and &#8220;no reminders&#8221;, and the only one your code can write.<\/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-preference-layers-table.svg\"><img decoding=\"async\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/diagram-preference-layers-table.svg\" alt=\"Table comparing six controls over who may be messaged: operator DND, Blocked Numbers, WhatsApp opt-in list, WhatsApp marketing stop, your consent log and your preference rows, by who sets each, its scope, channels and whether code can read or write it\" class=\"wp-image-3100\"\/><\/a><figcaption class=\"wp-element-caption\">Only the bottom two rows are yours to read and write. Everything above them is a backstop you learn about from failures or maintain by hand.<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The practical rule that falls out of this: your gate has to pass layer three on its own. Layers one and two will catch some mistakes, but a send that only stopped because the operator blocked it is still a send you shouldn&#8217;t have made, and on most channels it still costs you something.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"the-matrix\" class=\"wp-block-heading\">Purposes, Channels and the Matrix<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The single biggest design decision is the shape of the grid. Two common shapes fail.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One master switch (&#8220;receive marketing messages&#8221;) is too blunt. People who are happy to get delivery updates on WhatsApp but hate promotional SMS will hit the only button available, and you lose both.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One switch per channel, no purposes, is just as bad. It can&#8217;t express &#8220;WhatsApp for my orders, nothing else&#8221;, which is exactly what a lot of people want.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The shape that works is a matrix: purposes down the side, channels across the top.<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Purpose<\/th><th>SMS<\/th><th>WhatsApp<\/th><th>RCS<\/th><th>Telegram<\/th><\/tr><\/thead><tbody><tr><td>Security codes and transaction alerts<\/td><td>Always on<\/td><td>Always on<\/td><td>Always on<\/td><td>Always on<\/td><\/tr><tr><td>Order and delivery updates<\/td><td>On<\/td><td>On<\/td><td>Off<\/td><td>Off<\/td><\/tr><tr><td>Appointment reminders<\/td><td>On<\/td><td>Off<\/td><td>Off<\/td><td>Off<\/td><\/tr><tr><td>Offers and promotions<\/td><td>Off<\/td><td>On<\/td><td>Off<\/td><td>Off<\/td><\/tr><tr><td>Newsletter<\/td><td>Off<\/td><td>Off<\/td><td>Off<\/td><td>On<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Five rules make the grid usable.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Keep purposes few and concrete.<\/strong> Five to eight rows. &#8220;Offers and promotions&#8221; is something a person can decide about. &#8220;Engagement communications&#8221; isn&#8217;t.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Give purposes stable keys.<\/strong> The label can change (&#8220;Offers&#8221; becomes &#8220;Deals and offers&#8221;) without touching stored choices. Store <code>offers<\/code>, display whatever the copy team likes.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Only show channels you actually use for that purpose.<\/strong> If you never send reminders on Telegram, don&#8217;t render that cell. An empty toggle invites people to switch on something that will never arrive.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Only show channels you hold an address for.<\/strong> A Telegram column needs a <code>chatId<\/code>, which you only get after the person has started your bot. Show a &#8220;connect&#8221; link instead of a toggle until then.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Add a &#8220;stop everything&#8221; control that&#8217;s as easy to find as any toggle.<\/strong> This one doesn&#8217;t write consent events for every cell. It writes a single suppression event with scope <code>all<\/code>, which is what the opt-out design already expects.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"dlt-mapping\" class=\"wp-block-heading\">Mapping Purposes to DLT Categories<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">On SMS in India, every message goes out against a registered DLT template, and each template has a category. That category decides two things the preference centre has to respect: whether the operator&#8217;s DND registry applies, and whether the customer can reasonably switch it off.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Your SMS templates already carry the category. <code>SMSApi\/template\/read<\/code> returns it per template as <code>dltTemplateType<\/code>, alongside <code>mtId<\/code> and <code>dltTemplateId<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"template\": {\n    \"mtId\": \"39\",\n    \"identifier\": \"bank details without statement\",\n    \"dltTemplateId\": \"10070142034xxxxxxxx\",\n    \"dltTemplateType\": \"Service Implicit\",\n    \"status\": \"ENABLED\"\n  }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">So the mapping is a join, not guesswork: purpose to template, template to category. Keep it as a table in your own database (<code>purpose_template<\/code>) and check it in CI. If someone attaches a promotional template to the &#8220;order updates&#8221; purpose, the build should fail, because that template will be held by DND for some recipients and the customer&#8217;s &#8220;on&#8221; toggle can&#8217;t fix that.<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>DLT category<\/th><th>Operator DND applies<\/th><th>Customer can switch off on your page<\/th><th>Typical purposes<\/th><\/tr><\/thead><tbody><tr><td>Service implicit<\/td><td>No<\/td><td>No, shown as locked<\/td><td>Security codes, transaction alerts, delivery of something they ordered<\/td><\/tr><tr><td>Service explicit<\/td><td>Yes<\/td><td>Yes<\/td><td>Reminders, account notices sent with explicit consent<\/td><\/tr><tr><td>Promotional<\/td><td>Yes<\/td><td>Yes<\/td><td>Offers, newsletters, surveys<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The DND column comes straight from the platform&#8217;s own explanation: promotional and service-explicit messages <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/why-do-i-get-a-dnd-error-when-sending-transactional-or-otp-messages\/\">aren&#8217;t delivered to DND-registered numbers<\/a>, service-implicit ones are. For a refresher on the categories themselves, see <a href=\"https:\/\/www.smsgatewaycenter.com\/service-implicit-sms\/\">Service Implicit SMS<\/a> and <a href=\"https:\/\/www.smsgatewaycenter.com\/service-explicit-sms\/\">Service Explicit SMS<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Build two consequences in from day one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">First, a &#8220;yes&#8221; on your page for a service-explicit or promotional purpose is necessary but not sufficient. If the operator says no, the send fails as <code>NCPR_FAIL<\/code> or <code>DND_PREFERENCE_NUMBER<\/code>. Record that in a separate hold table (as the suppression design does), not as an opt-out, and don&#8217;t keep retrying.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Second, if a customer switches offers off, leave them alone about it. TRAI&#8217;s 2025 amendment to the commercial communications rules says a customer who opts out <a href=\"https:\/\/trai.gov.in\/sites\/default\/files\/2025-02\/PR_No.11of2025.pdf\" target=\"_blank\" rel=\"noopener nofollow\">can&#8217;t be asked for consent again for 90 days<\/a>, though they&#8217;re free to opt back in themselves at any time. So the page can show the switch, but nothing in your system should prompt them to flip it back inside that window.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"locked-rows\" class=\"wp-block-heading\">The Rows a Customer Can&#8217;t Switch Off<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Some messages have to go out whatever the page says. A one-time password for a login the customer just started. A debit alert. A delivery code. Hiding these rows entirely makes the page look dishonest the first time an OTP arrives after someone switched &#8220;everything&#8221; off. Showing them as working toggles is worse, because the switch does nothing.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Show them locked, with a one-line reason:<\/p>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p class=\"wp-block-paragraph\"><strong>Security codes and transaction alerts<\/strong> (always on): we send these only when you&#8217;ve done something on your account, like logging in or making a payment.<\/p>\n<\/blockquote>\n\n\n\n<p class=\"wp-block-paragraph\">Then keep the definition narrow. The temptation is to file anything you&#8217;d like to send into the locked row. Resist it. A locked row should only contain messages triggered by an action the customer took or a transaction on their account, sent on a service-implicit template. If a message in that row would make sense to send to someone who didn&#8217;t just do anything, it belongs in an unlocked row.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">&#8220;Stop everything&#8221; has one wrinkle here. A suppression with scope <code>all<\/code> should stop every purpose you can stop, and the page should say plainly that security codes will still arrive while they have an active account. If they don&#8217;t want those either, the answer is closing the account or turning off SMS-based login, not a hidden exception in the gate.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"data-model\" class=\"wp-block-heading\">The Data Model: Preferences Write Events, Not Flags<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s the whole storage picture. Two tables already exist if you&#8217;ve followed the consent and suppression designs. The rest is new.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>-- Already exists (consent design): one row per grant or withdrawal.\n-- consent_event(event_id, msisdn, channel, purpose, action, origin,\n--               capture_point, notice_id, occurred_at, ...)\n-- action: 'grant' | 'confirm' | 'withdraw' | 'expire'\n\n-- Already exists (opt-out design): one row per suppress or lift.\n-- suppression_event(id, channel, address, address_kind, scope, action, source, ...)\n-- scope: 'all' | 'marketing' | 'brand:&lt;key&gt;'\n\n-- New: the non-consent choices, one current row per person.\nCREATE TABLE contact_preference (\n    person_id        TEXT PRIMARY KEY,\n    version          INTEGER NOT NULL DEFAULT 1,      -- optimistic lock\n    timezone         TEXT NOT NULL DEFAULT 'Asia\/Kolkata',\n    quiet_start      TIME,                             -- null = no quiet hours\n    quiet_end        TIME,\n    paused_until     TIMESTAMPTZ,                      -- null = not paused\n    updated_at       TIMESTAMPTZ NOT NULL DEFAULT now()\n);\n\nCREATE TABLE frequency_cap (\n    person_id        TEXT NOT NULL,\n    purpose          TEXT NOT NULL,\n    max_per_window   INTEGER NOT NULL CHECK (max_per_window &gt; 0),\n    window_days      INTEGER NOT NULL CHECK (window_days IN (1, 7, 30)),\n    PRIMARY KEY (person_id, purpose)\n);\n\n-- Append-only history of the non-consent choices, for support and audit.\nCREATE TABLE preference_change (\n    change_id        BIGSERIAL PRIMARY KEY,\n    person_id        TEXT NOT NULL,\n    field            TEXT NOT NULL,          -- 'paused_until', 'quiet_start', 'cap:offers', ...\n    old_value        TEXT,\n    new_value        TEXT,\n    capture_point    TEXT NOT NULL,          -- 'web:preference-centre', 'support:agent-17', ...\n    notice_id        TEXT,                   -- version of the page copy the person saw\n    occurred_at      TIMESTAMPTZ NOT NULL DEFAULT now()\n);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Why split it this way? Because the two kinds of record answer different questions.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Consent events answer &#8220;did this person agree, when, and to what wording?&#8221; They need to survive for as long as you might have to prove it, and they need the exact notice the person saw. A toggle on the preference page is a capture point like any other: it writes <code>grant<\/code> or <code>withdraw<\/code> with <code>capture_point = 'web:preference-centre'<\/code> and the <code>notice_id<\/code> of the page copy that was live.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Preference rows answer &#8220;how should I behave right now?&#8221; Pause, quiet hours and caps don&#8217;t need proof of agreement. They change often, and only the current value matters to the gate. Keep a change history anyway, because support will want to see &#8220;they paused on the 3rd, not us&#8221;.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">What you shouldn&#8217;t store is a <code>prefs<\/code> JSON blob with <code>offers_sms: true<\/code> in it. That&#8217;s the third list. The page reads the current consent state by folding events, exactly as the gate does, and renders the grid from that.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"frequency-and-pause\" class=\"wp-block-heading\">Frequency Caps, Pauses and Quiet Hours<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">These three are the reason people visit a preference centre instead of replying STOP. Get them right and you keep customers you&#8217;d otherwise lose.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Frequency caps.<\/strong> Cap per purpose, not per channel. &#8220;Two offers a week&#8221; should mean two offers, whether they arrive by SMS or WhatsApp. Count at the moment your gate approves a send, not when the platform reports delivery, because delivery reports arrive late and some never arrive. A message the gate approved and then failed to deliver still counts. That errs toward sending less, which is the right direction.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Pick the window in the customer&#8217;s time zone. A &#8220;daily&#8221; cap that resets at midnight UTC resets at 5:30 in the morning in India. A message at 5:20 and another at 5:40 then fall on different UTC days, so a cap of one a day lets both through, twenty minutes apart.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Pauses.<\/strong> &#8220;Pause everything until the 30th&#8221; is a single <code>paused_until<\/code> timestamp. It stops every unlocked purpose and leaves consent alone, so nothing has to be re-granted when it ends. Store it as an instant, computed from the date they chose at the end of that day in their time zone.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Quiet hours.<\/strong> These are the customer&#8217;s window, and they stack with whatever the platform does. On SMS, the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/store-forward-owh-automated-sms-compliance\/\">Store and Forward OWH<\/a> setting holds promotional and service-explicit messages outside the account&#8217;s posting hours and releases them later, while transactional traffic goes immediately. A customer&#8217;s own quiet hours are usually stricter, and the platform doesn&#8217;t know about them. So your gate holds the message itself, and the stricter window wins.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Quiet hours shouldn&#8217;t hold locked rows. An OTP for a login at 11 at night has to arrive at 11 at night.<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Choice<\/th><th>Stored as<\/th><th>Applies to<\/th><th>Ends<\/th><\/tr><\/thead><tbody><tr><td>Frequency cap<\/td><td><code>frequency_cap<\/code> row<\/td><td>One purpose, all channels<\/td><td>When changed<\/td><\/tr><tr><td>Pause<\/td><td><code>paused_until<\/code><\/td><td>Every unlocked purpose<\/td><td>At the instant stored<\/td><\/tr><tr><td>Quiet hours<\/td><td><code>quiet_start<\/code>, <code>quiet_end<\/code>, <code>timezone<\/code><\/td><td>Every unlocked purpose<\/td><td>When changed<\/td><\/tr><tr><td>Stop everything<\/td><td><code>suppression_event<\/code>, scope <code>all<\/code><\/td><td>Every unlocked purpose, every channel<\/td><td>Only when the person lifts it<\/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=\"signed-links\" class=\"wp-block-heading\">Getting the Customer There: Signed Links<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Most people reach a preference centre from a message, not from your account settings. So you need a link that identifies the person without a login, can&#8217;t be guessed, and can&#8217;t be replayed forever.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The usual shape is an HMAC-signed token in the path:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>https:&#47;&#47;prefs.example.com\/p\/&lt;token&gt;\ntoken = base64url(person_ref&#91;8] | channel&#91;1] | expires_at&#91;4] | HMAC-SHA256(key, first 13 bytes)&#91;:10])<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The details matter more than the crypto.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Keep it short.<\/strong> On SMS every character counts against the segment, and a long token can push a 160-character message into two parts. An 8-byte random person reference (not their phone number), a 1-byte channel, a 4-byte expiry and a 10-byte truncated MAC come to 23 bytes, which is 31 characters of base64url. The sample in the code section produces exactly that. Check the full message with the <a href=\"https:\/\/www.smsgatewaycenter.com\/sms-length-calculator\/\">SMS length calculator<\/a> before you lock the template.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Whitelist the domain on DLT.<\/strong> A URL in an Indian SMS has to match what&#8217;s registered for the template, using the <code>{#url#}<\/code> variable tag. If your preference link lives on a domain you haven&#8217;t whitelisted, the message fails at the operator. The same steps that cover <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-click-tracking-attribution-smart-links\/\">link tracking and DLT whitelisting<\/a> apply here.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Don&#8217;t track it.<\/strong> Running the preference link through a click tracker adds a redirect and, more importantly, makes &#8220;opened the preference page&#8221; look like engagement in your campaign stats. Use your own short domain and keep it out of attribution.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Expire it, but generously.<\/strong> Thirty days is reasonable for a link in a promotional message. When an expired link is opened, don&#8217;t show an error with nothing else. Show the opt-out option anyway, plus a way to request a fresh link. A person who wants out shouldn&#8217;t be blocked by your token policy.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Never put the phone number in the URL.<\/strong> Links get forwarded, screenshotted and logged by browsers and proxies. A random reference leaks nothing on its own.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"asymmetric-auth\" class=\"wp-block-heading\">Easy Out, Careful In<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The signed link proves that someone opened a link sent to a number. It doesn&#8217;t prove it&#8217;s the number&#8217;s owner. Forwarded messages, shared phones and a previous owner of a recycled number all break that assumption.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So treat changes asymmetrically.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Anything that sends less needs only the link.<\/strong> Switching a purpose off, pausing, tightening a cap, adding quiet hours, stopping everything. The worst case is that someone other than the owner reduces messages to that number. That&#8217;s annoying, not harmful, and the law points the same way: India&#8217;s DPDP Act says withdrawing consent should be as easy as giving it (<a href=\"https:\/\/www.trai.gov.in\/sites\/default\/files\/2024-09\/DPDT_11082024.pdf\">section 6(4)<\/a>). Asking for a login to opt out fails that test.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Anything that sends more needs proof on the channel itself.<\/strong> Switching a purpose on, adding a channel, lifting a pause early, removing a cap. Here the worst case is messaging someone who never agreed. So the page records a pending grant and sends a confirmation on that channel (&#8220;Reply YES to get offers by SMS&#8221;), and the grant only counts once the confirmation comes back. That&#8217;s the double opt-in pattern from the consent design, and the event log already has a <code>confirm<\/code> action for it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A logged-in session in your app is a stronger identity than the link, but it&#8217;s still not proof that the person controls a given phone number today. Use the same rule for both entry points. It keeps the code simple and the evidence consistent.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"saving-changes\" class=\"wp-block-heading\">Saving a Change: Diff, Version, Idempotency<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The save path is where most preference centres quietly corrupt their own history. The page posts the whole grid, the server writes the whole grid, and now the consent log says the person &#8220;granted&#8221; offers on SMS again today, when all they did was switch on quiet hours. Months later, that&#8217;s the record you&#8217;ll be showing a regulator.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Five steps avoid it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>One. Load current state and its version together.<\/strong> When the page renders, include the <code>contact_preference.version<\/code> and a hash of the folded consent state in a hidden field. These are what the save compares against.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Two. Diff the submitted grid against current state, cell by cell.<\/strong> Only cells that changed produce events. An unchanged &#8220;on&#8221; produces nothing. Mature preference platforms give the same advice: send only what the person changed, so the history shows their choices and not your form resubmitting itself.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Three. Split the diff by direction.<\/strong> Cells going off become <code>withdraw<\/code> events immediately. Cells going on become pending grants and trigger a confirmation on that channel (see the previous section). Pause, quiet hours and caps go to <code>contact_preference<\/code> and <code>preference_change<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Four. Write in one transaction with a version check.<\/strong> <code>UPDATE contact_preference SET ..., version = version + 1 WHERE person_id = $1 AND version = $2<\/code>. If no row is updated, someone saved in another tab or a support agent changed something. Re-render with the fresh state and say so, rather than overwriting.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Five. Make the submit idempotent.<\/strong> Put a one-time form token in the page and store it with the change. A double click or a mobile browser retry then lands once. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/message-idempotency-preventing-duplicate-sends\/\">idempotency patterns<\/a> used for sends work just as well for form posts.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One exception to the version check: a &#8220;stop everything&#8221; submit should never fail on a version conflict. If two writes race and one of them is a stop, the stop wins. Write the suppression event first, outside the version check, then deal with the rest of the form.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"send-gate\" class=\"wp-block-heading\">The Send Gate With Preferences In It<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Everything above is only useful if one function in front of every send reads it. The gate takes a candidate send and answers allow, hold or drop.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>allow  if  purpose is locked (service implicit), and the person has an active account\n       or  consent(person, purpose, channel) is active\n       and not suppressed(channel, address, scope covering purpose)\n       and not paused(person, now)\n       and now is outside quiet hours(person)\n       and count(person, purpose, window) &lt; cap\nhold   if  only the pause or quiet-hours check failed   -&gt; re-evaluate when the window ends\ndrop   otherwise                                         -&gt; record the reason<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Get these behaviours right.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Fail closed on anything you can&#8217;t read.<\/strong> If the consent fold errors, the preference row is missing a time zone, or the template&#8217;s category isn&#8217;t one you recognise, the answer is drop (or hold, for timing), never allow. A message delayed by a bug is a support ticket. A message sent against someone&#8217;s wishes is a complaint.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Hold, then re-check.<\/strong> A message held for quiet hours goes back through the whole gate when the window ends, not straight to the send call. The person might have opted out at 8 in the morning, and the 9 o&#8217;clock release has to see that.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Record why.<\/strong> Every drop and hold writes a reason (<code>no_consent<\/code>, <code>suppressed<\/code>, <code>cap_reached<\/code>, <code>quiet_hours<\/code>). Your support team will ask &#8220;why didn&#8217;t she get the reminder?&#8221; and that column answers it in seconds. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/observability-for-messaging-pipelines\/\">observability<\/a> metrics you already have can then count drops by reason, which is the cheapest way to spot a broken mapping.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Count on allow.<\/strong> Increment the cap counter in the same transaction that approves the send, so two workers can&#8217;t both approve the &#8220;last&#8221; message of the week.<\/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-preference-centre-flow.svg\"><img decoding=\"async\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/diagram-preference-centre-flow.svg\" alt=\"Three-panel flow from the preference page through the event log and preference rows to the send gate, with banners on writing events instead of flags, failing closed and counting caps on allow\" class=\"wp-image-3101\"\/><\/a><figcaption class=\"wp-element-caption\">The page writes, the gate reads, and nothing in between keeps a copy of its own.<\/figcaption><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"queued-messages\" class=\"wp-block-heading\">Changes That Hit Messages Already Queued<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A preference change at 10:02 doesn&#8217;t help if a campaign was handed to the platform at 10:00 for delivery at 11:00. You need to know where each pending message lives and what you can still do about it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Messages still in your own queue<\/strong> are easy. They pass through the gate again when they&#8217;re picked up, and the gate sees the new state.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>SMS booked on the platform<\/strong> can be cancelled. <code>SMSApi\/schedule\/delete<\/code> takes the <code>uuid<\/code> of the booking. The catch is that <code>SMSApi\/schedule\/read<\/code> doesn&#8217;t return recipient numbers, so you can only find the right booking if you saved the <code>uuid<\/code> against the recipients when you booked it. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/outbound-message-table-schema-design\/\">outbound message table<\/a> is the natural place. On a withdrawal or a stop, look up pending bookings for that person and purpose, and cancel them.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If a booking covers many recipients and only one opted out, you can&#8217;t remove one number from it. Cancel and rebook without them, or (simpler) don&#8217;t book group sends far ahead in the first place.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>WhatsApp messages sent with a future <code>scheduletime<\/code><\/strong> have no read or cancel call. Once handed over, they&#8217;ll go. The answer is to keep bookings in your own scheduler until close to send time, a &#8220;handoff horizon&#8221;. <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/scheduling-messages-sms-whatsapp-rcs-telegram\/\">Scheduling Messages Across Four Channels<\/a> covers that in detail, and a preference centre is one more reason to keep the horizon short.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>RCS and Telegram<\/strong> take no schedule parameter on send, so anything pending is in your queue and the gate handles it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Tell the customer the truth on the confirmation screen: &#8220;You won&#8217;t get any more offers from us. A message that was already on its way in the next few minutes might still arrive.&#8221; Size &#8220;few minutes&#8221; to your actual handoff horizon.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"channel-specifics\" class=\"wp-block-heading\">Channel Specifics: SMS, WhatsApp, RCS, Telegram<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The matrix looks uniform. The channels underneath aren&#8217;t.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>SMS.<\/strong> The address is the mobile number. Every purpose maps to a DLT template and therefore a category, and the operator&#8217;s DND registry sits on top for promotional and service-explicit content. STOP replies arrive on your long code through the inbound push, if you run one, and should land in the suppression log the same way a page click does (the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/two-way-sms-keyword-routing-auto-reply-design\/\">two-way keyword design<\/a> routes opt-out words first, before anything else). TRAI&#8217;s 2025 amendment also expects promotional messages to carry an opt-out option, and a link that lands on a working opt-out is a natural way to offer one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>WhatsApp.<\/strong> Same number, different rules. Business-initiated messages need opt-in, which you already track per purpose. WhatsApp also lets the person stop marketing messages from inside the app, and a send that hits that state fails with Meta error <a href=\"https:\/\/developers.facebook.com\/documentation\/business-messaging\/whatsapp\/support\/error-codes\">131050<\/a>. Treat 131050 as a <code>withdraw<\/code> for every marketing purpose on WhatsApp for that number, with <code>origin = 'platform'<\/code>, and show it on the page next time they visit. Don&#8217;t flip it back on just because they ticked something on your page. They switched it off in WhatsApp, so the confirmation has to happen in WhatsApp.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>RCS.<\/strong> The address is again the number, but whether a given handset can receive RCS is decided per send. The preference cell means &#8220;RCS is acceptable for this purpose&#8221;. If you use RCS with an SMS fallback, the fallback has to pass the gate as an SMS send for the same purpose. A person who said &#8220;RCS yes, SMS no&#8221; for offers shouldn&#8217;t get the fallback.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Telegram.<\/strong> The address is a <code>chatId<\/code>, which you only have once the person has started your bot. A cell for Telegram stays as a &#8220;connect&#8221; link until then. Telegram has no platform-side blocked list, so your suppression log is the only thing standing between a &#8220;stop&#8221; and the next send.<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Channel<\/th><th>Address the gate checks<\/th><th>Platform backstop<\/th><th>Opt-out signal to ingest<\/th><\/tr><\/thead><tbody><tr><td>SMS<\/td><td>Mobile number<\/td><td>Operator DND, Blocked Numbers<\/td><td>STOP on your long code<\/td><\/tr><tr><td>WhatsApp<\/td><td>Mobile number<\/td><td>Blocked Numbers, opt-in list (dashboard)<\/td><td>Error 131050, replies in the inbox<\/td><\/tr><tr><td>RCS<\/td><td>Mobile number<\/td><td>None for opt-out<\/td><td>Replies in the RCS inbox<\/td><\/tr><tr><td>Telegram<\/td><td><code>chatId<\/code><\/td><td>None for opt-out<\/td><td>Replies in the Telegram inbox<\/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=\"mirroring\" class=\"wp-block-heading\">Mirroring to the Platform Where You Can<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The platform lists are a backstop. They&#8217;re worth keeping in step for the cases that fit them, but don&#8217;t try to force purposes into them.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Mirror &#8220;stop everything&#8221; to Blocked Numbers.<\/strong> A suppression with scope <code>all<\/code> on SMS or WhatsApp maps cleanly to a Blocked Numbers entry for that number and product. Blocked sends then fail as <code>BLOCK_NUMBER<\/code>, and blocked messages aren&#8217;t charged. That&#8217;s a useful second line if a bug ever lets something past your gate.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Don&#8217;t mirror purpose-level choices.<\/strong> Blocked Numbers is per number and per product. &#8220;No offers on SMS, but reminders are fine&#8221; has no representation there. Blocking the number would stop the reminders too.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Keep the WhatsApp opt-in list in step with the log.<\/strong> It&#8217;s per <a href=\"https:\/\/www.smsgatewaycenter.com\/whatsapp-business-solutions\/\">WABA number<\/a> and managed in the dashboard. Treat your consent log as the source and the dashboard list as a copy you reconcile. Never read the dashboard list back as evidence.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Lift carefully.<\/strong> When a person lifts a &#8220;stop everything&#8221; (on your page, with confirmation on the channel), remove the mirrored Blocked Numbers entry as well. If you forget, your gate allows and the platform blocks, and the customer who just opted back in never hears from you. Run a scheduled reconciliation that compares your scope-<code>all<\/code> suppressions against an export of the Blocked Numbers list and flags drift in both directions.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Mirroring is dashboard and export work, so do it in a nightly batch with a person checking the diff. Your gate doesn&#8217;t wait for it.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"code-samples\" class=\"wp-block-heading\">Four Code Samples, Four Traps<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Each sample is short and does one job. Each one also shows a mistake that&#8217;s easy to make and hard to spot in review.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Python: the signed preference link<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Trap: comparing MACs with <code>==<\/code>, and refusing expired links outright. A plain equality check can leak timing information. An expired link that shows only an error blocks someone who just wants out.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import base64, hashlib, hmac, struct, time\n\nKEY = b\"replace-with-32-random-bytes-from-your-secret-store\"\nCHANNELS = {\"sms\": 1, \"whatsapp\": 2, \"rcs\": 3, \"telegram\": 4}\n\ndef b64u(b: bytes) -&gt; str:\n    return base64.urlsafe_b64encode(b).rstrip(b\"=\").decode()\n\ndef unb64u(s: str) -&gt; bytes:\n    return base64.urlsafe_b64decode(s + \"=\" * (-len(s) % 4))\n\ndef issue(person_ref: bytes, channel: str, days: int = 30, now: int | None = None) -&gt; str:\n    \"\"\"person_ref: 8 random bytes you map to the person, never the phone number.\"\"\"\n    now = int(time.time()) if now is None else now\n    payload = person_ref + struct.pack(\"&gt;BI\", CHANNELS&#91;channel], now + days * 86400)\n    mac = hmac.new(KEY, payload, hashlib.sha256).digest()&#91;:10]\n    return b64u(payload + mac)\n\ndef verify(token: str, now: int | None = None):\n    now = int(time.time()) if now is None else now\n    try:\n        raw = unb64u(token)\n    except Exception:\n        return None\n    if len(raw) != 23:\n        return None\n    payload, mac = raw&#91;:13], raw&#91;13:]\n    good = hmac.new(KEY, payload, hashlib.sha256).digest()&#91;:10]\n    if not hmac.compare_digest(mac, good):   # never `mac == good`\n        return None\n    person_ref = payload&#91;:8]\n    channel_id, expires = struct.unpack(\"&gt;BI\", payload&#91;8:])\n    return {\"person_ref\": person_ref.hex(), \"channel_id\": channel_id,\n            \"expired\": now &gt; expires}       # expired still opens the opt-out<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">With an 8-byte reference, this produces a 31-character token such as <code>P5oSwHvkXYEBav1bAIk_bPROJodtB2Y<\/code>. Behind a short domain the whole link stays around 50 characters. When <code>verify<\/code> returns <code>expired: True<\/code>, render the opt-out controls anyway and offer a fresh link for everything else. Rotate <code>KEY<\/code> the way you&#8217;d rotate any secret, and accept the previous key for one link lifetime after a rotation.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Node.js: the send gate as a pure function<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Trap: trusting a category string you&#8217;ve never seen. If <code>dltTemplateType<\/code> comes back spelled differently from what you expected, a permissive gate treats it as allowed. This one drops it, and the drop reason tells you exactly what to fix.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ Pure gate: no I\/O. The caller loads state; this decides.\n\/\/ Allowlist of dltTemplateType values you have actually seen in SMSApi\/template\/read.\n\/\/ \"Service Implicit\" is the value in the published sample. Add the others only\n\/\/ after your own templates return them, spelled exactly as returned.\nconst KNOWN_TYPES = new Map(&#91;\n  &#91;\"Service Implicit\", \"locked\"],\n  \/\/ &#91;\"&lt;value seen for your service-explicit templates&gt;\", \"consent\"],\n  \/\/ &#91;\"&lt;value seen for your promotional templates&gt;\", \"consent\"],\n]);\n\nfunction decide(send, state, nowMs) {\n  \/\/ 1. Category: unknown values fail closed.\n  const kind = KNOWN_TYPES.get(send.dltTemplateType);\n  if (send.channel === \"sms\" &amp;&amp; kind === undefined) return { verdict: \"drop\", reason: \"unknown_category\" };\n  const locked = send.channel === \"sms\" ? kind === \"locked\" : send.lockedPurpose === true;\n\n  if (locked) return state.accountActive ? { verdict: \"allow\" } : { verdict: \"drop\", reason: \"no_account\" };\n\n  \/\/ 2. Consent and suppression, both required.\n  if (state.consent?.&#91;send.purpose]?.&#91;send.channel] !== \"active\") return { verdict: \"drop\", reason: \"no_consent\" };\n  if (state.suppressedAll || (state.suppressedMarketing &amp;&amp; send.marketing)) return { verdict: \"drop\", reason: \"suppressed\" };\n\n  \/\/ 3. Timing: hold, never drop.\n  if (state.pausedUntilMs &amp;&amp; nowMs &lt; state.pausedUntilMs) return { verdict: \"hold\", until: state.pausedUntilMs, reason: \"paused\" };\n  if (state.quietUntilMs &amp;&amp; nowMs &lt; state.quietUntilMs) return { verdict: \"hold\", until: state.quietUntilMs, reason: \"quiet_hours\" };\n\n  \/\/ 4. Cap: the counter is incremented by the caller in the same transaction as the allow.\n  const cap = state.caps?.&#91;send.purpose];\n  if (cap &amp;&amp; state.sentInWindow?.&#91;send.purpose] &gt;= cap) return { verdict: \"drop\", reason: \"cap_reached\" };\n\n  return { verdict: \"allow\" };\n}\nmodule.exports = { decide };<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Keeping <code>decide<\/code> free of I\/O means you can test every row of your matrix in milliseconds. Feed it a table of states and expected verdicts in CI, including at least one unknown category and one suppressed marketing purpose.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">SQL: counting a frequency cap without a race<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Trap: reading the count, checking it, then incrementing it in a second statement. Two workers can both read &#8220;1 of 2&#8221; and both send. Do the check and the increment in one statement and treat &#8220;no row returned&#8221; as &#8220;cap reached&#8221;.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>CREATE TABLE cap_counter (\n    person_id    TEXT NOT NULL,\n    purpose      TEXT NOT NULL,\n    window_start DATE NOT NULL,      -- start of the window in the customer's time zone\n    sent         INTEGER NOT NULL,\n    PRIMARY KEY (person_id, purpose, window_start)\n);\n\n-- Claim one slot. $3 is the window start computed in the customer's zone,\n-- for example date_trunc('week', now() AT TIME ZONE 'Asia\/Kolkata')::date.\nINSERT INTO cap_counter (person_id, purpose, window_start, sent)\nVALUES ($1, $2, $3, 1)\nON CONFLICT (person_id, purpose, window_start)\nDO UPDATE SET sent = cap_counter.sent + 1\nWHERE cap_counter.sent &lt; $4          -- $4 = max_per_window\nRETURNING sent;                      -- zero rows back: cap reached, drop the send<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Run it in the same transaction that records the gate&#8217;s allow. With a cap of two, the first two calls return <code>1<\/code> and <code>2<\/code>, and every call after that returns nothing until the next window.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Java: quiet hours in the customer&#8217;s zone<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Trap: <code>LocalTime.now()<\/code> uses the server&#8217;s zone, and quiet hours usually cross midnight. A check written as <code>t &gt;= start &amp;&amp; t &lt; end<\/code> is never true for a 21:00 to 09:00 window.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import java.time.*;\n\npublic final class QuietHours {\n    \/** Returns the instant the quiet window ends, or null if `now` is outside it. *\/\n    static Instant quietUntil(Instant now, String zone, LocalTime start, LocalTime end) {\n        if (start == null || end == null) return null;\n        ZonedDateTime local = now.atZone(ZoneId.of(zone));   \/\/ the customer's zone, never ZoneId.systemDefault()\n        LocalTime t = local.toLocalTime();\n        boolean overnight = start.isAfter(end);              \/\/ 21:00 -&gt; 09:00 crosses midnight\n        boolean inside = overnight ? (!t.isBefore(start) || t.isBefore(end))\n                                   : (!t.isBefore(start) &amp;&amp; t.isBefore(end));\n        if (!inside) return null;\n        LocalDate endDate = (overnight &amp;&amp; !t.isBefore(start)) ? local.toLocalDate().plusDays(1)\n                                                              : local.toLocalDate();\n        return ZonedDateTime.of(endDate, end, local.getZone()).toInstant();\n    }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">For a customer in <code>Asia\/Kolkata<\/code> with quiet hours from 21:00 to 09:00, a send at <code>2026-10-19T16:00:00Z<\/code> (21:30 their time) is held until <code>2026-10-20T03:30:00Z<\/code>, which is 09:00 the next morning for them. A send at <code>2026-10-20T04:00:00Z<\/code> (09:30) goes straight through. Hand the returned instant to your scheduler, and run the whole gate again when it fires.<\/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>If you need to&#8230;<\/th><th>Do this<\/th><th>Not this<\/th><\/tr><\/thead><tbody><tr><td>Let someone stop offers but keep delivery updates<\/td><td>Purpose by channel matrix, <code>withdraw<\/code> for <code>offers<\/code> only<\/td><td>One &#8220;marketing&#8221; switch<\/td><\/tr><tr><td>Let someone stop everything<\/td><td>One <code>suppression_event<\/code> with scope <code>all<\/code>, mirrored to Blocked Numbers<\/td><td>A <code>withdraw<\/code> for every cell<\/td><\/tr><tr><td>Show OTPs and transaction alerts<\/td><td>Locked row with a one-line reason<\/td><td>Hidden row, or a toggle that does nothing<\/td><\/tr><tr><td>Let someone add a channel or purpose<\/td><td>Pending grant plus a confirmation on that channel<\/td><td>Trust the link or the session alone<\/td><\/tr><tr><td>Reduce frequency<\/td><td><code>frequency_cap<\/code> row, counted on allow<\/td><td>Counting delivery reports<\/td><\/tr><tr><td>Pause for a holiday<\/td><td><code>paused_until<\/code> instant<\/td><td>Withdrawing and re-granting consent<\/td><\/tr><tr><td>Respect a customer&#8217;s night<\/td><td>Quiet hours in their zone, held by your gate<\/td><td>Relying on the platform posting window<\/td><\/tr><tr><td>Handle WhatsApp error 131050<\/td><td><code>withdraw<\/code> for WhatsApp marketing purposes, origin <code>platform<\/code><\/td><td>Retrying, or treating it as a delivery fault<\/td><\/tr><tr><td>Handle <code>NCPR_FAIL<\/code> or <code>DND_PREFERENCE_NUMBER<\/code><\/td><td>Hold table, no retry<\/td><td>Recording an opt-out<\/td><\/tr><tr><td>Save the form<\/td><td>Diff, version check, idempotency token<\/td><td>Rewriting every cell on every save<\/td><\/tr><tr><td>Cancel queued SMS after an opt-out<\/td><td><code>SMSApi\/schedule\/delete<\/code> by the <code>uuid<\/code> you saved<\/td><td>Hunting through <code>schedule\/read<\/code>, which has no recipients<\/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\">Launch Checklist<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Purposes have stable keys and five to eight rows at most.<\/li>\n\n\n\n<li>Every purpose maps to templates in <code>purpose_template<\/code>, and CI fails if a promotional template sits under a locked purpose.<\/li>\n\n\n\n<li>Locked rows show a reason and contain only action-triggered, service-implicit messages.<\/li>\n\n\n\n<li>&#8220;Stop everything&#8221; is as visible as any toggle and writes one scope-<code>all<\/code> suppression.<\/li>\n\n\n\n<li>Toggles write consent events with <code>capture_point = 'web:preference-centre'<\/code> and the live <code>notice_id<\/code>.<\/li>\n\n\n\n<li>No table anywhere stores a copy of the consent grid.<\/li>\n\n\n\n<li>Turning things on creates a pending grant and a confirmation on that channel.<\/li>\n\n\n\n<li>Turning things off works from the link alone, including an expired link.<\/li>\n\n\n\n<li>Saves diff against current state, check the version, and carry an idempotency token.<\/li>\n\n\n\n<li>A stop always wins a version conflict.<\/li>\n\n\n\n<li>The preference link domain is whitelisted on DLT and excluded from click tracking.<\/li>\n\n\n\n<li>The full SMS with the link fits the segment count you budgeted for.<\/li>\n\n\n\n<li>The gate fails closed on unknown categories, missing time zones and fold errors.<\/li>\n\n\n\n<li>Held messages go back through the whole gate when the hold ends.<\/li>\n\n\n\n<li>Cap counters increment in the same transaction as the allow, windowed in the customer&#8217;s zone.<\/li>\n\n\n\n<li>Withdrawals and stops cancel pending SMS bookings by saved <code>uuid<\/code>.<\/li>\n\n\n\n<li>WhatsApp bookings stay in your scheduler until the handoff horizon.<\/li>\n\n\n\n<li>Error 131050 becomes a WhatsApp marketing <code>withdraw<\/code>.<\/li>\n\n\n\n<li>A nightly job reconciles scope-<code>all<\/code> suppressions against the Blocked Numbers export.<\/li>\n\n\n\n<li>Nothing prompts a person to opt back in within 90 days of opting out.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"unspecified-behaviour\" class=\"wp-block-heading\">Unspecified Behaviour and How to Code Around It<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Some of the behaviour a preference centre depends on isn&#8217;t pinned down by anything you can read today. Each item below gives the choice that stays safe whichever way the answer turns out, and none of them require waiting for one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>One. Accept only the <code>dltTemplateType<\/code> values you&#8217;ve seen.<\/strong> The published template sample shows <code>\"Service Implicit\"<\/code> and nothing else. Read your own templates, add each value you see to the allowlist exactly as spelled, and let anything new fail closed. A new spelling then costs you a dropped send with a clear reason, not a promotional message sent as if it were locked.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Two. Treat operator DND as something you learn from failures.<\/strong> There&#8217;s no call that tells you a number&#8217;s registry preferences before you send. Send, record <code>NCPR_FAIL<\/code> and <code>DND_PREFERENCE_NUMBER<\/code> in a hold table, and stop sending that category to that number until something changes. The design works whether or not you ever get a lookup.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Three. Mirror only &#8220;stop everything&#8221; to Blocked Numbers.<\/strong> The list holds a number and products, nothing about purpose. Whatever else it can or can&#8217;t do, a scope-<code>all<\/code> mirror is always correct, and a purpose-level mirror is always wrong.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Four. Never read the WhatsApp opt-in list back as evidence.<\/strong> It&#8217;s maintained by hand in the dashboard. Your consent log is the record. If the two disagree, the log wins and the dashboard gets fixed.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Five. Save the <code>uuid<\/code> of every SMS booking against its recipients and purpose.<\/strong> <code>SMSApi\/schedule\/read<\/code> returns bookings without numbers. With your own mapping, cancelling the right booking after an opt-out is a lookup, however the read evolves.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Six. Assume a WhatsApp booking can&#8217;t be recalled.<\/strong> There&#8217;s no read or cancel for future <code>scheduletime<\/code> sends. Keep bookings in your scheduler until shortly before send time. If a cancel ever appears, you lose nothing by having waited.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Seven. Apply the stricter of your quiet hours and the account&#8217;s posting window.<\/strong> The account&#8217;s posting hours are set in the dashboard profile, so your code never sees them. Your gate holds to the customer&#8217;s window, which in practice is the narrower one, and the platform&#8217;s own hold is a second layer either way.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Eight. Confirm opt-outs on screen, not with another message.<\/strong> Whether a follow-up &#8220;you&#8217;ve been unsubscribed&#8221; SMS counts as one more contact under each category&#8217;s rules is a question you don&#8217;t have to answer if you don&#8217;t send one. The confirmation page is enough.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Nine. Treat 131050 as covering every marketing purpose on WhatsApp.<\/strong> The error says the person stopped marketing messages, not which of your purposes. Withdrawing all of them is correct whether WhatsApp means one purpose or every one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Ten. Count caps on allow, not on delivery.<\/strong> Delivery reports can be late, partial or missing. Counting at approval is correct under all three and errs toward fewer messages.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Eleven. Keep the opt-out working when your own systems aren&#8217;t.<\/strong> If the consent store is down, the page should still accept a stop and queue it for writing. Losing an opt-in is an inconvenience. Losing an opt-out is a complaint.<\/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\">Is a preference centre the same as an unsubscribe page?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">No, it&#8217;s a superset. An unsubscribe page has one button. A preference centre lets people keep the messages they want and drop the rest, which is why it&#8217;s worth building. It still needs a &#8220;stop everything&#8221; control that works as simply as an unsubscribe page would.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Can SMSGatewayCenter host the preference centre for me?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Not as a hosted page you link to. The platform gives you the Blocked Numbers list and the WhatsApp opt-in list in the dashboard, which work at the number level. Purpose-level choices, frequency and quiet hours live in your own system, and the page is yours to host.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Do customers need to log in to change their preferences?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Not to send less. A signed link in the message is enough for opting out, pausing or tightening a cap. To send more, such as switching a purpose back on, ask for a confirmation on that channel.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Can a customer switch off OTPs?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Not from the preference page. Security codes and transaction alerts go out because the customer did something, and they&#8217;re sent on service-implicit templates. Show them as a locked row with the reason. If someone wants none of them, the route is turning off SMS-based login or closing the account.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">If a customer turns offers back on, does that override DND?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">No. The operator&#8217;s registry still decides what reaches a DND-registered number for promotional and service-explicit content. Your &#8220;on&#8221; is necessary, not sufficient.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How fast does an opt-out have to take effect?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Immediately at your gate. Messages still in your queue are caught at once. Pending SMS bookings should be cancelled in the same job. A WhatsApp message already handed over may still arrive, so keep that handoff window short and say so on the confirmation screen.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Should the preference link be a tracked smart link?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">No. Use your own whitelisted short domain. Tracking it adds a redirect and makes preference visits look like campaign engagement.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">What happens to frequency caps when someone switches channels?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Nothing, if you cap by purpose. Two offers a week stays two offers a week whether they go by SMS, WhatsApp or both.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Where do I store the time zone?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">On the preference row, defaulting to your main market (<code>Asia\/Kolkata<\/code> for India) and editable on the page. Quiet hours, pauses and cap windows are all computed from it.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">Start Building Compliant Opt-In Flows<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Building opt-in, opt-out and preference handling on top of SMS, WhatsApp, RCS or Telegram? Start with the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/\">developer API reference<\/a>, try the flows in the <a href=\"https:\/\/www.smsgatewaycenter.com\/demo\/\">free demo account<\/a>, or <a href=\"https:\/\/www.smsgatewaycenter.com\/contact\/\">talk to our team<\/a> about your integration.<\/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\/api-key-rotation-sms-api-without-dropping-messages\/\">API Key Rotation for an SMS API: Rotating a Single-Slot Key Without Dropping Messages<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-message-encryption-aes-wire-format-key-handling\/\">SMS Message Encryption: What It Hides, What It Doesn\u2019t, and How to Get the Wire Format Right<\/a><\/p>\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<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n","protected":false},"excerpt":{"rendered":"<p>A preference centre is the page where customers choose what you may send them, on which channel and how often. Built badly it becomes a third list that disagrees with your consent log and your suppression table. Here is how to build one that writes into those records instead, maps purposes to DLT categories, handles rows nobody can switch off, and feeds a send gate that fails closed across SMS, WhatsApp, RCS and Telegram.<\/p>\n","protected":false},"author":118,"featured_media":3099,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[2010],"tags":[1045,1464,2351,2350,2319,2347,2348,2349,2305,2316],"class_list":["post-3098","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-developer-guides","tag-communication-preferences","tag-consent-management","tag-dlt-categories","tag-frequency-capping","tag-messaging-compliance","tag-preference-center","tag-preference-centre","tag-quiet-hours","tag-sms-opt-out","tag-whatsapp-opt-in"],"_links":{"self":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/3098","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=3098"}],"version-history":[{"count":0,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/3098\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media\/3099"}],"wp:attachment":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media?parent=3098"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/categories?post=3098"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/tags?post=3098"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}