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 request and response schemas see the links API reference. 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.

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 defaulthttps://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.

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_score1.0 confident human, 0.0 confident bot, 0.3 missing UA. A verified click means score >= 0.5.
  • quality_reasonbot_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.

7. 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.

8. 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).
  • 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.