SMSGatewayCenter Blog

SMS Click Tracking and Attribution: Joining Smart Link Clicks to Messages and Recipients

A click count on a dashboard isn'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.

Featured image for SMS Click Tracking and Attribution: Joining Smart Link Clicks to Messages and Recipients
Abstract diagram of messages passing through a short link and joining into a grid of records
A click only becomes attribution once it joins back to a message and a recipient in a table you own.

Table of Contents

  1. The Short Answer
  2. TL;DR
  3. Two Ways to Get a Tracked Link
  4. The Four Linktrack Endpoints at a Glance
  5. Creating a Link and Finding Its lcId
  6. Turning On Tracking at Send Time
  7. What the Short URL Does to Message Length
  8. DLT: Whitelist the Short Domain, Not Just Yours
  9. Reading Clicks Back: The Visitor Report
  10. Parsing the Sentinels: “0”, “NA” and “0%”
  11. The Attribution Data Model
  12. Joining a Click to a Message and a Recipient
  13. Polling Without a Click Webhook
  14. Bot and Preview Clicks
  15. Computing a Click Rate You Can Defend
  16. Clicks Are Personal Data
  17. Deleting Links Safely
  18. Decision Matrix
  19. Pre-Launch Checklist
  20. Unspecified Behaviour and How to Code Around It
  21. FAQs

The Short Answer

You get tracked links on SMS Gateway Center two ways. Either you turn on trackLink=true when you call SMSApi/send, and the platform swaps the last URL in your message for a short link, or you create a link up front with SMSApi/linktrack/create and put the short URL into the message yourself. Clicks come back through SMSApi/linktrack/report, one row per visit, with a ciId, a mobileNo, device and network details and an epoch-millisecond timestamp. There’s no click webhook, so you poll.

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’s lcId. A click ledger keyed on ciId, deduplicated. A join from (lcId, mobileNo) 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.

TL;DR

  • Two paths. Send-time trackLink=true (plus smartLinkTitle and appendHttp) on SMSApi/send, or a standalone link from SMSApi/linktrack/create. Only the send-time path ties clicks to recipients’ mobile numbers, per the product page.
  • Only the last URL in a message gets converted at send time. Put the one you care about last.
  • Create returns the short URL and no id. The response is linktrackList: ["http://t1x.in/WypK"]. Read the list back with SMSApi/linktrack/read and match on a unique identifier you chose, to find the lcId.
  • Read has no filters. It takes credentials and output only. Pull the list and filter on your side.
  • Report takes campaignid and returns rows with quoted ciId, mobileNo, timestamp. A mobileNo of "0" means the click isn’t tied to a number. "NA" shows up for unknown location and operator.
  • Delete is keyed id and succeeds with code "192", not "200".
  • Short links are 21 to 23 characters with https://, 13 to 15 without. That can take a message from two segments to one. It can also lengthen a message whose URL was already short.
  • Whitelist the short link’s domain on your DLT portal, not just your own website’s.
  • Store ids and timestamps as text, parse at the edge, treat "0" and "NA" as null.
  • Flag suspected bot clicks; never delete them. Previews and scanners fetch links within seconds of delivery.
  • Your click rate is unique human-flagged clicking recipients over delivered recipients, computed from your own tables, not the clickThroughRate string.

Two Ways to Get a Tracked Link

Start with this choice, because it decides what you can attribute later.

Send-time tracking. You call SMSApi/send with trackLink=true. The send page describes it in one line: “Enable true to track links from your SMS content.” You can add smartLinkTitle (“A brief title to recognise your smart link campaign”) and appendHttp (default true; set it to false to drop https:// from the short link). The platform finds the URL in your message, shortens it and tracks the clicks. The Smart Link Tracker product page says the system tracks “each click with unique identifiers tied to the recipient’s mobile number.” That’s the path that gives you per-recipient clicks.

Standalone links. You call SMSApi/linktrack/create with a longurl, an identifier and a type of url or attachment, and you get back a short URL. You then paste that URL into whatever you’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 mobileNo set to "0".

One rule from the product page FAQ matters on the send-time path: “We will only convert SINGLE URL which is at the end of the message when system finds multiple URL in the message content.” 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’ll be campaign-level.

Send-time trackLink=trueStandalone linktrack/create
Where you turn it onSMSApi/send parameterSeparate call before sending
What gets shortenedThe last URL in the messageThe longurl you pass, or an uploaded attachment
Per-recipient clicksYes, per the product pageNot from the link alone
Works for attachmentsNoYes, type=attachment with form-data
What you know up frontNothing until the send goes outThe short URL, before you send
Best forCampaigns where you care who clickedShared links, attachments, non-SMS channels

The AI-powered link tracking announcement covers the dashboard side (geography, device and the insights tab). This guide is about the API side and the tables you build around it.


The Four Linktrack Endpoints at a Glance

All four live under https://unify.smsgateway.center/SMSApi/linktrack/. They take the usual credentials: userid and password as form fields, or the apikey header with userid. Send the header. Every response wraps its body in response with api set to "linktrack" and action set to the operation.

EndpointMethodKey inputWhat comes back
SMSApi/linktrack/createPOSTidentifier, longurl, type (url or attachment), optional attachment as form-datamsg “Campaign created successfully.”, code “200”, count 1, linktrackList as an array of short URL strings
SMSApi/linktrack/readPOST or GETCredentials and output onlylinktrackList[i].linktrack with lcId, identifier, destination, shortUrl, shortUrlAdvance, type, totalUploads, totalViewed, clickThroughRate, lastClickTimestamp, timestamp
SMSApi/linktrack/reportPOSTcampaignidlinktrackList[i].linktrack with ciId, mobileNo, os, browser, device, deviceName, ip, city, state, country, mobLocation, mobOperator, timestamp
SMSApi/linktrack/deletePOSTidmsg “Campaign deleted successfully.”, code “192”
Table comparing the four linktrack endpoints and send-time trackLink by method, key input, identifier returned, success code, list shape and main trap
Create hands back a URL, not an id. Read has no filters. Report’s mobileNo can be “0”. Delete succeeds with “192”.

Three things in that table will bite you if you skip them.

The list key is the same, linktrackList, on all three list-returning endpoints, but the row shape isn’t. On create it’s an array of plain strings. On read and report each row is wrapped in an object keyed linktrack. A single parser that assumes one shape breaks on the other.

Delete’s success code is "192". If your client treats anything other than "200" as failure, a successful delete looks like an error. Read the status field (“success”) instead, which is what you should be doing everywhere anyway.

And create gives you no lcId. The next section is about that.


Creating a Link and Finding Its lcId

Here’s the create sample response, verbatim:

{
    "response": {
        "api": "linktrack",
        "action": "create",
        "status": "success",
        "msg": "Campaign created successfully.",
        "code": "200",
        "count": 1,
        "linktrackList": [
            "http://t1x.in/WypK"
        ]
    }
}

You get the short URL. You don’t get the lcId, and the lcId is what you’ll want as the key in your link ledger and, most likely, as the campaignid when you ask for the visitor report. So you read the list back and find your row.

Read takes no filter parameters, so you can’t ask for one link. You get the list and pick yours out. The safe way to pick is by identifier, which is free text you supplied (“Enter a breif desciption to remember smart link campaign URL”). Make it unique and machine-readable: put your own key in it. ship-2026-10-12-7f3a beats October shipping.

Match on both identifier and shortUrl. If exactly one row matches both, you’ve got your lcId. If none or several match, stop and don’t guess.

import requests

BASE = "https://unify.smsgateway.center/SMSApi/linktrack/"

def post(path, userid, apikey, **fields):
    r = requests.post(
        BASE + path,
        headers={"apikey": apikey},
        data={"userid": userid, "output": "json", **fields},
        timeout=30,
    )
    r.raise_for_status()
    body = r.json()
    resp = body.get("response") if isinstance(body, dict) else None
    if not isinstance(resp, dict) or resp.get("status") != "success":
        raise RuntimeError(f"linktrack/{path} failed: {body!r}")
    return resp

def create_and_resolve(userid, apikey, identifier, long_url):
    created = post("create", userid, apikey,
                   identifier=identifier, longurl=long_url, type="url")
    urls = created.get("linktrackList") or []
    if len(urls) != 1 or not isinstance(urls[0], str):
        raise RuntimeError(f"unexpected create payload: {created!r}")
    short_url = urls[0]

    listing = post("read", userid, apikey)
    rows = [item.get("linktrack", {}) for item in listing.get("linktrackList") or []
            if isinstance(item, dict)]
    hits = [row for row in rows
            if row.get("identifier") == identifier and row.get("shortUrl") == short_url]
    if len(hits) != 1:
        raise RuntimeError(f"{len(hits)} rows match {identifier!r}; not guessing")
    return {"lc_id": str(hits[0]["lcId"]), "short_url": short_url,
            "destination": hits[0].get("destination")}

Two traps in that code are deliberate. The lcId goes through str() and stays text: it’s quoted in the sample ("690"), 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 lcId the moment you resolve it, and never resolve the same link twice.

If the create call succeeds and the read-back fails, you have a live short URL with no lcId in your ledger. Save the row anyway with lc_id null and a pending_resolve status, and let a retry job finish it. Don’t create a second link to “fix” it, or you’ll have two links with the same identifier and the uniqueness check above will refuse both forever.


Turning On Tracking at Send Time

On the send-time path you add three optional fields to a normal SMSApi/send call:

ParameterValuesWhat the send page says
trackLinktrue or false“Enable true to track links from your SMS content.”
smartLinkTitleFree text“A brief title to recognise your smart link campaign.”
appendHttptrue or false“System default is true. Disable to remove https:// from smartlink.”
curl -s https://unify.smsgateway.center/SMSApi/send \
  -H "apikey: $SGC_APIKEY" \
  --data-urlencode "userid=$SGC_USERID" \
  --data-urlencode "sendMethod=quick" \
  --data-urlencode "mobile=919xxxxxxxxx" \
  --data-urlencode "senderid=ACMEIN" \
  --data-urlencode "msgType=text" \
  --data-urlencode "msg=Your order 4471 has shipped. Track it here: https://www.example.com/orders/track?order=4471" \
  --data-urlencode "trackLink=true" \
  --data-urlencode "smartLinkTitle=ship-2026-10-12-7f3a" \
  --data-urlencode "appendHttp=true" \
  --data-urlencode "output=json"

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 {#url#} slot in your registered template. More on that in the DLT section.

The send response is the normal one: status, mobile, invalidMobile, a quoted nineteen-digit transactionId, statusCode "200" and reason. Nothing in it names the link. So treat smartLinkTitle the way you treat identifier on create: put your own unique key in it. After the send, read the link list and look for exactly one row whose identifier equals that title. If you find it, store its lcId against the send. If you don’t, leave the link unresolved and keep the send’s transactionId, which you’ll need for the join anyway.

You’ll also see a field on read rows called shortUrlAdvance, and in the sample it looks like this: "http://t1x.in/WypK%xxxxxx%". That’s the short URL with a placeholder suffix on the end. It’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’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.

Keep your own copy of what you asked for. The outbound row from the outbound message table already has the transactionId, the recipient, the template and the time. Add two columns: track_link (boolean, what you sent) and link_title (the smartLinkTitle you sent). Those two columns are what let you ask, months later, “which messages carried a tracked link, and which campaign was it?”


What the Short URL Does to Message Length

The product page FAQ gives the lengths: “The short URL range between 21 to 23 characters long”, and “When you Exclude HTTPS then actual Short URL count would be 13 to 15 characters.” The difference is the eight characters of https://. (The append HTTP knowledge base answer rounds that to “about 6 to 8 characters”. It’s eight.)

Here’s a shipping message with a long tracking URL, worked out in code rather than by eye:

VersionURL lengthMessage lengthGSM-7 segments
Original long URL with UTM tags1041762
Short link with https:// (23 chars max)23951
Short link without https:// (15 chars max)15871

The message text before the URL is Your order 4471 has shipped and will arrive by Thursday. Track it here: (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.

Notice what that means for previews and cost estimates. The message you preview isn’t the message that goes out. If you estimate segments from the text you submit, you’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’s worst case, 23 characters with https:// or 15 without, in place of the last URL. The preview and cost estimation guide has the segment rules, and the SMS length calculator is handy for spot checks.

Should you drop https://? It buys eight characters. The append HTTP knowledge base answer recommends keeping it on “unless you absolutely need to save characters”, because people are warier of links without it. That’s a fair default. Only turn it off when it’s the difference between one part and two on a large send, and check the click rate on a small batch first.


DLT: Whitelist the Short Domain, Not Just Yours

In India, a URL in a commercial SMS has to match a call-to-action that’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.

The DLT whitelisting knowledge base answer says it plainly: “if you use a link shortener, including Smart Links, whitelist the short link’s domain, not only your website’s domain.” Do that before the first tracked send, on every DLT portal you send through.

Which domain? The API samples show t1x.in. The product page FAQ shows tx1.in. Those are different hosts, and you shouldn’t guess between them. Take the host from a real shortUrl your account returns (create one throwaway link and read it), whitelist that, and add a check in your sender that the host of every shortUrl you store is on your whitelist list. If you’ve set up your own short domain (the custom short domain answer says the team configures it for you), whitelist that one.

The template side matters too. The URL must sit in a {#url#} variable in the registered template, not as literal text, because the text that goes out isn’t the text you wrote. The variable tags answer covers which tag fits which value, and DLT template rejections covers what happens when the shape is wrong.


Reading Clicks Back: The Visitor Report

SMSApi/linktrack/report is POST only and takes one required input, campaignid, described as “Enter a specific campaign ID to view reports.” Here’s the sample row:

{
    "linktrack": {
        "ciId": "200272",
        "mobileNo": "0",
        "os": "Mac OS X 10.14.5",
        "browser": "Safari 12",
        "device": "Desktop",
        "deviceName": "Apple Macintosh",
        "ip": "1.10.247.193",
        "city": "Mumbai",
        "state": "Maharashtra",
        "country": "India",
        "mobLocation": "NA",
        "mobOperator": "NA",
        "timestamp": "1563114285782"
    }
}

Each row is a visit. ciId is the visit’s id, and it’s the one thing you can dedupe on. timestamp is epoch milliseconds as a string: 1563114285782 is 2019-07-14 19:54:45.782 IST. The link it belongs to in the read sample was created at 1563114084731, 19:51:24.731 IST, so that click came 201 seconds after the link existed.

There are two kinds of location in the row. city, state and country come from the IP address. mobLocation and mobOperator describe the mobile number’s circle and operator, and they’re "NA" here because mobileNo is "0": there’s no number to look up. On a click that’s tied to a recipient you’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’s home circle. They disagree all the time, and both are useful.

What’s “campaign ID” here? The parameter is called campaignid, and delete’s id is described the same way (“Campaign ID”). The read rows carry lcId, and create’s message says “Campaign created successfully”, so the link and the “campaign” 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 lcId as campaignid and check your click is there. Put the result in a contract test so you hear about it if it changes. The contract testing harness guide shows how to pin a shape like this.


Parsing the Sentinels: “0”, “NA” and “0%”

The linktrack payloads use strings for nearly everything, and they use particular strings to mean “nothing”. If you load them as-is, your reports fill with a phone number called 0, a city called NA and a click rate you can’t add up.

FieldSample valueWhat it meansStore as
mobileNo"0"No recipient tied to this clickNULL
mobLocation, mobOperator"NA"Not knownNULL
lastClickTimestamp"NA"No clicks yetNULL
clickThroughRate"0%"A display percentageDon’t store it; compute your own
totalViewed, totalUploads"0"Counts, quotedInteger, after parsing
timestamp"1563114285782"Epoch millisecondstimestamptz, keep the raw text too
ciId, lcId"200272", "690"Ids, quotedText

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 "-", a null), you want to fix one function and replay, not hunt through reports.

// normalise.js: one place where linktrack strings become typed values
const NULLISH = new Set(["", "0", "NA", "N/A", "null", "-"]);

function nullable(value) {
  if (value === null || value === undefined) return null;
  const s = String(value).trim();
  return NULLISH.has(s) ? null : s;
}

function epochMs(value) {
  const s = nullable(value);
  if (s === null || !/^\d{12,14}$/.test(s)) return null; // refuse seconds, refuse junk
  return new Date(Number(s)).toISOString();
}

function normaliseClick(row, lcId) {
  const r = row && typeof row === "object" ? row.linktrack ?? row : {};
  const ciId = r.ciId === undefined || r.ciId === null ? null : String(r.ciId);
  if (!ciId) return null; // no id, nothing to dedupe on: park it, don't insert it
  return {
    ci_id: ciId,
    lc_id: String(lcId),
    mobile_no: nullable(r.mobileNo), // "0" -> null, never the number zero
    ip: nullable(r.ip),
    os: nullable(r.os),
    browser: nullable(r.browser),
    device: nullable(r.device),
    device_name: nullable(r.deviceName),
    ip_city: nullable(r.city),
    ip_state: nullable(r.state),
    ip_country: nullable(r.country),
    mob_location: nullable(r.mobLocation),
    mob_operator: nullable(r.mobOperator),
    clicked_at: epochMs(r.timestamp),
    raw: r,
  };
}

module.exports = { nullable, epochMs, normaliseClick };

if (require.main === module) {
  const sample = { linktrack: { ciId: "200272", mobileNo: "0", os: "Mac OS X 10.14.5",
    browser: "Safari 12", device: "Desktop", deviceName: "Apple Macintosh",
    ip: "1.10.247.193", city: "Mumbai", state: "Maharashtra", country: "India",
    mobLocation: "NA", mobOperator: "NA", timestamp: "1563114285782" } };
  console.log(normaliseClick(sample, "690"));
}

Run that and the sample comes out with mobile_no: null, mob_location: null and clicked_at: '2019-07-14T14:24:45.782Z', which is the same instant as 19:54:45.782 IST.

The "0" rule needs one caveat. "0" is nullish for mobileNo and wrong for counts. That’s why the counts (totalViewed, totalUploads) don’t go through nullable at all. Parse them with their own integer function that treats "0" as zero.


The Attribution Data Model

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.

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
The platform counts clicks. Your tables turn them into attribution.
-- One row per short link, whichever path made it.
CREATE TABLE link_campaign (
  link_key        text PRIMARY KEY,          -- your key, also in identifier / smartLinkTitle
  lc_id           text UNIQUE,               -- platform lcId; NULL until resolved
  created_via     text NOT NULL CHECK (created_via IN ('create', 'send')),
  short_url       text,                      -- exactly as returned
  short_host      text,                      -- host of short_url; must be DLT-whitelisted
  destination     text NOT NULL,
  link_type       text NOT NULL CHECK (link_type IN ('url', 'attachment')),
  resolve_status  text NOT NULL DEFAULT 'pending'
                  CHECK (resolve_status IN ('pending', 'resolved', 'failed')),
  created_at      timestamptz NOT NULL DEFAULT now(),
  retired_at      timestamptz                -- when you stopped putting it in messages
);

-- Which outbound messages carried which link. Fill it when you send.
CREATE TABLE message_link (
  transaction_id  text NOT NULL,             -- from SMSApi/send, as text (19 digits)
  msisdn          text NOT NULL,             -- the recipient, normalised
  link_key        text NOT NULL REFERENCES link_campaign(link_key),
  sent_at         timestamptz NOT NULL,
  PRIMARY KEY (transaction_id, msisdn, link_key)
);

-- One row per visit from linktrack/report. Never updated, never deleted by the ingester.
CREATE TABLE link_click (
  ci_id           text PRIMARY KEY,          -- platform ciId: the dedupe key
  lc_id           text NOT NULL,
  msisdn          text,                      -- mobileNo, NULL when "0"
  clicked_at      timestamptz,
  ip              text,
  os              text,
  browser         text,
  device          text,
  device_name     text,
  ip_city         text,
  ip_state        text,
  ip_country      text,
  mob_location    text,
  mob_operator    text,
  bot_suspect     boolean NOT NULL DEFAULT false,
  bot_reason      text,
  raw             jsonb NOT NULL,
  ingested_at     timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX link_click_join ON link_click (lc_id, msisdn, clicked_at);
CREATE INDEX message_link_join ON message_link (link_key, msisdn, sent_at);

Why ci_id as the primary key and nothing else? Because it’s the only thing in a click row that’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’ll spot a preview fetch followed by a tap). Dedupe by ciId and let ON CONFLICT DO NOTHING absorb the overlap between polls.

Why a separate message_link 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’ll only ever have one tracked link per message, a link_key column on the outbound row does the same job, and that’s fine too.


Joining a Click to a Message and a Recipient

The join has two keys: which link, and which phone. The link comes from lc_id, through link_campaign, to your link_key. The phone comes from msisdn on the click. On the send-time path that’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).

The rule: a click belongs to the latest message carrying that link to that number that was sent before the click.

-- Attribute each recipient-tied click to the latest earlier send of the same link.
SELECT c.ci_id,
       c.clicked_at,
       c.msisdn,
       lc.link_key,
       m.transaction_id,
       m.sent_at,
       c.clicked_at - m.sent_at AS time_to_click,
       c.bot_suspect
FROM link_click c
JOIN link_campaign lc ON lc.lc_id = c.lc_id
LEFT JOIN LATERAL (
  SELECT ml.transaction_id, ml.sent_at
  FROM message_link ml
  WHERE ml.link_key = lc.link_key
    AND ml.msisdn   = c.msisdn
    AND ml.sent_at <= c.clicked_at
  ORDER BY ml.sent_at DESC
  LIMIT 1
) m ON true
WHERE c.msisdn IS NOT NULL;

Three outcomes come out of that query, and each one tells you something.

A row with a transaction_id is attributed. That’s the goal.

A row with an msisdn but no transaction_id means the platform tied the click to a number you have no record of sending this link to. Usually that’s a forward: the recipient sent the message to someone else, and the platform recorded the forwardee’s network. Sometimes it’s a number-format mismatch (919... on one side, +91... on the other). Normalise numbers the same way on both tables before you decide it’s a forward.

Clicks with msisdn null aren’t in this query at all. They’re campaign-level. Count them, report them as unattributed, and don’t spread them across recipients.

Normalise the number format first. The send call wants a country code without + (919999999999 in the send page table). The report’s mobileNo sample is "0", so the format of a real one isn’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 + is the classic way to report a zero click rate for a campaign that worked.


Polling Without a Click Webhook

The webhook surface on this platform covers delivery reports and inbound messages. Clicks aren’t on it. You poll SMSApi/linktrack/report, one call per link, and because the page lists no date range, paging or “since” parameter, each call returns everything that link has collected so far.

That shapes the poller:

  • Poll per active link, not per account. 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’s last send. Tune it from your own data: once time_to_click from the join query stops producing new rows past some age, shorten it.
  • Back off as the link ages. 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’s click count, not with the time since your last call. Polling an old busy link every five minutes is pure waste.
  • Insert with ON CONFLICT (ci_id) DO NOTHING. Overlap between polls is expected. The primary key absorbs it.
  • Count what you see. 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.
  • Respect status. Parse the envelope, check status is "success", then read linktrackList. On anything else, keep the old data and retry later. Never treat an error as “zero clicks”.
import time

SCHEDULE = [(2 * 3600, 300), (2 * 86400, 3600), (30 * 86400, 86400)]  # (age limit, interval) in seconds

def due(link_age_s, since_last_poll_s):
    for limit, interval in SCHEDULE:
        if link_age_s <= limit:
            return since_last_poll_s >= interval
    return False  # past the cut-off: stop polling this link

def poll_link(db, client, lc_id):
    resp = client.post("report", campaignid=lc_id)   # raises unless status == "success"
    rows = resp.get("linktrackList") or []
    inserted = 0
    for item in rows:
        click = normalise_click(item, lc_id)          # same rules as the Node normaliser
        if click is None:
            db.park_unkeyed(lc_id, item)
            continue
        inserted += db.insert_click_if_new(click)     # INSERT ... ON CONFLICT (ci_id) DO NOTHING
    db.record_poll(lc_id, returned=len(rows), inserted=inserted, at=time.time())
    return inserted

If you’d rather not poll at all, use the destination side. Your landing page sees every human visit with a query string you control. That’s the subject of the click rate section, and it’s the more reliable signal anyway.


Bot and Preview Clicks

On SMS, a meaningful share of clicks aren’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.

The vendors who’ve measured this agree on the main signs. Braze’s bot click filtering looks at user agents from crawlers, link previews and messaging security software. TrueDialog’s clicks report notes that you can’t tell from the link alone whether a person tapped or the phone previewed, and that deduplicating by contact doesn’t fix it. So the click ledger has to carry a judgement, and that judgement has to be revisable.

Flag, don’t delete. The bot_suspect and bot_reason columns are there so you can change your rules and recompute without having thrown anything away. Rules that are a good start:

SignalWhy it points to a botRule to start with
Click very soon after deliveryPreviews and scanners fire as the message landsUnder 10 seconds after the delivery time from your DLR row
Data-centre or scanner user agentReal phones don’t browse as bot, crawler, preview, scannerCase-insensitive match on os, browser, device_name
Several clicks from one IP across many recipients in a minuteOne scanner working through a batchMore than 5 distinct msisdn from one ip within 60 seconds
A click before the message was deliveredNothing human can do thatclicked_at earlier than the delivery time
Desktop OS on a recipient-tied click within secondsGateways, not people, open links on servers that fastdevice = Desktop and under 30 seconds

Every threshold in that table is a starting point, not a published rule. Look at the distribution of time_to_click 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.

A useful pattern: when a recipient-tied click is flagged as a bot and the same msisdn 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.


Computing a Click Rate You Can Defend

The read endpoint gives you clickThroughRate as a string like "0%". The dashboard knowledge base answer defines it as “the percentage of people who clicked the link out of the total opens”, which isn’t a number you can reproduce, because “opens” for a link is the click. Don’t store it, don’t chart it and don’t compare campaigns on it. Compute your own from your tables, so every input is visible.

-- Recipient-level click rate for one link, from your own tables.
WITH delivered AS (
  SELECT DISTINCT ml.msisdn
  FROM message_link ml
  JOIN dlr_event d ON d.transaction_id = ml.transaction_id AND d.msisdn = ml.msisdn
  WHERE ml.link_key = :link_key
    AND d.status = 'DELIVERED'
),
human_clickers AS (
  SELECT DISTINCT c.msisdn
  FROM link_click c
  JOIN link_campaign lc ON lc.lc_id = c.lc_id
  WHERE lc.link_key = :link_key
    AND c.msisdn IS NOT NULL
    AND NOT c.bot_suspect
)
SELECT (SELECT count(*) FROM delivered)                                   AS delivered_recipients,
       (SELECT count(*) FROM human_clickers h JOIN delivered d USING (msisdn)) AS clicking_recipients,
       round(100.0 * (SELECT count(*) FROM human_clickers h JOIN delivered d USING (msisdn))
             / NULLIF((SELECT count(*) FROM delivered), 0), 2)            AS click_rate_pct;

The denominator is delivered recipients, not submitted. A message that never arrived can’t be clicked, and counting it hides delivery problems inside a “low engagement” story. That’s why the query joins to your delivery reports. The delivery report ingestion guide builds the dlr_event table this assumes.

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 msisdn). If raw clicks are three times human clicks, people should see that, not have it smoothed away.

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 ?m=<link_key>, and have the landing page log a server-side event when it’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.


Clicks Are Personal Data

A click row ties a phone number to an IP address, a device, an approximate location and a time. In India that’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.

Practical rules that keep you out of trouble:

  • Purpose. Collect clicks to measure and improve messaging you were allowed to send. Don’t quietly turn them into a tracking profile used for something else.
  • Retention. 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 ip, msisdn and device_name, keep the counts. Write the rule down and run it as a job.
  • Access. The click ledger doesn’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.
  • Opt-outs. A recipient who opts out stops getting messages. Their old clicks can stay in aggregate counts, but don’t use them to target anything new. The opt-out and suppression guide covers the suppression table this ties into.
  • Notice. If your messages carry tracked links, your privacy notice should say that link clicks are recorded and what’s recorded. One sentence is enough.

None of this is legal advice for your particular case. It’s the minimum an engineering team should build in before the first tracked send, rather than after the first question from a customer.


SMSApi/linktrack/delete is POST only, keyed id (“Enter a campaign ID to delete”), and a success looks like this:

{
    "response": {
        "api": "linktrack",
        "action": "delete",
        "status": "success",
        "msg": "Campaign deleted successfully.",
        "code": "192"
    }
}

Two things to settle before you ever call it.

What happens to links already in people’s inboxes. A message sent last week still has the short URL in it. Whether that URL keeps redirecting after a delete isn’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’s in a message still sitting in a schedule. Retire it in your own table (retired_at) and stop putting it in new messages instead.

What happens to its click history. Pull the full report and confirm the row count matches your ledger before deleting. After a delete, assume the report is gone. Your link_click rows stay, because they’re yours.

If you need a clean-up job at all, make it delete only links that are older than your retention period, have retired_at set, have had no clicks in the last 90 days, and whose report you’ve ingested in full within the last day. Check status, not code, on the response, since success comes back as "192".


Decision Matrix

You needUseWhy
Who clicked, per recipient, on an SMS campaignSend-time trackLink=true with a unique smartLinkTitleIt’s the path the product page ties to recipients’ numbers
A tracked link for an attachment (PDF, image)linktrack/create with type=attachmentSend-time tracking shortens URLs only
One link shared across SMS, WhatsApp, email and a QR codelinktrack/createYou get the short URL before you send, and clicks are campaign-level anyway
Two tracked URLs in one SMSSend-time for the last URL, standalone link for the otherOnly the last URL gets converted at send time
A message right at 160 charactersMeasure with the short link’s worst case; drop https:// only if it saves a partThe short link can lengthen a message whose URL was already short
Revenue or conversion numbersYour own landing-page event, keyed by a query parameter on the long URLBots load pages; they rarely convert
A click rate for a weekly reportYour SQL over delivered recipients and human-flagged clicksclickThroughRate is a display string with an unclear definition
Clicks in near real timePoll linktrack/report on a backoff scheduleThere’s no click webhook
Removing old linksRetire them in your table; delete only past retention after a full ingestA deleted link may stop redirecting from old messages

Pre-Launch Checklist

  • Take the short host from a real shortUrl your account returns, and whitelist it on every DLT portal you send through.
  • Put the tracked URL in a {#url#} slot in the registered template, and make it the last URL in the message.
  • Generate a unique key per link and send it as identifier (create) or smartLinkTitle (send).
  • Resolve lcId by reading the list back and matching exactly one row; store it as text.
  • Save track_link and link_title on every outbound row, and fill message_link at send time.
  • Estimate segments with 23 characters (or 15 without https://) in place of the last URL.
  • Normalise "0", "NA" and empty strings to null in one function; keep the raw row.
  • Store ciId, lcId, transactionId and phone numbers as text in one canonical format.
  • Insert clicks with ON CONFLICT (ci_id) DO NOTHING; never update or delete click rows from the ingester.
  • Poll per active link on a backoff schedule with a cut-off; log rows returned per call.
  • Flag suspected bot clicks with a reason; never drop them.
  • Compute click rate over delivered recipients, from your own tables.
  • Add a landing-page conversion event keyed by your link key.
  • Write down retention for raw click rows and schedule the anonymising job.
  • Send one test message to a phone you own, tap the link, and check the click appears with your number.

Unspecified Behaviour and How to Code Around It

A few behaviours here aren’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.

One. Resolve lcId by exact match on two fields, and fail closed. Create hands back a URL and no id. Match identifier and shortUrl together and accept exactly one row. Zero or two matches leave the link pending, which costs you a retry, not a wrong join.

Two. Treat smartLinkTitle as a probable identifier, and confirm it with a read-back. 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’ve got the lcId. If it doesn’t, your attribution still works through transactionId and msisdn; you’ve only lost the shortcut.

Three. Use the lcId as campaignid, and prove it with your own click. Report asks for a “Campaign ID” and the only id the read list gives you is lcId. 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.

Four. Never fill shortUrlAdvance yourself. The %xxxxxx% suffix looks like a per-recipient slot, but the rule for what goes there isn’t yours to guess. Let trackLink=true 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.

Five. Don’t hard-code the short domain. The samples say t1x.in and the product FAQ says tx1.in. Read the host off every shortUrl you store and check it against your whitelist before the send. A mismatch blocks one send instead of failing a whole DLT batch.

Six. Send appendHttp exactly as the send table spells it, and check the result. The send page writes appendHttp; the knowledge base answer writes appendHTTP. Use the send page spelling, and on your test message confirm the link does or doesn’t start with https:// as you asked. If it ignores the flag, plan length on the 23-character worst case and you’re covered either way.

Seven. Treat every "0" phone number as unattributed, whichever path made the link. A click without a number can’t be pinned on a recipient, and spreading it across recipients invents data. Count it as campaign-level.

Eight. Assume a deleted link stops redirecting. If it keeps working, retiring rather than deleting cost you nothing. If it stops, you didn’t break a message in someone’s inbox.

Nine. Assume the report has no paging and no history guarantees. Ingest everything on each poll, dedupe on ciId and log row counts. If rows ever start disappearing from the platform’s side (retention, a cap on returned rows), your ledger already has them and the falling count tells you when it began.

Ten. Read status, not code. Success arrives as "200" on create, read and report and as "192" on delete. A client that branches on status handles both and doesn’t care which numbers come next.

Eleven. Assume a link on a non-SMS channel produces campaign-level clicks. The send-time switch is on SMSApi/send. 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.


FAQs

Can I see exactly which recipient clicked an SMS link?
Yes, if you turn on trackLink=true when you send. The visitor report then carries a mobileNo for clicks tied to a recipient. Clicks on a standalone link created with linktrack/create and pasted into messages come back without one ("0").

Does linktrack/create return an id I can use later?
No. It returns the short URL in linktrackList. Read the list with linktrack/read and match your unique identifier and the shortUrl to get the lcId.

Why did only one of my two links get tracked?
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.

Will a tracked link make my message longer or shorter?
Usually shorter. Short links are 21 to 23 characters with https:// and 13 to 15 without. If your original URL was already shorter than that, the message gets longer, so estimate with the short link’s length.

Do I need to whitelist anything on DLT?
Yes. Whitelist the short link’s domain on every DLT portal you send through, not just your own website’s domain. Take the domain from a real short URL your account returns.

Is there a webhook for clicks?
Not for link clicks. Poll SMSApi/linktrack/report per link, and slow the polling down as the link gets older.

Why is my click rate so much higher than my sales?
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.

Can I use clickThroughRate from the read endpoint in reports?
Not as a source of truth. It’s a display string like "0%" with no stated numerator or denominator. Compute your own rate from delivered recipients and human-flagged clicks.

What does a delete response look like?
status “success”, msg “Campaign deleted successfully.” and code “192”. Check status, and don’t delete links that are still in messages people might open.

Can I track attachment links?
Yes. Create the link with type=attachment and upload the file as form-data. You’ll get a short URL to put in the message. Clicks on it are campaign-level unless the report shows numbers.


Unlock End-to-End Click Analytics via API

Want click data you can join to your own message records? Try the API in the sandbox, turn on trackLink=true for one test send to your own phone, and watch the click come back through SMSApi/linktrack/report. When you’re ready for production volumes or a custom short domain, talk to our team.


More From Our Blog

Consent Capture and Evidence for SMS, WhatsApp, RCS and Telegram: Building an Opt-In Record You Can Prove

Two-Way SMS Keyword Design: Routing, Auto-Replies and Conversation State on a Long Code

Opt-Out and Suppression Across SMS, WhatsApp, RCS and Telegram: Building the List That Actually Stops Sends

How to Schedule Messages on SMS, WhatsApp, RCS and Telegram (and Why You Still Need Your Own Scheduler)

Multi-Brand Messaging Architecture: Accounts, Sub-Users and Reseller Child Accounts for SMS, WhatsApp, RCS and Telegram


Save this interesting page on your favorite Social Media

Blog Author logo

SMS Gateway Center Desk

SMS Gateway Center is one of the largest and leading SMS Provider in India. It is run by a large professional team to cater small companies to large corporate companies. SMS Gateway Center is associated with the best operators in India covering the entire states in India. SMS Gateway Center has been serving through its SMS Resellers in more than 20 states in India. To become our SMS Reseller, kindly contact us

Looking for the best business communication solutions, get in touch!