{"id":3075,"date":"2026-10-12T13:48:25","date_gmt":"2026-10-12T08:18:25","guid":{"rendered":"https:\/\/www.smsgatewaycenter.com\/blog\/?p=3075"},"modified":"2026-10-12T13:48:28","modified_gmt":"2026-10-12T08:18:28","slug":"sms-click-tracking-attribution-smart-links","status":"publish","type":"post","link":"https:\/\/www.smsgatewaycenter.com\/blog\/sms-click-tracking-attribution-smart-links\/","title":{"rendered":"SMS Click Tracking and Attribution: Joining Smart Link Clicks to Messages and Recipients"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">A click count on a dashboard isn&#8217;t attribution. This guide shows how to wire the four <code>SMSApi\/linktrack<\/code> endpoints and the send-time <code>trackLink<\/code> switch into a click ledger you own, join every click back to a message and a recipient, filter preview and scanner clicks, and compute a click rate you can defend.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/sms-click-tracking-attribution-smart-links.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"584\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/sms-click-tracking-attribution-smart-links-1024x584.webp\" alt=\"Abstract diagram of messages passing through a short link and joining into a grid of records\" class=\"wp-image-3076\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/sms-click-tracking-attribution-smart-links-1024x584.webp 1024w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/sms-click-tracking-attribution-smart-links-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/sms-click-tracking-attribution-smart-links-768x438.webp 768w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/sms-click-tracking-attribution-smart-links.webp 1200w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><figcaption class=\"wp-element-caption\">A click only becomes attribution once it joins back to a message and a recipient in a table you own.<\/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=\"#two-paths\">Two Ways to Get a Tracked Link<\/a><\/li>\n\n\n\n<li><a href=\"#four-endpoints\">The Four Linktrack Endpoints at a Glance<\/a><\/li>\n\n\n\n<li><a href=\"#create-and-find\">Creating a Link and Finding Its lcId<\/a><\/li>\n\n\n\n<li><a href=\"#send-time-tracking\">Turning On Tracking at Send Time<\/a><\/li>\n\n\n\n<li><a href=\"#message-length\">What the Short URL Does to Message Length<\/a><\/li>\n\n\n\n<li><a href=\"#dlt-whitelist\">DLT: Whitelist the Short Domain, Not Just Yours<\/a><\/li>\n\n\n\n<li><a href=\"#visitor-report\">Reading Clicks Back: The Visitor Report<\/a><\/li>\n\n\n\n<li><a href=\"#parsing-sentinels\">Parsing the Sentinels: &#8220;0&#8221;, &#8220;NA&#8221; and &#8220;0%&#8221;<\/a><\/li>\n\n\n\n<li><a href=\"#data-model\">The Attribution Data Model<\/a><\/li>\n\n\n\n<li><a href=\"#click-join\">Joining a Click to a Message and a Recipient<\/a><\/li>\n\n\n\n<li><a href=\"#polling\">Polling Without a Click Webhook<\/a><\/li>\n\n\n\n<li><a href=\"#bot-clicks\">Bot and Preview Clicks<\/a><\/li>\n\n\n\n<li><a href=\"#click-rate\">Computing a Click Rate You Can Defend<\/a><\/li>\n\n\n\n<li><a href=\"#personal-data\">Clicks Are Personal Data<\/a><\/li>\n\n\n\n<li><a href=\"#deleting-links\">Deleting Links Safely<\/a><\/li>\n\n\n\n<li><a href=\"#decision-matrix\">Decision Matrix<\/a><\/li>\n\n\n\n<li><a href=\"#checklist\">Pre-Launch Checklist<\/a><\/li>\n\n\n\n<li><a href=\"#unspecified-behaviour\">Unspecified Behaviour and How to Code Around It<\/a><\/li>\n\n\n\n<li><a href=\"#faqs\">FAQs<\/a><\/li>\n<\/ol>\n\n\n\n<h2 id=\"the-short-answer\" class=\"wp-block-heading\">The Short Answer<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">You get <a href=\"https:\/\/www.smsgatewaycenter.com\/smart-link-tracking\/\">tracked links<\/a> on <strong>SMS Gateway Center<\/strong> two ways. Either you turn on <code>trackLink=true<\/code> when you call <code>SMSApi\/send<\/code>, and the platform swaps the last URL in your message for a short link, or you create a link up front with <code>SMSApi\/linktrack\/create<\/code> and put the short URL into the message yourself. Clicks come back through <code>SMSApi\/linktrack\/report<\/code>, one row per visit, with a <code>ciId<\/code>, a <code>mobileNo<\/code>, device and network details and an epoch-millisecond <code>timestamp<\/code>. There&#8217;s no click webhook, so you poll.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The dashboard will happily show you totals. Attribution is a different job: it means every click row ends up joined to the message that carried the link and the recipient who got it, in a table you own. To get there you need four things. A link ledger keyed on the platform&#8217;s <code>lcId<\/code>. A click ledger keyed on <code>ciId<\/code>, deduplicated. A join from <code>(lcId, mobileNo)<\/code> to your outbound message rows. And a flag on every click that might be a link preview or a security scanner rather than a person, because on SMS a lot of them are.<\/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>Two paths.<\/strong> Send-time <code>trackLink=true<\/code> (plus <code>smartLinkTitle<\/code> and <code>appendHttp<\/code>) on <code>SMSApi\/send<\/code>, or a standalone link from <code>SMSApi\/linktrack\/create<\/code>. Only the send-time path ties clicks to recipients&#8217; mobile numbers, per the product page.<\/li>\n\n\n\n<li><strong>Only the last URL in a message gets converted<\/strong> at send time. Put the one you care about last.<\/li>\n\n\n\n<li><strong>Create returns the short URL and no id.<\/strong> The response is <code>linktrackList: [\"http:\/\/t1x.in\/WypK\"]<\/code>. Read the list back with <code>SMSApi\/linktrack\/read<\/code> and match on a unique <code>identifier<\/code> you chose, to find the <code>lcId<\/code>.<\/li>\n\n\n\n<li><strong>Read has no filters.<\/strong> It takes credentials and <code>output<\/code> only. Pull the list and filter on your side.<\/li>\n\n\n\n<li><strong>Report takes <code>campaignid<\/code><\/strong> and returns rows with quoted <code>ciId<\/code>, <code>mobileNo<\/code>, <code>timestamp<\/code>. A <code>mobileNo<\/code> of <code>\"0\"<\/code> means the click isn&#8217;t tied to a number. <code>\"NA\"<\/code> shows up for unknown location and operator.<\/li>\n\n\n\n<li><strong>Delete is keyed <code>id<\/code><\/strong> and succeeds with <code>code<\/code> <code>\"192\"<\/code>, not <code>\"200\"<\/code>.<\/li>\n\n\n\n<li><strong>Short links are 21 to 23 characters with <code>https:\/\/<\/code>, 13 to 15 without.<\/strong> That can take a message from two segments to one. It can also lengthen a message whose URL was already short.<\/li>\n\n\n\n<li><strong>Whitelist the short link&#8217;s domain on your DLT portal<\/strong>, not just your own website&#8217;s.<\/li>\n\n\n\n<li><strong>Store ids and timestamps as text, parse at the edge, treat <code>\"0\"<\/code> and <code>\"NA\"<\/code> as null.<\/strong><\/li>\n\n\n\n<li><strong>Flag suspected bot clicks; never delete them.<\/strong> Previews and scanners fetch links within seconds of delivery.<\/li>\n\n\n\n<li><strong>Your click rate is unique human-flagged clicking recipients over delivered recipients<\/strong>, computed from your own tables, not the <code>clickThroughRate<\/code> string.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"two-paths\" class=\"wp-block-heading\">Two Ways to Get a Tracked Link<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Start with this choice, because it decides what you can attribute later.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Send-time tracking.<\/strong> You call <code>SMSApi\/send<\/code> with <code>trackLink=true<\/code>. The send page describes it in one line: &#8220;Enable true to track links from your SMS content.&#8221; You can add <code>smartLinkTitle<\/code> (&#8220;A brief title to recognise your smart link campaign&#8221;) and <code>appendHttp<\/code> (default <code>true<\/code>; set it to <code>false<\/code> to drop <code>https:\/\/<\/code> from the short link). The platform finds the URL in your message, shortens it and tracks the clicks. The <a href=\"https:\/\/www.smsgatewaycenter.com\/smart-link-tracking\/\">Smart Link Tracker product page<\/a> says the system tracks &#8220;each click with unique identifiers tied to the recipient&#8217;s mobile number.&#8221; That&#8217;s the path that gives you per-recipient clicks.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Standalone links.<\/strong> You call <code>SMSApi\/linktrack\/create<\/code> with a <code>longurl<\/code>, an <code>identifier<\/code> and a <code>type<\/code> of <code>url<\/code> or <code>attachment<\/code>, and you get back a short URL. You then paste that URL into whatever you&#8217;re sending. Every recipient gets the same short URL, so the platform has nothing in the link itself that says who tapped it. Expect clicks on these links to come back as campaign-level traffic. The report sample shows exactly that shape: a desktop click with <code>mobileNo<\/code> set to <code>\"0\"<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One rule from the product page FAQ matters on the send-time path: &#8220;We will only convert SINGLE URL which is at the end of the message when system finds multiple URL in the message content.&#8221; If your message has a help link and an offer link, only the last one gets tracked. Put the URL you want to measure last, or create a standalone link for the other one and accept that it&#8217;ll be campaign-level.<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th><\/th><th>Send-time <code>trackLink=true<\/code><\/th><th>Standalone <code>linktrack\/create<\/code><\/th><\/tr><\/thead><tbody><tr><td>Where you turn it on<\/td><td><code>SMSApi\/send<\/code> parameter<\/td><td>Separate call before sending<\/td><\/tr><tr><td>What gets shortened<\/td><td>The last URL in the message<\/td><td>The <code>longurl<\/code> you pass, or an uploaded attachment<\/td><\/tr><tr><td>Per-recipient clicks<\/td><td>Yes, per the product page<\/td><td>Not from the link alone<\/td><\/tr><tr><td>Works for attachments<\/td><td>No<\/td><td>Yes, <code>type=attachment<\/code> with form-data<\/td><\/tr><tr><td>What you know up front<\/td><td>Nothing until the send goes out<\/td><td>The short URL, before you send<\/td><\/tr><tr><td>Best for<\/td><td>Campaigns where you care who clicked<\/td><td>Shared links, attachments, non-SMS channels<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/ai-location-link-tracking-for-sms-campaigns\/\">AI-powered link tracking announcement<\/a> covers the dashboard side (geography, device and the insights tab). This guide is about the API side and the tables you build around it.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"four-endpoints\" class=\"wp-block-heading\">The Four Linktrack Endpoints at a Glance<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">All four live under <code>https:\/\/unify.smsgateway.center\/SMSApi\/linktrack\/<\/code>. They take the usual credentials: <code>userid<\/code> and <code>password<\/code> as form fields, or the <code>apikey<\/code> header with <code>userid<\/code>. Send the header. Every response wraps its body in <code>response<\/code> with <code>api<\/code> set to <code>\"linktrack\"<\/code> and <code>action<\/code> set to the operation.<\/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 input<\/th><th>What comes back<\/th><\/tr><\/thead><tbody><tr><td><code>SMSApi\/linktrack\/create<\/code><\/td><td>POST<\/td><td><code>identifier<\/code>, <code>longurl<\/code>, <code>type<\/code> (<code>url<\/code> or <code>attachment<\/code>), optional <code>attachment<\/code> as form-data<\/td><td><code>msg<\/code> &#8220;Campaign created successfully.&#8221;, <code>code<\/code> &#8220;200&#8221;, <code>count<\/code> 1, <code>linktrackList<\/code> as an array of short URL strings<\/td><\/tr><tr><td><code>SMSApi\/linktrack\/read<\/code><\/td><td>POST or GET<\/td><td>Credentials and <code>output<\/code> only<\/td><td><code>linktrackList[i].linktrack<\/code> with <code>lcId<\/code>, <code>identifier<\/code>, <code>destination<\/code>, <code>shortUrl<\/code>, <code>shortUrlAdvance<\/code>, <code>type<\/code>, <code>totalUploads<\/code>, <code>totalViewed<\/code>, <code>clickThroughRate<\/code>, <code>lastClickTimestamp<\/code>, <code>timestamp<\/code><\/td><\/tr><tr><td><code>SMSApi\/linktrack\/report<\/code><\/td><td>POST<\/td><td><code>campaignid<\/code><\/td><td><code>linktrackList[i].linktrack<\/code> with <code>ciId<\/code>, <code>mobileNo<\/code>, <code>os<\/code>, <code>browser<\/code>, <code>device<\/code>, <code>deviceName<\/code>, <code>ip<\/code>, <code>city<\/code>, <code>state<\/code>, <code>country<\/code>, <code>mobLocation<\/code>, <code>mobOperator<\/code>, <code>timestamp<\/code><\/td><\/tr><tr><td><code>SMSApi\/linktrack\/delete<\/code><\/td><td>POST<\/td><td><code>id<\/code><\/td><td><code>msg<\/code> &#8220;Campaign deleted successfully.&#8221;, <code>code<\/code> &#8220;192&#8221;<\/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-linktrack-endpoints.svg\"><img decoding=\"async\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/diagram-linktrack-endpoints.svg\" alt=\"Table comparing the four linktrack endpoints and send-time trackLink by method, key input, identifier returned, success code, list shape and main trap\" class=\"wp-image-3078\"\/><\/a><figcaption class=\"wp-element-caption\">Create hands back a URL, not an id. Read has no filters. Report&#8217;s mobileNo can be &#8220;0&#8221;. Delete succeeds with &#8220;192&#8221;.<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Three things in that table will bite you if you skip them.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The list key is the same, <code>linktrackList<\/code>, on all three list-returning endpoints, but the row shape isn&#8217;t. On create it&#8217;s an array of plain strings. On read and report each row is wrapped in an object keyed <code>linktrack<\/code>. A single parser that assumes one shape breaks on the other.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Delete&#8217;s success <code>code<\/code> is <code>\"192\"<\/code>. If your client treats anything other than <code>\"200\"<\/code> as failure, a successful delete looks like an error. Read the <code>status<\/code> field (&#8220;success&#8221;) instead, which is what you should be doing everywhere anyway.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">And create gives you no <code>lcId<\/code>. The next section is about that.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"create-and-find\" class=\"wp-block-heading\">Creating a Link and Finding Its lcId<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s the create sample response, verbatim:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n    \"response\": {\n        \"api\": \"linktrack\",\n        \"action\": \"create\",\n        \"status\": \"success\",\n        \"msg\": \"Campaign created successfully.\",\n        \"code\": \"200\",\n        \"count\": 1,\n        \"linktrackList\": &#91;\n            \"http:\/\/t1x.in\/WypK\"\n        ]\n    }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">You get the short URL. You don&#8217;t get the <code>lcId<\/code>, and the <code>lcId<\/code> is what you&#8217;ll want as the key in your link ledger and, most likely, as the <code>campaignid<\/code> when you ask for the visitor report. So you read the list back and find your row.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Read takes no filter parameters, so you can&#8217;t ask for one link. You get the list and pick yours out. The safe way to pick is by <code>identifier<\/code>, which is free text you supplied (&#8220;Enter a breif desciption to remember smart link campaign URL&#8221;). Make it unique and machine-readable: put your own key in it. <code>ship-2026-10-12-7f3a<\/code> beats <code>October shipping<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Match on both <code>identifier<\/code> and <code>shortUrl<\/code>. If exactly one row matches both, you&#8217;ve got your <code>lcId<\/code>. If none or several match, stop and don&#8217;t guess.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import requests\n\nBASE = \"https:\/\/unify.smsgateway.center\/SMSApi\/linktrack\/\"\n\ndef post(path, userid, apikey, **fields):\n    r = requests.post(\n        BASE + path,\n        headers={\"apikey\": apikey},\n        data={\"userid\": userid, \"output\": \"json\", **fields},\n        timeout=30,\n    )\n    r.raise_for_status()\n    body = r.json()\n    resp = body.get(\"response\") if isinstance(body, dict) else None\n    if not isinstance(resp, dict) or resp.get(\"status\") != \"success\":\n        raise RuntimeError(f\"linktrack\/{path} failed: {body!r}\")\n    return resp\n\ndef create_and_resolve(userid, apikey, identifier, long_url):\n    created = post(\"create\", userid, apikey,\n                   identifier=identifier, longurl=long_url, type=\"url\")\n    urls = created.get(\"linktrackList\") or &#91;]\n    if len(urls) != 1 or not isinstance(urls&#91;0], str):\n        raise RuntimeError(f\"unexpected create payload: {created!r}\")\n    short_url = urls&#91;0]\n\n    listing = post(\"read\", userid, apikey)\n    rows = &#91;item.get(\"linktrack\", {}) for item in listing.get(\"linktrackList\") or &#91;]\n            if isinstance(item, dict)]\n    hits = &#91;row for row in rows\n            if row.get(\"identifier\") == identifier and row.get(\"shortUrl\") == short_url]\n    if len(hits) != 1:\n        raise RuntimeError(f\"{len(hits)} rows match {identifier!r}; not guessing\")\n    return {\"lc_id\": str(hits&#91;0]&#91;\"lcId\"]), \"short_url\": short_url,\n            \"destination\": hits&#91;0].get(\"destination\")}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two traps in that code are deliberate. The <code>lcId<\/code> goes through <code>str()<\/code> and stays text: it&#8217;s quoted in the sample (<code>\"690\"<\/code>), and an id that arrives quoted today can arrive as a number tomorrow. And the read is a full list on every call, which is fine for a few hundred links and slow for tens of thousands. Cache the <code>lcId<\/code> the moment you resolve it, and never resolve the same link twice.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If the create call succeeds and the read-back fails, you have a live short URL with no <code>lcId<\/code> in your ledger. Save the row anyway with <code>lc_id<\/code> null and a <code>pending_resolve<\/code> status, and let a retry job finish it. Don&#8217;t create a second link to &#8220;fix&#8221; it, or you&#8217;ll have two links with the same identifier and the uniqueness check above will refuse both forever.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"send-time-tracking\" class=\"wp-block-heading\">Turning On Tracking at Send Time<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">On the send-time path you add three optional fields to a normal <code>SMSApi\/send<\/code> call:<\/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>Values<\/th><th>What the send page says<\/th><\/tr><\/thead><tbody><tr><td><code>trackLink<\/code><\/td><td><code>true<\/code> or <code>false<\/code><\/td><td>&#8220;Enable true to track links from your SMS content.&#8221;<\/td><\/tr><tr><td><code>smartLinkTitle<\/code><\/td><td>Free text<\/td><td>&#8220;A brief title to recognise your smart link campaign.&#8221;<\/td><\/tr><tr><td><code>appendHttp<\/code><\/td><td><code>true<\/code> or <code>false<\/code><\/td><td>&#8220;System default is true. Disable to remove https:\/\/ from smartlink.&#8221;<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<pre class=\"wp-block-code\"><code>curl -s https:\/\/unify.smsgateway.center\/SMSApi\/send \\\n  -H \"apikey: $SGC_APIKEY\" \\\n  --data-urlencode \"userid=$SGC_USERID\" \\\n  --data-urlencode \"sendMethod=quick\" \\\n  --data-urlencode \"mobile=919xxxxxxxxx\" \\\n  --data-urlencode \"senderid=ACMEIN\" \\\n  --data-urlencode \"msgType=text\" \\\n  --data-urlencode \"msg=Your order 4471 has shipped. Track it here: https:\/\/www.example.com\/orders\/track?order=4471\" \\\n  --data-urlencode \"trackLink=true\" \\\n  --data-urlencode \"smartLinkTitle=ship-2026-10-12-7f3a\" \\\n  --data-urlencode \"appendHttp=true\" \\\n  --data-urlencode \"output=json\"<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The trap here is in the message, not the flags. The URL has to be the last one in the text, or a different URL gets tracked. And because the message goes through DLT template matching, the URL sits in a <code>{#url#}<\/code> slot in your registered template. More on that in the DLT section.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The send response is the normal one: <code>status<\/code>, <code>mobile<\/code>, <code>invalidMobile<\/code>, a quoted nineteen-digit <code>transactionId<\/code>, <code>statusCode<\/code> <code>\"200\"<\/code> and <code>reason<\/code>. Nothing in it names the link. So treat <code>smartLinkTitle<\/code> the way you treat <code>identifier<\/code> on create: put your own unique key in it. After the send, read the link list and look for exactly one row whose <code>identifier<\/code> equals that title. If you find it, store its <code>lcId<\/code> against the send. If you don&#8217;t, leave the link unresolved and keep the send&#8217;s <code>transactionId<\/code>, which you&#8217;ll need for the join anyway.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">You&#8217;ll also see a field on read rows called <code>shortUrlAdvance<\/code>, and in the sample it looks like this: <code>\"http:\/\/t1x.in\/WypK%xxxxxx%\"<\/code>. That&#8217;s the short URL with a placeholder suffix on the end. It&#8217;s a strong hint at how per-recipient tracking works on the send-time path: one base link per campaign, plus a suffix per recipient. Don&#8217;t build your own suffixes by filling that placeholder in. Let the send call do it, and measure the result with one test message to a number you own.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Keep your own copy of what you asked for. The outbound row from the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/outbound-message-table-schema-design\/\">outbound message table<\/a> already has the <code>transactionId<\/code>, the recipient, the template and the time. Add two columns: <code>track_link<\/code> (boolean, what you sent) and <code>link_title<\/code> (the <code>smartLinkTitle<\/code> you sent). Those two columns are what let you ask, months later, &#8220;which messages carried a tracked link, and which campaign was it?&#8221;<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"message-length\" class=\"wp-block-heading\">What the Short URL Does to Message Length<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The product page FAQ gives the lengths: &#8220;The short URL range between 21 to 23 characters long&#8221;, and &#8220;When you Exclude HTTPS then actual Short URL count would be 13 to 15 characters.&#8221; The difference is the eight characters of <code>https:\/\/<\/code>. (The append HTTP knowledge base answer rounds that to &#8220;about 6 to 8 characters&#8221;. It&#8217;s eight.)<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s a shipping message with a long tracking URL, worked out in code rather than by eye:<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Version<\/th><th>URL length<\/th><th>Message length<\/th><th>GSM-7 segments<\/th><\/tr><\/thead><tbody><tr><td>Original long URL with UTM tags<\/td><td>104<\/td><td>176<\/td><td>2<\/td><\/tr><tr><td>Short link with <code>https:\/\/<\/code> (23 chars max)<\/td><td>23<\/td><td>95<\/td><td>1<\/td><\/tr><tr><td>Short link without <code>https:\/\/<\/code> (15 chars max)<\/td><td>15<\/td><td>87<\/td><td>1<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The message text before the URL is <code>Your order 4471 has shipped and will arrive by Thursday. Track it here:<\/code> (72 characters). A single GSM-7 SMS holds 160 characters. Once you go over, each part holds 153, so 176 characters bills as two parts. With a short link it fits in one either way.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Notice what that means for previews and cost estimates. The message you preview isn&#8217;t the message that goes out. If you estimate segments from the text you submit, you&#8217;ll overestimate when a long URL gets shortened. If your URL was already short (say, a 12-character domain with no path), shortening can make it longer, and a message that sat at 158 characters can tip into two parts. Estimate with the short link&#8217;s worst case, 23 characters with <code>https:\/\/<\/code> or 15 without, in place of the last URL. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/message-preview-cost-estimation-before-sending\/\">preview and cost estimation guide<\/a> has the segment rules, and the <a href=\"https:\/\/www.smsgatewaycenter.com\/sms-length-calculator\/\">SMS length calculator<\/a> is handy for spot checks.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Should you drop <code>https:\/\/<\/code>? It buys eight characters. The append HTTP knowledge base answer recommends keeping it on &#8220;unless you absolutely need to save characters&#8221;, because people are warier of links without it. That&#8217;s a fair default. Only turn it off when it&#8217;s the difference between one part and two on a large send, and check the click rate on a small batch first.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"dlt-whitelist\" class=\"wp-block-heading\">DLT: Whitelist the Short Domain, Not Just Yours<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">In India, a URL in a commercial SMS has to match a call-to-action that&#8217;s whitelisted on your DLT portal. A tracked link replaces your URL with one on the short link domain. If only your own domain is whitelisted, the shortened message no longer matches, and it fails.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/how-do-i-whitelist-urls-app-links-and-callback-numbers-on-my-dlt-portal\/\">DLT whitelisting knowledge base answer<\/a> says it plainly: &#8220;if you use a link shortener, including Smart Links, whitelist the short link&#8217;s domain, not only your website&#8217;s domain.&#8221; Do that before the first tracked send, on every DLT portal you send through.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Which domain? The API samples show <code>t1x.in<\/code>. The product page FAQ shows <code>tx1.in<\/code>. Those are different hosts, and you shouldn&#8217;t guess between them. Take the host from a real <code>shortUrl<\/code> your account returns (create one throwaway link and read it), whitelist that, and add a check in your sender that the host of every <code>shortUrl<\/code> you store is on your whitelist list. If you&#8217;ve set up your own short domain (the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/can-i-use-my-own-short-domain-for-smart-links\/\">custom short domain answer<\/a> says the team configures it for you), whitelist that one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The template side matters too. The URL must sit in a <code>{#url#}<\/code> variable in the registered template, not as literal text, because the text that goes out isn&#8217;t the text you wrote. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/what-are-dlt-variable-tags-and-which-one-should-i-use\/\">variable tags answer<\/a> covers which tag fits which value, and <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/dlt-template-rejected-reasons-and-fixes\/\">DLT template rejections<\/a> covers what happens when the shape is wrong.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"visitor-report\" class=\"wp-block-heading\">Reading Clicks Back: The Visitor Report<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><code>SMSApi\/linktrack\/report<\/code> is POST only and takes one required input, <code>campaignid<\/code>, described as &#8220;Enter a specific campaign ID to view reports.&#8221; Here&#8217;s the sample row:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n    \"linktrack\": {\n        \"ciId\": \"200272\",\n        \"mobileNo\": \"0\",\n        \"os\": \"Mac OS X 10.14.5\",\n        \"browser\": \"Safari 12\",\n        \"device\": \"Desktop\",\n        \"deviceName\": \"Apple Macintosh\",\n        \"ip\": \"1.10.247.193\",\n        \"city\": \"Mumbai\",\n        \"state\": \"Maharashtra\",\n        \"country\": \"India\",\n        \"mobLocation\": \"NA\",\n        \"mobOperator\": \"NA\",\n        \"timestamp\": \"1563114285782\"\n    }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Each row is a visit. <code>ciId<\/code> is the visit&#8217;s id, and it&#8217;s the one thing you can dedupe on. <code>timestamp<\/code> is epoch milliseconds as a string: <code>1563114285782<\/code> is 2019-07-14 19:54:45.782 IST. The link it belongs to in the read sample was created at <code>1563114084731<\/code>, 19:51:24.731 IST, so that click came 201 seconds after the link existed.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">There are two kinds of location in the row. <code>city<\/code>, <code>state<\/code> and <code>country<\/code> come from the IP address. <code>mobLocation<\/code> and <code>mobOperator<\/code> describe the mobile number&#8217;s circle and operator, and they&#8217;re <code>\"NA\"<\/code> here because <code>mobileNo<\/code> is <code>\"0\"<\/code>: there&#8217;s no number to look up. On a click that&#8217;s tied to a recipient you&#8217;d expect both to be filled. Keep the two kinds in separate columns. An IP-based city is where the network said the request came from. A number-based location is the subscriber&#8217;s home circle. They disagree all the time, and both are useful.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">What&#8217;s &#8220;campaign ID&#8221; here? The parameter is called <code>campaignid<\/code>, and delete&#8217;s <code>id<\/code> is described the same way (&#8220;Campaign ID&#8221;). The read rows carry <code>lcId<\/code>, and create&#8217;s message says &#8220;Campaign created successfully&#8221;, so the link and the &#8220;campaign&#8221; look like the same thing on this API. Test that once: create a link, click it from your own phone, then call report with that <code>lcId<\/code> as <code>campaignid<\/code> and check your click is there. Put the result in a contract test so you hear about it if it changes. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/contract-testing-harness-messaging-api\/\">contract testing harness guide<\/a> shows how to pin a shape like this.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"parsing-sentinels\" class=\"wp-block-heading\">Parsing the Sentinels: &#8220;0&#8221;, &#8220;NA&#8221; and &#8220;0%&#8221;<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The linktrack payloads use strings for nearly everything, and they use particular strings to mean &#8220;nothing&#8221;. If you load them as-is, your reports fill with a phone number called <code>0<\/code>, a city called <code>NA<\/code> and a click rate you can&#8217;t add up.<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Field<\/th><th>Sample value<\/th><th>What it means<\/th><th>Store as<\/th><\/tr><\/thead><tbody><tr><td><code>mobileNo<\/code><\/td><td><code>\"0\"<\/code><\/td><td>No recipient tied to this click<\/td><td><code>NULL<\/code><\/td><\/tr><tr><td><code>mobLocation<\/code>, <code>mobOperator<\/code><\/td><td><code>\"NA\"<\/code><\/td><td>Not known<\/td><td><code>NULL<\/code><\/td><\/tr><tr><td><code>lastClickTimestamp<\/code><\/td><td><code>\"NA\"<\/code><\/td><td>No clicks yet<\/td><td><code>NULL<\/code><\/td><\/tr><tr><td><code>clickThroughRate<\/code><\/td><td><code>\"0%\"<\/code><\/td><td>A display percentage<\/td><td>Don&#8217;t store it; compute your own<\/td><\/tr><tr><td><code>totalViewed<\/code>, <code>totalUploads<\/code><\/td><td><code>\"0\"<\/code><\/td><td>Counts, quoted<\/td><td>Integer, after parsing<\/td><\/tr><tr><td><code>timestamp<\/code><\/td><td><code>\"1563114285782\"<\/code><\/td><td>Epoch milliseconds<\/td><td><code>timestamptz<\/code>, keep the raw text too<\/td><\/tr><tr><td><code>ciId<\/code>, <code>lcId<\/code><\/td><td><code>\"200272\"<\/code>, <code>\"690\"<\/code><\/td><td>Ids, quoted<\/td><td>Text<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Do the cleanup in one function at the edge, and keep the raw row next to it. When a new sentinel turns up (an empty string, a <code>\"-\"<\/code>, a <code>null<\/code>), you want to fix one function and replay, not hunt through reports.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ normalise.js: one place where linktrack strings become typed values\nconst NULLISH = new Set(&#91;\"\", \"0\", \"NA\", \"N\/A\", \"null\", \"-\"]);\n\nfunction nullable(value) {\n  if (value === null || value === undefined) return null;\n  const s = String(value).trim();\n  return NULLISH.has(s) ? null : s;\n}\n\nfunction epochMs(value) {\n  const s = nullable(value);\n  if (s === null || !\/^\\d{12,14}$\/.test(s)) return null; \/\/ refuse seconds, refuse junk\n  return new Date(Number(s)).toISOString();\n}\n\nfunction normaliseClick(row, lcId) {\n  const r = row &amp;&amp; typeof row === \"object\" ? row.linktrack ?? row : {};\n  const ciId = r.ciId === undefined || r.ciId === null ? null : String(r.ciId);\n  if (!ciId) return null; \/\/ no id, nothing to dedupe on: park it, don't insert it\n  return {\n    ci_id: ciId,\n    lc_id: String(lcId),\n    mobile_no: nullable(r.mobileNo), \/\/ \"0\" -&gt; null, never the number zero\n    ip: nullable(r.ip),\n    os: nullable(r.os),\n    browser: nullable(r.browser),\n    device: nullable(r.device),\n    device_name: nullable(r.deviceName),\n    ip_city: nullable(r.city),\n    ip_state: nullable(r.state),\n    ip_country: nullable(r.country),\n    mob_location: nullable(r.mobLocation),\n    mob_operator: nullable(r.mobOperator),\n    clicked_at: epochMs(r.timestamp),\n    raw: r,\n  };\n}\n\nmodule.exports = { nullable, epochMs, normaliseClick };\n\nif (require.main === module) {\n  const sample = { linktrack: { ciId: \"200272\", mobileNo: \"0\", os: \"Mac OS X 10.14.5\",\n    browser: \"Safari 12\", device: \"Desktop\", deviceName: \"Apple Macintosh\",\n    ip: \"1.10.247.193\", city: \"Mumbai\", state: \"Maharashtra\", country: \"India\",\n    mobLocation: \"NA\", mobOperator: \"NA\", timestamp: \"1563114285782\" } };\n  console.log(normaliseClick(sample, \"690\"));\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Run that and the sample comes out with <code>mobile_no: null<\/code>, <code>mob_location: null<\/code> and <code>clicked_at: '2019-07-14T14:24:45.782Z'<\/code>, which is the same instant as 19:54:45.782 IST.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>\"0\"<\/code> rule needs one caveat. <code>\"0\"<\/code> is nullish for <code>mobileNo<\/code> and wrong for counts. That&#8217;s why the counts (<code>totalViewed<\/code>, <code>totalUploads<\/code>) don&#8217;t go through <code>nullable<\/code> at all. Parse them with their own integer function that treats <code>\"0\"<\/code> as zero.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"data-model\" class=\"wp-block-heading\">The Attribution Data Model<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Four tables do the work. Two are new (links and clicks), one you already have (outbound messages), and one is a small mapping that says which messages carried which link.<\/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-click-attribution-flow.svg\"><img decoding=\"async\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/diagram-click-attribution-flow.svg\" alt=\"Flow from sending a message with a tracked link, to a click landing on the short link, to the click joined to a message and recipient in your own tables\" class=\"wp-image-3077\"\/><\/a><figcaption class=\"wp-element-caption\">The platform counts clicks. Your tables turn them into attribution.<\/figcaption><\/figure>\n\n\n\n<pre class=\"wp-block-code\"><code>-- One row per short link, whichever path made it.\nCREATE TABLE link_campaign (\n  link_key        text PRIMARY KEY,          -- your key, also in identifier \/ smartLinkTitle\n  lc_id           text UNIQUE,               -- platform lcId; NULL until resolved\n  created_via     text NOT NULL CHECK (created_via IN ('create', 'send')),\n  short_url       text,                      -- exactly as returned\n  short_host      text,                      -- host of short_url; must be DLT-whitelisted\n  destination     text NOT NULL,\n  link_type       text NOT NULL CHECK (link_type IN ('url', 'attachment')),\n  resolve_status  text NOT NULL DEFAULT 'pending'\n                  CHECK (resolve_status IN ('pending', 'resolved', 'failed')),\n  created_at      timestamptz NOT NULL DEFAULT now(),\n  retired_at      timestamptz                -- when you stopped putting it in messages\n);\n\n-- Which outbound messages carried which link. Fill it when you send.\nCREATE TABLE message_link (\n  transaction_id  text NOT NULL,             -- from SMSApi\/send, as text (19 digits)\n  msisdn          text NOT NULL,             -- the recipient, normalised\n  link_key        text NOT NULL REFERENCES link_campaign(link_key),\n  sent_at         timestamptz NOT NULL,\n  PRIMARY KEY (transaction_id, msisdn, link_key)\n);\n\n-- One row per visit from linktrack\/report. Never updated, never deleted by the ingester.\nCREATE TABLE link_click (\n  ci_id           text PRIMARY KEY,          -- platform ciId: the dedupe key\n  lc_id           text NOT NULL,\n  msisdn          text,                      -- mobileNo, NULL when \"0\"\n  clicked_at      timestamptz,\n  ip              text,\n  os              text,\n  browser         text,\n  device          text,\n  device_name     text,\n  ip_city         text,\n  ip_state        text,\n  ip_country      text,\n  mob_location    text,\n  mob_operator    text,\n  bot_suspect     boolean NOT NULL DEFAULT false,\n  bot_reason      text,\n  raw             jsonb NOT NULL,\n  ingested_at     timestamptz NOT NULL DEFAULT now()\n);\n\nCREATE INDEX link_click_join ON link_click (lc_id, msisdn, clicked_at);\nCREATE INDEX message_link_join ON message_link (link_key, msisdn, sent_at);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Why <code>ci_id<\/code> as the primary key and nothing else? Because it&#8217;s the only thing in a click row that&#8217;s meant to be unique. Two real clicks from the same phone a second apart are two rows, and you want both (the second one is how you&#8217;ll spot a preview fetch followed by a tap). Dedupe by <code>ciId<\/code> and let <code>ON CONFLICT DO NOTHING<\/code> absorb the overlap between polls.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Why a separate <code>message_link<\/code> table instead of a column on the outbound row? Because one message can, in principle, carry more than one link (a standalone link in the middle and a tracked one at the end), and one link carries many messages. A mapping table handles both without nulls. If you know you&#8217;ll only ever have one tracked link per message, a <code>link_key<\/code> column on the outbound row does the same job, and that&#8217;s fine too.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"click-join\" class=\"wp-block-heading\">Joining a Click to a Message and a Recipient<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The join has two keys: which link, and which phone. The link comes from <code>lc_id<\/code>, through <code>link_campaign<\/code>, to your <code>link_key<\/code>. The phone comes from <code>msisdn<\/code> on the click. On the send-time path that&#8217;s enough to find the recipient. To find the exact message, you also need time, because the same number can get the same link more than once (a reminder, a resend after a failure).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The rule: a click belongs to the latest message carrying that link to that number that was sent before the click.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>-- Attribute each recipient-tied click to the latest earlier send of the same link.\nSELECT c.ci_id,\n       c.clicked_at,\n       c.msisdn,\n       lc.link_key,\n       m.transaction_id,\n       m.sent_at,\n       c.clicked_at - m.sent_at AS time_to_click,\n       c.bot_suspect\nFROM link_click c\nJOIN link_campaign lc ON lc.lc_id = c.lc_id\nLEFT JOIN LATERAL (\n  SELECT ml.transaction_id, ml.sent_at\n  FROM message_link ml\n  WHERE ml.link_key = lc.link_key\n    AND ml.msisdn   = c.msisdn\n    AND ml.sent_at &lt;= c.clicked_at\n  ORDER BY ml.sent_at DESC\n  LIMIT 1\n) m ON true\nWHERE c.msisdn IS NOT NULL;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Three outcomes come out of that query, and each one tells you something.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A row with a <code>transaction_id<\/code> is attributed. That&#8217;s the goal.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A row with an <code>msisdn<\/code> but no <code>transaction_id<\/code> means the platform tied the click to a number you have no record of sending this link to. Usually that&#8217;s a forward: the recipient sent the message to someone else, and the platform recorded the forwardee&#8217;s network. Sometimes it&#8217;s a number-format mismatch (<code>919...<\/code> on one side, <code>+91...<\/code> on the other). Normalise numbers the same way on both tables before you decide it&#8217;s a forward.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Clicks with <code>msisdn<\/code> null aren&#8217;t in this query at all. They&#8217;re campaign-level. Count them, report them as unattributed, and don&#8217;t spread them across recipients.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Normalise the number format first. The send call wants a country code without <code>+<\/code> (<code>919999999999<\/code> in the send page table). The report&#8217;s <code>mobileNo<\/code> sample is <code>\"0\"<\/code>, so the format of a real one isn&#8217;t shown. Store every number in one canonical form (digits only, country code included) and convert on the way in from both sides. A join that silently fails on a <code>+<\/code> is the classic way to report a zero click rate for a campaign that worked.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"polling\" class=\"wp-block-heading\">Polling Without a Click Webhook<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The webhook surface on this platform covers delivery reports and inbound messages. Clicks aren&#8217;t on it. You poll <code>SMSApi\/linktrack\/report<\/code>, one call per link, and because the page lists no date range, paging or &#8220;since&#8221; parameter, each call returns everything that link has collected so far.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That shapes the poller:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Poll per active link, not per account.<\/strong> A link is active from its first send until some cut-off you choose. Most clicks on an SMS link arrive within hours of delivery. A tail goes on for days. A reasonable starting cut-off is 30 days after the link&#8217;s last send. Tune it from your own data: once <code>time_to_click<\/code> from the join query stops producing new rows past some age, shorten it.<\/li>\n\n\n\n<li><strong>Back off as the link ages.<\/strong> Every 5 minutes for the first two hours, hourly for the first two days, daily after that. Since each call returns the full history, the cost of a call grows with the link&#8217;s click count, not with the time since your last call. Polling an old busy link every five minutes is pure waste.<\/li>\n\n\n\n<li><strong>Insert with <code>ON CONFLICT (ci_id) DO NOTHING<\/code>.<\/strong> Overlap between polls is expected. The primary key absorbs it.<\/li>\n\n\n\n<li><strong>Count what you see.<\/strong> Log the number of rows returned per call. If a link that returned 4,000 rows yesterday returns 3,000 today, something changed on the platform side (a retention cut, a filter). You want to notice, not discover it when a report is short.<\/li>\n\n\n\n<li><strong>Respect <code>status<\/code>.<\/strong> Parse the envelope, check <code>status<\/code> is <code>\"success\"<\/code>, then read <code>linktrackList<\/code>. On anything else, keep the old data and retry later. Never treat an error as &#8220;zero clicks&#8221;.<\/li>\n<\/ul>\n\n\n\n<pre class=\"wp-block-code\"><code>import time\n\nSCHEDULE = &#91;(2 * 3600, 300), (2 * 86400, 3600), (30 * 86400, 86400)]  # (age limit, interval) in seconds\n\ndef due(link_age_s, since_last_poll_s):\n    for limit, interval in SCHEDULE:\n        if link_age_s &lt;= limit:\n            return since_last_poll_s &gt;= interval\n    return False  # past the cut-off: stop polling this link\n\ndef poll_link(db, client, lc_id):\n    resp = client.post(\"report\", campaignid=lc_id)   # raises unless status == \"success\"\n    rows = resp.get(\"linktrackList\") or &#91;]\n    inserted = 0\n    for item in rows:\n        click = normalise_click(item, lc_id)          # same rules as the Node normaliser\n        if click is None:\n            db.park_unkeyed(lc_id, item)\n            continue\n        inserted += db.insert_click_if_new(click)     # INSERT ... ON CONFLICT (ci_id) DO NOTHING\n    db.record_poll(lc_id, returned=len(rows), inserted=inserted, at=time.time())\n    return inserted<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">If you&#8217;d rather not poll at all, use the destination side. Your landing page sees every human visit with a query string you control. That&#8217;s the subject of the click rate section, and it&#8217;s the more reliable signal anyway.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"bot-clicks\" class=\"wp-block-heading\">Bot and Preview Clicks<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">On SMS, a meaningful share of clicks aren&#8217;t people. Phones fetch links to draw previews. Carriers and security apps open links to check for phishing. Corporate mail and chat gateways do the same when a message gets forwarded. At the redirect, every one of those looks like a click: a request, an IP, a user agent, a timestamp.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The vendors who&#8217;ve measured this agree on the main signs. <a href=\"https:\/\/www.braze.com\/docs\/user_guide\/channels\/sms_mms_and_rcs\/message_features_and_optimization\/bot_click_filtering\" target=\"_blank\" rel=\"noopener nofollow\">Braze&#8217;s bot click filtering<\/a> looks at user agents from crawlers, link previews and messaging security software. <a href=\"https:\/\/wiki.truedialog.com\/knowledge-base\/viewing-clicks-report\/\" target=\"_blank\" rel=\"noopener nofollow\">TrueDialog&#8217;s clicks report notes<\/a> that you can&#8217;t tell from the link alone whether a person tapped or the phone previewed, and that deduplicating by contact doesn&#8217;t fix it. So the click ledger has to carry a judgement, and that judgement has to be revisable.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Flag, don&#8217;t delete. The <code>bot_suspect<\/code> and <code>bot_reason<\/code> columns are there so you can change your rules and recompute without having thrown anything away. Rules that are a good start:<\/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>Why it points to a bot<\/th><th>Rule to start with<\/th><\/tr><\/thead><tbody><tr><td>Click very soon after delivery<\/td><td>Previews and scanners fire as the message lands<\/td><td>Under 10 seconds after the delivery time from your DLR row<\/td><\/tr><tr><td>Data-centre or scanner user agent<\/td><td>Real phones don&#8217;t browse as <code>bot<\/code>, <code>crawler<\/code>, <code>preview<\/code>, <code>scanner<\/code><\/td><td>Case-insensitive match on <code>os<\/code>, <code>browser<\/code>, <code>device_name<\/code><\/td><\/tr><tr><td>Several clicks from one IP across many recipients in a minute<\/td><td>One scanner working through a batch<\/td><td>More than 5 distinct <code>msisdn<\/code> from one <code>ip<\/code> within 60 seconds<\/td><\/tr><tr><td>A click before the message was delivered<\/td><td>Nothing human can do that<\/td><td><code>clicked_at<\/code> earlier than the delivery time<\/td><\/tr><tr><td>Desktop OS on a recipient-tied click within seconds<\/td><td>Gateways, not people, open links on servers that fast<\/td><td><code>device<\/code> = Desktop and under 30 seconds<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Every threshold in that table is a starting point, not a published rule. Look at the distribution of <code>time_to_click<\/code> for your own traffic. If you see a sharp spike in the first few seconds and a smooth curve after, that spike is your preview traffic, and the boundary between them is your threshold.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A useful pattern: when a recipient-tied click is flagged as a bot and the same <code>msisdn<\/code> clicks again a minute later from a phone user agent, the second one is almost always the person. Keep both rows. Count the recipient as a human click.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"click-rate\" class=\"wp-block-heading\">Computing a Click Rate You Can Defend<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The read endpoint gives you <code>clickThroughRate<\/code> as a string like <code>\"0%\"<\/code>. The dashboard knowledge base answer defines it as &#8220;the percentage of people who clicked the link out of the total opens&#8221;, which isn&#8217;t a number you can reproduce, because &#8220;opens&#8221; for a link is the click. Don&#8217;t store it, don&#8217;t chart it and don&#8217;t compare campaigns on it. Compute your own from your tables, so every input is visible.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>-- Recipient-level click rate for one link, from your own tables.\nWITH delivered AS (\n  SELECT DISTINCT ml.msisdn\n  FROM message_link ml\n  JOIN dlr_event d ON d.transaction_id = ml.transaction_id AND d.msisdn = ml.msisdn\n  WHERE ml.link_key = :link_key\n    AND d.status = 'DELIVERED'\n),\nhuman_clickers AS (\n  SELECT DISTINCT c.msisdn\n  FROM link_click c\n  JOIN link_campaign lc ON lc.lc_id = c.lc_id\n  WHERE lc.link_key = :link_key\n    AND c.msisdn IS NOT NULL\n    AND NOT c.bot_suspect\n)\nSELECT (SELECT count(*) FROM delivered)                                   AS delivered_recipients,\n       (SELECT count(*) FROM human_clickers h JOIN delivered d USING (msisdn)) AS clicking_recipients,\n       round(100.0 * (SELECT count(*) FROM human_clickers h JOIN delivered d USING (msisdn))\n             \/ NULLIF((SELECT count(*) FROM delivered), 0), 2)            AS click_rate_pct;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The denominator is delivered recipients, not submitted. A message that never arrived can&#8217;t be clicked, and counting it hides delivery problems inside a &#8220;low engagement&#8221; story. That&#8217;s why the query joins to your delivery reports. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/delivery-report-ingestion-system-of-record\/\">delivery report ingestion guide<\/a> builds the <code>dlr_event<\/code> table this assumes.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Report three numbers side by side, every time: delivered recipients, human clicking recipients, and the rate. Then two more underneath: raw clicks (bots included) and unattributed clicks (no <code>msisdn<\/code>). If raw clicks are three times human clicks, people should see that, not have it smoothed away.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For anything that matters commercially (a sale, a booking, a form), measure on the destination too. Add your own query parameter to the long URL before it gets shortened, something like <code>?m=&lt;link_key&gt;<\/code>, and have the landing page log a server-side event when it&#8217;s loaded and another when the visitor does the thing. Bots load pages. They rarely complete checkouts. A conversion count from your own server is the number to put in front of the finance team. The click rate is the leading indicator.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"personal-data\" class=\"wp-block-heading\">Clicks Are Personal Data<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A click row ties a phone number to an IP address, a device, an approximate location and a time. In India that&#8217;s personal data under the Digital Personal Data Protection Act, 2023, and the same is true almost anywhere else you send. Treat the click ledger the way you treat the outbound message table.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Practical rules that keep you out of trouble:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Purpose.<\/strong> Collect clicks to measure and improve messaging you were allowed to send. Don&#8217;t quietly turn them into a tracking profile used for something else.<\/li>\n\n\n\n<li><strong>Retention.<\/strong> Pick a retention period for raw click rows (for example, 13 months, so you can compare a campaign with the same month last year) and delete or anonymise past it: drop <code>ip<\/code>, <code>msisdn<\/code> and <code>device_name<\/code>, keep the counts. Write the rule down and run it as a job.<\/li>\n\n\n\n<li><strong>Access.<\/strong> The click ledger doesn&#8217;t need to be readable by everyone who can see campaign dashboards. Give dashboards aggregates. Give raw rows to the few people who debug the pipeline.<\/li>\n\n\n\n<li><strong>Opt-outs.<\/strong> A recipient who opts out stops getting messages. Their old clicks can stay in aggregate counts, but don&#8217;t use them to target anything new. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/opt-out-suppression-sms-whatsapp-rcs-telegram\/\">opt-out and suppression guide<\/a> covers the suppression table this ties into.<\/li>\n\n\n\n<li><strong>Notice.<\/strong> If your messages carry tracked links, your privacy notice should say that link clicks are recorded and what&#8217;s recorded. One sentence is enough.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">None of this is legal advice for your particular case. It&#8217;s the minimum an engineering team should build in before the first tracked send, rather than after the first question from a customer.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"deleting-links\" class=\"wp-block-heading\">Deleting Links Safely<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><code>SMSApi\/linktrack\/delete<\/code> is POST only, keyed <code>id<\/code> (&#8220;Enter a campaign ID to delete&#8221;), and a success looks like this:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n    \"response\": {\n        \"api\": \"linktrack\",\n        \"action\": \"delete\",\n        \"status\": \"success\",\n        \"msg\": \"Campaign deleted successfully.\",\n        \"code\": \"192\"\n    }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two things to settle before you ever call it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What happens to links already in people&#8217;s inboxes.<\/strong> A message sent last week still has the short URL in it. Whether that URL keeps redirecting after a delete isn&#8217;t something you should find out from a customer. Assume it stops. That means: never delete a link that went out in a message inside your retention window, and never delete a link that&#8217;s in a message still sitting in a schedule. Retire it in your own table (<code>retired_at<\/code>) and stop putting it in new messages instead.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What happens to its click history.<\/strong> Pull the full report and confirm the row count matches your ledger before deleting. After a delete, assume the report is gone. Your <code>link_click<\/code> rows stay, because they&#8217;re yours.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you need a clean-up job at all, make it delete only links that are older than your retention period, have <code>retired_at<\/code> set, have had no clicks in the last 90 days, and whose report you&#8217;ve ingested in full within the last day. Check <code>status<\/code>, not <code>code<\/code>, on the response, since success comes back as <code>\"192\"<\/code>.<\/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>You need<\/th><th>Use<\/th><th>Why<\/th><\/tr><\/thead><tbody><tr><td>Who clicked, per recipient, on an SMS campaign<\/td><td>Send-time <code>trackLink=true<\/code> with a unique <code>smartLinkTitle<\/code><\/td><td>It&#8217;s the path the product page ties to recipients&#8217; numbers<\/td><\/tr><tr><td>A tracked link for an attachment (PDF, image)<\/td><td><code>linktrack\/create<\/code> with <code>type=attachment<\/code><\/td><td>Send-time tracking shortens URLs only<\/td><\/tr><tr><td>One link shared across SMS, WhatsApp, email and a QR code<\/td><td><code>linktrack\/create<\/code><\/td><td>You get the short URL before you send, and clicks are campaign-level anyway<\/td><\/tr><tr><td>Two tracked URLs in one SMS<\/td><td>Send-time for the last URL, standalone link for the other<\/td><td>Only the last URL gets converted at send time<\/td><\/tr><tr><td>A message right at 160 characters<\/td><td>Measure with the short link&#8217;s worst case; drop <code>https:\/\/<\/code> only if it saves a part<\/td><td>The short link can lengthen a message whose URL was already short<\/td><\/tr><tr><td>Revenue or conversion numbers<\/td><td>Your own landing-page event, keyed by a query parameter on the long URL<\/td><td>Bots load pages; they rarely convert<\/td><\/tr><tr><td>A click rate for a weekly report<\/td><td>Your SQL over delivered recipients and human-flagged clicks<\/td><td><code>clickThroughRate<\/code> is a display string with an unclear definition<\/td><\/tr><tr><td>Clicks in near real time<\/td><td>Poll <code>linktrack\/report<\/code> on a backoff schedule<\/td><td>There&#8217;s no click webhook<\/td><\/tr><tr><td>Removing old links<\/td><td>Retire them in your table; delete only past retention after a full ingest<\/td><td>A deleted link may stop redirecting from old messages<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"checklist\" class=\"wp-block-heading\">Pre-Launch Checklist<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Take the short host from a real <code>shortUrl<\/code> your account returns, and whitelist it on every DLT portal you send through.<\/li>\n\n\n\n<li>Put the tracked URL in a <code>{#url#}<\/code> slot in the registered template, and make it the last URL in the message.<\/li>\n\n\n\n<li>Generate a unique key per link and send it as <code>identifier<\/code> (create) or <code>smartLinkTitle<\/code> (send).<\/li>\n\n\n\n<li>Resolve <code>lcId<\/code> by reading the list back and matching exactly one row; store it as text.<\/li>\n\n\n\n<li>Save <code>track_link<\/code> and <code>link_title<\/code> on every outbound row, and fill <code>message_link<\/code> at send time.<\/li>\n\n\n\n<li>Estimate segments with 23 characters (or 15 without <code>https:\/\/<\/code>) in place of the last URL.<\/li>\n\n\n\n<li>Normalise <code>\"0\"<\/code>, <code>\"NA\"<\/code> and empty strings to null in one function; keep the raw row.<\/li>\n\n\n\n<li>Store <code>ciId<\/code>, <code>lcId<\/code>, <code>transactionId<\/code> and phone numbers as text in one canonical format.<\/li>\n\n\n\n<li>Insert clicks with <code>ON CONFLICT (ci_id) DO NOTHING<\/code>; never update or delete click rows from the ingester.<\/li>\n\n\n\n<li>Poll per active link on a backoff schedule with a cut-off; log rows returned per call.<\/li>\n\n\n\n<li>Flag suspected bot clicks with a reason; never drop them.<\/li>\n\n\n\n<li>Compute click rate over delivered recipients, from your own tables.<\/li>\n\n\n\n<li>Add a landing-page conversion event keyed by your link key.<\/li>\n\n\n\n<li>Write down retention for raw click rows and schedule the anonymising job.<\/li>\n\n\n\n<li>Send one test message to a phone you own, tap the link, and check the click appears with your number.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"unspecified-behaviour\" class=\"wp-block-heading\">Unspecified Behaviour and How to Code Around It<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A few behaviours here aren&#8217;t pinned down by anything you can read before you build. Each item below gives you a choice that stays safe whichever way the platform actually behaves, and none of them means waiting for an answer.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>One. Resolve <code>lcId<\/code> by exact match on two fields, and fail closed.<\/strong> Create hands back a URL and no id. Match <code>identifier<\/code> and <code>shortUrl<\/code> together and accept exactly one row. Zero or two matches leave the link <code>pending<\/code>, which costs you a retry, not a wrong join.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Two. Treat <code>smartLinkTitle<\/code> as a probable identifier, and confirm it with a read-back.<\/strong> The send-time link should show up in the read list with your title somewhere on it. If one read after the send finds exactly one matching row, you&#8217;ve got the <code>lcId<\/code>. If it doesn&#8217;t, your attribution still works through <code>transactionId<\/code> and <code>msisdn<\/code>; you&#8217;ve only lost the shortcut.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Three. Use the <code>lcId<\/code> as <code>campaignid<\/code>, and prove it with your own click.<\/strong> Report asks for a &#8220;Campaign ID&#8221; and the only id the read list gives you is <code>lcId<\/code>. One tapped test link settles which it is for your account. Pin the answer in a contract test so a change shows up as a failing test, not an empty report.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Four. Never fill <code>shortUrlAdvance<\/code> yourself.<\/strong> The <code>%xxxxxx%<\/code> suffix looks like a per-recipient slot, but the rule for what goes there isn&#8217;t yours to guess. Let <code>trackLink=true<\/code> generate recipient links. If you need per-recipient links on another channel, put your own key on the long URL and attribute on your landing page instead.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Five. Don&#8217;t hard-code the short domain.<\/strong> The samples say <code>t1x.in<\/code> and the product FAQ says <code>tx1.in<\/code>. Read the host off every <code>shortUrl<\/code> you store and check it against your whitelist before the send. A mismatch blocks one send instead of failing a whole DLT batch.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Six. Send <code>appendHttp<\/code> exactly as the send table spells it, and check the result.<\/strong> The send page writes <code>appendHttp<\/code>; the knowledge base answer writes <code>appendHTTP<\/code>. Use the send page spelling, and on your test message confirm the link does or doesn&#8217;t start with <code>https:\/\/<\/code> as you asked. If it ignores the flag, plan length on the 23-character worst case and you&#8217;re covered either way.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Seven. Treat every <code>\"0\"<\/code> phone number as unattributed, whichever path made the link.<\/strong> A click without a number can&#8217;t be pinned on a recipient, and spreading it across recipients invents data. Count it as campaign-level.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Eight. Assume a deleted link stops redirecting.<\/strong> If it keeps working, retiring rather than deleting cost you nothing. If it stops, you didn&#8217;t break a message in someone&#8217;s inbox.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Nine. Assume the report has no paging and no history guarantees.<\/strong> Ingest everything on each poll, dedupe on <code>ciId<\/code> and log row counts. If rows ever start disappearing from the platform&#8217;s side (retention, a cap on returned rows), your ledger already has them and the falling count tells you when it began.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Ten. Read <code>status<\/code>, not <code>code<\/code>.<\/strong> Success arrives as <code>\"200\"<\/code> on create, read and report and as <code>\"192\"<\/code> on delete. A client that branches on <code>status<\/code> handles both and doesn&#8217;t care which numbers come next.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Eleven. Assume a link on a non-SMS channel produces campaign-level clicks.<\/strong> The send-time switch is on <code>SMSApi\/send<\/code>. If a WhatsApp or RCS click ever does come back with a number, treat it as a bonus and attribute it through the same join, which already handles both cases.<\/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>Can I see exactly which recipient clicked an SMS link?<\/strong><br>Yes, if you turn on <code>trackLink=true<\/code> when you send. The visitor report then carries a <code>mobileNo<\/code> for clicks tied to a recipient. Clicks on a standalone link created with <code>linktrack\/create<\/code> and pasted into messages come back without one (<code>\"0\"<\/code>).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Does <code>linktrack\/create<\/code> return an id I can use later?<\/strong><br>No. It returns the short URL in <code>linktrackList<\/code>. Read the list with <code>linktrack\/read<\/code> and match your unique <code>identifier<\/code> and the <code>shortUrl<\/code> to get the <code>lcId<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Why did only one of my two links get tracked?<\/strong><br>Because send-time tracking converts a single URL, the last one in the message. Put the link you want to measure at the end, or create a standalone link for the other.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Will a tracked link make my message longer or shorter?<\/strong><br>Usually shorter. Short links are 21 to 23 characters with <code>https:\/\/<\/code> and 13 to 15 without. If your original URL was already shorter than that, the message gets longer, so estimate with the short link&#8217;s length.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Do I need to whitelist anything on DLT?<\/strong><br>Yes. Whitelist the short link&#8217;s domain on every DLT portal you send through, not just your own website&#8217;s domain. Take the domain from a real short URL your account returns.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Is there a webhook for clicks?<\/strong><br>Not for link clicks. Poll <code>SMSApi\/linktrack\/report<\/code> per link, and slow the polling down as the link gets older.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Why is my click rate so much higher than my sales?<\/strong><br>Mostly previews and security scanners. They fetch links within seconds of delivery and look like clicks. Flag them, compute a human click rate, and measure conversions on your own landing page.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Can I use <code>clickThroughRate<\/code> from the read endpoint in reports?<\/strong><br>Not as a source of truth. It&#8217;s a display string like <code>\"0%\"<\/code> with no stated numerator or denominator. Compute your own rate from delivered recipients and human-flagged clicks.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What does a delete response look like?<\/strong><br><code>status<\/code> &#8220;success&#8221;, <code>msg<\/code> &#8220;Campaign deleted successfully.&#8221; and <code>code<\/code> &#8220;192&#8221;. Check <code>status<\/code>, and don&#8217;t delete links that are still in messages people might open.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Can I track attachment links?<\/strong><br>Yes. Create the link with <code>type=attachment<\/code> and upload the file as form-data. You&#8217;ll get a short URL to put in the message. Clicks on it are campaign-level unless the report shows numbers.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">Unlock End-to-End Click Analytics via API<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Want click data you can join to your own message records? <a href=\"https:\/\/www.smsgatewaycenter.com\/demo\/\">Try the API in the sandbox<\/a>, turn on <code>trackLink=true<\/code> for one test send to your own phone, and watch the click come back through <code>SMSApi\/linktrack\/report<\/code>. When you&#8217;re ready for production volumes or a custom short domain, <a href=\"https:\/\/www.smsgatewaycenter.com\/contact\/\">talk to our team<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">More From Our Blog<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/consent-capture-evidence-sms-whatsapp-rcs-telegram\/\">Consent Capture and Evidence for SMS, WhatsApp, RCS and Telegram: Building an Opt-In Record You Can Prove<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/two-way-sms-keyword-routing-auto-reply-design\/\">Two-Way SMS Keyword Design: Routing, Auto-Replies and Conversation State on a Long Code<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/opt-out-suppression-sms-whatsapp-rcs-telegram\/\">Opt-Out and Suppression Across SMS, WhatsApp, RCS and Telegram: Building the List That Actually Stops Sends<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/scheduling-messages-sms-whatsapp-rcs-telegram\/\">How to Schedule Messages on SMS, WhatsApp, RCS and Telegram (and Why You Still Need Your Own Scheduler)<\/a><\/p>\n\n\n\n<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<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n","protected":false},"excerpt":{"rendered":"<p>A click count on a dashboard isn&#8217;t attribution. This guide shows how to wire the four SMSApi\/linktrack endpoints and the send-time trackLink switch into a click ledger you own, join every click back to a message and a recipient, filter preview and scanner clicks, and compute a click rate you can defend.<\/p>\n","protected":false},"author":118,"featured_media":3076,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[2010],"tags":[2326,2224,1244,2328,2325,2323,1438,2322,2327,2324],"class_list":["post-3075","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-developer-guides","tag-bot-clicks","tag-data-model","tag-dlt-url-whitelisting","tag-link-tracking-api","tag-linktrack","tag-smart-links","tag-sms-analytics","tag-sms-attribution","tag-sms-click-tracking","tag-url-shortener"],"_links":{"self":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/3075","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=3075"}],"version-history":[{"count":0,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/3075\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media\/3076"}],"wp:attachment":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media?parent=3075"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/categories?post=3075"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/tags?post=3075"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}