> ## 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 cookbook: mint, send, measure, react

> Runnable short-link recipes — mint a one-off tracked link and decode the envelope, send an SMS with metadata.shorten_urls and watch the on-wire swap, read per-campaign link-analytics plus per-contact clicks and tenant-wide insights, publish a landing page, and handle the short_link.click webhook with the decoded payload.

# Short links cookbook

Every recipe here runs against the links surface (`/api/v1/links`) end to end — request in, decoded response out — so you can copy the exact envelope your integration receives. The concept guide ([Short links with click tracking](/guides/short-links-and-click-tracking)) explains the modes and quality signals; this page shows the wire format. Field and parameter definitions are in the [links API reference](/api-reference/links).

Authenticate every request with `X-API-Key`. Run against the sandbox with a `dv_test_sk_…` key, then swap in your live key.

## Task index

| # | Recipe                                                                                                | Endpoints used                                                                                     |
| - | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| 1 | [Mint a one-off tracked link](#1-mint-a-one-off-tracked-link)                                         | `POST /links`                                                                                      |
| 2 | [Send an SMS with inline shortening](#2-send-an-sms-with-inline-shortening)                           | `POST /messages/sms`, `metadata.shorten_urls`                                                      |
| 3 | [Read campaign, per-contact, and tenant analytics](#3-read-campaign-per-contact-and-tenant-analytics) | `GET /links/campaigns/:id/link-analytics`, `GET /links/contacts/:id/clicks`, `GET /links/insights` |
| 4 | [Build and publish a landing page](#4-build-and-publish-a-landing-page)                               | `POST /links/landing-pages`, `POST /links/landing-pages/:id/publish`                               |
| 5 | [Handle the short\_link.click webhook](#5-handle-the-short_linkclick-webhook)                         | webhook                                                                                            |
| 6 | [Pick a read surface](#6-pick-a-read-surface)                                                         | decision table                                                                                     |

## 1. Mint a one-off tracked link

`POST /api/v1/links` mints a tracked link outside any send — use it when the URL goes into a surface you do not control through the Orbit send pipeline (a web page, a social bio, print collateral, a partner hand-off). Send the destination URL and, when the link belongs to a campaign, the campaign id so it lands on the per-campaign rollup.

```bash cURL theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/links \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/promo?utm_campaign=may26",
    "campaign_id": "cmp_may26"
  }'
```

A `201 Created` returns the full decoded envelope:

```json theme={null}
{
  "data": {
    "id": "link_01J4ZK8H2GQX7M9B3TNVAW4CDE",
    "code": "gH4kM2",
    "original_url": "https://example.com/promo?utm_campaign=may26",
    "tenant_id": "tenant_7f2a",
    "campaign_id": "cmp_may26",
    "message_id": null,
    "clicks": 0,
    "short_url": "https://api.orbit.devotel.io/l/gH4kM2",
    "created_at": "2026-08-27T09:14:22.417Z"
  },
  "meta": {
    "request_id": "req_01J6PZ3WT2Y1N0MDKX4H8RQB",
    "timestamp": "2026-08-27T09:14:22.512Z"
  }
}
```

Three things to keep from the response:

* **`data.code`** is the 6-character public code. The shareable URL is `short_url` — `{short-domain}/l/{code}` — and `GET /l/{code}` is the anonymous, rate-limited public redirect (mounted at the API host root, outside `/api/v1`). On a tenant with a branded domain the same mint returns `https://go.yourbrand.com/l/gH4kM2`.
* **`data.campaign_id`** is the attribution handle. Links minted without it drift out of the per-campaign rollup (recipe 3), even though their clicks still count tenant-wide.
* **Only `http://` and `https://` URLs mint.** A missing, non-string, or unparsable `url` answers `422`; so does a `javascript:` or `data:` target. The write rate limit is 20 requests per minute.

## 2. Send an SMS with inline shortening

You do not have to mint links yourself before a send. Pass `metadata.shorten_urls: true` on the SMS send and the pipeline rewrites every `http(s)://` URL in the body into a tracked short link before the message leaves — stamping the campaign and message ids onto each minted link so clicks attribute back to this send.

```bash cURL theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/messages/sms \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: promo-may26-98421" \
  -d '{
    "from": "+16572262362",
    "to": "+14155552671",
    "body": "May promo: 20% off at https://example.com/promo?utm_campaign=may26 — ends Sunday",
    "campaign_id": "cmp_may26",
    "metadata": { "shorten_urls": true }
  }'
```

The send returns the persisted message in the standard envelope:

```json theme={null}
{
  "data": {
    "id": "msg_01J4ZM9WQ8D5RTN6BC2HXEVYKA",
    "status": "sent",
    "to": "+14155552671",
    "segments": 1
  },
  "meta": {
    "request_id": "req_01J6PZ4AE6K3M8NSDY2T5RWV",
    "timestamp": "2026-08-27T09:20:05.104Z"
  }
}
```

**The on-wire swap.** What the recipient's handset renders is the original text with the URL replaced:

```
May promo: 20% off at https://api.orbit.devotel.io/l/gH4kM2 — ends Sunday
```

Every minted link carries `campaign_id` and `message_id` from the send, so the click resolves back to this recipient (recipe 3). Two guards matter on SMS:

* **Net-shortening only.** On SMS a URL is swapped only when the minted short URL is strictly shorter than the original — shortening on SMS exists to cut segment cost, so an equal-or-longer swap is refused and the original URL stays verbatim. On non-SMS channels every `http(s)` URL shortens regardless of length, because there the point is click analytics, not length.
* **Mint failures never fail the send.** Shortening is fire-and-forget per URL: if the mint fails, the original URL ships untouched and the send proceeds.

If you skip the metadata flag entirely, the per-tenant SMS auto-shorten setting (`sms_auto_shorten_urls`, default ON) still rewrites URLs longer than 30 characters. The explicit `metadata.shorten_urls: true` opt-in is for URLs below that floor.

## 3. Read campaign, per-contact, and tenant analytics

Three read surfaces answer three different questions. All clamp out-of-range `window_days` / `top_limit` values instead of rejecting them, and every `top_links` row re-attaches the resolved `short_url` so you can display it without reconstructing the domain.

**Per-campaign rollup — "did this campaign's links drive clicks?"**

```bash cURL theme={null}
curl "https://api.orbit.devotel.io/api/v1/links/campaigns/cmp_may26/link-analytics?window_days=30&top_limit=5" \
  -H "X-API-Key: dv_test_sk_YOUR_KEY"
```

```json theme={null}
{
  "data": {
    "campaign_id": "cmp_may26",
    "window_days": 30,
    "totals": {
      "links": 3,
      "clicks": 1184,
      "verified_clicks": 412,
      "unique_clicker_recipients": 267,
      "attributed_recipients": 2400,
      "unique_attribution_rate": 0.1113,
      "verified_attribution_rate": 0.348
    },
    "by_channel": [
      { "channel": "sms", "links": 3, "clicks": 1184, "verified_clicks": 412, "unique_clicker_recipients": 267 }
    ],
    "top_links": [
      {
        "link_id": "link_01J4ZK8H2GQX7M9B3TNVAW4CDE",
        "code": "gH4kM2",
        "original_url": "https://example.com/promo?utm_campaign=may26",
        "channel": "sms",
        "clicks": 721,
        "verified_clicks": 251,
        "unique_recipients": 189,
        "short_url": "https://api.orbit.devotel.io/l/gH4kM2"
      }
    ]
  },
  "meta": { "request_id": "req_01J6PZ5MKX7R2WTNQ9HB4DYS", "timestamp": "2026-08-27T09:31:11.220Z" }
}
```

Read `verified_clicks` for human-only CTR — link-preview fetches and scanners inflate raw `clicks` by design. `unique_attribution_rate` is unique clicker recipients over attributed recipients (the recipients the campaign's tracked messages reached); both derived rates report `0` when the denominator is empty, never NaN.

**Per-contact history — "which links did THIS recipient tap?"**

```bash cURL theme={null}
curl "https://api.orbit.devotel.io/api/v1/links/contacts/con_9d2kf/clicks?limit=25" \
  -H "X-API-Key: dv_test_sk_YOUR_KEY"
```

```json theme={null}
{
  "data": {
    "contact_id": "con_9d2kf",
    "total": 2,
    "clicks": [
      {
        "click_id": "linkClick_01J5A12RRHH8D5CTMK2Z9EQBFN",
        "link_id": "link_01J4ZK8H2GQX7M9B3TNVAW4CDE",
        "code": "gH4kM2",
        "original_url": "https://example.com/promo?utm_campaign=may26",
        "campaign_id": "cmp_may26",
        "message_id": "msg_01J4ZM9WQ8D5RTN6BC2HXEVYKA",
        "referrer": null,
        "country": "US",
        "clicked_at": "2026-08-27T09:41:03.740Z"
      },
      {
        "click_id": "linkClick_01J4Q8WPPZ2KR9DTGA6XVYHMCU",
        "link_id": "link_01J3YH7KF1QP5W8D2NCAZRGMBS",
        "code": "kT9wP4",
        "original_url": "https://example.com/guide",
        "campaign_id": null,
        "message_id": "msg_01J3YQ2LN4RT8XB7HDCMZPWKFA",
        "referrer": "https://www.google.com/",
        "country": "US",
        "clicked_at": "2026-08-21T15:02:44.019Z"
      }
    ]
  },
  "meta": { "request_id": "req_01J6PZ6TCQ8WN3MVH7RF0GKZ", "timestamp": "2026-08-27T09:41:04.001Z" }
}
```

Newest first, bounded by `limit` (1–500, default 100). Clicks resolve to a contact through the originating message, so an anonymous click on a link that was never message-attributed never appears here.

**Tenant-wide insights — one call for the dashboard KPI row**

```bash cURL theme={null}
curl "https://api.orbit.devotel.io/api/v1/links/insights?window_days=7&top_limit=3" \
  -H "X-API-Key: dv_test_sk_YOUR_KEY"
```

```json theme={null}
{
  "data": {
    "summary": {
      "total_links": 42,
      "total_clicks_window": 2310,
      "unique_visitors_window": 980,
      "avg_clicks_per_link": 55.0,
      "verified_clicks_window": 901,
      "verified_unique_visitors_window": 614
    },
    "top_links": [
      {
        "link_id": "link_01J4ZK8H2GQX7M9B3TNVAW4CDE",
        "code": "gH4kM2",
        "original_url": "https://example.com/promo?utm_campaign=may26",
        "campaign_id": "cmp_may26",
        "total_clicks": 721,
        "unique_visitors": 312,
        "verified_clicks": 251,
        "short_url": "https://api.orbit.devotel.io/l/gH4kM2"
      }
    ],
    "timeline": [
      { "date": "2026-08-21", "clicks": 312 },
      { "date": "2026-08-22", "clicks": 441 },
      { "date": "2026-08-27", "clicks": 388 }
    ]
  },
  "meta": { "request_id": "req_01J6PZ7HGN4WQ1TKB9CD2RXM", "timestamp": "2026-08-27T09:45:30.578Z" }
}
```

`GET /api/v1/links/insights` bundles summary KPIs, the top-N ranking, and a day-by-day click timeline in one call — the same aggregates that power **Insights → Link tracking** in the dashboard. Pass `campaign_id` to restrict the whole bundle to one campaign.

## 4. Build and publish a landing page

Landing pages are no-code microsites built from a block list, served under the same `/links` base path. A page starts in `draft`; publishing mints its trackable short URL, so clicks attribute exactly like any other tracked link.

```bash cURL theme={null}
# 1. Create a draft with content blocks
curl -X POST https://api.orbit.devotel.io/api/v1/links/landing-pages \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "May promo",
    "campaign_id": "cmp_may26",
    "content": {
      "blocks": [
        { "type": "heading", "text": "20% off ends Sunday" },
        { "type": "paragraph", "text": "Show this page at checkout to claim the offer." },
        { "type": "button", "text": "Claim the offer", "url": "https://example.com/promo" }
      ]
    }
  }'
```

```json theme={null}
{
  "data": {
    "id": "landing-page_01J6Q03HEY8ZXM4NPTKVWB2DRS",
    "title": "May promo",
    "slug": "may-promo",
    "status": "draft",
    "campaign_id": "cmp_may26",
    "short_code": null,
    "short_url": null,
    "render_url": null,
    "created_at": "2026-08-27T10:02:41.913Z"
  },
  "meta": { "request_id": "req_01J6PZ8JBW2FR4NDH7QS9TXK", "timestamp": "2026-08-27T10:02:42.020Z" }
}
```

While `status` is `draft`, `short_url` and `render_url` stay null — the page has no public address. A slug you pass is suffixed on collision (`may-promo-3fka`) so it never fails on a duplicate.

```bash cURL theme={null}
# 2. Publish — the response carries the minted trackable short_url
curl -X POST https://api.orbit.devotel.io/api/v1/links/landing-pages/landing-page_01J6Q03HEY8ZXM4NPTKVWB2DRS/publish \
  -H "X-API-Key: dv_test_sk_YOUR_KEY"
```

```json theme={null}
{
  "data": {
    "id": "landing-page_01J6Q03HEY8ZXM4NPTKVWB2DRS",
    "status": "published",
    "slug": "may-promo",
    "short_code": "mN7qR2",
    "short_url": "https://api.orbit.devotel.io/l/mN7qR2",
    "render_url": "https://api.orbit.devotel.io/lp/mN7qR2"
  },
  "meta": { "request_id": "req_01J6PZ9KCV3GT5QEJ8RU1WYM", "timestamp": "2026-08-27T10:04:09.331Z" }
}
```

Publish enforces a non-empty block list — a page with zero blocks answers `422`. The minted `short_url` (`/l/{code}`) redirects to the internal render path (`/lp/{code}`), so the click-tracking pipeline covers landing-page traffic with no extra wiring. Per-page visits and conversions then roll up by campaign and by originating message:

```bash cURL theme={null}
curl "https://api.orbit.devotel.io/api/v1/links/landing-pages/landing-page_01J6Q03HEY8ZXM4NPTKVWB2DRS/analytics?top_limit=5" \
  -H "X-API-Key: dv_test_sk_YOUR_KEY"
```

```json theme={null}
{
  "data": {
    "page": { "id": "landing-page_01J6Q03HEY8ZXM4NPTKVWB2DRS", "status": "published", "short_url": "https://api.orbit.devotel.io/l/mN7qR2" },
    "analytics": {
      "totals": { "visits": 1184, "conversions": 96 },
      "by_campaign": [ { "campaign_id": "cmp_may26", "campaign_name": "May promo", "visits": 1184, "conversions": 96 } ],
      "by_message": [ { "message_id": "msg_01J4ZM9WQ8D5RTN6BC2HXEVYKA", "visits": 721, "conversions": 58 } ],
      "recent_conversions": [
        { "id": "conv_01J6Q9", "conversion_kind": "cta_click", "campaign_id": "cmp_may26", "message_id": "msg_01J4ZM9WQ8D5RTN6BC2HXEVYKA", "contact_id": "con_9d2kf", "value": null, "created_at": "2026-08-27T11:12:40.204Z" }
      ]
    }
  },
  "meta": { "request_id": "req_01J6PAA0LX6HT2PCNB5RF9DZW", "timestamp": "2026-08-27T11:12:41.900Z" }
}
```

## 5. Handle the short\_link.click webhook

Subscribe an endpoint under **Settings → Webhooks** to `short_link.click` and every tracked click POSTs to you in real time — the recipient-side sibling of `email.clicked`. Verify the `X-Orbit-Signature` header before trusting the body (one function call per [Webhook consumer](/guides/webhook-consumer)).

The decoded payload:

```json theme={null}
{
  "type": "short_link.click",
  "data": {
    "link_id": "link_01J4ZK8H2GQX7M9B3TNVAW4CDE",
    "code": "gH4kM2",
    "url": "https://example.com/promo?utm_campaign=may26",
    "click_id": "linkClick_01J5A12RRHH8D5CTMK2Z9EQBFN",
    "message_id": "msg_01J4ZM9WQ8D5RTN6BC2HXEVYKA",
    "campaign_id": "cmp_may26",
    "ip": "203.0.113.44",
    "user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15",
    "referrer": null,
    "country": "US",
    "clicked_at": "2026-08-27T09:41:03.740Z",
    "is_bot": false,
    "quality_score": 1.0,
    "quality_reason": null
  }
}
```

```typescript Node consumer theme={null}
app.post('/webhooks/orbit', (req, res) => {
  if (!verifyOrbitSignature(req)) return res.sendStatus(401);
  const { type, data } = req.body;
  res.sendStatus(200); // ack immediately — dispatch is fire-and-forget

  if (type === 'short_link.click') {
    if (data.is_bot || (data.quality_score ?? 0) < 0.5) return; // preview/scanner — exclude from CTR
    // human click: attribute by data.campaign_id / data.message_id, log to your CRM,
    // or trigger a follow-up Flow.
  }
});
```

Filter on `is_bot` / `quality_score` (`>= 0.5` means verified human) exactly the way the analytics endpoints do, so your CTR matches the dashboard. A missing `user_agent` scores 0.3 because a real browser essentially always sends one; `quality_reason` names the verdict (`bot_user_agent` | `missing_user_agent`). `country` is the edge-resolved ISO-3166-1 alpha-2 code, or null when the edge supplies no geo header. Delivery never blocks the redirect — a slow consumer cannot slow the clicker's page load, and Orbit retries a failed POST on its own tail.

## 6. Pick a read surface

Raw clicks accumulate faster than most consumers should poll them. Match the question to the surface:

| You need…                                       | Use                                                       | Why                                                                             |
| ----------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------- |
| Real-time click stream into your CRM / alerting | `short_link.click` webhook                                | Push, not poll; per-click attribution + quality flag; off the redirect hot path |
| One link's click curve and recent click rows    | `GET /api/v1/links/{id}/stats`                            | Counters plus `recent_clicks` and `clicks_by_day` for a single id               |
| Campaign CTR over a window                      | `GET /api/v1/links/campaigns/{campaignId}/link-analytics` | Verified/raw split, attribution rates, per-channel + top-links in one rollup    |
| The operator dashboard KPI row                  | `GET /api/v1/links/insights`                              | Summary + top-N + timeline in one call, no waterfall                            |
| A recipient's engagement history                | `GET /api/v1/links/contacts/{contactId}/clicks`           | Contact-resolved, newest-first, for the CDP view                                |
| Forensic per-request detail                     | Dashboard **Developer → Delivery logs**                   | Redirect-level rows when the aggregate is not granular enough                   |

Rule of thumb: the webhook for streams, the analytics endpoints for aggregates, the delivery logs for forensics. Poll an aggregate endpoint on a schedule only when you genuinely cannot run an endpoint for the webhook.

## Troubleshooting

* **My mint returned the platform default host, not my branded domain.** The tenant branded-domain value must be a bare `https://host` with no path/query/fragment; a malformed value falls back to the default rather than failing. Fix the setting and re-mint — existing short URLs keep resolving.
* **A `422` on `POST /api/v1/links`.** `url` is missing, not a string, or fails URL parsing; a non-`http(s)` protocol is also refused. On landing pages, `422` at publish means the block list is empty.
* **Click counts look inflated on SMS or WhatsApp sends.** Read `verified_clicks` (or filter the webhook on `quality_score >= 0.5`). Preview fetchers inflate raw clicks by design; the raw count is kept so retro-filtering applies to already-collected data.
* **No `short_link.click` for a WhatsApp template send.** Template/broadcast sends carry no on-wire body to shorten — automatic tracking only fires on free-text bodies. Tracking inside a template's URL has to live in the approved template itself.
