Link trackingnew
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.
Link tracking rewrites every URL in an outbound text — sent by a teammate, by the AI texting agent, by Astra during a call, or by your code through POST /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.
- The stored message body is the text as sent (short links). Each activity carries the originals as
trackedLinks[]with per-link click counts. - Every human click fires the
link.clickedwebhook (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
enabledboolean — Rewrite links in outbound texts as tracked short links. Defaulttrue; only a storedfalseturns it off.showInThreadboolean — 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. Defaulttrue.utmTemplatestring | null — Query string appended to every destination,k=v&k=v, ≤ 512 characters.nullmeans 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.
curl https://api.ody.co/v1/api/link-tracking \
-H "Authorization: Bearer $ODY_API_KEY"const lt = await ody.linkTracking.get();
console.log(lt.settings.enabled, lt.settings.utmTemplate ?? lt.defaults.utmTemplate);lt = client.link_tracking.get()
print(lt["settings"]["enabled"], lt["settings"]["utmTemplate"] or lt["defaults"]["utmTemplate"]){
"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"]
}#Turn it off or back on, show clicks in the thread, set the template
/v1/api/link-trackingScope: workspace:manage
Send only the fields you want to change. utmTemplate: null (or "") resets to the default.
enabledbooleanshowInThreadbooleanutmTemplatestring | null
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.
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 }'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 defaultclient.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{
"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"]
}#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
302withCache-Control: no-storeandX-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 and configure_link_tracking, gated by the same workspace:manage scope.