> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Short links with click tracking and campaign attribution

> Mint tracked short links on a send or as a one-off, brand them on your own domain, filter bot clicks out of click-through analytics, and fan click events into campaigns, contacts, and webhooks.

# 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](/guides/quick-action-deep-links) covers dashboard compose URLs, which are a separate feature.

For request and response schemas see the [links API reference](/api-reference/links). This page covers when to use each mode and what each click surface gives you.

## 1. What a short link is in Orbit

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.

## 3. Explicit shortening — one-off links

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

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

## 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](/guides/error-handling-examples).

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