{"id":3108,"date":"2026-10-23T08:37:25","date_gmt":"2026-10-23T03:07:25","guid":{"rendered":"https:\/\/www.smsgatewaycenter.com\/blog\/?p=3108"},"modified":"2026-10-23T08:37:28","modified_gmt":"2026-10-23T03:07:28","slug":"messaging-data-retention-erasure-sms-whatsapp-rcs-telegram","status":"publish","type":"post","link":"https:\/\/www.smsgatewaycenter.com\/blog\/messaging-data-retention-erasure-sms-whatsapp-rcs-telegram\/","title":{"rendered":"Data Retention and Erasure for SMS, WhatsApp, RCS and Telegram: Deleting a Person Without Breaking Opt-Outs"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">A working design for retention and right-to-erasure in a messaging stack: what you can delete at the provider by API, what only a written request removes, how to keep opt-outs working after you erase someone, and how to prove all of it without keeping the data you just deleted.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/messaging-data-retention-erasure-featured.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"584\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/messaging-data-retention-erasure-featured-1024x584.webp\" alt=\"Abstract rows of teal records passing through a blue filter, leaving outlines and a few orange tokens on the far side.\" class=\"wp-image-3109\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/messaging-data-retention-erasure-featured-1024x584.webp 1024w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/messaging-data-retention-erasure-featured-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/messaging-data-retention-erasure-featured-768x438.webp 768w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/messaging-data-retention-erasure-featured.webp 1200w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><figcaption class=\"wp-element-caption\">Erasure removes the person, not the proof: a few keyed tokens survive so opt-outs keep working.<\/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-the-law-asks\">What the Law Asks of a Messaging System<\/a><\/li>\n\n\n\n<li><a href=\"#data-inventory\">Where Personal Data Lives in a Messaging Stack<\/a><\/li>\n\n\n\n<li><a href=\"#provider-delete\">What You Can Delete at the Provider by API<\/a><\/li>\n\n\n\n<li><a href=\"#request-only\">What Only a Request Can Remove<\/a><\/li>\n\n\n\n<li><a href=\"#support-ticket-api\">Filing Processor Erasure Through the Support Ticket API<\/a><\/li>\n\n\n\n<li><a href=\"#retention-schedule\">The Retention Schedule as Data<\/a><\/li>\n\n\n\n<li><a href=\"#three-actions\">Erase, Redact or Pseudonymise<\/a><\/li>\n\n\n\n<li><a href=\"#suppression-paradox\">The Suppression Paradox<\/a><\/li>\n\n\n\n<li><a href=\"#consent-after-erasure\">Consent Evidence After Erasure<\/a><\/li>\n\n\n\n<li><a href=\"#erasure-model\">The Erasure Request Data Model<\/a><\/li>\n\n\n\n<li><a href=\"#contact-walk\">Finding a Person&#8217;s Contacts Without a Search Endpoint<\/a><\/li>\n\n\n\n<li><a href=\"#erasure-worker\">The Erasure Worker, Step by Step<\/a><\/li>\n\n\n\n<li><a href=\"#late-arrivals\">Data That Arrives After Erasure<\/a><\/li>\n\n\n\n<li><a href=\"#retention-jobs\">Retention Jobs You Can Trust<\/a><\/li>\n\n\n\n<li><a href=\"#legal-holds\">Legal Holds and Disputes<\/a><\/li>\n\n\n\n<li><a href=\"#proving-it\">Proving It Happened<\/a><\/li>\n\n\n\n<li><a href=\"#decision-matrix\">Decision Matrix<\/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\">Erasing someone from a messaging system is three different jobs, and most teams only do the first one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The first job is your own database: message bodies, recipient numbers, delivery reports, inbound replies, click logs. You control all of it, so you can delete, redact or pseudonymise on a schedule.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The second job is the provider side you can reach by API. On <strong>SMSGatewayCenter<\/strong> that means contact rows (<code>SMSApi\/contact\/delete<\/code>), pending scheduled bookings (<code>SMSApi\/schedule\/delete<\/code>), uploaded WhatsApp media (<code>WAApi\/media<\/code> with a <code>mediaId<\/code>), groups and smart links. Each of those deletes needs a read-back afterwards, because a success message only tells you the call was accepted.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The third job is everything the provider holds that has no delete endpoint: delivery report rows, inbox rows, activity logs, credit history. Under India&#8217;s DPDP Act a Data Fiduciary has to &#8220;cause its Data Processor to erase&#8221; data it shared, so you need a written request. The Support Ticket API (<code>SMSApi\/support\/create<\/code>) lets you file and track that request from code. Put transaction IDs in the ticket, never phone numbers, because tickets can&#8217;t be deleted by API either.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The part that trips people up: you can&#8217;t erase the fact that someone opted out. If you delete the number, the next import puts them straight back on your list. So keep a keyed hash (HMAC-SHA256) of the normalised number in your suppression table, check every send against it, and erase everything else.<\/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>Write the retention schedule down as rows in a table<\/strong>, one per data class, with a duration, an action (delete, redact or pseudonymise) and the reason. Jobs read the table. People change the table, not the code.<\/li>\n\n\n\n<li><strong>Erasure has two triggers under DPDP section 8(7):<\/strong> consent withdrawn, or the purpose no longer being served, whichever comes first. A &#8220;purpose served&#8221; sweep is as important as a request handler.<\/li>\n\n\n\n<li><strong>Provider deletes you can call:<\/strong> <code>SMSApi\/contact\/delete<\/code> (by <code>cId<\/code>, POST), <code>SMSApi\/schedule\/delete<\/code> (by <code>uuid<\/code>), <code>WAApi\/media<\/code> DELETE (by <code>mediaId<\/code>), plus group and smart link deletes. Read back after every one.<\/li>\n\n\n\n<li><strong><code>SMSApi\/contact\/read<\/code> has no filter and no paging.<\/strong> To find a person you walk the whole list and match on a normalised <code>mobileNo<\/code>. One person can be several rows, one per group.<\/li>\n\n\n\n<li><strong><code>schedule\/read<\/code> returns no recipient numbers<\/strong>, so you can only find a person&#8217;s pending bookings through your own outbound table. A booking with several recipients can&#8217;t be edited down to fewer; cancel and rebook.<\/li>\n\n\n\n<li><strong>WhatsApp media can only be deleted within 30 days of upload.<\/strong> Delete it as soon as the send settles. Keep <code>mediaId<\/code> as a string: <code>7074855483357676350<\/code> becomes <code>7074855483357677000<\/code> as a JavaScript number.<\/li>\n\n\n\n<li><strong>Everything else needs a request.<\/strong> File it with <code>SMSApi\/support\/create<\/code>, list transaction IDs and date ranges, poll <code>SMSApi\/support\/read<\/code> for status, and keep the ticket ID in your erasure ledger.<\/li>\n\n\n\n<li><strong>Suppression survives erasure as an HMAC<\/strong>, never a plain SHA-256. There are only about four billion Indian mobile numbers, so an unkeyed hash is reversible by brute force.<\/li>\n\n\n\n<li><strong>Late delivery reports and replies will keep arriving<\/strong> for someone you&#8217;ve erased. Redact them at write time by checking the HMAC against an <code>erased_subject<\/code> table.<\/li>\n\n\n\n<li><strong>Prove it with a ledger that records what was done and when, never who.<\/strong><\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"what-the-law-asks\" class=\"wp-block-heading\">What the Law Asks of a Messaging System<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">If you send messages to people in India, the rule that shapes your retention design is section 8(7) of the Digital Personal Data Protection Act, 2023. Here&#8217;s the text from the <a href=\"https:\/\/www.trai.gov.in\/sites\/default\/files\/2024-09\/DPDT_11082024.pdf\" target=\"_blank\" rel=\"noopener nofollow\">Gazette copy of the Act<\/a>:<\/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\">(7) A Data Fiduciary shall, unless retention is necessary for compliance with any law for the time being in force,<br>(a) erase personal data, upon the Data Principal withdrawing her consent or as soon as it is reasonable to assume that the specified purpose is no longer being served, whichever is earlier; and<br>(b) cause its Data Processor to erase any personal data that was made available by the Data Fiduciary for processing to such Data Processor.<\/p>\n<\/blockquote>\n\n\n\n<p class=\"wp-block-paragraph\">Read it as an engineer and three requirements fall out.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>There are two triggers, not one.<\/strong> Withdrawal is the obvious one: someone says stop, or deletes their account, and you act. The second trigger is quieter. Once the purpose you collected the number for is done, the clock is already running. A delivery-slot reminder number has served its purpose once the delivery is complete. Nobody files a request for that, so if your only erasure path is a request handler, you&#8217;ll never meet the second trigger. You need a scheduled sweep as well.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>&#8220;Whichever is earlier&#8221; means both paths run.<\/strong> A withdrawal on day 3 beats a purpose that would have ended on day 30. A purpose that ends on day 3 beats a withdrawal that never comes.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Your provider is your problem.<\/strong> Clause (b) puts the duty on you to make your processor erase what you gave it. When you send through a messaging platform, the platform holds recipient numbers, message text, delivery reports and inbound replies. Some of that you can delete by API. Some you can&#8217;t. Either way, it&#8217;s on you to get it done and to be able to show that you did.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Section 8(8) adds that the purpose is deemed no longer served if the person doesn&#8217;t approach you or exercise their rights &#8220;for such time period as may be prescribed&#8221;. The DPDP Rules fill in those periods for some classes of business, and they also set some minimum retention. The commencement dates for different parts of the Rules have been reported differently in different places, so treat specific day counts as configuration you set from the notified text, not constants you hard-code. The design below keeps every period in one table for exactly that reason.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">There&#8217;s also an exception that matters a lot for messaging: &#8220;unless retention is necessary for compliance with any law&#8221;. Telecom marketing rules are law. Under the TRAI commercial communications regulations, an opt-out has to stick. TRAI&#8217;s <a href=\"https:\/\/trai.gov.in\/sites\/default\/files\/2025-02\/PR_No.11of2025.pdf\" target=\"_blank\" rel=\"noopener nofollow\">press release on the 2025 amendments<\/a> says a sender can&#8217;t even ask for consent again within 90 days of an opt-out. You can&#8217;t honour that if you&#8217;ve deleted every trace of the person. That tension is the whole of <a href=\"#suppression-paradox\">the suppression paradox<\/a> below, and it&#8217;s solvable.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">What the Act doesn&#8217;t tell you is how long to keep a delivery report or a message body. That&#8217;s a business decision you make per data class, write down, and enforce in code.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"data-inventory\" class=\"wp-block-heading\">Where Personal Data Lives in a Messaging Stack<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Before you can delete a person you need a map of everywhere they are. In a typical stack built on SMSGatewayCenter, the inventory looks like this.<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Data<\/th><th>Where it lives<\/th><th>Holds personal data because<\/th><th>Who can delete it<\/th><\/tr><\/thead><tbody><tr><td>Outbound message rows<\/td><td>Your database<\/td><td>recipient number, message body<\/td><td>You<\/td><\/tr><tr><td>Delivery report rows<\/td><td>Your database, plus provider reports (<code>SMSApi\/reports\/status<\/code>, <code>WAApi\/report<\/code>, RCS and Telegram delivery reports)<\/td><td>recipient number, sometimes the body<\/td><td>You; provider copy by request<\/td><\/tr><tr><td>Inbound replies<\/td><td>Your database, plus provider inboxes (<code>rest\/wa\/v1\/inbox<\/code>, <code>rest\/rcs\/v1\/inbox<\/code>, <code>rest\/tg\/v1\/inbox<\/code>)<\/td><td>sender number or chat ID, message content<\/td><td>You; provider copy by request<\/td><\/tr><tr><td>Contacts and groups<\/td><td>Provider (<code>SMSApi\/contact\/*<\/code>, <code>SMSApi\/group\/*<\/code>), often mirrored locally<\/td><td>name, number<\/td><td>You, by API<\/td><\/tr><tr><td>Pending scheduled SMS<\/td><td>Provider (<code>SMSApi\/schedule\/*<\/code>)<\/td><td>message body for future recipients<\/td><td>You, by API<\/td><\/tr><tr><td>WhatsApp media<\/td><td>Provider (<code>WAApi\/media<\/code>)<\/td><td>images and documents you uploaded<\/td><td>You, by API, within 30 days of upload<\/td><\/tr><tr><td>Smart link clicks<\/td><td>Provider visitor logs, your click table<\/td><td>IP address, user agent, link-to-person join<\/td><td>Smart link by API; visitor rows by request<\/td><\/tr><tr><td>Consent and suppression<\/td><td>Your database<\/td><td>number, timestamps, notice version<\/td><td>You, with care<\/td><\/tr><tr><td>Account audit trail<\/td><td>Provider activity log, your mirror<\/td><td>IP addresses of your own staff<\/td><td>Your mirror; provider log by request<\/td><\/tr><tr><td>Support tickets<\/td><td>Provider (<code>SMSApi\/support\/*<\/code>)<\/td><td>whatever you typed into them<\/td><td>Nobody, by API<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Two rows in that table cause most of the trouble.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The first is the delivery report row. It&#8217;s written by the provider, it carries the recipient number, and there&#8217;s no endpoint to delete it. Your copy of it is easy. Their copy isn&#8217;t.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The second is the support ticket. It&#8217;s a natural place to send an erasure request, and the Support Ticket API&#8217;s error table lists code 456: &#8220;Delete action is not available via API&#8221;. So anything you type into a ticket stays there until someone at the provider removes it. Don&#8217;t put the numbers you&#8217;re trying to erase into the very system you can&#8217;t erase from.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you&#8217;ve built the tables from earlier guides, you already have most of the map: the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/outbound-message-table-schema-design\/\">outbound message table<\/a>, the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/delivery-report-ingestion-system-of-record\/\">delivery report store<\/a>, the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/receiving-messages-four-channel-inbox-contracts\/\">inbound inbox tables<\/a>, the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/opt-out-suppression-sms-whatsapp-rcs-telegram\/\">suppression tables<\/a>, the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/consent-capture-evidence-sms-whatsapp-rcs-telegram\/\">consent log<\/a> and the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-click-tracking-attribution-smart-links\/\">click table<\/a>. Each one needs a retention rule and an erasure path.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"provider-delete\" class=\"wp-block-heading\">What You Can Delete at the Provider by API<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s what you can actually call. Every one of these takes the <code>apikey<\/code> header with <code>userid<\/code>, the same as the rest of the API.<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Endpoint<\/th><th>Method<\/th><th>Key<\/th><th>Success shape<\/th><th>What to check afterwards<\/th><\/tr><\/thead><tbody><tr><td><code>SMSApi\/contact\/delete<\/code><\/td><td>POST<\/td><td><code>id<\/code> (the contact&#8217;s <code>cId<\/code>)<\/td><td><code>response.status<\/code> &#8220;success&#8221;, <code>code<\/code> &#8220;200&#8221; (quoted)<\/td><td><code>SMSApi\/contact\/read<\/code> no longer lists that <code>cId<\/code><\/td><\/tr><tr><td><code>SMSApi\/group\/delete<\/code><\/td><td>see the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/delete-group\/\">delete group page<\/a><\/td><td>group id<\/td><td>success code &#8220;224&#8221;<\/td><td><code>SMSApi\/group\/read<\/code> no longer lists the group<\/td><\/tr><tr><td><code>SMSApi\/schedule\/delete<\/code><\/td><td>POST or GET<\/td><td><code>uuid<\/code><\/td><td><code>status<\/code> &#8220;success&#8221;<\/td><td><code>SMSApi\/schedule\/read<\/code> no longer lists that <code>uuId<\/code><\/td><\/tr><tr><td><code>WAApi\/media<\/code><\/td><td>DELETE in the sample<\/td><td><code>mediaId<\/code><\/td><td>flat <code>status<\/code> &#8220;success&#8221;, <code>mediaId<\/code> quoted, <code>statusCode<\/code> &#8220;200&#8221;<\/td><td>the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/whatsapp-read-one-media\/\">read by media ID<\/a> call no longer finds it<\/td><\/tr><tr><td>Smart link delete<\/td><td>see the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/delete-smart-link\/\">delete smart link page<\/a><\/td><td>link id<\/td><td>see the page<\/td><td>the smart link read no longer lists it<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">A few things about these deletes are worth knowing before you write the worker.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>contact\/delete<\/code> removes one row, not one person.<\/strong> The <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/read-contact\/\">read contact<\/a> sample returns rows with <code>cId<\/code>, <code>contactName<\/code>, <code>contactStatus<\/code>, <code>groupId<\/code>, <code>mobileNo<\/code>, <code>groupName<\/code> and <code>groupType<\/code>. Each row is a contact inside a group. The same number in three groups is three rows with three <code>cId<\/code> values. Delete one and the other two are still there. <a href=\"#contact-walk\">Finding a person&#8217;s contacts<\/a> covers the walk.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>schedule\/delete<\/code> cancels a booking, and a booking can hold many people.<\/strong> The schedule read returns <code>uuId<\/code>, <code>status<\/code>, <code>total<\/code>, <code>timestamp<\/code>, <code>scheduledTimestamp<\/code> and <code>lastupdatedTimestamp<\/code>. It doesn&#8217;t return recipient numbers. If the person you&#8217;re erasing is one of 5,000 recipients in a booking, deleting that booking cancels the message for all 5,000. <code>schedule\/update<\/code> only changes the time, so you can&#8217;t edit them out. Either cancel and rebook for everyone else, or (better) don&#8217;t book far-future sends with many recipients at the provider in the first place. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/scheduling-messages-sms-whatsapp-rcs-telegram\/\">scheduling guide<\/a> explains the handoff horizon that makes this a non-issue.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>WhatsApp media has a short window.<\/strong> The <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/whatsapp-delete-media\/\">delete media page<\/a> says media &#8220;can only be deleted before expiration (within 30 days of upload)&#8221;. If an invoice PDF with a customer&#8217;s name on it went out as a document message, the clean time to delete it is once the send has settled, not when an erasure request turns up two months later. Put media deletion in the send pipeline&#8217;s cleanup step.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The <code>mediaId<\/code> is too big for a JavaScript number.<\/strong> The parameter table calls it an integer. The sample value is <code>7074855483357676350<\/code>. Run <code>Number(\"7074855483357676350\")<\/code> in Node and you get <code>7074855483357677000<\/code>, which is a different ID. Keep it as a string from the upload response to the delete call.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ Node 18+. Keep provider IDs as strings end to end.\nconst raw = \"7074855483357676350\";\nconsole.log(Number(raw).toString()); \/\/ 7074855483357677000  (wrong ID)\nconsole.log(BigInt(raw).toString()); \/\/ 7074855483357676350  (fine, but you never needed a number)\n\nasync function deleteMedia(mediaId, { userid, apikey }) {\n  if (typeof mediaId !== \"string\" || !\/^\\d+$\/.test(mediaId)) {\n    throw new Error(\"mediaId must be a digit string\");\n  }\n  const qs = new URLSearchParams({ userid, mediaId, output: \"json\" });\n  const res = await fetch(`https:\/\/unify.smsgateway.center\/WAApi\/media?${qs}`, {\n    method: \"DELETE\",\n    headers: { apikey },\n  });\n  let body = {};\n  try { body = JSON.parse(await res.text()); } catch { \/* treat as failure below *\/ }\n  return body.status === \"success\" &amp;&amp; body.mediaId === mediaId;\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The last line checks that the ID echoed back matches the one you sent, as a string. If the provider ever echoes something else, you want to know.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"request-only\" class=\"wp-block-heading\">What Only a Request Can Remove<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">This list is longer than people expect, and it&#8217;s where most of a person&#8217;s data actually sits at the provider.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>SMS delivery reports.<\/strong> <code>SMSApi\/reports\/status<\/code> with <code>method=getDlr<\/code> returns rows with the recipient number, sender, status, cost and timing. Read-only.<\/li>\n\n\n\n<li><strong>WhatsApp reports.<\/strong> <code>WAApi\/report<\/code> rows carry <code>mobileNo<\/code> and <code>wabaNumber<\/code>. Read-only.<\/li>\n\n\n\n<li><strong>RCS and Telegram delivery reports.<\/strong> Read-only.<\/li>\n\n\n\n<li><strong>Inboxes.<\/strong> The WhatsApp, RCS and Telegram inboxes are read-only polls. The SMS inbound push goes to your callback URL, and whatever copy the platform keeps of it is out of your reach.<\/li>\n\n\n\n<li><strong>Telegram recipients.<\/strong> <code>rest\/tg\/v1\/recipients<\/code> lists chat IDs, display names and usernames. Read-only.<\/li>\n\n\n\n<li><strong>Smart link visitor logs.<\/strong> IPs and user agents for each click.<\/li>\n\n\n\n<li><strong>The Blocked Numbers list and the WhatsApp opt-in list.<\/strong> These are dashboard features without an API. Someone on your team edits them by hand.<\/li>\n\n\n\n<li><strong>Recurring campaign history.<\/strong> The recurring SMS help page on <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/how-do-i-permanently-delete-a-recurring-campaign\/\">deleting a recurring campaign<\/a> says &#8220;Historical execution reports&#8221; and &#8220;Transaction IDs&#8221; are preserved after deletion.<\/li>\n\n\n\n<li><strong>Workflow rows.<\/strong> Deleting a workflow in the Workflow Builder is a soft delete. The help entry <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/why-does-delete-on-manage-not-remove-the-row\/\">on why the row stays<\/a> says it &#8220;sets the workflow status to deleted&#8221; and &#8220;the row remains in the database&#8221;.<\/li>\n\n\n\n<li><strong>Support tickets.<\/strong> Code 456, as above.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Two lessons from that list.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">First, &#8220;deleted&#8221; in a product UI often means hidden. A soft delete is a perfectly good product choice. It just isn&#8217;t erasure, so don&#8217;t count a UI delete as an erasure step in your ledger.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Second, you&#8217;ll never be able to show a regulator a row-by-row deletion receipt for the provider&#8217;s copy. What you can show is that you identified the data, asked for it to be erased in writing, tracked the request to closure, and stopped adding more. That&#8217;s what the next section builds.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"support-ticket-api\" class=\"wp-block-heading\">Filing Processor Erasure Through the Support Ticket API<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/support\/\">Support Ticket API<\/a> has four calls: <code>SMSApi\/support\/create<\/code> (POST), <code>SMSApi\/support\/reply<\/code> (POST), <code>SMSApi\/support\/read<\/code> (POST or GET) and <code>SMSApi\/support\/star<\/code> (POST). It was built for help requests from the mobile app, but it&#8217;s exactly what you need for an erasure pipeline: a written request with an ID you can store and a status you can poll.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">What create takes:<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Parameter<\/th><th>Rule from the page<\/th><\/tr><\/thead><tbody><tr><td><code>subject<\/code><\/td><td>5 to 200 characters, can&#8217;t be a URL, must include letters or numbers<\/td><\/tr><tr><td><code>message<\/code><\/td><td>5 to 10,000 characters<\/td><\/tr><tr><td><code>output<\/code><\/td><td><code>json<\/code> recommended<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">What it returns on success:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"response\": {\n    \"api\": \"support\",\n    \"action\": \"create\",\n    \"status\": \"success\",\n    \"msg\": \"Support ticket created successfully - Ticket ID: 9320799726018928\",\n    \"code\": 200,\n    \"count\": 1,\n    \"ticket\": {\n      \"ticketId\": \"9320799726018928\",\n      \"id\": 12345,\n      \"status\": \"open\",\n      \"timestamp\": \"2025-12-04T15:14:02+05:30\"\n    }\n  }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Save <code>response.ticket.ticketId<\/code> as text. The page describes it as &#8220;UUID format&#8221; while the sample is a 16-digit string, so don&#8217;t validate it as either. Treat it as an opaque string.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Now the design decisions.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Put transaction IDs in the ticket, not phone numbers.<\/strong> The provider can find a delivery report row by its <code>uuId<\/code> or <code>transactionId<\/code>. You have those in your outbound table already. A ticket that says &#8220;erase report rows for these 212 transaction IDs between 2026-03-01 and 2026-09-30, and inbox rows for WhatsApp messages with these message IDs&#8221; is precise and contains no new copy of the person&#8217;s number. A ticket that lists the numbers creates a fresh, undeletable copy of exactly what you&#8217;re trying to erase.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Batch requests, one ticket per day.<\/strong> Code 434 is &#8220;You already have an open ticket with similar message&#8221;. The page doesn&#8217;t say how &#8220;similar&#8221; is measured, so a stream of near-identical tickets, one per request, will start bouncing. Collect the day&#8217;s erasure requests and file one ticket. If a ticket for the same purpose is still open, add the new batch with <code>SMSApi\/support\/reply<\/code> instead (code 443 is the equivalent &#8220;similar reply&#8221; check, so lead every message with a unique batch reference such as <code>ERASURE-BATCH 2026-10-23-01<\/code>).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Chunk long lists.<\/strong> 10,000 characters holds a bit under 500 nineteen-digit IDs once you allow for commas and a short header. If a batch is bigger, split it into several replies on the same ticket, each with its own batch reference and a part number.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Poll for status, and don&#8217;t treat &#8220;closed&#8221; as proof.<\/strong> <code>SMSApi\/support\/read<\/code> with <code>ticketId<\/code> returns the ticket, its <code>status<\/code> and a <code>replies<\/code> array with <code>isFromAdmin<\/code> on each reply. The status values on the page are <code>open<\/code>, <code>answered<\/code> and <code>closed<\/code>. &#8220;Answered&#8221; means someone replied, not that the erasure happened. &#8220;Closed&#8221; means the ticket was closed. Your ledger should only move a batch to <code>provider_confirmed<\/code> when a reply from the provider confirms the erasure, and a person on your side has read it. Automate the polling, not the judgement.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Read <code>status<\/code>, never <code>code<\/code>.<\/strong> On this API <code>code<\/code> is an unquoted integer (<code>200<\/code>). On <code>SMSApi\/contact\/delete<\/code> it&#8217;s a quoted string (<code>\"200\"<\/code>). And the numbers overlap across families: the support page uses 448 to 456 for star and general errors, while the connected-devices logout calls use the same range for their own errors. A client that branches on <code>code<\/code> will misread one of them.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s the filing step in Python:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import json\nimport requests\n\nBASE = \"https:\/\/unify.smsgateway.center\"\n\ndef _inner(body):\n    \"\"\"Return the object that carries `status`, whichever envelope came back.\"\"\"\n    if isinstance(body, dict) and isinstance(body.get(\"response\"), dict):\n        return body&#91;\"response\"]\n    return body if isinstance(body, dict) else {}\n\ndef call(path, data, userid, apikey, method=\"POST\", timeout=20):\n    payload = {\"userid\": userid, \"output\": \"json\", **data}\n    headers = {\"apikey\": apikey}\n    if method in (\"GET\", \"DELETE\"):\n        # The WAApi\/media DELETE sample sends its parameters in the query string.\n        r = requests.request(method, f\"{BASE}\/{path}\", params=payload, headers=headers, timeout=timeout)\n    else:\n        r = requests.post(f\"{BASE}\/{path}\", data=payload, headers=headers, timeout=timeout)\n    try:\n        return r.json()\n    except ValueError:\n        return {}\n\ndef chunk_ids(ids, header, limit=10_000):\n    \"\"\"Split IDs into messages that each stay under the character limit.\"\"\"\n    parts, current = &#91;], &#91;]\n    for txn in ids:\n        trial = header + \"\\n\" + \",\".join(current + &#91;txn])\n        if len(trial) &gt; limit and current:\n            parts.append(current)\n            current = &#91;txn]\n        else:\n            current.append(txn)\n    if current:\n        parts.append(current)\n    return parts\n\ndef file_erasure_batch(batch_ref, txn_ids, date_from, date_to, creds, open_ticket_id=None):\n    \"\"\"File (or extend) the provider-side erasure request. Returns the ticket ID or None.\"\"\"\n    header = (\n        f\"ERASURE-BATCH {batch_ref}\\n\"\n        f\"Please erase delivery report rows, inbox rows and click visitor rows linked to the \"\n        f\"transaction IDs below, sent between {date_from} and {date_to}.\\n\"\n        f\"Transaction IDs:\"\n    )\n    # Size the chunks with room for the \" (part n of m)\" suffix added below.\n    parts = chunk_ids(sorted(set(txn_ids)), header + \" (part 999 of 999)\")\n    ticket_id = open_ticket_id\n    for n, part in enumerate(parts, start=1):\n        message = f\"{header} (part {n} of {len(parts)})\\n\" + \",\".join(part)\n        if ticket_id is None:\n            body = call(\"SMSApi\/support\/create\",\n                        {\"subject\": f\"Personal data erasure request {batch_ref}\", \"message\": message},\n                        **creds)\n            inner = _inner(body)\n            if inner.get(\"status\") != \"success\":\n                return None\n            ticket_id = str(inner.get(\"ticket\", {}).get(\"ticketId\", \"\")) or None\n            if ticket_id is None:\n                return None\n        else:\n            body = call(\"SMSApi\/support\/reply\", {\"ticketId\": ticket_id, \"message\": message}, **creds)\n            if _inner(body).get(\"status\") != \"success\":\n                return None\n    return ticket_id<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>creds<\/code> is <code>{\"userid\": ..., \"apikey\": ...}<\/code>. If the function returns <code>None<\/code>, the batch stays in the <code>ticket_pending<\/code> state and the next run tries again. Notice that it never looks at <code>code<\/code> or parses <code>msg<\/code>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"retention-schedule\" class=\"wp-block-heading\">The Retention Schedule as Data<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Retention periods change. Legal reads the Rules again, finance wants seven years of invoices, security wants the audit log for longer. If the periods live in code, each change is a deploy. Put them in a table instead.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>CREATE TABLE retention_rule (\n  data_class      TEXT PRIMARY KEY,           -- 'otp_code', 'message_body', 'recipient', ...\n  keep_for_days   INTEGER NOT NULL CHECK (keep_for_days &gt;= 0),\n  action          TEXT NOT NULL CHECK (action IN ('delete', 'redact', 'pseudonymise')),\n  clock_starts    TEXT NOT NULL CHECK (clock_starts IN ('created', 'purpose_served', 'last_contact')),\n  basis           TEXT NOT NULL,              -- why this period: a law, a contract, a business need\n  owner           TEXT NOT NULL,              -- who signs off a change\n  updated_at      TEXT NOT NULL\n);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Three columns do the real work.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>action<\/code> says what happens when the period ends. Not everything gets deleted. A delivery report row can lose its recipient number and keep its cost and status, so your monthly reconciliation still adds up.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>clock_starts<\/code> says when the period begins. For an OTP it&#8217;s creation. For an order update it&#8217;s when the order closes, which is the &#8220;purpose served&#8221; moment from section 8(7). For a marketing contact it&#8217;s the person&#8217;s last interaction, which is how section 8(8) frames it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>basis<\/code> is the sentence you&#8217;ll need when someone asks why you kept something. &#8220;Finance requires invoice-level message cost for statutory audit&#8221; is a basis. &#8220;We might need it&#8221; is not.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s an example configuration. The numbers are placeholders to show the shape. Set your own from your legal and finance requirements.<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>data_class<\/th><th>keep_for_days<\/th><th>action<\/th><th>clock_starts<\/th><th>basis (example)<\/th><\/tr><\/thead><tbody><tr><td><code>otp_code<\/code><\/td><td>1<\/td><td>delete<\/td><td>created<\/td><td>no use after verification<\/td><\/tr><tr><td><code>message_body<\/code><\/td><td>90<\/td><td>redact<\/td><td>created<\/td><td>customer disputes about what was sent<\/td><\/tr><tr><td><code>recipient<\/code><\/td><td>365<\/td><td>pseudonymise<\/td><td>purpose_served<\/td><td>delivery evidence for the service relationship<\/td><\/tr><tr><td><code>dlr_row<\/code><\/td><td>365<\/td><td>pseudonymise<\/td><td>created<\/td><td>cost reconciliation keeps status and amount<\/td><\/tr><tr><td><code>inbound_content<\/code><\/td><td>180<\/td><td>redact<\/td><td>created<\/td><td>support history<\/td><\/tr><tr><td><code>click_visitor<\/code><\/td><td>30<\/td><td>delete<\/td><td>created<\/td><td>campaign reporting only<\/td><\/tr><tr><td><code>contact_row<\/code><\/td><td>0<\/td><td>delete<\/td><td>purpose_served<\/td><td>list membership ends with the purpose<\/td><\/tr><tr><td><code>consent_event<\/code><\/td><td>2555<\/td><td>pseudonymise<\/td><td>last_contact<\/td><td>proof of consent, see below<\/td><\/tr><tr><td><code>suppression<\/code><\/td><td>never<\/td><td>pseudonymise<\/td><td>created<\/td><td>opt-out must keep working<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">&#8220;never&#8221; in that last row is stored as a sentinel in practice (a very large number, or a nullable column with a check). Pick one convention and stick to it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The job that enforces this reads the table, not a config file, and logs every run: rows considered, rows changed, the rule version it used. When the rule changes, the next run picks it up.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"three-actions\" class=\"wp-block-heading\">Erase, Redact or Pseudonymise<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">These three words get used loosely. In the schedule they mean specific operations.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Delete<\/strong> removes the row. Use it when nothing about the row is worth keeping: OTP codes, click visitor rows after the report is built, contact rows once the list&#8217;s purpose is over.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Redact<\/strong> keeps the row and blanks the personal fields. A message row keeps its ID, timestamps, channel, template key, part count and cost, and loses its body. You keep <code>billable_units<\/code> because you stored it at send time. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/outbound-message-table-schema-design\/\">outbound table guide<\/a> explains why you should never recompute units from a body you might later redact.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Pseudonymise<\/strong> replaces an identifier with a keyed hash. The recipient column changes from <code>919876543210<\/code> to a 64-character hex string. Rows for the same person still join to each other, and to the suppression table, but nobody reading the table can see the number.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The key is the whole point. A plain SHA-256 of a phone number looks anonymous and isn&#8217;t. Indian mobile numbers are ten digits starting with 6, 7, 8 or 9, which is four billion possibilities. Hashing all of them is an afternoon on a laptop. So the &#8220;hash&#8221; of <code>919876543210<\/code> gives nothing away only if an attacker can&#8217;t compute it themselves:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import hashlib, hmac, re\n\ndef normalise_msisdn(raw, default_cc=\"91\"):\n    d = re.sub(r\"\\D\", \"\", str(raw or \"\"))\n    if d.startswith(\"00\"):\n        d = d&#91;2:]\n    if len(d) == 11 and d.startswith(\"0\"):\n        d = default_cc + d&#91;1:]\n    if len(d) == 10:\n        d = default_cc + d\n    return d\n\ndef hmac_hex(key: bytes, normalised: str) -&gt; str:\n    return hmac.new(key, normalised.encode(), hashlib.sha256).hexdigest()\n\nn = normalise_msisdn(\"+91 98765 43210\")            # '919876543210'\nprint(hashlib.sha256(n.encode()).hexdigest())\n# 92b5072176e723878b5e06ff3ca61898e4eb74e8c46642a0f2db800b17364ab0   anyone can compute this\nprint(hmac_hex(b\"example-key-do-not-use\", n))\n# a94d65c23c26a4276a97ed19be3fd17f943d36989547a9cfc3b59c18c0e2a0ea   needs the key<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">In the later snippets, <code>hmac_hex<\/code> receives a key object from a key ring with <code>.secret<\/code> and <code>.id<\/code>; pass <code>key.secret<\/code> where this one takes raw bytes.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Keep the HMAC key in your secrets manager, not in the database next to the hashes. Give it an ID and store that ID beside every pseudonym, because you&#8217;ll rotate it one day (more on that in the <a href=\"#unspecified-behaviour\">unspecified behaviour<\/a> list).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">And normalise before you hash. <code>+91 98765 43210<\/code>, <code>09876543210<\/code>, <code>9876543210<\/code> and <code>919876543210<\/code> are the same person. If they hash differently, your suppression check silently misses:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ Normalise to country code + national number, digits only, before hashing.\nconst crypto = require(\"node:crypto\");\n\nfunction normaliseMsisdn(raw, defaultCc = \"91\") {\n  let d = String(raw ?? \"\").replace(\/\\D\/g, \"\");\n  if (d.startsWith(\"00\")) d = d.slice(2);\n  if (d.length === 11 &amp;&amp; d.startsWith(\"0\")) d = defaultCc + d.slice(1);\n  if (d.length === 10) d = defaultCc + d;\n  return d;\n}\n\nfunction subjectHmac(raw, key) {\n  return crypto.createHmac(\"sha256\", key).update(normaliseMsisdn(raw)).digest(\"hex\");\n}\n\nconst key = Buffer.from(\"example-key-do-not-use\");\nconst forms = &#91;\"+91 98765 43210\", \"09876543210\", \"9876543210\", \"919876543210\"];\nconsole.log(new Set(forms.map((f) =&gt; subjectHmac(f, key))).size); \/\/ 1<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That normaliser is deliberately simple and India-first. If you send internationally, use a proper number-parsing library and keep the output format (E.164 without the plus sign is a good choice) identical in every service that hashes.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"suppression-paradox\" class=\"wp-block-heading\">The Suppression Paradox<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Someone replies STOP. A month later they ask you to delete everything you hold about them. You delete it all, including the suppression row. Next quarter, marketing imports a purchased list that happens to include their number. Your send gate checks the suppression table, finds nothing, and messages them. You&#8217;ve now breached the opt-out rule you were trying to respect, and you can&#8217;t even see that you did it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The fix is to treat the opt-out as something you keep for compliance with law (the section 8(7) exception) while removing everything that identifies the person in readable form. In practice:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Keep the suppression row, but replace the number with its HMAC.<\/li>\n\n\n\n<li>Make the send gate compute the HMAC of every recipient and check it against the table.<\/li>\n\n\n\n<li>Drop the free-text fields (name, reason typed by an agent, source campaign name) from the row.<\/li>\n\n\n\n<li>Keep the channel, the scope (all marketing, one brand) and the date, because those decide whether a send is allowed.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/opt-out-suppression-sms-whatsapp-rcs-telegram\/\">suppression guide<\/a> builds an append-only <code>suppression_event<\/code> table with an <code>address<\/code> column. After erasure that column holds the HMAC and a new <code>address_kind<\/code> value says so:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>-- address_kind was 'msisdn' | 'tg_chat_id'; add the pseudonymised forms.\n-- (In Postgres, replace the CHECK constraint; in SQLite, rebuild the table.)\n-- 'msisdn_hmac'      address holds HMAC-SHA256 hex of the normalised MSISDN\n-- 'tg_chat_id_hmac'  address holds HMAC-SHA256 hex of the chat ID as a decimal string\n\nALTER TABLE suppression_event ADD COLUMN hmac_key_id TEXT;  -- which key produced the hash<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The gate then checks both forms, because most rows will still hold plain numbers:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def is_suppressed(db, channel, raw_msisdn, keyring):\n    n = normalise_msisdn(raw_msisdn)\n    candidates = &#91;(\"msisdn\", n)] + &#91;\n        (\"msisdn_hmac\", hmac_hex(key.secret, n)) for key in keyring.active_and_retired()\n    ]\n    for kind, value in candidates:\n        if db.suppressed(channel=channel, address_kind=kind, address=value):\n            return True\n    return False<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>keyring.active_and_retired()<\/code> matters. If you rotated the HMAC key last year, older erased entries were hashed with the old key. You can&#8217;t rehash them (you deleted the numbers), so the gate has to try every key that still has entries. That&#8217;s why a retired HMAC key is never destroyed while any row references its <code>hmac_key_id<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Do you have to keep the provider-side Blocked Numbers entry too? If you added the number to the dashboard&#8217;s Blocked Numbers list, that list holds the plain number and has no API. You have two reasonable options. Keep it there, as a suppression record held for compliance, and note that in your erasure ledger. Or remove it by hand and rely entirely on your own gate. The second is cleaner on paper but means the provider no longer blocks a send that slips past your gate. Pick one, write it into the retention schedule, and do the same thing every time.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"consent-after-erasure\" class=\"wp-block-heading\">Consent Evidence After Erasure<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Section 6(10) of the Act puts the burden of proving notice and consent on the Data Fiduciary. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/consent-capture-evidence-sms-whatsapp-rcs-telegram\/\">consent log<\/a> exists for that: an append-only <code>consent_event<\/code> table plus a <code>notice_version<\/code> table with a SHA-256 of each notice text.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">When someone withdraws and asks to be erased, you&#8217;ll want two things that pull in opposite directions. You want the data gone. You also want to be able to answer a complaint that says &#8220;you messaged me without consent in March&#8221;.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The way through is the same pseudonymisation. Keep each consent event with the MSISDN replaced by its HMAC, keep <code>purpose<\/code>, <code>action<\/code>, <code>origin<\/code>, <code>capture_point<\/code>, <code>notice_id<\/code> and <code>occurred_at<\/code>, and drop anything else. If a complaint arrives, the complainant gives you their number, you compute the HMAC, and the history appears. Without the number, the history is unreadable.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">How long to keep pseudonymised consent events is a legal call, and it goes in <code>retention_rule<\/code> with a written basis like any other class. The engineering point is that this decision is independent of the erasure request. Erasure changes the form of the record. The retention rule decides its lifetime.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">There&#8217;s one thing you must not do: keep consent evidence in readable form &#8220;just in case&#8221;. That&#8217;s the exact pattern section 8(7) is written against.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"erasure-model\" class=\"wp-block-heading\">The Erasure Request Data Model<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">You need to track each request through several systems, retry the parts that fail, and show the result later without storing the person&#8217;s number in the tracking table. Four tables do it.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>-- One row per erasure request or purpose-served sweep hit.\nCREATE TABLE erasure_request (\n  request_id     TEXT PRIMARY KEY,                 -- your own reference, e.g. 'ER-2026-10-0042'\n  subject_hmac   TEXT NOT NULL,                    -- HMAC of the normalised number\n  hmac_key_id    TEXT NOT NULL,\n  trigger        TEXT NOT NULL CHECK (trigger IN ('withdrawal', 'purpose_served', 'account_closed')),\n  received_at    TEXT NOT NULL,\n  status         TEXT NOT NULL DEFAULT 'received' CHECK (status IN\n                   ('received', 'held', 'local_done', 'provider_api_done',\n                    'ticket_pending', 'ticket_filed', 'provider_confirmed', 'closed')),\n  batch_ref      TEXT,                             -- which provider ticket batch carried it\n  closed_at      TEXT\n);\n\n-- One row per thing that has to happen for a request.\nCREATE TABLE erasure_task (\n  request_id     TEXT NOT NULL REFERENCES erasure_request(request_id),\n  system         TEXT NOT NULL,                    -- 'local:outbound', 'provider:contact', 'provider:schedule', ...\n  target_ref     TEXT NOT NULL,                    -- a cId, a uuId, a mediaId, a table name. Never a phone number.\n  action         TEXT NOT NULL CHECK (action IN ('delete', 'redact', 'pseudonymise', 'request')),\n  status         TEXT NOT NULL DEFAULT 'pending' CHECK (status IN\n                   ('pending', 'done', 'failed', 'not_applicable')),\n  attempts       INTEGER NOT NULL DEFAULT 0,\n  last_error     TEXT,\n  done_at        TEXT,\n  PRIMARY KEY (request_id, system, target_ref)\n);\n\n-- Ingest paths check this to redact late arrivals.\nCREATE TABLE erased_subject (\n  subject_hmac   TEXT NOT NULL,\n  hmac_key_id    TEXT NOT NULL,\n  erased_at      TEXT NOT NULL,\n  PRIMARY KEY (subject_hmac, hmac_key_id)\n);\n\n-- Provider ticket batches.\nCREATE TABLE erasure_batch (\n  batch_ref      TEXT PRIMARY KEY,                 -- 'ERASURE-BATCH 2026-10-23-01'\n  ticket_id      TEXT,                             -- opaque string from support\/create\n  filed_at       TEXT,\n  last_polled_at TEXT,\n  ticket_status  TEXT,                             -- last seen 'open' | 'answered' | 'closed'\n  confirmed_at   TEXT,                             -- set by a person after reading the reply\n  confirmed_by   TEXT\n);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A few choices in there are deliberate.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>target_ref<\/code> never holds a phone number. It holds the provider&#8217;s ID for the thing you deleted, or the name of the local table you redacted. That way the task table can live as long as you need it to as evidence.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>(request_id, system, target_ref)<\/code> is the primary key, so re-running the planner for a request can&#8217;t create duplicate tasks. An <code>INSERT ... ON CONFLICT DO NOTHING<\/code> makes planning idempotent.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>held<\/code> status exists for <a href=\"#legal-holds\">legal holds<\/a>. A request on hold doesn&#8217;t progress until the hold is lifted, and the hold reason lives elsewhere.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>confirmed_at<\/code> and <code>confirmed_by<\/code> sit on the batch, and they&#8217;re filled in by a person. Polling can tell you a ticket was answered. Only a human can tell you the answer says &#8220;done&#8221;.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"contact-walk\" class=\"wp-block-heading\">Finding a Person&#8217;s Contacts Without a Search Endpoint<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><code>SMSApi\/contact\/read<\/code> takes your credentials and <code>output<\/code>. That&#8217;s it. No mobile filter, no group filter, no limit or offset. It returns every contact row in the account as <code>response.contactList[i].contact<\/code>, and <code>response.count<\/code> is the number of rows.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So to find one person you read everything and filter locally. Three details make that harder than it sounds.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>One person, many rows.<\/strong> Each row has its own <code>cId<\/code> and a <code>groupId<\/code>. The same <code>mobileNo<\/code> in four groups is four rows. You need all four <code>cId<\/code> values.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The number format isn&#8217;t guaranteed to match yours.<\/strong> The sample shows <code>mobileNo<\/code> as <code>\"919999999999\"<\/code>, country code plus ten digits. Contacts uploaded from a spreadsheet years ago might not follow that. Normalise both sides with the same function you use for hashing.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Every field is a string.<\/strong> <code>cId<\/code>, <code>groupId<\/code> and <code>contactStatus<\/code> are all quoted in the sample. Keep <code>cId<\/code> as a string when you pass it back as <code>id<\/code>.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def find_contact_ids(raw_msisdn, creds):\n    \"\"\"Return every cId whose mobileNo normalises to the same number. Raises on a failed read.\"\"\"\n    target = normalise_msisdn(raw_msisdn)\n    body = call(\"SMSApi\/contact\/read\", {}, **creds)\n    inner = _inner(body)\n    if inner.get(\"status\") != \"success\":\n        raise RuntimeError(\"contact read failed\")\n    ids = &#91;]\n    for row in inner.get(\"contactList\") or &#91;]:\n        contact = row.get(\"contact\") if isinstance(row, dict) else None\n        if not isinstance(contact, dict):\n            continue\n        if normalise_msisdn(contact.get(\"mobileNo\")) == target:\n            ids.append(str(contact.get(\"cId\")))\n    return ids<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">On a big account this read is heavy, and you&#8217;ll be calling it twice per person (once to find, once to confirm). Don&#8217;t do it per request. Run erasure in batches: read the contact list once, build a map from normalised number to <code>cId<\/code> list, process every pending request against that map, delete, then read once more to confirm.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">There&#8217;s a better long-term fix: stop treating provider contact lists as your source of truth. If your own database holds the list and you send with explicit recipient lists, there&#8217;s nothing at the provider to erase. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/contact-groups-segments-data-model-group-send\/\">contact groups guide<\/a> weighs group sends against explicit lists; erasure is one more point on the explicit-list side.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"erasure-worker\" class=\"wp-block-heading\">The Erasure Worker, Step by Step<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s the whole flow for one batch of requests. Each step writes its result to <code>erasure_task<\/code> before moving on, so a crash at any point resumes cleanly.<\/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-erasure-request-flow.svg\"><img decoding=\"async\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/diagram-erasure-request-flow.svg\" alt=\"Three panels showing an erasure request moving from your own systems, to provider deletes by API, to a provider erasure request filed as a support ticket, with banners for suppression, late arrivals and the ledger.\" class=\"wp-image-3110\"\/><\/a><figcaption class=\"wp-element-caption\">One request, three jobs. Your data first, then what the provider lets you delete, then a written request for the rest.<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step 1. Accept the request and hash the number straight away.<\/strong> Compute the HMAC, write <code>erasure_request<\/code> and <code>erased_subject<\/code>, and drop the plain number from memory. From here on, the worker only uses the HMAC and the number it reads back from your own tables.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step 2. Check for a legal hold.<\/strong> If the subject is under hold, set the request to <code>held<\/code> and stop. Nothing else happens until the hold is released.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step 3. Plan the tasks.<\/strong> Look up the subject in your own tables by HMAC (or by the plain number in rows that haven&#8217;t been pseudonymised yet) and insert one <code>erasure_task<\/code> per thing to do: local tables to redact, provider <code>cId<\/code> values to delete, pending booking <code>uuId<\/code> values to cancel, <code>mediaId<\/code> values to delete, and transaction IDs for the provider ticket.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step 4. Stop new sends first.<\/strong> Add the suppression entry (as an HMAC) before you delete anything. If you delete first and suppress second, a send can sneak through in the gap.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step 5. Cancel pending bookings.<\/strong> For each pending provider booking that includes the subject, call <code>SMSApi\/schedule\/delete<\/code> with <code>uuid<\/code>. If the booking has other recipients, rebook it for them without the subject before you cancel, and record both IDs.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step 6. Delete provider contacts and media.<\/strong> Call <code>SMSApi\/contact\/delete<\/code> for each <code>cId<\/code> and <code>WAApi\/media<\/code> DELETE for each <code>mediaId<\/code> still inside its 30-day window.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step 7. Read back.<\/strong> Re-read contacts and schedules and confirm the IDs are gone. Anything still present goes back to <code>pending<\/code> with an incremented attempt count.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step 8. Redact and pseudonymise locally.<\/strong> Apply the actions from <code>retention_rule<\/code> to every row of the subject&#8217;s data in your own tables.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step 9. Queue the provider request.<\/strong> Add the subject&#8217;s transaction IDs to today&#8217;s batch. File it with the ticket function from <a href=\"#support-ticket-api\">the support ticket section<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step 10. Close when confirmed.<\/strong> When a person marks the batch confirmed, every request in it moves to <code>closed<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here are steps 5 to 7 as code, using the <code>call<\/code> and <code>_inner<\/code> helpers from the support ticket section.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def run_provider_deletes(request_id, db, creds):\n    \"\"\"Steps 5 to 7: cancel bookings, delete contacts and media, then confirm by reading back.\"\"\"\n    tasks = db.pending_tasks(request_id, systems=(\"provider:schedule\", \"provider:contact\", \"provider:media\"))\n    for t in tasks:\n        if t.system == \"provider:schedule\":\n            body = call(\"SMSApi\/schedule\/delete\", {\"uuid\": t.target_ref}, **creds)\n            ok = _inner(body).get(\"status\") == \"success\"\n        elif t.system == \"provider:contact\":\n            body = call(\"SMSApi\/contact\/delete\", {\"id\": t.target_ref}, **creds)\n            ok = _inner(body).get(\"status\") == \"success\"\n        else:\n            body = call(\"WAApi\/media\", {\"mediaId\": t.target_ref}, method=\"DELETE\", **creds)\n            inner = _inner(body)\n            ok = inner.get(\"status\") == \"success\" and str(inner.get(\"mediaId\")) == t.target_ref\n        db.record_attempt(t, ok)\n\n    # Read back. A \"success\" reply is not proof; absence from a fresh read is.\n    contacts = _inner(call(\"SMSApi\/contact\/read\", {}, **creds))\n    schedules = _inner(call(\"SMSApi\/schedule\/read\", {}, **creds))\n    if contacts.get(\"status\") != \"success\" or schedules.get(\"status\") != \"success\":\n        return False  # can't confirm anything; leave tasks as they are and retry the read later\n    live_cids = {str((r.get(\"contact\") or {}).get(\"cId\")) for r in contacts.get(\"contactList\") or &#91;]}\n    live_uuids = {str((r.get(\"schedule\") or {}).get(\"uuId\")) for r in schedules.get(\"scheduleList\") or &#91;]}\n\n    for t in db.tasks(request_id, status=\"done\"):\n        still_there = (\n            (t.system == \"provider:contact\" and t.target_ref in live_cids) or\n            (t.system == \"provider:schedule\" and t.target_ref in live_uuids)\n        )\n        if still_there:\n            db.reopen(t, \"present on read-back\")\n    return True<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The status check before the read-back is the line people leave out. If the contact read fails, <code>contactList<\/code> is missing, <code>live_cids<\/code> is empty, and every delete looks confirmed. Failing reads must never count as proof of absence. A task marked <code>done<\/code> means the delete call was accepted; the request itself only moves to <code>provider_api_done<\/code> after a read-back that succeeded and came back clean.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"late-arrivals\" class=\"wp-block-heading\">Data That Arrives After Erasure<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">You&#8217;ve erased someone. Two days later a delivery report webhook arrives for a message you sent them last week. Then they reply to an old WhatsApp conversation. Neither is unusual, and both will put their number straight back into your database unless the ingest path knows to stop it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Handle it at write time. Every ingest path (delivery report webhook, report polling, inbox polling, SMS inbound callback, click sync) does the same check before it stores a row:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def guard_inbound_row(row, keyring, db):\n    \"\"\"Called by every ingest path before insert.\"\"\"\n    n = normalise_msisdn(row.get(\"mobile\") or row.get(\"mobileNo\") or row.get(\"phoneno\") or \"\")\n    if not n:\n        return row\n    for key in keyring.active_and_retired():\n        h = hmac_hex(key.secret, n)\n        if db.is_erased(h, key.id):\n            row = dict(row)\n            for field in (\"mobile\", \"mobileNo\", \"phoneno\"):\n                if field in row:\n                    row&#91;field] = None\n            for field in (\"msg\", \"message\", \"content\", \"text\"):\n                if field in row:\n                    row&#91;field] = None\n            row&#91;\"subject_hmac\"], row&#91;\"hmac_key_id\"] = h, key.id\n            return row\n    return row<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Delivery reports for erased subjects still update status and cost on the outbound row (by transaction ID), so your invoice reconciliation stays whole. They just never write the number or the body again.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Inbound messages need one more decision. If an erased person messages you, they&#8217;ve approached you again. A STOP still has to be honoured, and the suppression entry already covers it. Anything else is a new conversation. Route it, but don&#8217;t stitch it back to the old history: the old history is pseudonymised, and the new message starts a new record. If the person later asks to be erased again, you run a second request against the new data.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Store the field names your ingest paths actually receive. The SMS inbound callback template uses <code>phoneno<\/code> and <code>content<\/code>. WhatsApp report rows use <code>mobileNo<\/code>. The guard above checks several names because one guard serves every path; keep that list in sync with the parsers in the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/receiving-messages-four-channel-inbox-contracts\/\">inbox guide<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"retention-jobs\" class=\"wp-block-heading\">Retention Jobs You Can Trust<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The erasure worker handles one person. The retention job handles everyone, every night, and it&#8217;s the one that quietly satisfies the &#8220;purpose no longer served&#8221; trigger. It&#8217;s also the job most likely to delete the wrong thing, so build it carefully.<\/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-retention-actions-table.svg\"><img decoding=\"async\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/diagram-retention-actions-table.svg\" alt=\"A table of six data classes against five columns: what you do in your database, what you can delete by API, what needs a provider request, what survives, and what starts the clock.\" class=\"wp-image-3111\"\/><\/a><figcaption class=\"wp-element-caption\">Each data class gets its own action. Very little is simply deleted; most is redacted or pseudonymised so the numbers still add up.<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Partition by time and drop partitions where you can.<\/strong> For the big tables (outbound messages, delivery reports) a monthly partition that ages out is a metadata operation instead of a mass delete. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/outbound-message-table-schema-design\/\">outbound table guide<\/a> covers the partitioning. Use it for <code>delete<\/code> rules. <code>redact<\/code> and <code>pseudonymise<\/code> rules still need row updates, so run them in batches.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Batch, commit, repeat.<\/strong> Update a few thousand rows per transaction, commit, sleep briefly, continue. One giant update holds locks for hours and blocks the send path that writes to the same table.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Make every run idempotent.<\/strong> A redaction sets the body to <code>NULL<\/code> and stamps <code>redacted_at<\/code>. The next run&#8217;s <code>WHERE<\/code> clause skips rows with <code>redacted_at<\/code> set. Run the job twice by mistake and nothing changes.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>-- Example: redact message bodies older than the rule allows, in batches.\n-- :keep_days comes from retention_rule where data_class = 'message_body'.\nUPDATE outbound_message\nSET body = NULL,\n    redacted_at = CURRENT_TIMESTAMP\nWHERE id IN (\n  SELECT id FROM outbound_message\n  WHERE redacted_at IS NULL\n    AND created_at &lt; datetime('now', '-' || :keep_days || ' days')\n    AND legal_hold_id IS NULL\n  LIMIT 5000\n);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Dry-run first, then compare.<\/strong> Before each run, count the rows the job is about to touch, per rule. If today&#8217;s count is ten times yesterday&#8217;s, stop and page someone. A typo in a rule (<code>keep_for_days<\/code> of 3 instead of 30) shows up as a huge spike, and that&#8217;s your one chance to catch it before the data&#8217;s gone.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Never let the job touch suppression rows.<\/strong> Suppression has its own rule (pseudonymise, never delete). Put a hard-coded exclusion in the job as well as the rule, so a bad row in <code>retention_rule<\/code> can&#8217;t delete your opt-out list.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Log what you did, not who you did it to.<\/strong> Each run writes one row per rule: rule version, rows matched, rows changed, start and end time, and whether it stopped early. That&#8217;s your evidence that retention runs. It contains no personal data at all.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Run the &#8220;purpose served&#8221; sweep alongside.<\/strong> The time-based rules handle old rows. The sweep handles relationships that ended: orders closed, accounts deleted, campaigns finished. For each one it opens an <code>erasure_request<\/code> with <code>trigger = 'purpose_served'<\/code>, and from there it&#8217;s the same pipeline as a customer request.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"legal-holds\" class=\"wp-block-heading\">Legal Holds and Disputes<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Sometimes you must not delete. A customer disputes a charge and says they never got the OTP. A regulator asks about a campaign. Litigation is threatened. In each case the relevant records have to survive until the matter&#8217;s over, even if a retention rule or an erasure request says otherwise.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That&#8217;s the &#8220;unless retention is necessary for compliance with any law&#8221; clause again, and it needs a mechanism, not a promise.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>CREATE TABLE legal_hold (\n  hold_id       TEXT PRIMARY KEY,\n  subject_hmac  TEXT,              -- a person, or NULL for a scope-wide hold\n  scope         TEXT NOT NULL,     -- 'subject' | 'campaign:&lt;key&gt;' | 'date_range:&lt;from&gt;\/&lt;to&gt;'\n  reason        TEXT NOT NULL,     -- short, no personal data\n  placed_by     TEXT NOT NULL,\n  placed_at     TEXT NOT NULL,\n  released_at   TEXT\n);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Rows covered by a hold get its <code>hold_id<\/code> in a <code>legal_hold_id<\/code> column, and both the retention job and the erasure worker skip them (look back at the <code>AND legal_hold_id IS NULL<\/code> line above). An erasure request for a held subject goes to <code>held<\/code> and waits.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two rules keep this from turning into a loophole. Holds are placed and released by named people, never by code. And every open hold shows up on a weekly report, so a hold that should have been released six months ago gets noticed.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">When the hold&#8217;s released, the normal rules apply at the next run. Anything that was already past its retention date gets processed straight away, and a pending erasure request picks up where it stopped.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"proving-it\" class=\"wp-block-heading\">Proving It Happened<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">When someone asks &#8220;did you delete my data?&#8221;, or an auditor asks &#8220;how do you know retention runs?&#8221;, you want an answer that doesn&#8217;t require keeping the data you deleted.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Your evidence is four things:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>The erasure request row<\/strong>, with its HMAC, trigger, timestamps and final status. Give the person their <code>request_id<\/code> when you acknowledge the request; that&#8217;s what they&#8217;ll quote back.<\/li>\n\n\n\n<li><strong>The task rows<\/strong>, showing each system touched, the provider ID used, the action, and when it finished.<\/li>\n\n\n\n<li><strong>The batch row<\/strong>, with the provider ticket ID, the dates it was filed and confirmed, and who confirmed it.<\/li>\n\n\n\n<li><strong>The retention run log<\/strong>, showing that the scheduled rules ran and what they changed.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">None of those contain a phone number or a message body. Someone holding the person&#8217;s number can compute the HMAC and find their request. Nobody else can tell whose it was.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Protect the ledger from quiet edits. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-account-security-audit-trail-activity-logs-connected-devices\/\">account security audit trail guide<\/a> builds a SHA-256 hash chain over activity log rows, with an insertion-order column so rows written in the same second still verify. The same technique works here: chain the <code>erasure_request<\/code> status changes and the retention run rows, and verify the chain on a schedule. The <a href=\"https:\/\/cheatsheetseries.owasp.org\/cheatsheets\/Logging_Cheat_Sheet.html\" target=\"_blank\" rel=\"noopener nofollow\">OWASP Logging Cheat Sheet<\/a> makes the same two points from the security side: give the log writer the least privilege it needs, and keep personal data out of logs in the first place.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One more thing to prove: that the guard on your ingest paths works. Once a month, take an erased test subject (a number you own, put through the full pipeline), send it a message, let the report and a reply come back, and check that neither the number nor the text landed in your tables.<\/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<p class=\"wp-block-paragraph\">Use this when you&#8217;re deciding what to do with a specific kind of data.<\/p>\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>Not this<\/th><\/tr><\/thead><tbody><tr><td>Person withdraws consent and asks for erasure<\/td><td>Suppress by HMAC first, then run the full pipeline<\/td><td>Delete everything including the suppression row<\/td><\/tr><tr><td>Order or service relationship ends<\/td><td>Open a <code>purpose_served<\/code> request from the sweep<\/td><td>Wait for the customer to ask<\/td><\/tr><tr><td>Person is in a pending multi-recipient provider booking<\/td><td>Rebook for the others, then cancel the original<\/td><td>Cancel without rebooking, or leave them in<\/td><\/tr><tr><td>WhatsApp media with personal content was sent<\/td><td>Delete it once the send settles, inside 30 days<\/td><td>Leave it until an erasure request arrives<\/td><\/tr><tr><td>Provider holds delivery reports you can&#8217;t delete<\/td><td>File a ticket with transaction IDs, track to confirmation<\/td><td>Paste phone numbers into the ticket<\/td><\/tr><tr><td>Delivery report arrives for an erased subject<\/td><td>Update status and cost by transaction ID, drop number and body<\/td><td>Store it as normal and clean up later<\/td><\/tr><tr><td>Erased person messages you again<\/td><td>Honour STOP via suppression; treat anything else as new<\/td><td>Re-link to their old pseudonymised history<\/td><\/tr><tr><td>You need consent proof after erasure<\/td><td>Keep consent events with the number as HMAC<\/td><td>Keep a readable copy &#8220;just in case&#8221;<\/td><\/tr><tr><td>Dispute or regulator enquiry<\/td><td>Place a legal hold; erasure waits<\/td><td>Pause the retention job for everyone<\/td><\/tr><tr><td>Rotating the HMAC key<\/td><td>Keep the old key while any row uses its <code>hmac_key_id<\/code><\/td><td>Destroy the old key after rotation<\/td><\/tr><tr><td>Provider shows &#8220;deleted&#8221; in a UI<\/td><td>Check whether it&#8217;s a soft delete before counting it<\/td><td>Record it as erasure in the ledger<\/td><\/tr><tr><td>Retention job count jumps tenfold<\/td><td>Stop the job and check the rule<\/td><td>Let it run and investigate tomorrow<\/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=\"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 behaviour in this area isn&#8217;t pinned down by anything you can read: how long the provider keeps report rows, exactly what a delete does underneath, how the ticket system decides two messages are &#8220;similar&#8221;. Each item below gives you the choice that stays safe whichever way the answer goes. None of them need you to wait for an answer before you ship.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>One. Assume provider-side report and inbox rows exist until someone confirms in writing that they don&#8217;t.<\/strong> Keep every request at <code>ticket_filed<\/code> until a person marks the batch confirmed. If the provider keeps reports for a short window, the ticket costs you nothing. If it keeps them for years, the ticket is the only thing that gets them removed.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Two. Re-read contacts after every delete and loop until no row matches.<\/strong> Whether <code>contact\/delete<\/code> removes one membership row or every row for that number, the read-back answers the question for you. Delete by <code>cId<\/code>, read, delete whatever still matches, and stop when the read comes back clean.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Three. Send transaction IDs to the provider, never phone numbers.<\/strong> Whether or not support tickets are ever purged, a ticket that holds no numbers has nothing to purge. The provider can find report rows by <code>uuId<\/code> or <code>transactionId<\/code>; your outbound table has them already.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Four. Send the WhatsApp media delete the way the sample does, then read it back.<\/strong> The delete media page says POST in its intro and uses DELETE with query parameters in its sample. Use the sample&#8217;s form, then call read by media ID. If the media&#8217;s still there, retry with the other method once and log which one worked. Whichever the API accepts, the read-back tells you whether the file is gone.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Five. Keep <code>mediaId<\/code> and <code>ticketId<\/code> as strings everywhere.<\/strong> The table calls <code>mediaId<\/code> an integer and the sample is nineteen digits. The ticket ID is described as UUID format and the sample is sixteen digits. Strings survive both descriptions, any JSON parser and any database column type.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Six. Never leave an erased subject inside a provider booking.<\/strong> You can&#8217;t read the recipients of a booking, and you can&#8217;t remove one recipient from it. So treat any booking that includes the subject as contaminated: rebook for the others, cancel the original, confirm with <code>schedule\/read<\/code>. For the future, keep far-ahead multi-recipient sends in your own scheduler, where you can edit the list.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Seven. Redact late arrivals at write time, whatever the provider does with its own copy.<\/strong> It doesn&#8217;t matter how long the provider keeps sending reports for old messages, or whether a reply arrives months later. The guard on your ingest paths makes your copy correct either way.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Eight. Branch on <code>status<\/code>, not <code>code<\/code>, on every erasure-related call.<\/strong> <code>code<\/code> is a quoted string on contact delete, an integer on support calls, and the support error range 448 to 456 overlaps the range used by the connected-devices logout calls. <code>status<\/code> is &#8220;success&#8221; on all of them when things worked.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Nine. Count a UI delete as erasure only when the help text says the row is gone.<\/strong> The Workflow Builder delete is documented as a soft delete. Recurring campaign deletion is documented as preserving execution reports and transaction IDs. If a delete&#8217;s behaviour isn&#8217;t described, put that data class in the provider ticket as well. An extra line in a ticket is cheap; a missed copy isn&#8217;t.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Ten. Batch tickets daily and lead every message with a unique batch reference.<\/strong> Code 434 rejects &#8220;similar&#8221; open tickets and 443 rejects &#8220;similar&#8221; replies, and neither defines similar. A unique first line (<code>ERASURE-BATCH 2026-10-23-01<\/code>) plus a part number makes each message different in the way most similarity checks look at first. If a 434 still comes back, reply on the open ticket instead of creating a new one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Eleven. Store the HMAC key ID with every pseudonym and never destroy a key that&#8217;s still referenced.<\/strong> You can&#8217;t rehash erased data because you no longer have the input. The gate has to try every key that has live rows. A key ID column makes that cheap and makes it safe to rotate.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Twelve. Decide once what happens to the dashboard Blocked Numbers entry, and do the same thing every time.<\/strong> There&#8217;s no API to read or change that list. Whether you keep the entry as a compliance record or remove it by hand, write the choice into <code>retention_rule<\/code> and have the erasure checklist include the manual step, so it&#8217;s never decided case by case.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"faqs\" class=\"wp-block-heading\">FAQs<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Does erasing someone mean deleting their opt-out?<\/strong><br>No. Keep the opt-out as a pseudonymised suppression entry (an HMAC of the normalised number) and check every send against it. Deleting it means the next list import can message them again, which breaks the opt-out rule you were trying to honour.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Can I just hash phone numbers with SHA-256 and call them anonymous?<\/strong><br>No. There are only about four billion possible Indian mobile numbers, so anyone can hash them all and reverse an unkeyed SHA-256. Use HMAC-SHA256 with a secret key held outside the database, and store the key ID with each hash.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Is there an API to delete delivery reports on SMSGatewayCenter?<\/strong><br>Not directly. The report endpoints are read-only. Delete your own copy, then file a provider-side erasure request through <code>SMSApi\/support\/create<\/code> listing the transaction IDs and dates, and track it with <code>SMSApi\/support\/read<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I find all of a person&#8217;s contacts if <code>contact\/read<\/code> has no search?<\/strong><br>Read the whole contact list, normalise every <code>mobileNo<\/code> with the same function you use for hashing, and collect every <code>cId<\/code> that matches. One person can be several rows, one per group. Delete each <code>cId<\/code>, then read again to confirm.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What happens if I delete a scheduled booking that has other recipients?<\/strong><br>It cancels the message for everyone in it. <code>schedule\/update<\/code> only changes the time, so you can&#8217;t remove one person. Rebook for the others first, then cancel the original. Better still, keep long-range multi-recipient schedules in your own scheduler.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>When should I delete WhatsApp media?<\/strong><br>As soon as the send has settled. The delete media page says media can only be deleted within 30 days of upload, so waiting for an erasure request can leave you unable to delete it at all.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Do I need to wait for a customer request to erase data?<\/strong><br>No. Section 8(7) of the DPDP Act also triggers erasure &#8220;as soon as it is reasonable to assume that the specified purpose is no longer being served&#8221;. Run a scheduled sweep that opens erasure requests when orders close, accounts end or campaigns finish.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How long should I keep message bodies and delivery reports?<\/strong><br>That&#8217;s a business and legal decision per data class. Store each period in a <code>retention_rule<\/code> table with a written basis, and let your jobs read it. Many teams redact bodies well before they pseudonymise delivery rows, because reconciliation needs status and cost long after nobody needs the text.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What if a customer disputes a message after I&#8217;ve erased them?<\/strong><br>Ask for their number, compute the HMAC, and look up the pseudonymised history: consent events, delivery status, timestamps. If a dispute is already open when the erasure request arrives, place a legal hold and the request waits until the matter&#8217;s resolved.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Does logging out every device or rotating my API key erase anything?<\/strong><br>No. Those change who can get into the account, not the message data you&#8217;ve already sent through it. Recipient data needs the deletes and the provider request described above.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\"><strong>Build erasure into your messaging stack from day one.<\/strong><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Try the SMS, WhatsApp, RCS and Telegram APIs in the <a href=\"https:\/\/www.smsgatewaycenter.com\/demo\/\">SMSGatewayCenter sandbox<\/a>, wire up the delete and support ticket endpoints, and check your erasure pipeline end to end before a real request arrives. Questions about your setup? <a href=\"https:\/\/www.smsgatewaycenter.com\/contact\/\">Get in touch<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">More from our blog<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-account-security-audit-trail-activity-logs-connected-devices\/\">SMS Account Security Audit Trail: Activity Logs, Login Alerts and Connected Devices via API<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-whatsapp-preference-centre-design-purposes-channels\/\">Building an SMS and WhatsApp Preference Centre: Purposes, Channels, Frequency and the Send Gate<\/a><\/p>\n\n\n\n<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<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n","protected":false},"excerpt":{"rendered":"<p>A working design for retention and right-to-erasure in a messaging stack: what you can delete at the provider by API, what only a written request removes, how to keep opt-outs working after you erase someone, and how to prove all of it without keeping the data you just deleted.<\/p>\n","protected":false},"author":118,"featured_media":3109,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[2010],"tags":[2362,2359,2227,2320,2361,2360,2234,2358,481,2306,2252,632],"class_list":["post-3108","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-developer-guides","tag-compliance-engineering","tag-data-deletion","tag-data-retention","tag-dpdp-act","tag-hmac","tag-pseudonymisation","tag-rcs","tag-right-to-erasure","tag-sms-api","tag-suppression-list","tag-telegram","tag-whatsapp-business-api"],"_links":{"self":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/3108","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=3108"}],"version-history":[{"count":0,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/3108\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media\/3109"}],"wp:attachment":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media?parent=3108"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/categories?post=3108"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/tags?post=3108"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}