{"id":3071,"date":"2026-10-09T13:11:07","date_gmt":"2026-10-09T07:41:07","guid":{"rendered":"https:\/\/www.smsgatewaycenter.com\/blog\/?p=3071"},"modified":"2026-10-09T13:11:10","modified_gmt":"2026-10-09T07:41:10","slug":"consent-capture-evidence-sms-whatsapp-rcs-telegram","status":"publish","type":"post","link":"https:\/\/www.smsgatewaycenter.com\/blog\/consent-capture-evidence-sms-whatsapp-rcs-telegram\/","title":{"rendered":"Consent Capture and Evidence for SMS, WhatsApp, RCS and Telegram: Building an Opt-In Record You Can Prove"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">A tick box isn&#8217;t proof. Here&#8217;s how to capture consent as an append-only event log with the exact notice version, fold it into a per-channel send gate next to your suppression list, keep WhatsApp&#8217;s opt-in list in step, read CONSENT_FAILED properly, and produce an evidence pack the day someone complains.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/consent-capture-evidence-ledger-gate.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"584\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/consent-capture-evidence-ledger-gate-1024x584.webp\" alt=\"Stacked teal slabs forming a ledger with a blue line passing through an orange gate, representing consent events feeding a send gate\" class=\"wp-image-3072\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/consent-capture-evidence-ledger-gate-1024x584.webp 1024w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/consent-capture-evidence-ledger-gate-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/consent-capture-evidence-ledger-gate-768x438.webp 768w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/consent-capture-evidence-ledger-gate.webp 1200w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><figcaption class=\"wp-element-caption\">Consent is a history of events, not a flag. The send gate reads that history every time.<\/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-counts\">What Counts as Proof of Consent<\/a><\/li>\n\n\n\n<li><a href=\"#three-rulebooks\">Three Rulebooks, One Record<\/a><\/li>\n\n\n\n<li><a href=\"#per-channel\">Where Consent Lives on Each Channel<\/a><\/li>\n\n\n\n<li><a href=\"#event-table\">The Consent Event Table<\/a><\/li>\n\n\n\n<li><a href=\"#notice-versions\">Versioning the Notice<\/a><\/li>\n\n\n\n<li><a href=\"#capture-points\">Capture Points: Forms, Keywords, Chat and Counter<\/a><\/li>\n\n\n\n<li><a href=\"#double-opt-in\">Double Opt-In Over SMS<\/a><\/li>\n\n\n\n<li><a href=\"#current-state\">Folding Events Into Current State<\/a><\/li>\n\n\n\n<li><a href=\"#send-gate\">The Send Gate: Consent and Suppression Together<\/a><\/li>\n\n\n\n<li><a href=\"#withdrawal\">Withdrawal as Easy as the Grant<\/a><\/li>\n\n\n\n<li><a href=\"#whatsapp-sync\">Keeping the WhatsApp Opt-In List in Step<\/a><\/li>\n\n\n\n<li><a href=\"#consent-failed\">Reading CONSENT_FAILED and Other Signals<\/a><\/li>\n\n\n\n<li><a href=\"#evidence-pack\">Evidence Packs: Answering a Complaint<\/a><\/li>\n\n\n\n<li><a href=\"#retention\">Retention and Keeping Only What You Need<\/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\">Store consent as an append-only log of events, one row per grant or withdrawal, per phone number, per channel, per purpose. Every grant row points at the exact notice text the person saw (saved once, addressed by its hash), records how they said yes (form tick, keyword, chat button, signed paper) and carries enough context to replay the moment later. Your send gate folds those events into a current answer and checks it next to your suppression list before every send. That&#8217;s the whole design.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Why go to the trouble? Because the messaging platform doesn&#8217;t hold your consent for you. On <strong>SMSGatewayCenter<\/strong> there&#8217;s a WhatsApp opt-in list in the dashboard and a Blocked Numbers list, but no API to write either, and nothing at all for RCS or Telegram. Indian SMS adds <strong><a href=\"https:\/\/www.smsgatewaycenter.com\/dlt-sms\/\">DLT consent templates<\/a><\/strong> and a <code>CONSENT_FAILED<\/code> rejection on top. And India&#8217;s <strong>Digital Personal Data Protection Act <\/strong>puts the burden on you: where consent is the basis of processing and it&#8217;s questioned in a proceeding, you have to prove the notice was given and consent was given. A boolean column can&#8217;t do that. An event log with the notice attached can.<\/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>A flag is not evidence.<\/strong> Keep <code>consent_event<\/code> rows (grant, withdraw, confirm, expire) and derive the current state. Never update a row in place.<\/li>\n\n\n\n<li><strong>Save the notice, not just the tick.<\/strong> Each grant points to a <code>notice_version<\/code> row holding the full text and its SHA-256. If the wording changes, that&#8217;s a new version.<\/li>\n\n\n\n<li><strong>Scope consent by channel and purpose.<\/strong> &#8220;Order updates on SMS&#8221; and &#8220;offers on WhatsApp&#8221; are different grants. One checkbox for everything is weak consent and weaker evidence.<\/li>\n\n\n\n<li><strong>Double opt-in for anything typed in by hand.<\/strong> A web form can&#8217;t prove the number belongs to the person who typed it. A reply from the handset can.<\/li>\n\n\n\n<li><strong>One gate, two inputs.<\/strong> Send only if the folded consent says yes for that channel and purpose AND the suppression list from <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/opt-out-suppression-sms-whatsapp-rcs-telegram\/\">our opt-out guide<\/a> says not blocked. Unknown means no.<\/li>\n\n\n\n<li><strong>Withdrawal must be as easy as the grant.<\/strong> If someone opted in with a keyword, a keyword gets them out. If they ticked a box, one click undoes it.<\/li>\n\n\n\n<li><strong>WhatsApp&#8217;s dashboard opt-in list is a copy, not the source.<\/strong> Sync it from your log. There&#8217;s no API for it, so it&#8217;s an operator task with a reconciliation report.<\/li>\n\n\n\n<li><strong><code>CONSENT_FAILED<\/code> is a DLT verdict, not a bug.<\/strong> Stop retrying, mark the number, and fix the consent record or the template category.<\/li>\n\n\n\n<li><strong>Build the evidence pack as a query, not a scramble.<\/strong> One number in, every event, notice text, hash and source out.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"what-counts\" class=\"wp-block-heading\">What Counts as Proof of Consent<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Picture the complaint. A customer says they never agreed to your offers on WhatsApp. What do you need to show?<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">You need to show four things, and they line up neatly with columns:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>What they were told.<\/strong> The notice text, word for word, as it was on that day. Not the current version of your signup page. The one they saw.<\/li>\n\n\n\n<li><strong>What they did.<\/strong> A clear affirmative action: ticked an unticked box, sent a keyword, tapped a button in chat, signed a form. Pre-ticked boxes and &#8220;by continuing you agree&#8221; don&#8217;t qualify as an action.<\/li>\n\n\n\n<li><strong>When and where.<\/strong> Timestamp in UTC (plus the zone you displayed), the capture point (web form id, long code and keyword, WABA number, store id), and enough request context to tie it to a real session.<\/li>\n\n\n\n<li><strong>That it was them.<\/strong> For a typed-in phone number, a confirmation from that handset. For an inbound keyword or a WhatsApp chat, the inbound message itself already comes from the number.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">And then the fifth thing, which people forget: <strong>that nothing later cancelled it.<\/strong> If they replied STOP last month, your grant from last year is history, not permission. That&#8217;s why the record has to be a timeline.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s what usually exists instead: a <code>marketing_opt_in BOOLEAN<\/code> on the customer row, updated by three different code paths, with no idea which one set it or when. It answers &#8220;can I send right now?&#8221; badly and &#8220;can you prove it?&#8221; not at all.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"three-rulebooks\" class=\"wp-block-heading\">Three Rulebooks, One Record<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">You&#8217;re not designing for one regulator. In India, a business messaging customers usually lives under three sets of rules at once, and the nice thing is that one well-built record satisfies all three.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The DPDP Act: the burden is yours<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The <a href=\"https:\/\/www.trai.gov.in\/sites\/default\/files\/2024-09\/DPDT_11082024.pdf\" target=\"_blank\" rel=\"noopener nofollow\">Digital Personal Data Protection Act, 2023<\/a> sets the bar for consent in section 6. Consent has to be &#8220;free, specific, informed, unconditional and unambiguous with a clear affirmative action&#8221;. Section 6(4) gives the person the right to withdraw at any time, &#8220;with the ease of doing so being comparable to the ease with which such consent was given&#8221;. Section 6(6) says that after withdrawal the business must, within a reasonable time, stop processing and get its processors to stop too.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Then section 6(10), the one that shapes the schema. Where consent is the basis of processing and a question arises in a proceeding, the business is obliged to prove that a notice was given and that consent was given in accordance with the Act and its rules. Put plainly: if it&#8217;s ever disputed, you carry the proof.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The Act commences provision by provision on dates the government notifies, and its rules phase in on their own timeline. Check MeitY&#8217;s <a href=\"https:\/\/www.meity.gov.in\/data-protection-framework\" target=\"_blank\" rel=\"noopener nofollow\">data protection framework page<\/a> for what&#8217;s in force when you read this. The design below doesn&#8217;t depend on the date. It&#8217;s the record you&#8217;d want either way.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">TRAI&#8217;s TCCCPR: consent, opt-out and timing for commercial messages<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The telecom side is TRAI&#8217;s commercial communication regulations (<a href=\"https:\/\/trai.gov.in\/tcccpr\" target=\"_blank\" rel=\"noopener nofollow\">TCCCPR overview<\/a>), amended in February 2025 (<a href=\"https:\/\/trai.gov.in\/sites\/default\/files\/2025-02\/PR_No.11of2025.pdf\" target=\"_blank\" rel=\"noopener nofollow\">press release 11\/2025<\/a>). Points that land directly in your data model:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"https:\/\/www.smsgatewaycenter.com\/promotional-sms\/\">Promotional messages<\/a> must carry an opt-out option.<\/li>\n\n\n\n<li>After a customer opts out, you can&#8217;t ask them for consent again for 90 days. They can opt back in themselves at any time.<\/li>\n\n\n\n<li>Consent tied to an ongoing transaction is valid for 7 days.<\/li>\n\n\n\n<li>Implicit consent for <a href=\"https:\/\/www.smsgatewaycenter.com\/transactional-sms\/\">transactional and service messages<\/a> lasts only for the duration of the contract or relationship.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">That&#8217;s four different expiry rules. Your record needs a <code>purpose<\/code>, an <code>expires_at<\/code> and a way to tell &#8220;they asked us&#8221; from &#8220;we asked them&#8221;.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">On the DLT side, promotional and service-explicit content templates are linked to registered consent templates (our walkthrough of <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/how-register-consent-template-dlt-portals\/\">registering a consent template on DLT<\/a> shows the fields: template name, brand name and the scope of consent, with no variables allowed). TRAI is also piloting a central Digital Consent Acquisition system on DLT. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/what-is-dlt-digital-consent-and-do-i-need-to-act-on-it-now\/\">pilot<\/a> started in December 2025 with eleven banks, whose customers get an SMS from short code 127000 linking to a page where they can keep, change or revoke consents. No nationwide date has been announced. If you&#8217;re not one of those banks, there&#8217;s no new registration step today, but the direction is clear: being able to show where each consent came from is going to matter more, not less.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">WhatsApp: opt-in before business-initiated messages<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/whatsapp-business-solutions\/\">WhatsApp Busienss API<\/a> requires an opt-in before you start conversations with business-initiated messages. Accepted collection points include SMS, website forms, a WhatsApp thread, IVR flows and in person or on paper (<a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/how-do-i-get-opt-in-consent-from-customers-and-manage-them-for-whatsapp-messaging-using-smsgatewaycenter\/\">how opt-in works on SMSGatewayCenter<\/a>). The opt-in has to name your business and the channel. And a customer can stop marketing messages from inside WhatsApp, which surfaces to you as <strong>error 131050<\/strong> on a later send (see the <a href=\"https:\/\/developers.facebook.com\/documentation\/business-messaging\/whatsapp\/support\/error-codes\" target=\"_blank\" rel=\"noopener nofollow\">WhatsApp error code list<\/a>). That&#8217;s a withdrawal event you didn&#8217;t capture yourself, and your log has to take it in.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The overlap<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">All three want the same thing from you: a specific, affirmative, provable grant, scoped to a purpose, easy to withdraw, and honoured quickly when withdrawn. Build for the strictest reading and you&#8217;re covered on the others.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"per-channel\" class=\"wp-block-heading\">Where Consent Lives on Each Channel<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Before designing your table, it helps to know which parts of consent the platform can see and which parts only you can see.<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th><\/th><th>SMS (India, DLT)<\/th><th>WhatsApp<\/th><th>RCS<\/th><th>Telegram<\/th><\/tr><\/thead><tbody><tr><td>Who has to hold the grant<\/td><td>You, plus DLT consent templates for promotional and service-explicit<\/td><td>You, plus Meta&#8217;s opt-in rules<\/td><td>You<\/td><td>The user, by starting your bot<\/td><\/tr><tr><td>Platform-side list<\/td><td>Blocked Numbers (dashboard)<\/td><td>Opt-in list per WABA number, and Blocked Numbers (dashboard)<\/td><td>None<\/td><td>None<\/td><\/tr><tr><td>API to write that list<\/td><td>No<\/td><td>No<\/td><td>n\/a<\/td><td>n\/a<\/td><\/tr><tr><td>Rejection you&#8217;ll see<\/td><td><code>CONSENT_FAILED<\/code>, <code>NCPR_FAIL<\/code>, <code>DND_PREFERENCE_NUMBER<\/code>, <code>BLOCK_NUMBER<\/code><\/td><td>131050 (stopped marketing), <code>BLOCK_NUMBER<\/code><\/td><td>Nothing consent-specific<\/td><td>Nothing consent-specific<\/td><\/tr><tr><td>Withdrawal arrives as<\/td><td>STOP keyword on your long code, or a complaint<\/td><td>STOP in chat, 131050, or your own unsubscribe link<\/td><td>A reply in the RCS inbox<\/td><td>User blocks the bot or stops replying<\/td><\/tr><tr><td>Your record&#8217;s role<\/td><td>Authoritative<\/td><td>Authoritative, synced to the dashboard list<\/td><td>Authoritative<\/td><td>Authoritative for purpose; the chat proves contact<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<figure class=\"wp-block-image size-full\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/diagram-consent-per-channel.svg\"><img decoding=\"async\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/diagram-consent-per-channel.svg\" alt=\"Table comparing where consent lives on SMS, WhatsApp, RCS and Telegram and the role of your own consent ledger\" class=\"wp-image-3073\"\/><\/a><figcaption class=\"wp-element-caption\">Every channel leaves the proof with you. The platform lists are copies at best.<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">A few notes on that table, channel by channel.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>SMS.<\/strong> DLT checks consent for the template category, not for your customer&#8217;s actual history with you. When it rejects, you get <code>CONSENT_FAILED<\/code> (<a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/what-does-consent_failed-consent-fail-by-dlt-mean\/\">what it means<\/a>). Separately, promotional and <a href=\"https:\/\/www.smsgatewaycenter.com\/service-explicit-sms\/\">service-explicit<\/a> traffic doesn&#8217;t reach numbers on the national do-not-disturb register. None of that replaces your own record. It&#8217;s a second check that sits downstream of yours.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>WhatsApp.<\/strong> The dashboard has an Add Opt-In page: pick the WABA number, paste numbers with their country code, save. The Manage page shows each number as opted in or opted out, and you can mark one opted out with the thumbs-down button or delete it. There&#8217;s no API for any of that, which is exactly why your log stays the source and the dashboard list becomes something you reconcile against (see <a href=\"#whatsapp-sync\">Keeping the WhatsApp Opt-In List in Step<\/a>).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>RCS.<\/strong> Nothing on the platform side stores RCS consent. If you&#8217;re using RCS for promotional content alongside SMS, treat its consent the same way you treat SMS consent: a separate <code>channel<\/code> value with its own grants.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Telegram.<\/strong> A bot can&#8217;t start a conversation; the user has to message it first (<a href=\"https:\/\/core.telegram.org\/bots\/faq\" target=\"_blank\" rel=\"noopener nofollow\">Telegram bots FAQ<\/a>). That gives you proof of contact for free, and the <code>chatId<\/code> model in our <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/telegram-messaging-api-chat-id-model\/\">Telegram API guide<\/a> ties the chat to a person. It doesn&#8217;t give you consent for every purpose. Someone who started your bot to track a parcel hasn&#8217;t agreed to a weekly offers digest. Record the purpose.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"event-table\" class=\"wp-block-heading\">The Consent Event Table<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s the core schema. It&#8217;s Postgres, but nothing in it needs anything exotic.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>CREATE TABLE notice_version (\n    notice_id      TEXT PRIMARY KEY,          -- e.g. 'web-signup-v3'\n    sha256         CHAR(64) NOT NULL UNIQUE,  -- hash of body_text, UTF-8\n    body_text      TEXT NOT NULL,             -- exactly what was shown\n    language       TEXT NOT NULL,             -- 'en', 'hi', ...\n    channels       TEXT&#91;] NOT NULL,           -- channels the notice names\n    purposes       TEXT&#91;] NOT NULL,           -- purposes the notice names\n    published_at   TIMESTAMPTZ NOT NULL,\n    retired_at     TIMESTAMPTZ\n);\n\nCREATE TABLE consent_event (\n    event_id       BIGSERIAL PRIMARY KEY,\n    msisdn         TEXT NOT NULL,             -- E.164 digits, no '+', stored as text\n    channel        TEXT NOT NULL,             -- 'sms' | 'whatsapp' | 'rcs' | 'telegram'\n    purpose        TEXT NOT NULL,             -- 'service_updates' | 'offers' | ...\n    action         TEXT NOT NULL,             -- 'grant' | 'confirm' | 'withdraw' | 'expire'\n    origin         TEXT NOT NULL,             -- 'customer' | 'business' | 'platform'\n    capture_point  TEXT NOT NULL,             -- 'web:signup-form', 'longcode:919223344556:ACME', ...\n    notice_id      TEXT REFERENCES notice_version(notice_id),\n    occurred_at    TIMESTAMPTZ NOT NULL,      -- when the person acted\n    recorded_at    TIMESTAMPTZ NOT NULL DEFAULT now(),\n    expires_at     TIMESTAMPTZ,               -- set for time-limited grants\n    evidence       JSONB NOT NULL,            -- raw request, inbound message, form id\n    dedupe_key     TEXT NOT NULL UNIQUE,\n    CHECK (action IN ('grant','confirm','withdraw','expire')),\n    CHECK (action &lt;&gt; 'grant' OR notice_id IS NOT NULL)\n);\n\nCREATE INDEX consent_event_lookup\n    ON consent_event (msisdn, channel, purpose, occurred_at DESC);\n\n-- Nobody updates or deletes history.\nREVOKE UPDATE, DELETE ON consent_event FROM app_writer;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Some choices in there that matter more than they look:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>msisdn<\/code> is text.<\/strong> Same reason as transaction ids: long digit strings and floats don&#8217;t mix, and leading zeros or a stray <code>+<\/code> will make two rows for one person. Normalise to digits with country code at the door and store that.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>purpose<\/code> is separate from <code>channel<\/code>.<\/strong> &#8220;Service updates on SMS&#8221; and &#8220;offers on SMS&#8221; are two grants. The DLT world already thinks this way (transactional, service-implicit, service-explicit, promotional, see <a href=\"https:\/\/www.smsgatewaycenter.com\/sms-types\/\">SMS types<\/a>); your record should too.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>origin<\/code> tells you who started it.<\/strong> A customer texting your keyword is <code>customer<\/code>. You sending a &#8220;reply YES to get offers&#8221; message is <code>business<\/code>. A 131050 from WhatsApp is <code>platform<\/code>. This is how you enforce TRAI&#8217;s 90-day rule: a business-originated consent request inside 90 days of a withdrawal is blocked, a customer-originated opt-in isn&#8217;t.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>occurred_at<\/code> versus <code>recorded_at<\/code>.<\/strong> Paper forms get typed in a week later. Imports arrive in batches. You want both: when the person acted, and when your system learned about it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>A grant can&#8217;t exist without a notice.<\/strong> The <code>CHECK<\/code> makes it impossible to save a grant that doesn&#8217;t say what the person agreed to. Withdrawals don&#8217;t need one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>evidence<\/code> is the raw stuff.<\/strong> The inbound SMS parameters, the form POST minus anything secret, the WhatsApp message id, the scanned form&#8217;s storage key. Keep it boring and complete.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>dedupe_key<\/code> stops double inserts.<\/strong> Retried webhooks and double-clicked forms are normal. Build the key from the things that make an event unique (number, channel, purpose, action, capture point, a request id or the inbound message&#8217;s own timestamp) and let the unique index do the work.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>No updates, ever.<\/strong> If a row is wrong, you add a correcting event and keep the wrong one. That&#8217;s annoying exactly once, and it&#8217;s what makes the log believable.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"notice-versions\" class=\"wp-block-heading\">Versioning the Notice<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The notice is the part most teams lose. Marketing edits the signup page, the old wording is gone, and now every grant from before the edit points at text nobody can reproduce.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Fix it with content addressing. Whenever a notice goes live (web form label, keyword confirmation message, chat button prompt, IVR script, printed form), save its exact text and its SHA-256 in <code>notice_version<\/code>. Capture code doesn&#8217;t send the text around, it sends the <code>notice_id<\/code>, and the server checks the id is current for that capture point.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s a real example, computed rather than made up. This notice body:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>Yes, send me offers from Acme on WhatsApp. Reply STOP in this chat at any time to stop. v3<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">hashes (UTF-8, no trailing newline) to:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>2a570bf72174e7a69da982bd39abe5b3f248fb3aad5b1fac0615809a5ce5bbd6<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Change a single character and you get a different hash, and therefore a different <code>notice_id<\/code>. That&#8217;s the point. Two rules keep it working:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>The hash covers the exact bytes shown.<\/strong> Not the template with placeholders, the rendered text in the shown language. If your form shows the brand name from config, render it, then hash.<\/li>\n\n\n\n<li><strong>Front-end and back-end agree on the id, not the text.<\/strong> The form posts <code>notice_id=web-signup-v3<\/code>. The server rejects a grant whose notice id is retired or doesn&#8217;t cover the channel and purpose being granted.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">Notice that the example names the business, one channel, one purpose and the way out. If you want offers on SMS too, that&#8217;s a second checkbox with its own notice. A notice that says &#8220;we may contact you&#8221; covers nothing specific, and section 6(1) asks for specific.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"capture-points\" class=\"wp-block-heading\">Capture Points: Forms, Keywords, Chat and Counter<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Every place a person can say yes is a capture point, and each one leaves different evidence. Name them explicitly (<code>capture_point<\/code> is a stable string, not free text) and decide up front what each one stores.<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Capture point<\/th><th>Proves the number?<\/th><th>What goes in <code>evidence<\/code><\/th><th>Needs confirmation?<\/th><\/tr><\/thead><tbody><tr><td>Web or app form with an unticked box<\/td><td>No<\/td><td>Form id, notice id, request id, timestamp, hashed IP and user agent<\/td><td>Yes<\/td><\/tr><tr><td><a href=\"https:\/\/www.smsgatewaycenter.com\/long-code-sms-services\/\">Inbound SMS<\/a> keyword to your long code<\/td><td>Yes<\/td><td>The inbound push parameters as received<\/td><td>No<\/td><\/tr><tr><td><a href=\"https:\/\/www.smsgatewaycenter.com\/agents-chat-whatsapp-business-api\/\">WhatsApp chat<\/a> (button tap or typed reply)<\/td><td>Yes<\/td><td>Inbound message id, WABA number, message text<\/td><td>No<\/td><\/tr><tr><td>Telegram bot command or button<\/td><td>Yes, for the chat<\/td><td><code>chatId<\/code>, update id, command text<\/td><td>No<\/td><\/tr><tr><td>IVR keypress<\/td><td>Yes, if the call came from that number<\/td><td>Call id, caller number, prompt version, digit pressed<\/td><td>No<\/td><\/tr><tr><td>Paper or in-store form<\/td><td>No<\/td><td>Scan storage key, staff id, store id<\/td><td>Yes<\/td><\/tr><tr><td>Bulk import from an older system<\/td><td>No<\/td><td>Source file name, row number, original consent date and wording if known<\/td><td>Treat as unproven until confirmed<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Two rows in there deserve a second look.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Forms don&#8217;t prove the number.<\/strong> Anyone can type anyone&#8217;s phone number into a form. The tick proves someone agreed; it doesn&#8217;t prove the owner of that number agreed. That&#8217;s what double opt-in fixes.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Imports are the riskiest grants you&#8217;ll ever hold.<\/strong> If the old system kept a boolean and nothing else, you&#8217;ve got no notice and no action to point to. Import them as <code>grant<\/code> events with <code>capture_point = 'import:legacy-crm'<\/code> and a notice row that honestly says what&#8217;s known (even if that&#8217;s &#8220;wording not retained&#8221;), and keep them out of promotional sends until each number has confirmed through a fresh opt-in.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For inbound SMS keywords, the capture code sits inside your keyword router. If you&#8217;re building that router, <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/two-way-sms-keyword-routing-auto-reply-design\/\">our two-way SMS keyword guide<\/a> covers normalising the text, routing commands and dedupe; a JOIN or YES command simply ends in a consent event instead of a reply.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"double-opt-in\" class=\"wp-block-heading\">Double Opt-In Over SMS<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Double opt-in turns &#8220;someone typed this number&#8221; into &#8220;the person holding this phone agreed&#8221;. The flow:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>The form posts. You save a <code>grant<\/code> event with <code>capture_point = 'web:signup-form'<\/code>. Its state is pending, not active.<\/li>\n\n\n\n<li>You send one confirmation SMS from a registered template, something like &#8220;Reply ACME YES to get Acme offers on WhatsApp. Ignore this to skip.&#8221; It goes out through <code>SMSApi\/send<\/code> like any other message.<\/li>\n\n\n\n<li>The customer replies to your long code. The platform matches your approved keyword and forwards the inbound push to your callback URL with parameters such as <code>phoneno<\/code>, <code>content<\/code>, <code>keyword<\/code> and, if your callback template includes it, <code>time<\/code> (epoch milliseconds).<\/li>\n\n\n\n<li>You match the reply to the pending grant for that number and write a <code>confirm<\/code> event pointing at the same notice.<\/li>\n\n\n\n<li>Only now does the fold report the grant as active.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s what the two events look like with real, computed timestamps. The form was submitted at 2026-10-09 10:15:00 IST (<code>1791521100000<\/code> ms) and the reply landed at 10:16:30 IST (<code>1791521190000<\/code> ms):<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&#91;\n  {\"msisdn\": \"919812345678\", \"channel\": \"whatsapp\", \"purpose\": \"offers\",\n   \"action\": \"grant\", \"origin\": \"customer\",\n   \"capture_point\": \"web:signup-form\", \"notice_id\": \"web-signup-v3\",\n   \"occurred_at\": \"2026-10-09T04:45:00Z\",\n   \"evidence\": {\"form_id\": \"signup\", \"request_id\": \"c1f0e2\"}},\n  {\"msisdn\": \"919812345678\", \"channel\": \"whatsapp\", \"purpose\": \"offers\",\n   \"action\": \"confirm\", \"origin\": \"customer\",\n   \"capture_point\": \"longcode:919223344556:ACME\", \"notice_id\": \"web-signup-v3\",\n   \"occurred_at\": \"2026-10-09T04:46:30Z\",\n   \"evidence\": {\"phonecode\": \"919223344556\", \"phoneno\": \"919812345678\",\n                \"keyword\": \"ACME\", \"content\": \"ACME YES\", \"time\": \"1791521190000\"}}\n]<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A few things to get right:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Give the pending grant a short window.<\/strong> If no reply arrives in, say, 48 hours, write an <code>expire<\/code> event. A pending grant that hangs around forever invites someone to &#8220;fix&#8221; it later by marking it active.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Send one confirmation, not a sequence.<\/strong> Chasing an unconfirmed number with reminders is a string of business-originated consent requests to someone who didn&#8217;t answer the first one. That&#8217;s how numbers end up reported as spam.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Confirm on a keyword, not on &#8220;any reply&#8221;.<\/strong> &#8220;STOP&#8221; is a reply too. Your router should treat STOP as a withdrawal with the highest priority, and only the specific YES command as confirmation.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Link-click confirmation is weaker than a reply.<\/strong> You can put a one-time link in the SMS instead. But link scanners and preview bots open links on their own, so make the landing page require a button press, and save the token, the press time and the request context. Prefer the reply where you can.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"current-state\" class=\"wp-block-heading\">Folding Events Into Current State<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The send gate never reads raw events. It reads the result of folding them, per <code>(msisdn, channel, purpose)<\/code>. The rules are short:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Order events by <code>occurred_at<\/code>, then by <code>event_id<\/code> to break ties.<\/li>\n\n\n\n<li>Start in state <code>none<\/code>.<\/li>\n\n\n\n<li>A <code>grant<\/code> moves to <code>pending<\/code> if its capture point needs confirmation, otherwise to <code>active<\/code>.<\/li>\n\n\n\n<li>A <code>confirm<\/code> moves <code>pending<\/code> to <code>active<\/code>. A <code>confirm<\/code> with no pending grant does nothing (log it as a warning).<\/li>\n\n\n\n<li>A <code>withdraw<\/code> moves anything to <code>withdrawn<\/code> and records <code>withdrawn_at<\/code>.<\/li>\n\n\n\n<li>An <code>expire<\/code> moves <code>pending<\/code> or <code>active<\/code> to <code>expired<\/code>.<\/li>\n\n\n\n<li>After the fold, an <code>active<\/code> state whose grant has an <code>expires_at<\/code> in the past is <code>expired<\/code>.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">The fold also returns two dates you&#8217;ll need: <code>last_withdrawn_at<\/code>, for the 90-day rule, and the <code>notice_id<\/code> of the grant that made it active, for evidence.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">You can fold in the query for low volumes, or keep a <code>consent_state<\/code> table that a single writer updates in the same transaction as the event insert. If you do keep a state table, treat it as a cache. You should be able to drop it and rebuild it from events at any time, and a nightly job that does exactly that and diffs the result is a cheap way to catch bugs.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">What about the 90-day rule? It applies to <em>you asking<\/em>. Before sending any message whose purpose is to request consent, check <code>last_withdrawn_at<\/code> for that number and purpose. If it&#8217;s less than 90 days ago, don&#8217;t send. A withdrawal at 2026-10-09 10:15 IST means the earliest a business-originated request can go is 2027-01-07 10:15 IST (<code>1799297100000<\/code> ms). If the customer texts JOIN on their own before then, that&#8217;s a customer-originated grant and it&#8217;s fine.<\/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: Consent and Suppression Together<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Every outbound message passes one function before it reaches the API:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>allowed(msisdn, channel, purpose) =\n      consent_state(msisdn, channel, purpose) == 'active'\n  AND NOT suppressed(msisdn, channel)<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two inputs, both required. Consent comes from the fold above. Suppression comes from the table described in our <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/opt-out-suppression-sms-whatsapp-rcs-telegram\/\">opt-out and suppression guide<\/a>: DND holds, blocked numbers, hard opt-outs from any source. You might ask why both, since a withdrawal shows up in each. Because they fail differently. Suppression catches numbers you must never message regardless of consent (a legal hold, a complaint, a reported abuse). Consent catches numbers you never had permission for in the first place. Either one saying no is enough.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Three rules make the gate safe:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Unknown is no.<\/strong> If the fold returns <code>none<\/code>, <code>pending<\/code> or a state you don&#8217;t recognise, the answer is no. If the consent store is unreachable, the answer is no. A delayed campaign is recoverable. A message sent without permission isn&#8217;t.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Check at send time, not at audience-build time.<\/strong> Campaign audiences are often built hours or days before the send. Someone who withdraws in between must not get the message. Run the gate again in the worker that calls the API, and for scheduled SMS bookings, purge pending bookings on withdrawal as described in the suppression guide.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Service messages need a purpose too.<\/strong> Order updates, OTPs and account alerts usually rest on the customer relationship rather than an opt-in, but put that in the log as well: a <code>grant<\/code> with <code>origin = 'business'<\/code>, <code>capture_point = 'contract:&lt;id&gt;'<\/code>, a notice row for your terms, and <code>expires_at<\/code> set when the relationship ends. Then the gate stays one rule for everything, and you&#8217;ll notice when a &#8220;service&#8221; purpose starts carrying offers.<\/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-consent-capture-to-gate.svg\"><img decoding=\"async\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/diagram-consent-capture-to-gate.svg\" alt=\"Three-step flow from capture points to an append-only consent event log, then to a send gate that also checks suppression\" class=\"wp-image-3074\"\/><\/a><figcaption class=\"wp-element-caption\">Capture writes events. The fold turns them into a state. The gate checks state and suppression before every send.<\/figcaption><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"withdrawal\" class=\"wp-block-heading\">Withdrawal as Easy as the Grant<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Section 6(4) of the DPDP Act puts it in one line: withdrawing should be about as easy as giving. Map each capture point to an exit of the same effort.<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>They opted in by<\/th><th>They should be able to leave by<\/th><th>What you write<\/th><\/tr><\/thead><tbody><tr><td>Ticking a box on a form<\/td><td>One click in their account, or a link in your messages<\/td><td><code>withdraw<\/code>, <code>origin = customer<\/code>, <code>capture_point = 'web:preferences'<\/code><\/td><\/tr><tr><td>Texting a keyword<\/td><td>Texting STOP (or your opt-out keyword) to the same number<\/td><td><code>withdraw<\/code>, <code>capture_point = 'longcode:&lt;number&gt;:&lt;keyword&gt;'<\/code><\/td><\/tr><tr><td>Tapping a button in WhatsApp<\/td><td>Typing STOP in the chat, or WhatsApp&#8217;s own marketing stop<\/td><td><code>withdraw<\/code>, <code>origin = customer<\/code> or <code>platform<\/code><\/td><\/tr><tr><td>Starting a Telegram bot<\/td><td>A \/stop command or an &#8220;unsubscribe&#8221; button<\/td><td><code>withdraw<\/code>, <code>capture_point = 'telegram:&lt;bot&gt;'<\/code><\/td><\/tr><tr><td>Signing a paper form<\/td><td>Telling staff, calling, or any digital route<\/td><td><code>withdraw<\/code> with the staff id or call id in <code>evidence<\/code><\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Three things decide whether withdrawal actually works in practice.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Every route writes the same event.<\/strong> The STOP handler in your keyword router, the preferences page, the support agent&#8217;s tool and the 131050 handler all call one function that inserts a <code>withdraw<\/code> event and writes to the suppression table in the same transaction. If any of them only updates a flag somewhere, you&#8217;ll eventually send to someone who left.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Withdraw broadly when the scope is unclear.<\/strong> &#8220;STOP&#8221; on your SMS long code almost certainly means &#8220;stop texting me&#8221;, not &#8220;stop offers but keep the order updates&#8221;. Withdraw every non-essential purpose on that channel. If someone wants service updates back, they&#8217;ll tell you, and you&#8217;ll record that as a fresh customer-originated grant.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Act on it fast.<\/strong> Section 6(6) says a reasonable time. Your users would say &#8220;now&#8221;. The gate checks state at send time, so a withdrawal takes effect on the very next send as long as the event is written synchronously. Anything already handed to the platform (a scheduled SMS booking, a queued campaign batch) needs its own cleanup, covered in the suppression guide.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"whatsapp-sync\" class=\"wp-block-heading\">Keeping the WhatsApp Opt-In List in Step<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The dashboard&#8217;s WhatsApp opt-in list is useful: operators can see it, and it&#8217;s tied to the WABA number. But it&#8217;s managed by hand, there&#8217;s no API to write it, and it doesn&#8217;t know about purposes. So treat it as a downstream copy of your log, and reconcile.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A workable routine:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Export from your side.<\/strong> Nightly, fold every <code>(msisdn, 'whatsapp', purpose)<\/code> and produce two lists per WABA number: numbers with any active WhatsApp purpose, and numbers whose WhatsApp consent was withdrawn since the last run.<\/li>\n\n\n\n<li><strong>Apply changes in the dashboard.<\/strong> An operator pastes new opt-ins on the Add Opt-In page (country code included, comma or newline separated) and marks withdrawals as opted out with the thumbs-down on the Manage page.<\/li>\n\n\n\n<li><strong>Record that it was done.<\/strong> Write an <code>ops_sync<\/code> row with the run id, counts, operator and time. It&#8217;s not a consent event, it&#8217;s an operational record, so it goes in its own table.<\/li>\n\n\n\n<li><strong>Reconcile the other way.<\/strong> If an operator marks someone opted out in the dashboard first (because a customer rang up), that&#8217;s a real withdrawal. Have the operator log it through your support tool so it becomes a <code>withdraw<\/code> event too. Never let the dashboard list and your log quietly disagree.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">Prefer marking a number opted out over deleting it. An opted-out row is evidence that you knew and acted. A deleted row is just gone.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Your gate doesn&#8217;t read the dashboard list. It reads your fold. The dashboard is there so the platform side and your operators see the same picture you do.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"consent-failed\" class=\"wp-block-heading\">Reading CONSENT_FAILED and Other Signals<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Some withdrawals and refusals reach you as delivery statuses or send errors rather than as messages from the customer. Each one should become an event, a suppression entry or both, and none of them should be retried blindly.<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Signal<\/th><th>Channel<\/th><th>What it tells you<\/th><th>What to write<\/th><\/tr><\/thead><tbody><tr><td><code>CONSENT_FAILED<\/code><\/td><td>SMS<\/td><td>DLT rejected the message: no valid consent recorded for this type of message or sender<\/td><td>Mark the <code>(msisdn, purpose)<\/code> as blocked for that template category; open a task to check the consent template link or the template&#8217;s category<\/td><\/tr><tr><td><code>NCPR_FAIL<\/code>, <code>DND_PREFERENCE_NUMBER<\/code><\/td><td>SMS<\/td><td>The number is on the do-not-disturb register for this category<\/td><td>A DND hold in suppression, not a consent withdrawal<\/td><\/tr><tr><td><code>BLOCK_NUMBER<\/code><\/td><td>SMS, WhatsApp<\/td><td>The number is on your account&#8217;s Blocked Numbers list<\/td><td>Nothing new; check your suppression table already has it, and if not, find out why<\/td><\/tr><tr><td>131050<\/td><td>WhatsApp<\/td><td>The person stopped marketing messages from your business inside WhatsApp<\/td><td><code>withdraw<\/code> for WhatsApp marketing purposes, <code>origin = 'platform'<\/code><\/td><\/tr><tr><td>STOP in an inbound message<\/td><td>Any<\/td><td>The person wants out<\/td><td><code>withdraw<\/code> with the inbound message as evidence<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><code>CONSENT_FAILED<\/code> deserves a closer look because it&#8217;s easy to misread. It doesn&#8217;t mean your consent record is wrong. It means the DLT check didn&#8217;t find consent for what you tried to send, which usually points to the template side: the content template isn&#8217;t linked to the right consent template, or a promotional message went out under a service-explicit template. Fix the link or the category. Don&#8217;t retry the same message, and don&#8217;t &#8220;correct&#8221; your consent log to match, because your log is about what the customer agreed to, not about what DLT has on file.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For SMS delivery reports in general, the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/delivery-report-ingestion-system-of-record\/\">delivery report ingestion guide<\/a> explains how to get statuses into your own tables reliably. Add a small consumer on top that watches for the statuses above and writes events.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"evidence-pack\" class=\"wp-block-heading\">Evidence Packs: Answering a Complaint<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">When a complaint arrives, you want to answer with a document, not a meeting. An evidence pack is a single query over your tables that, given one phone number, returns:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Every consent event for that number, in order, with channel, purpose, action, origin, capture point and both timestamps.<\/li>\n\n\n\n<li>For every grant and confirm, the full notice text, its language and its SHA-256, so anyone can rehash the text and check it matches.<\/li>\n\n\n\n<li>The raw <code>evidence<\/code> for each event (with secrets already stripped at capture time).<\/li>\n\n\n\n<li>The current folded state per channel and purpose, and when each became what it is.<\/li>\n\n\n\n<li>Every message sent to that number in the period in question, from your outbound message table, with the purpose each was sent under.<\/li>\n\n\n\n<li>Every suppression entry and every consent-related delivery status received.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">Item 5 is the one that closes the loop. It lets you show not just that consent existed, but that each message was sent while it was active and for the purpose it covered. If your outbound table doesn&#8217;t carry a <code>purpose<\/code> column yet, add one: the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/outbound-message-table-schema-design\/\">outbound message table design<\/a> has the rest of the columns you&#8217;ll want next to it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two habits make packs trustworthy:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Rehash on export.<\/strong> The export code recomputes SHA-256 over each notice body and refuses to produce a pack if any hash doesn&#8217;t match. That catches anyone who &#8220;tidied up&#8221; old notice text.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Export the same way every time.<\/strong> A fixed JSON layout plus a human-readable PDF or HTML rendering of the same data. Version the exporter so you can say which version produced a given pack.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"retention\" class=\"wp-block-heading\">Retention and Keeping Only What You Need<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">There&#8217;s a tension here. Proof needs history. Privacy law wants you to keep only what you need for as long as you need it. Resolve it by being deliberate about what each table holds.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Keep the consent event and notice tables for as long as you might have to prove a send.<\/strong> That&#8217;s at least as long as you keep outbound message records, plus whatever period complaints and proceedings can reach back. Set that period with your legal team and write it down in the table comment.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Keep <code>evidence<\/code> lean from day one.<\/strong> Store what proves the event, not everything you happened to receive. Hash IP addresses and user agents with a keyed hash rather than storing them raw. Never store passwords, OTP values, API keys or payment data in <code>evidence<\/code>, even by accident: strip them in the capture function, not later.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>When someone asks you to erase their data,<\/strong> you&#8217;ll usually still need a minimal record that they opted out, otherwise you can&#8217;t keep honouring it. Keep the withdrawal events and the suppression entry, and remove or redact the parts that aren&#8217;t needed to show that. Decide this with your legal team before the first request arrives.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Don&#8217;t let the dashboard become a second archive.<\/strong> The WhatsApp opt-in list and Blocked Numbers are operational. Your log is the record.<\/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 each one is about a different way consent code goes wrong. They assume the tables from <a href=\"#event-table\">The Consent Event Table<\/a>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Python: capture a grant without trusting the browser<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The trap: taking the notice text, or worse a &#8220;consented: true&#8221;, from the client. The browser sends a <code>notice_id<\/code>; the server decides whether that id is live and covers what&#8217;s being granted.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import hashlib, json, re\nfrom datetime import datetime, timezone\nfrom flask import Flask, request, jsonify\nimport psycopg\n\napp = Flask(__name__)\nDB = \"postgresql:\/\/app_writer@localhost\/consent\"\n\ndef normalise_msisdn(raw: str, default_cc: str = \"91\") -&gt; str | None:\n    digits = re.sub(r\"\\D\", \"\", raw or \"\")\n    if len(digits) == 10:\n        digits = default_cc + digits\n    return digits if 11 &lt;= len(digits) &lt;= 15 else None\n\n@app.post(\"\/consent\/grant\")\ndef grant():\n    f = request.form\n    msisdn = normalise_msisdn(f.get(\"mobile\", \"\"))\n    channel, purpose, notice_id = f.get(\"channel\"), f.get(\"purpose\"), f.get(\"notice_id\")\n    if f.get(\"agree\") != \"on\" or not msisdn:      # unticked box or junk number\n        return jsonify(ok=False, reason=\"no_affirmative_action\"), 400\n    now = datetime.now(timezone.utc)\n    req_id = request.headers.get(\"X-Request-Id\", \"\")\n    key = hashlib.sha256(f\"{msisdn}|{channel}|{purpose}|grant|web|{req_id}\".encode()).hexdigest()\n    with psycopg.connect(DB) as conn, conn.cursor() as cur:\n        cur.execute(\n            \"\"\"SELECT 1 FROM notice_version\n               WHERE notice_id = %s AND retired_at IS NULL\n                 AND %s = ANY(channels) AND %s = ANY(purposes)\"\"\",\n            (notice_id, channel, purpose))\n        if cur.fetchone() is None:\n            return jsonify(ok=False, reason=\"notice_not_current\"), 409\n        cur.execute(\n            \"\"\"INSERT INTO consent_event\n               (msisdn, channel, purpose, action, origin, capture_point,\n                notice_id, occurred_at, evidence, dedupe_key)\n               VALUES (%s,%s,%s,'grant','customer','web:signup-form',%s,%s,%s,%s)\n               ON CONFLICT (dedupe_key) DO NOTHING\"\"\",\n            (msisdn, channel, purpose, notice_id, now,\n             json.dumps({\"form_id\": \"signup\", \"request_id\": req_id}), key))\n    # Pending until the handset confirms; queue the confirmation SMS elsewhere.\n    return jsonify(ok=True, state=\"pending\"), 202<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Note what&#8217;s not in <code>evidence<\/code>: the raw IP, the full user agent, cookies. If you want them for fraud checks, store a keyed hash.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Node.js: a fold that fails closed<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The trap: a fold that treats anything it doesn&#8217;t understand as &#8220;probably fine&#8221;. This one returns <code>none<\/code> for an unknown action and orders ties deterministically.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>'use strict';\nconst NEEDS_CONFIRM = (cp) =&gt; cp.startsWith('web:') || cp.startsWith('paper:') || cp.startsWith('import:');\n\nfunction fold(events, nowMs) {\n  const sorted = &#91;...events].sort((a, b) =&gt;\n    (a.occurredAt - b.occurredAt) || (a.eventId - b.eventId));\n  let s = { state: 'none', noticeId: null, expiresAt: null, lastWithdrawnAt: null };\n  for (const e of sorted) {\n    switch (e.action) {\n      case 'grant':\n        s = { ...s, state: NEEDS_CONFIRM(e.capturePoint) ? 'pending' : 'active',\n              noticeId: e.noticeId, expiresAt: e.expiresAt ?? null };\n        break;\n      case 'confirm':\n        if (s.state === 'pending') s = { ...s, state: 'active' };\n        break;\n      case 'withdraw':\n        s = { ...s, state: 'withdrawn', lastWithdrawnAt: e.occurredAt };\n        break;\n      case 'expire':\n        if (s.state === 'pending' || s.state === 'active') s = { ...s, state: 'expired' };\n        break;\n      default:\n        return { ...s, state: 'none' };        \/\/ unknown action: fail closed\n    }\n  }\n  if (s.state === 'active' &amp;&amp; s.expiresAt !== null &amp;&amp; s.expiresAt &lt;= nowMs) {\n    s = { ...s, state: 'expired' };\n  }\n  return s;\n}\n\nmodule.exports = { fold };<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Run it against the double opt-in example and you get <code>pending<\/code> after the grant alone, <code>active<\/code> once the confirm is added, and <code>withdrawn<\/code> if a STOP at the same millisecond carries a higher event id.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Java: a gate that says no when it can&#8217;t tell<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The trap: catching an exception from the consent store and carrying on with the send. The gate&#8217;s only safe failure is &#8220;not allowed&#8221;.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import java.time.Duration;\nimport java.util.concurrent.*;\n\npublic final class SendGate {\n    public interface ConsentStore { String state(String msisdn, String channel, String purpose) throws Exception; }\n    public interface SuppressionStore { boolean suppressed(String msisdn, String channel) throws Exception; }\n\n    private final ConsentStore consent;\n    private final SuppressionStore suppression;\n    private final ExecutorService pool = Executors.newFixedThreadPool(4);\n    private final Duration timeout = Duration.ofMillis(300);\n\n    public SendGate(ConsentStore c, SuppressionStore s) { this.consent = c; this.suppression = s; }\n\n    public boolean allowed(String msisdn, String channel, String purpose) {\n        try {\n            Future&lt;String&gt; st = pool.submit(() -&gt; consent.state(msisdn, channel, purpose));\n            Future&lt;Boolean&gt; sup = pool.submit(() -&gt; suppression.suppressed(msisdn, channel));\n            String state = st.get(timeout.toMillis(), TimeUnit.MILLISECONDS);\n            boolean blocked = sup.get(timeout.toMillis(), TimeUnit.MILLISECONDS);\n            return \"active\".equals(state) &amp;&amp; !blocked;\n        } catch (Exception e) {\n            return false;   \/\/ timeout, store down, interrupted: never send on doubt\n        }\n    }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Log the refusals with their reason. A spike in &#8220;store unavailable&#8221; refusals is an incident; a spike in &#8220;withdrawn&#8221; refusals is a campaign aimed at the wrong list.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">PHP: an evidence export that checks its own notices<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The trap: exporting notice text that someone edited after the fact. The exporter rehashes every notice body and refuses to produce the pack if a hash doesn&#8217;t match.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;?php\nfunction evidencePack(PDO $db, string $msisdn): array {\n    $ev = $db-&gt;prepare(\n        'SELECT e.*, n.body_text, n.sha256, n.language\n           FROM consent_event e\n           LEFT JOIN notice_version n ON n.notice_id = e.notice_id\n          WHERE e.msisdn = ?\n          ORDER BY e.occurred_at, e.event_id');\n    $ev-&gt;execute(&#91;$msisdn]);\n    $rows = $ev-&gt;fetchAll(PDO::FETCH_ASSOC);\n\n    foreach ($rows as $r) {\n        if ($r&#91;'body_text'] !== null &amp;&amp; hash('sha256', $r&#91;'body_text']) !== $r&#91;'sha256']) {\n            throw new RuntimeException('Notice ' . $r&#91;'notice_id'] . ' does not match its hash');\n        }\n    }\n    return &#91;\n        'exporter_version' =&gt; '1.0.0',\n        'msisdn'           =&gt; $msisdn,          \/\/ text, never cast to int\n        'generated_at'     =&gt; gmdate('c'),\n        'events'           =&gt; $rows,\n    ];\n}\n\nheader('Content-Type: application\/json');\necho json_encode(evidencePack($pdo, $_GET&#91;'msisdn'] ?? ''), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Lock this endpoint down hard. It&#8217;s a complete history of one person, so it belongs behind staff authentication and an access log of its own.<\/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>Situation<\/th><th>Do this<\/th><th>Why<\/th><\/tr><\/thead><tbody><tr><td>Number typed into a web or app form<\/td><td>Save a pending grant, confirm from the handset<\/td><td>The form can&#8217;t prove who owns the number<\/td><\/tr><tr><td>Customer texts your keyword or messages your WhatsApp first<\/td><td>Save an active grant straight away<\/td><td>The inbound message already comes from the number<\/td><\/tr><tr><td>Notice wording changes<\/td><td>New <code>notice_version<\/code> row; old grants keep pointing at the old one<\/td><td>Proof is about what was shown that day<\/td><\/tr><tr><td>You want to send offers to service-only customers<\/td><td>Ask once, with a new notice, unless they withdrew in the last 90 days<\/td><td>A service relationship isn&#8217;t consent for promotions<\/td><\/tr><tr><td>Customer replies STOP<\/td><td>Withdraw all non-essential purposes on that channel, add suppression<\/td><td>The intent is broad; narrow it only if they say so<\/td><\/tr><tr><td>WhatsApp send fails with 131050<\/td><td><code>withdraw<\/code> WhatsApp marketing purposes with <code>origin = platform<\/code><\/td><td>They said no inside WhatsApp<\/td><\/tr><tr><td>SMS fails with <code>CONSENT_FAILED<\/code><\/td><td>Stop retrying, fix the template link or category<\/td><td>DLT is checking the template side, not your log<\/td><\/tr><tr><td>Legacy import with only a boolean<\/td><td>Import as unproven, exclude from promotions until reconfirmed<\/td><td>No notice, no action, no proof<\/td><\/tr><tr><td>Consent store is slow or down<\/td><td>Refuse the send and alert<\/td><td>Unknown means no<\/td><\/tr><tr><td>A complaint arrives<\/td><td>Run the evidence pack for the number<\/td><td>Answer with records, not recollection<\/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><code>consent_event<\/code> and <code>notice_version<\/code> exist, with <code>UPDATE<\/code> and <code>DELETE<\/code> revoked for the application role<\/li>\n\n\n\n<li>Every live notice (form, keyword reply, chat prompt, IVR, paper) has a <code>notice_version<\/code> row and its SHA-256<\/li>\n\n\n\n<li>Forms post a <code>notice_id<\/code>, never the notice text, and the server rejects retired or non-matching ids<\/li>\n\n\n\n<li>Phone numbers are normalised to digits with country code and stored as text<\/li>\n\n\n\n<li>Typed-in numbers go through double opt-in with a short expiry on pending grants<\/li>\n\n\n\n<li>Consent is recorded per channel and per purpose, with <code>origin<\/code> set on every event<\/li>\n\n\n\n<li>Service and transactional messages rest on a recorded business-origin grant tied to the relationship<\/li>\n\n\n\n<li>The send gate checks folded consent and suppression at send time, and refuses on any doubt<\/li>\n\n\n\n<li>STOP, the preferences page, support tools and the 131050 handler all write the same <code>withdraw<\/code> event<\/li>\n\n\n\n<li>Business-originated consent requests check the 90-day window after a withdrawal<\/li>\n\n\n\n<li>The WhatsApp dashboard opt-in list is synced from your log with a recorded nightly run<\/li>\n\n\n\n<li><code>CONSENT_FAILED<\/code>, DND statuses and <code>BLOCK_NUMBER<\/code> feed events or suppression, never automatic retries<\/li>\n\n\n\n<li>The outbound message table records the purpose each message was sent under<\/li>\n\n\n\n<li>The evidence pack exporter rehashes notices and is behind staff authentication<\/li>\n\n\n\n<li>A nightly job rebuilds folded state from events and diffs it against the cached state<\/li>\n\n\n\n<li>Retention periods for events, notices and evidence are written down and agreed<\/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 what touches consent isn&#8217;t pinned down anywhere you can read today: exact list behaviours, how long certain states last, which events reach you. Each item below gives you the choice that stays safe whichever way the real behaviour turns out, and none of them needs you to wait for an answer.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>One. Treat the WhatsApp dashboard opt-in list as advisory, never as your gate.<\/strong> You can&#8217;t read it from code, so you can&#8217;t prove what it held at a given moment. Your gate reads your fold; the dashboard list is kept in step for operators. If the two ever disagree, the stricter one wins until a human looks.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Two. Mark opted-out numbers rather than deleting them, on every list you touch.<\/strong> Whether a deleted entry leaves any trace on the platform side doesn&#8217;t matter if you never delete. An opted-out row is evidence; a missing row isn&#8217;t.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Three. Assume a <code>CONSENT_FAILED<\/code> number stays failed until you change something.<\/strong> Whether DLT re-evaluates on the next send or caches the verdict, a blind retry either fails again or succeeds for the wrong reason. Fix the template link or category first, then send one test to a number you control.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Four. Treat a 131050 as a withdrawal for every WhatsApp marketing purpose, not just the template that failed.<\/strong> Whether WhatsApp scopes the stop to your business, a category or a template, the broad reading is never the one that gets you a complaint.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Five. Don&#8217;t count on any consent signal being pushed to you.<\/strong> Withdrawals inside WhatsApp, DLT&#8217;s view of consent and the pilot consent system&#8217;s records may never arrive as an event in your system. Design so that the signals you do get (a failed send, an inbound STOP) are enough to stop the next message, and run a periodic check of failure statuses per number.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Six. Store the inbound message exactly as received, alongside your parsed version.<\/strong> Whether the keyword is included in <code>content<\/code> varies between sources, and whether <code>time<\/code> is present depends on your callback template. With the raw parameters saved, your evidence holds up whatever the parser did.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Seven. Give every pending grant an expiry you chose.<\/strong> Nothing tells you how long a confirmation reply can lag. A 48-hour window you set, followed by an <code>expire<\/code> event, removes the question. A reply after expiry starts a new grant.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Eight. Record notices per language, and hash what was rendered.<\/strong> If your form switches language by browser setting, each language is its own notice version. Then it doesn&#8217;t matter which one a given customer saw; the event says.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Nine. Keep imported consents out of promotions until they&#8217;re reconfirmed.<\/strong> Whether an old system&#8217;s &#8220;opted in&#8221; flag would stand up is unknowable after the fact. Excluding them costs some reach; including them stakes your sender reputation on someone else&#8217;s records.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Ten. Plan for a central consent registry without waiting for its spec.<\/strong> If the DLT consent system rolls out nationally, you&#8217;ll probably need to map your grants to it. Records that already carry purpose, channel, capture point, notice and timestamps map to almost anything. Records that carry a boolean map to nothing.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Eleven. Use your own clock for <code>recorded_at<\/code> and the person&#8217;s action time for <code>occurred_at<\/code>, and keep both in UTC.<\/strong> Whether a source sends local time, epoch milliseconds or no time at all, storing both lets you sort, prove and explain without guessing zones later.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"faqs\" class=\"wp-block-heading\">FAQs<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Is a checkbox on my signup form enough consent for promotional SMS?<\/strong><br>Not on its own. An unticked box the person ticks is a clear affirmative action, which is good. But it doesn&#8217;t prove the number belongs to them, so confirm from the handset, and the notice next to the box has to name your business, the channel and the purpose.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Do I need consent to send OTPs and order updates?<\/strong><br>They usually rest on the customer relationship rather than an opt-in. Record that too: a business-origin grant tied to the account or order, with an end date. That keeps one gate for everything and stops &#8220;service&#8221; messages drifting into offers.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Can I use one consent for SMS, WhatsApp and RCS?<\/strong><br>You can ask for all three in one notice if it names all three, but record them as separate grants. People withdraw per channel, and WhatsApp wants an opt-in that names WhatsApp.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How long does consent last?<\/strong><br>It depends on the basis. A grant you record stays until it&#8217;s withdrawn, unless you set an expiry. Consent tied to an ongoing transaction is valid for 7 days under TRAI&#8217;s 2025 amendment, and implicit service consent lasts for the duration of the relationship. Put the expiry in <code>expires_at<\/code> and let the fold handle it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Someone opted out last month. Can I text them asking to opt back in?<\/strong><br>No. After an opt-out, a business can&#8217;t request consent again for 90 days. If they come back on their own, by texting your keyword or ticking a box, that&#8217;s a fresh grant and it&#8217;s fine.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Does SMSGatewayCenter store my customers&#8217; consent for me?<\/strong><br>No. The dashboard has a WhatsApp opt-in list per <a href=\"https:\/\/www.smsgatewaycenter.com\/whatsapp-business-api\/\">WABA number<\/a> and a Blocked Numbers list, both managed by hand. Your own log is the record, and you sync those lists from it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What does CONSENT_FAILED mean?<\/strong><br>DLT rejected the message because no valid consent was recorded for that type of message or sender. Check that your content template is linked to the right consent template and is in the right category, rather than retrying.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Is double opt-in legally required?<\/strong><br>The rules ask you to prove consent, not to use a particular method. Double opt-in is how you prove the number&#8217;s owner agreed when the number was typed in by someone. For keyword and chat opt-ins, the inbound message already does that job.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What should I store as evidence for a web form opt-in?<\/strong><br>The form id, the <code>notice_id<\/code>, a request id, the timestamp, and keyed hashes of IP and user agent if you need them. Not passwords, not OTPs, not payment details, and not the raw IP unless you have a reason you can explain.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I handle a STOP that arrives on SMS for a customer who also gets WhatsApp?<\/strong><br>Withdraw the SMS purposes. Whether it covers WhatsApp too is a judgement call; the safe default is to ask once on WhatsApp whether they still want messages there, or simply withdraw both if your audience would expect that.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Can I edit a consent event that was recorded wrongly?<\/strong><br>No. Write a correcting event (a <code>withdraw<\/code> or a new <code>grant<\/code>) with a note in <code>evidence<\/code> explaining why, and keep the original. An editable log isn&#8217;t evidence.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I prove which version of my notice someone saw?<\/strong><br>Each grant points at a <code>notice_id<\/code>, and that row holds the exact text and its SHA-256. Anyone can rehash the text and check it matches. Change a single character and the hash changes, so edits after the fact show up.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What happens with Telegram, where the user starts the chat?<\/strong><br>Starting your bot proves contact, not consent for every purpose. Record the purpose they came for, and ask before adding others.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What&#8217;s the fastest way to answer a consent complaint?<\/strong><br>Run the evidence pack for the number: every event, every notice with its hash, the raw evidence, the current state and every message sent with its purpose. If that&#8217;s a single query, the answer takes minutes.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">Have us review your consent flow<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">If you&#8217;re putting a consent log in front of SMS, WhatsApp, RCS or Telegram sends and want to test the whole loop (form, confirmation SMS, keyword reply, send gate) before going live, start in the <a href=\"https:\/\/www.smsgatewaycenter.com\/demo\/\">SMSGatewayCenter sandbox<\/a> or <a href=\"https:\/\/www.smsgatewaycenter.com\/contact\/\">talk to us<\/a> about your setup.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">Checkout other posts<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/two-way-sms-keyword-routing-auto-reply-design\/\">Two-Way SMS Keyword Design: Routing, Auto-Replies and Conversation State on a Long Code<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/opt-out-suppression-sms-whatsapp-rcs-telegram\/\">Opt-Out and Suppression Across SMS, WhatsApp, RCS and Telegram: Building the List That Actually Stops Sends<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/scheduling-messages-sms-whatsapp-rcs-telegram\/\">How to Schedule Messages on SMS, WhatsApp, RCS and Telegram (and Why You Still Need Your Own Scheduler)<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/multi-brand-messaging-architecture-accounts-sub-users\/\">Multi-Brand Messaging Architecture: Accounts, Sub-Users and Reseller Child Accounts for SMS, WhatsApp, RCS and Telegram<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/contract-testing-harness-messaging-api\/\">Contract Testing a Messaging API: A Nightly Drift Harness That Never Sends a Message<\/a><\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n","protected":false},"excerpt":{"rendered":"<p>A tick box isn&#8217;t proof. Here&#8217;s how to capture consent as an append-only event log with the exact notice version, fold it into a per-channel send gate next to your suppression list, keep WhatsApp&#8217;s opt-in list in step, read CONSENT_FAILED properly, and produce an evidence pack the day someone complains.<\/p>\n","protected":false},"author":118,"featured_media":3072,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[2010],"tags":[2226,1464,2315,2224,2318,2321,2320,2319,2317,1955,2088,2316],"class_list":["post-3071","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-developer-guides","tag-audit-trail","tag-consent-management","tag-consent_failed","tag-data-model","tag-dlt-consent-template","tag-double-opt-in","tag-dpdp-act","tag-messaging-compliance","tag-opt-in","tag-sms-consent","tag-tcccpr","tag-whatsapp-opt-in"],"_links":{"self":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/3071","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=3071"}],"version-history":[{"count":0,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/3071\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media\/3072"}],"wp:attachment":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media?parent=3071"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/categories?post=3071"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/tags?post=3071"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}