# Authenticated links

Every URL in an SMS or MMS body, and every `href` in an email body, goes out
as an **authenticated link**:

```
   <link_prefix>/<token>
```

`link_prefix` is set on the action's tool call. It is the address of a host
**you** run. The platform appends only the token; any path belongs to the
prefix. When a person taps the link, the request reaches your host, and your
host asks the platform what the link is with one call and reports what the
person did on the page with a second.

SDK namespace: `api.authenticatedLinks` — `exchange` and
`updateMessageTracking`.

```
  workflow action  (sms_request · mms_request · email_request)
    link_prefix = "https://go.example.com/l"
    body        = "Book a slot: https://booking.example.com/slots"
         │ send
         ▼
  delivered     "Book a slot: https://go.example.com/l/q3v9RkZ0aB7xYtL"
         │ a person taps
         ▼
  your host at go.example.com/l
    exchange({ token, method, user_agent, ip, country, headers })
      → destination_url "https://booking.example.com/slots",
        legal_entity_id, workflow, run, action, expires_at, message
    send the person on to destination_url
         │ the page opens · the form is submitted
         ▼
    updateMessageTracking({ token, status: 'opened', opened_at })
    updateMessageTracking({ token, status: 'form_submitted', form_submitted_at })
```

**Both calls need only the tenant's publishable key.** The host is a key-only
client: no session, no user.

```typescript
import { createIsolatedPlatformApi } from '@alvera-ai/platform-sdk'

const links = createIsolatedPlatformApi({
  baseUrl: 'https://api.alvera.ai',
  apiKey: 'pk_live_tenant_publishable_key',
})
```

This replaces Connected Apps and page tokens, which the platform retired:
there is no app to register, no route discovery, no `resolvePage`, and no
`{{ connected_app_form_url }}`. Write the real destination URL into the
message body; the platform turns it into an authenticated link at send.

## 1. Wire shape

### On the action — set when you build the workflow

| Field | Where | Shape |
|---|---|---|
| `link_prefix` | `SmsCallRequest`, `MmsCallRequest`, `EmailCallRequest` | **required** string — the address of your host |
| `link_validity_days` | the same three | optional whole number, at least 1, default **365** — how many days each link resolves after the send |
| `metadata_template` | every action type | optional Liquid template rendered to JSON once per send |

```typescript
import type { AgenticWorkflowRequestWritable } from '@alvera-ai/platform-sdk'

type Action = NonNullable<AgenticWorkflowRequestWritable['actions']>[number]

const reviewRequest: Action['tool_call'] = {
  tool_call_type: 'sms_request',
  sms_type: 'transactional',
  to: { type: 'custom', body: '{{ event_dataset.phone }}' },
  body: {
    type: 'custom',
    body: 'Thanks for visiting. Tell us how it went: https://reviews.example.com/visit',
  },
  link_prefix: 'https://go.example.com/l',
  link_validity_days: 30,
}
```

### `exchange` — a tap on the link

```typescript
import type { AuthenticatedLinkExchangeRequest } from '@alvera-ai/platform-sdk'

const tap: AuthenticatedLinkExchangeRequest = {
  token: 'q3v9RkZ0aB7xYtL',            // required: from <link_prefix>/<token>
  method: 'GET',                        // the tap's HTTP method
  user_agent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)',
  ip: '203.0.113.7',
  country: 'US',
  headers: { 'accept-language': 'en-US' },
}
```

### `updateMessageTracking` — what happened on the page

```typescript
import type { AuthenticatedLinkUpdateMessageTrackingRequest } from '@alvera-ai/platform-sdk'

const submitted: AuthenticatedLinkUpdateMessageTrackingRequest = {
  token: 'q3v9RkZ0aB7xYtL',             // required: the same token
  status: 'form_submitted',              // typically 'opened' or 'form_submitted'
  form_submitted_at: '2026-10-04T10:00:00Z',
}
```

### The answer — `AuthenticatedLinkResponse`, from both calls

| Field | Meaning |
|---|---|
| `destination_url` | the URL as written in the message |
| `legal_entity_id` | the recipient's legal entity, when MDM resolved one — else `null` |
| `message_id` | the message the link went out in |
| `workflow_id` | the workflow that sent it |
| `workflow_execution_log_id` | the workflow run that sent it |
| `action_id` | the action that sent it |
| `expires_at` | when the link stops resolving |
| `link_clicked_at` | when a person first clicked this link — `null` until then |
| `message` | the message, a `MessageResponse` |

`message.metadata` holds the recipient's `to` merged with what the action's
`metadata_template` rendered, as stored at send. The message carries each
engagement status with its own time: `read` (`read_at`), `opened`
(`opened_at`), `clicked` (`clicked_at`) and `form_submitted`
(`form_submitted_at`).

## 2. Rules the type cannot encode

### `link_prefix` is an absolute URL with nothing after the path

An absolute `http` or `https` URL. A path is allowed —
`https://go.example.com/l` — but no query, no fragment and **no trailing
slash**: the platform appends `/<token>` itself.

### A person's tap moves the message to `clicked`; a machine's does not

`exchange` answers every tap, but only a person's moves anything. The first
click stamps the link's `link_clicked_at` and the message's `clicked_at`;
later taps keep both. The platform judges a tap from what your host forwards,
and these look automated and move nothing:

- a `HEAD` request;
- a prefetch header — `sec-purpose`, `purpose`, `x-purpose`, `x-moz`;
- an empty user agent, or one naming a bot, a link preview or a mail scanner.

Every tap is recorded with the details you forwarded and the signal that
flagged it, if one did. Headers are recorded as client headers — never cookies
or credentials.

### The message status only moves forward

`updateMessageTracking` applies its `status` through the message's order
guard: reporting `opened` after `form_submitted` leaves the message at
`form_submitted`. A time you leave empty keeps the message's value. The
`status` field is a plain string on the type; one a message cannot have is
refused with `422`.

### A link answers until it expires

Each link resolves for `link_validity_days` after the send, then answers
`410`. The response's `expires_at` says when.

### `metadata_template` renders once, at send

It renders to JSON once per send, on every action type, and is stored on the
message. A template that does not render **fails the send**. You read the
result back from `message.metadata` — not from a field on the link.

## 3. Field ownership

| Field | Written by |
|---|---|
| `link_prefix`, `link_validity_days`, `metadata_template` | you, on the action |
| `destination_url` | you, as the URL written in the body |
| the token, `expires_at`, `message_id`, `workflow_id`, `workflow_execution_log_id`, `action_id` | the platform, at send |
| `legal_entity_id` | the platform, when MDM resolves the recipient |
| `link_clicked_at`, `message.clicked_at` | the platform, on a person's first tap |
| `message.opened_at`, `message.form_submitted_at` | your host, through `updateMessageTracking` |

## 4. Error envelopes

Both calls throw on a refusal, and the thrown value carries the HTTP status
as `_httpStatus` (see `errors.md`). They refuse in ways a host must tell
apart:

| Status | `exchange` | `updateMessageTracking` | Meaning |
|---|---|---|---|
| `404` | yes | yes | a token the platform never minted |
| `410` | yes | yes | the link is past `expires_at` |
| `503` | yes | yes | the datalake cannot be reached right now |
| `422` | — | yes | a status the message cannot have |
| `401` | yes | yes | no publishable key, or one that does not resolve |

```typescript
async function resolveTap(token: string, userAgent: string): Promise<string> {
  try {
    const { data } = await api.authenticatedLinks.exchange(tenantSlug, datalakeSlug, {
      token,
      method: 'GET',
      user_agent: userAgent,
    })
    return data.destination_url
  } catch (err) {
    const status = (err as { _httpStatus?: number })._httpStatus
    if (status === 410) return 'https://go.example.com/expired'
    if (status === 404) return 'https://go.example.com/not-found'
    if (status === 503) return 'https://go.example.com/try-again'
    throw err
  }
}
```

## 5. Lifecycle

1. **Build.** Give every SMS, MMS and email action a `link_prefix`, and a
   `metadata_template` when your page needs more than the recipient.
2. **Send.** Each URL in the body becomes `<link_prefix>/<token>`, valid for
   `link_validity_days`.
3. **Tap.** Your host calls `exchange` with the token and the tap's request
   details, and sends the person on to `destination_url`. A person's tap
   moves the message to `clicked`.
4. **Page.** Your host reports `opened` when its page opens and
   `form_submitted` when the form is done, each with its time.
5. **Expiry.** After `expires_at` both calls answer `410`.

## 6. Gotchas

- **Forward what the tap said about itself.** Only the token is required, but
  a host that forwards nothing else gives the platform nothing to judge a
  person by. Pass the method, user agent and headers of the request that
  reached you, not those of your own server-side call.
- **Link previews are taps too.** Messaging apps fetch a link to draw its
  preview; those are recorded and do not count as clicks.
- **Put the path in the prefix.** The platform appends only the token, so
  `https://go.example.com/l` gives `https://go.example.com/l/<token>`. A
  trailing slash is refused.
- **`metadata` lives on the message.** Read `data.message.metadata`; the link
  itself has no metadata field.
- **Coming from Connected Apps.** `connected_app_id`, `connected_app_route`
  and `connected_app_metadata_template` are gone from actions;
  `link_metadata_template` is now `metadata_template`; the body carries the
  destination URL itself instead of `{{ connected_app_form_url }}`; and
  `updateMessageTracking` is keyed by the link's token, not a page token.
