# Hosted files

> Read or change how a workspace texts photos, videos and documents as a branded link.ody.co/m/… page (every attachment, every send, no per-send choice), and read opens from the activity and the media.* webhooks. On by default.

Source: https://dev.ody.co/api/hosted-media

**Hosted files** turns every attachment into a **page**. When a message goes out with a photo, a gallery, a video, a PDF — one file or twelve, small or large — Ody puts those files on one branded page at `link.ody.co/m/<token>` — the business's name, logo, accent colour and a tracked **Visit website** button on top, the content opened on first paint (a gallery shows its first photo large with the rest in a strip), Ody only as a small footer line — and sends one text: your `body`, then `3 photos: link.ody.co/m/…`. The recipient's **opens, photo views and saves** come back on the activity, in the thread and Analytics of the app, and as the [`media.opened`](https://dev.ody.co/webhooks/events#media-opened) / [`media.downloaded`](https://dev.ody.co/webhooks/events#media-downloaded) webhooks.

**On by default** (since 2026-11-07). It is a **workspace** preference — the same card the app shows under **Settings → Analytics → Hosted files** — and this page is how you read or change it from code. There is deliberately **no per-send choice**: with the switch on every attachment of every send is a page; `PATCH { "enabled": false }` restores the previous behaviour exactly: MMS when it fits, a bare `https://api.ody.co/m/<token>` secure link per oversized file when it doesn't, no page, no events.

> **What you get back elsewhere:**
- On every outbound activity that carried a page: [`hostedMedia[]`](https://dev.ody.co/api/messages#hosted-pages) (title, layout, item count, opens, viewed items, saves, website taps, expiry, revocation) and `media[].hostedUrl` / `media[].hostedMediaId` on the attachments that live on it.
- Every human open fires [`media.opened`](https://dev.ody.co/webhooks/events#media-opened); a Save fires [`media.downloaded`](https://dev.ody.co/webhooks/events#media-downloaded). Previewers and scanners never emit.
- The page's **Visit website** button is an ordinary tracked link (`source = media_page`), so its taps arrive as [`link.clicked`](https://dev.ody.co/webhooks/events#link-clicked) too.
- In the app: a glass pill in the thread ("Maria opened your 3 photos · looked at all 3 · saved 1"), the conversation lifted to the top of the inbox on the first open, and a **Files** block on the Analytics screen.

## The settings object

- `enabled` *boolean* — **Enable hosted files.** Every photo, video, PDF or file the team (or your integration) attaches arrives as a page. Default `true`; only a stored `false` turns it off, and then sending is exactly the pre-hosted-files behaviour (normal picture messages when they fit, a bare secure link when they don't).
- `displayName` *string | null* — Business name shown on the page. `null` = the workspace name. ≤ 60 characters.
- `logoKey` *string | null* — Storage key of the uploaded logo. Upload it in the app (Settings → Analytics → Hosted files → **Upload logo**); from the API you can only clear it with `null`.
- `accent` *string | null* — `#rrggbb` accent colour for the counter, buttons and dots. `null` = Ody neutral. The page picks black or white ink on top of it automatically.
- `websiteUrl` *string | null* — `https://` URL. Adds a **Visit website** button whose taps are tracked (`utm_medium=hosted_media`, `utm_campaign=ody_media_page`, the contact reference as `utm_content`). `null` removes the button.
- `expiryDays` *integer* — `0` (default) = the page stays open **as long as the conversation exists**; `7`, `30` or `90` days to expire it sooner. Pages can also be revoked from the thread at any time.
- `allowSave` *object* — `{ photos, documents, video }` booleans. Shows a **Save** button for that kind and a `downloadUrl` in the page's manifest. Default photos + documents `true`, video `false`.
- `trackOpens` *boolean* — Record opens / views / saves, show them in the thread and Analytics, and emit the `media.*` webhooks. Default `true`. Off keeps hosting the pages and records nothing.

Every response also carries `baseUrl` (`https://link.ody.co/m`).

Branding is **snapshotted** onto each page when the text is sent, so changing the logo or accent later never changes a page a customer already has.

**`GET /v1/api/hosted-media`**

### Read the settings

**Scope:** `workspace:manage`

cURL:

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

```ts
const hm = await ody.hostedMedia.get();
console.log(hm.settings.enabled, hm.settings.expiryDays, hm.baseUrl);
```
Python:

```python
hm = client.hosted_media.get()
print(hm["settings"]["enabled"], hm["settings"]["expiryDays"], hm["baseUrl"])
```

Response:

```json
{
  "settings": {
    "enabled": true,
    "displayName": null,
    "logoKey": "workspaces/8f93…/branding/logo-1762550400000.png",
    "accent": "#1c6bff",
    "websiteUrl": "https://glowsalon.com",
    "expiryDays": 0,
    "allowSave": { "photos": true, "documents": true, "video": false },
    "trackOpens": true
  },
  "baseUrl": "https://link.ody.co/m"
}
```

**`PATCH /v1/api/hosted-media`**

### Switch it off or on, brand the page, change expiry and saving

**Scope:** `workspace:manage`

Send only the fields you want to change. Changes apply to the next text; pages already sent keep their branding and keep working.

- `enabled` *boolean*
- `displayName` *string | null*
- `logoKey` *null* — Only `null` (clear) is accepted from the API; upload a new logo in the app.
- `accent` *string | null*
- `websiteUrl` *string | null*
- `expiryDays` *integer* — `0` (always) · `7` · `30` · `90`
- `allowSave` *object* — any subset of `{ photos, documents, video }`
- `trackOpens` *boolean*

cURL:

```bash
curl -X PATCH https://api.ody.co/v1/api/hosted-media \
  -H "Authorization: Bearer $ODY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "displayName": "Glow Salon", "accent": "#1c6bff", "websiteUrl": "https://glowsalon.com", "expiryDays": 90 }'
```
TypeScript:

```ts
await ody.hostedMedia.update({ displayName: "Glow Salon", accent: "#1c6bff", websiteUrl: "https://glowsalon.com" });
await ody.hostedMedia.update({ expiryDays: 90, allowSave: { video: true } });
await ody.hostedMedia.update({ trackOpens: false });   // keep the pages, stop recording
await ody.hostedMedia.update({ enabled: false });      // back to plain MMS + raw secure links
```
Python:

```python
client.hosted_media.update(display_name="Glow Salon", accent="#1c6bff", website_url="https://glowsalon.com")
client.hosted_media.update(expiry_days=90, allow_save={"video": True})
client.hosted_media.update(track_opens=False)  # keep the pages, stop recording
client.hosted_media.update(enabled=False)      # back to plain MMS + raw secure links
```

Response:

```json
{
  "settings": {
    "enabled": true,
    "displayName": "Glow Salon",
    "logoKey": null,
    "accent": "#1c6bff",
    "websiteUrl": "https://glowsalon.com",
    "expiryDays": 90,
    "allowSave": { "photos": true, "documents": true, "video": false },
    "trackOpens": true
  },
  "baseUrl": "https://link.ody.co/m"
}
```

**Errors** — `400 invalid_argument` when a field has the wrong type or value; the message is the sentence the app shows under the field (*Use a hex colour like #1c6bff*, *Enter a full website address starting with https://*, *Pages can stay open 7, 30, 90 or 0 days*, *Upload the logo from the app*, *Keep the name under 60 characters*). `403 permission_denied` when the key lacks `workspace:manage`.

## Every send is a page — there is no per-send flag

With Hosted files on, [`POST /messages`](https://dev.ody.co/api/messages) puts **every** attachment of the send on one page — a single small photo that would have fit as MMS exactly like a 40 MB video. You do not (and cannot) ask for it per message: the `sendAsLink` flag that existed for a few hours on 2026-11-07 is **ignored** if sent, and the `galleries` setting is gone. A workspace that wants plain picture messages turns the switch off (`PATCH { "enabled": false }`); nothing else changes.

```json
{ "conversationId": "c4f1…", "body": "Here is the quote", "mediaUrls": ["https://files.example.com/quote.pdf"] }
```

## What is on the page, and what is recorded

| On the page | Recorded (human only) |
|---|---|
| A gallery opened on photo 1, strip + `1 / 7` counter, swipe / arrow keys / tap-to-jump, pinch to zoom | `open` (page), `view` per photo that settles in the hero |
| A video or audio clip playing inline | `view` on play, `progress` at 25 / 50 / 75 / 100 % |
| A document card with **Open** (native viewer) and **Save** | `view` on Open, `download` on Save |
| **Visit website** (when set) | a `link.clicked` on a `media_page` tracked link |
| **Text back** — opens the recipient's messaging app on the line that sent the text | — |
| Footer: *Shared securely with Ody* · *Available until …* · *Report* | — |

Previewers and scanners (iMessage, WhatsApp, Slack, security gateways) receive a generic card — *"Glow Salon sent you 3 photos"* — never the content, and nothing is recorded.

## Security

- The URL token is **128-bit random** (22 base64url characters) and stored only as a SHA-256 hash; unknown and malformed tokens get the same `404`. Expired and revoked pages return `410`.
- Content is served from **5-minute signed URLs** that the page refreshes while open; nothing is cached (`Cache-Control: no-store`), indexed (`X-Robots-Tag: noindex`) or referrable (`Referrer-Policy: no-referrer`).
- The page ships a strict, nonce-based `Content-Security-Policy`, no cookies and no third-party scripts.
- Photos are re-encoded with location and camera metadata stripped; every file is sniffed by content before it is accepted.
- The open record keeps the device class and the country when the edge supplies it, and a daily-salted hash of the IP — never the IP itself.

## MCP

The same two operations are MCP tools: [`get_hosted_files`](https://dev.ody.co/mcp/tools#get_hosted_files) and [`configure_hosted_files`](https://dev.ody.co/mcp/tools#configure_hosted_files), gated by the same `workspace:manage` scope. `send_message` carries attachments the same way `POST /messages` does.
