# Link tracking

> Read or change a workspace's tracked short links from code: every URL in an outbound text becomes link.ody.co/<code>, clicks surface in the thread, in Analytics and as a webhook. On by default.

Source: https://dev.ody.co/api/link-tracking

**Link tracking** rewrites every URL in an **outbound** text — sent by a teammate, by the [AI texting agent](https://dev.ody.co/api/ai-texting), by Astra during a call, or by your code through [`POST /messages`](https://dev.ody.co/api/messages) — into a short `link.ody.co/<code>` link before it leaves (scheme-less in the text so it reads cleanly in a bubble; `trackedLinks[].shortUrl` and the `link.clicked` webhook carry the absolute `https://link.ody.co/<code>`). Tapping it 302s to the original URL with the workspace's UTM template appended, so the business's own website analytics attribute the visit to an Ody text. Human clicks are counted; previewers, scanners and prefetchers are filtered out so a click always means a person.

**On by default** (since 2026-11-07; `PATCH { "enabled": false }` opts a workspace out and that choice sticks). It is a **workspace** preference — the same three switches the app shows under **Settings → Analytics** — and this page is how you read or change them from code. Changes are immediate (the next text follows the new setting); texts already sent are unaffected either way and their short links keep working.

> **What you get back elsewhere:**
- The stored message **body is the text as sent** (short links). Each activity carries the originals as [`trackedLinks[]`](https://dev.ody.co/api/messages#tracked-links) with per-link click counts.
- Every human click fires the [`link.clicked`](https://dev.ody.co/webhooks/events#link-clicked) webhook (`conversationId`, `contactId`, `linkId`, destination host, `clickCount`, `repeat`, device, country).
- In the app: a "… clicked your link" line in the thread (when `showInThread`), the conversation lifted to the top of the inbox on the first click, and a **Link clicks** block on the Analytics screen.

## The settings object

- `enabled` *boolean* — Rewrite links in outbound texts as tracked short links. Default `true`; only a stored `false` turns it off.
- `showInThread` *boolean* — Show a "… clicked your link" entry in the conversation and move it to the top of the inbox on the first click (never an unread badge). Clicks are counted either way. Default `true`.
- `utmTemplate` *string | null* — Query string appended to every destination, `k=v&k=v`, ≤ 512 characters. `null` means the default: `utm_source=ody&utm_medium=sms&utm_campaign={source}&utm_content=c_{contactRef}`. Parameters already present on a link are **never** overwritten, so hand-built campaign links keep their attribution.

Template placeholders:

| Placeholder | Resolves to |
|---|---|
| `{source}` | `ody_text` (a teammate) · `ody_ai_text` (the AI texting agent) · `ody_ai_call_text` (Astra during a call) · `ody_api` (this API / MCP) |
| `{contactRef}` | An anonymous 8-character reference to the contact — never the phone number |
| `{workspaceRef}` | An anonymous reference to the workspace |
| `{line}` | The business number the text left from (E.164) |
| `{code}` | The short link's code |

Every response also carries `defaults.utmTemplate` (what `null` resolves to), `baseUrl` (`https://link.ody.co`) and `placeholders`.

**`GET /v1/api/link-tracking`**

### Read the settings

**Scope:** `workspace:manage`

cURL:

```bash
curl https://api.ody.co/v1/api/link-tracking \
  -H "Authorization: Bearer $ODY_API_KEY"
```
TypeScript:

```ts
const lt = await ody.linkTracking.get();
console.log(lt.settings.enabled, lt.settings.utmTemplate ?? lt.defaults.utmTemplate);
```
Python:

```python
lt = client.link_tracking.get()
print(lt["settings"]["enabled"], lt["settings"]["utmTemplate"] or lt["defaults"]["utmTemplate"])
```

Response:

```json
{
  "settings": { "enabled": true, "showInThread": true, "utmTemplate": null },
  "defaults": { "utmTemplate": "utm_source=ody&utm_medium=sms&utm_campaign={source}&utm_content=c_{contactRef}" },
  "baseUrl": "https://link.ody.co",
  "placeholders": ["source", "contactRef", "workspaceRef", "line", "code"]
}
```

**`PATCH /v1/api/link-tracking`**

### Turn it off or back on, show clicks in the thread, set the template

**Scope:** `workspace:manage`

Send only the fields you want to change. `utmTemplate: null` (or `""`) resets to the default.

- `enabled` *boolean*
- `showInThread` *boolean*
- `utmTemplate` *string | null*

cURL:

```bash
curl -X PATCH https://api.ody.co/v1/api/link-tracking \
  -H "Authorization: Bearer $ODY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "enabled": false }'
```
TypeScript:

```ts
await ody.linkTracking.update({ enabled: false });    // opt the workspace out
await ody.linkTracking.update({ enabled: true });     // back on
await ody.linkTracking.update({ utmTemplate: "utm_source=ody&utm_medium=sms&utm_campaign=spring_{source}" });
await ody.linkTracking.update({ showInThread: false });   // keep counting, hide the thread line
await ody.linkTracking.update({ utmTemplate: null });     // back to the default
```
Python:

```python
client.link_tracking.update(enabled=False)  # opt the workspace out
client.link_tracking.update(enabled=True)   # back on
client.link_tracking.update(utm_template="utm_source=ody&utm_medium=sms&utm_campaign=spring_{source}")
client.link_tracking.update(show_in_thread=False)
client.link_tracking.update(utm_template=None)  # back to the default
```

Response:

```json
{
  "settings": { "enabled": false, "showInThread": true, "utmTemplate": null },
  "defaults": { "utmTemplate": "utm_source=ody&utm_medium=sms&utm_campaign={source}&utm_content=c_{contactRef}" },
  "baseUrl": "https://link.ody.co",
  "placeholders": ["source", "contactRef", "workspaceRef", "line", "code"]
}
```

**Errors** — `400 invalid_argument` when a field has the wrong type, when no recognised field is sent, or when the template is invalid (the message is the same sentence the app shows under the field: empty, longer than 512 characters, contains spaces / `#` / `?`, a pair without `=`, or an unknown `{placeholder}`). `403 permission_denied` when the key lacks `workspace:manage`.

## What is — and isn't — rewritten

- **Outbound only.** Links in texts your customers send are never changed.
- Explicit `http(s)://…` URLs and bare domains with a recognised TLD (`farmdogs.com/book`); up to **5 links per text**. Email addresses are not links.
- Links to Ody itself (`link.ody.co`, `api.ody.co`) are left alone.
- Codes are **7 random base62 characters** — never sequential, so a code can't be used to enumerate the pages a business sends.
- The redirect is always a `302` with `Cache-Control: no-store` and `X-Robots-Tag: noindex`; a cached or indexed redirect would hide clicks and leak destinations.
- Test keys (`ody_test_…`) rewrite links too, and the short links resolve — you can watch the whole loop without texting anyone.

## Privacy

The click record stores the time, the device class (`mobile` / `desktop` / `other`), the country when the edge supplies it, and a **daily-salted hash** of the IP — never the IP itself. The attribution parameters identify the contact only by `{contactRef}`; no phone number ever reaches the destination.

## MCP

The same two operations are MCP tools: [`get_link_tracking`](https://dev.ody.co/mcp/tools#get_link_tracking) and [`configure_link_tracking`](https://dev.ody.co/mcp/tools#configure_link_tracking), gated by the same `workspace:manage` scope.
