Skip to main content

Short links with click tracking and campaign attribution

Orbit’s short-link subsystem runs on POST /api/v1/links. Send a URL, get a tracked short URL back, then read per-click analytics that attribute the tap back to the recipient, campaign, and message that sent it. Send time can mint links inline when metadata.shorten_urls is set, so a campaign template with a long https://… destination goes out with a compact, click-tracked URL without changing the template. This guide covers the managed short-link surface; the quick-action deep-links guide covers dashboard compose URLs, which are a separate feature. For runnable request/response pairs see the short links cookbook; for request and response schemas see the links API reference. For the channel-level rollup of SMS sends, tracked links, and CTR per campaign or queue, see Insights → SMS click-through. This page covers when to use each mode and what each click surface gives you. Every short link is a row with an id, a 6-character code, and the original URL it points to. The public redirect is GET /l/:code, mounted at the root of the API host (not under /api/v1), anonymous, and rate-limited per IP. A minted URL therefore looks like https://api.orbit.devotel.io/l/AbC123 on the platform default, or https://go.yourbrand.com/l/AbC123 on a branded domain. Each click on the redirect records a raw click row (IP, user-agent, referrer), a best-effort country from the edge CDN geo header, and a traffic-quality classification — then fans out to per-link stats, per-contact click history, and a short_link.click webhook event. Code generation uses rejection sampling over a 56-character alphanumeric alphabet (no ambiguous 0/O/1/I/l characters), so codes are uniformly distributed.

2. Auto-shorten inside a send

The send pipeline shortens URLs inline in three cases: Explicit opt-in. Pass metadata.shorten_urls: true on the send (the dashboard compose dialog shows a preview before launch). On non-SMS channels this shortens every http(s):// URL — shortening there is for click analytics, so length is irrelevant. On SMS it refuses to swap a URL for an equal-or-longer short link, because the whole point of SMS shortening is to cut segment cost. SMS auto-shorten. Defaults ON per tenant (sms_auto_shorten_urls in org settings; set it to false to disable). It only shortens URLs longer than 30 characters AND longer than the minted short-URL length for your resolved domain — so it never makes a body longer than the original, which matters on the platform default domain where minted links run ~37 characters. WhatsApp auto-track. Defaults ON per tenant (whatsapp_auto_shorten_urls). WhatsApp linkifies a scheme-less www. host into a tappable link, so this mode also mints for those spans (anchored on www. plus at least two dot-separated labels, so prose like index.js or version 2.0 is never mistaken for a link). Without it, a tap on a raw link in a free-text WhatsApp body never reaches our redirect and short_link.click never fires. Across all three modes, minting is fire-and-forget per URL: if a mint call fails, the original URL stays in the body verbatim and the send proceeds with the long link. Trailing prose punctuation captured alongside a URL (See https://example.com/sale. ) is stripped from the minted URL but left in the on-wire text, so the recipient still reads the original punctuation. POST /api/v1/links mints a tracked link outside any send — for web pages, social bios, print collateral, or partner distribution. The body is { url, campaign_id? }; the response carries the record plus short_url. Only http:// and https:// URLs are accepted. Use this when the link is going into a surface you don’t control through the Orbit send pipeline — you still get the same click-tracking, stats, and attribution as a send-embedded link. Add campaign_id explicitly when the link belongs to a campaign and you want it on the per-campaign analytics rollup. Mint one from the raw API or any SDK with your dv_test_sk_… key:
A 201 Created returns the link record plus the ready-to-share short_url. The cookbook recipe decodes the full response envelope and the links API reference lists every field.

4. Branded short domains

Set a branded domain under Settings → General → Branded short-link domain. The resolution order is:
  1. Your tenant’s branded domain, if set. It must be a bare https:// (or http:// for staging) host with no path, query, or fragment — https://go.yourbrand.com, not https://go.yourbrand.com/landing. DNS must point to Orbit’s redirector with a valid TLS certificate and a served GET /l/:code route.
  2. Platform default — https://api.orbit.devotel.io, resolved from DEVOTEL_SHORT_LINK_DOMAIN when an operator has provisioned a genuine vanity short host (e.g. https://orbt.to), then DEVOTEL_API_URL.
A malformed tenant value never blocks the send — it falls back to the platform default and logs a warn-level line so the operator can fix the setting. Provision DNS, TLS, and the /l/:code ingress rule before setting a branded domain; point it at a host that does not serve the redirect and newly minted links will fail to resolve. Activate it. Open Settings → General and find the Branded short-link domain card. Point your host’s DNS at Orbit’s redirector first (your ops contact provisions the CNAME and the managed TLS certificate), then paste the bare host — https://go.yourbrand.com — and Save. The card validates before writing: it accepts a bare http(s):// host only, silently strips a trailing slash, and rejects anything carrying a path, query, or fragment with an inline message. An active host shows a “Short links use” badge with the value; the Reset to default button clears it and reverts future mints to the platform domain (with a confirmation, since this changes every outbound short link you mint from then on). Clearing is the same tenant-side control — no support ticket needed either way. How minted URLs change shape. Nothing about the code, redirect, or analytics changes — only the base. Before: https://api.orbit.devotel.io/l/AbC123. After saving: https://go.yourbrand.com/l/AbC123. Every mint path honors it — one-off POST /api/v1/links calls, the compose dialog’s shorten preview, inbound-triggered mints, and all three auto-shorten modes — because the server resolves your branded host only when the stored value passes the same validation rules the card applies. Validation failures are upfront, not silent. The server-side resolver quietly falls back to the platform default for any invalid stored value, so a send is never black-holed — but the dashboard card rejects the bad value at save time instead of persisting a no-op. If you see links minting on the default host despite a saved value, re-open the card: the stored host almost certainly carries a path (https://go.yourbrand.com/landing) or a fragment, and save-time validation now tells you.

5. Click-quality signals — human vs scanner

A click is recorded raw, then scored against a user-agent bot signature list: generic automation (bot, crawl, spider), link-preview fetchers (WhatsApp, Slack, Telegram, Facebook, Discord, LinkedIn), search/SEO crawlers, headless browsers, uptime monitors, and scripted HTTP libraries (curl, python-requests, okhttp, …). Each click gets:
  • is_bot — true when the UA matches a signature, or the UA is missing entirely.
  • quality_score — 1.0 confident human, 0.0 confident bot, 0.3 missing UA. A verified click means score >= 0.5.
  • quality_reason — bot_user_agent, missing_user_agent, or null.
A missing user-agent is scored below verification threshold because a real browser essentially always sends one; a blank UA is overwhelmingly a script or a prefetch. The verified filter lives at the analytics read layer — raw clicks stay in the store — so every click ever recorded counts toward the raw totals, and retro-filtering applies to already-collected data.

6. Campaign attribution and webhooks

Every mint mints campaign_id and message_id onto the link row, so the click can resolve back to the recipient contact through the originating message. Three read surfaces expose the attribution chain:
  • Per-campaign rollup: GET /api/v1/links/campaigns/{campaignId}/link-analytics returns totals (minted links, raw clicks, verified clicks, unique clicker recipients, attributed recipients), per-channel breakdown, and a top-links ranking — with derived unique_attribution_rate and verified_attribution_rate guards so a campaign with no clicks never divides by zero.
  • Per-contact history: GET /api/v1/links/contacts/{contactId}/clicks closes the loop — the CDP engagement view answers “which links did THIS recipient tap,” newest first.
  • Tenant-wide insights: GET /api/v1/links/insights bundles KPIs, top links, and a day-by-day click timeline in one call.
The click also fires a short_link.click webhook (link_id, code, url, click_id, message_id, campaign_id, ip, user_agent, referrer, country, clicked_at, is_bot, quality_score, quality_reason). Subscribe to it from a webhook endpoint for real-time click streams in your CRM or alerting — the dispatch is fire-and-forget so a slow consumer never blocks the redirect. Use is_bot / quality_score in the consumer the same way the analytics layer does, so your CTR is filtered consistently. Verify the delivery before acting on it — every Orbit webhook is HMAC-signed, and short_link.click is easy to spoof otherwise. The verifier below is the same protocol as webhook security (canonical X-Orbit-Signature first, legacy X-Devotel-Signature fallback, 5-minute replay window, timing-safe compare):
Node.js — short_link.click handler with HMAC verification
The event catalog (events) lists the full short_link.click payload shape; the handler above reads the same is_bot / quality_score fields the analytics rollup filters on, so a bot pre-filter never has to be re-tuned downstream. Two aggregation scopes answer different questions, and mixing them up is the usual source of “the numbers don’t match” reports:
  • Per-campaign rollup (GET /api/v1/links/campaigns/{campaignId}/link-analytics) scopes every number to links whose mint carried that campaign_id — one campaign’s totals, channel breakdown, and top links.
  • Tenant-level rollup (GET /api/v1/links/insights) is the operator’s “how is link traffic doing across everything” view. One call returns the summary KPI block (total links minted, raw and verified clicks in the window, unique clickers, average clicks per link), a top_links ranking, and a day-by-day timeline. The window is window_days (default 7, up to 90) and top_limit sizes the ranking; Insights → SMS click-through layers the channel-side view on top.
Read it in the dashboard at Insights → Link tracking: the KPI cards show raw vs verified click totals over the selected window, the top-links table ranks your most-tapped destinations, the timeline plots per-day clicks, and a click into a link opens its per-click history (timestamp, IP country, user agent). The same page is also the manual mint surface (new link dialog) and where you delete a link along with its click history. The one discipline to hold: pass campaign_id to /links/insights when you want a campaign-scoped cut — it filters the ranking to that campaign’s links. Without it, the tenant rollup intentionally includes links attributed to no campaign at all (one-off mints via POST /api/v1/links with no campaign_id, SMS auto-shortens, and WhatsApp auto-tracks where attribution arrives later through the message). So a tenant total is never the sum of your per-campaign totals — the gap is exactly the un-attributed bucket, and a “campaigns sum less than the tenant total” reading is the system working, not missing data.

8. Landing pages

POST /api/v1/links/landing-pages builds a no-code microsite from a block list, on the same /links base path. A page starts in draft and only mints its trackable short URL when you POST /landing-pages/:id/publish. Per-page visits and conversions roll up at GET /landing-pages/:id/analytics by campaign and by originating message. The landing-page surface is tenant-facing API, not a platform introspection tool — the same state machine (draft → published → archived) applies whether you build pages via the API or the dashboard.

9. Error surface

POST /api/v1/links returns 422 Unprocessable Entity when url is missing, not a string, or fails URL parsing; the service additionally rejects any non-http(s) protocol with a validation error, so a javascript: or data: target never mints. Per-write rate limit: 20 requests per minute. Per-read: 60 requests per minute. On the public redirect (GET /l/:code), a malformed code or an unknown/expired link returns a 400 or 404 — but browsers get a self-contained, branded HTML page (not a raw JSON envelope), while API clients keep the canonical JSON error shape. The page is noindex, carries no tracking, and never echoes the raw code. A tampered stored target that somehow drifted to a non-http(s) protocol is refused at redirect time with the same branded page. For status taxonomy across the API, see error handling.

Troubleshooting

  • Links minted with the long platform default, not my branded domain. The tenant setting must be a bare https://host with no path/query/fragment — a value like https://go.yourbrand.com/page falls back to the default. Check the dashboard field or the warn log; after fixing, re-mint your links (existing short URLs keep resolving).
  • Branded domain saved, but inbox/social previews still show the old host — or the links resolve to nothing. DNS hasn’t propagated yet after you pointed the host, or the record points somewhere that isn’t Orbit’s redirector. The mint base is set at save time, so pre-set mints keep the default host; wait for propagation, and verify the CNAME target with your ops contact before reusing branded links in an outbound send — a link minted on a host whose redirect doesn’t serve GET /l/:code is a dead tap for recipients.
  • The branded-domain card rejects my value. Read the inline message — the usual causes are a path (/landing), a missing scheme, or a bare hostname without https://. A trailing slash is stripped silently; anything else is rejected so you never save a silently-ignored value.
  • My campaign totals don’t add up to the tenant total on Insights → Link tracking. Tenant rollups include un-attributed links — one-off mints without campaign_id, plus auto-shortened sends where attribution arrives later. Scope with the campaign_id filter when you want a campaign-only cut; the residual gap is the working-as-designed un-attributed bucket.
  • SMS body got longer with shorten_urls ON. Impossible after the net-shortening guard — the explicit opt-in path also refuses equal-or-longer swaps. If you saw growth on an older send, the link predates the guard; re-send and the current send pipeline will no-op on that URL.
  • Click counts are inflated for a WhatsApp or SMS send. Filter on verified_clicks and quality_score >= 0.5 — link-preview fetches inflate raw clicks by design, because the redirect can’t distinguish a preview from a tap until it reads the user-agent.
  • short_link.click webhook is absent for a free-text WhatsApp send. Template/broadcast sends carry no on-wire body (finalBody is empty — Meta renders the approved template at send time), so auto-track only fires for free-text bodies. For template clicks, the tracking has to live in the template’s approved URL, which is a Meta re-approval concern.