Skip to main content

SMS click-through parity

A channel-level click-through surface for SMS: for each campaign (or messaging queue), how many outbound sends went out, how many carried a tracked short link, how many got clicked, and the resulting CTR. Per-link statistics answer “who clicked this link”; this endpoint answers the channel question — “what is the click-through rate on this campaign.” Base path: /api/v1/analytics Dashboard: the same aggregation powers Insights → SMS click-throughs in the dashboard — the SMS click-through page documents the dashboard reading (group/window controls, the unattributed bucket, the verified-click filter).

Using the SDKs

Prefer the typed client, but this page’s endpoint has no helper yet — the generic request() keeps auth/retries and the { data, meta } envelope identical:
Python (same call via the SDK’s escape hatch):
Query parameters are appended inline to the path in the Node SDK (the ?group=queue&window=30d shown above) — or passed as the keyword argument params={...} in Python. The Python SDK is core-scope — it wraps the 8 core resources (messaging, voice, contacts, campaigns, verify, numbers) and reaches everything else through the generic client.request() escape hatch above. See the Python SDK. Ruby (stdlib Net::HTTP — the Ruby SDK has not published yet, so the standard-library client is the dependable form):
The vendored Ruby SDK reaches the same endpoint through its escape hatch once vendored from source — client.request("GET", "/api/v1/analytics/sms-click-through", query: { group: "queue", window: "30d", pageSize: 100 }) — but the stdlib form above needs nothing installed. See the Ruby SDK. PHP (the curl extension — same reasoning; no SDK package to install today):
The vendored PHP SDK’s escape hatch — $client->request(method: "GET", path: "/api/v1/analytics/sms-click-through", query: ["group" => "queue", "window" => "30d", "pageSize" => 100]) — returns the identical envelope. See the PHP SDK. Verify the full six-language construction with pnpm --filter @devotel/docs run docs:regen before pushing — the generator re-renders the code tabs and the coverage audit reads the same fences, per Writing compliant API samples §4. Raw curl in the body of this page works identically. Full SDK index at SDK quickstart.

What the parity surface measures

Verifying a click figure means reconciling two sides over the same window:
  • Sends side — outbound SMS rows for your tenant, grouped by the attribution key you pick (campaign or queue).
  • Click side — every shortened link embedded in an outbound SMS, joined back to its message, rolled up per the same key.
The public response carries both sides per bucket, so you can reconcile them in one read: sends on one axis, tracked sends and click rollups on the other, over the identical window and group. A bucket where the attribution key never resolved (ad-hoc single-message sends carry no campaign id) reports as unattributed instead of disappearing.

Endpoint

GET /api/v1/analytics/sms-click-through

Response shape

The standard envelope wraps the payload: { "data": { … }, "meta": { "request_id": "…", "timestamp": "…" } }. The data payload:
Per-bucket fields: Rate fields are null when the bucket carried no tracked sends — CTR is undefined there, and the API returns null rather than a misleading 0. When a bucket is unattributed, bucketKey is null and the dashboard renders it as unattributed. totals aggregates across all buckets in the window, not just the returned page, so the KPI header stays correct while you paginate. Responses are cached for 60 seconds behind a stampede lock; repeated polling within a minute returns the same payload.

Worked example — group by queue, 24-hour window

Flip group to queue to attribute clicks to the messaging service (queue) recorded at send time, with the provider tag as the fallback key — the same precedence the dashboard toggle uses. Buckets where the queue never resolved land under the unattributed row rather than disappearing.
The TX Alerts queue sends transactional SMS with no shortened links, so its rate fields are null and its clicks zero — the bucket still counts toward totals.sends. Page 2 of this response returns the remaining bucket (pagination.total is 3, totalPages is 2) — including null bucketKey rows for sends that never resolved a queue. See the Analytics API page for a pagination loop that drains every page.

Worked example — group by campaign, with the unattributed bucket

Flip group back to campaign (the default) to bucket sends by the campaign id recorded at send time. Ad-hoc single-message sends carry no campaign id, so they roll into the unattributed bucket — bucketKey and bucketLabel are null, and the dashboard row reads unattributed. The Request/Response pair below is a CodeGroup in all six languages — Ruby and PHP use the stdlib clients (Net::HTTP, the curl extension) until those SDKs publish, exactly the form the SDK index reports.
The second row is the unattributed bucket — sends with no campaign id, still carrying real click counts. A bucket can only disappear from rows when it carries no sends at all; an attribution failure never cancels the aggregation, it relocates it.

Interpretation

  • CTR disputes against delivery reports. A delivery status tells you the message reached the handset; it says nothing about engagement. Compare verified_click_rate (human clicks) against click_rate (any click) — a large gap means scanners, link previews, or bots are inflating the raw count, and the verified rate is the defensible figure.
  • Untracked sends. sends minus tracked_sends is the volume sent without a shortened link; those messages can never register a click, and the rate denominator excludes them by design.
  • Double counting. A send carrying several links, or one recipient clicking several times, lands in total_clicks. The send-level counters (tracked_sends, sends_with_clicks) count each message once, so the fan-out never inflates the denominator.

Relationship to campaign analytics

Use group=campaign as the click-through axis alongside your campaign-level delivery and cost dashboards. For A/B tests, verified_click_rate is the winner metric — it filters bot traffic that otherwise skews small-sample comparisons. ROAS surfaces attribute revenue; this surface attributes engagement, and a healthy ROAS with a falling CTR usually means the link placement or copy, not the offer, needs work.

See also

  • Analytics API — message traffic, deliverability, and scheduled reports
  • Short Links API — per-link creation, stats, and tenant-wide link insights