Hosted filesnew
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.
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 / 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.
- On every outbound activity that carried a page:
hostedMedia[](title, layout, item count, opens, viewed items, saves, website taps, expiry, revocation) andmedia[].hostedUrl/media[].hostedMediaIdon the attachments that live on it. - Every human open fires
media.opened; a Save firesmedia.downloaded. Previewers and scanners never emit. - The page's Visit website button is an ordinary tracked link (
source = media_page), so its taps arrive aslink.clickedtoo. - 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
enabledboolean — Enable hosted files. Every photo, video, PDF or file the team (or your integration) attaches arrives as a page. Defaulttrue; only a storedfalseturns 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).displayNamestring | null — Business name shown on the page.null= the workspace name. ≤ 60 characters.logoKeystring | 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 withnull.accentstring | null —#rrggbbaccent colour for the counter, buttons and dots.null= Ody neutral. The page picks black or white ink on top of it automatically.websiteUrlstring | null —https://URL. Adds a Visit website button whose taps are tracked (utm_medium=hosted_media,utm_campaign=ody_media_page, the contact reference asutm_content).nullremoves the button.expiryDaysinteger —0(default) = the page stays open as long as the conversation exists;7,30or90days to expire it sooner. Pages can also be revoked from the thread at any time.allowSaveobject —{ photos, documents, video }booleans. Shows a Save button for that kind and adownloadUrlin the page's manifest. Default photos + documentstrue, videofalse.trackOpensboolean — Record opens / views / saves, show them in the thread and Analytics, and emit themedia.*webhooks. Defaulttrue. 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.
curl https://api.ody.co/v1/api/hosted-media \
-H "Authorization: Bearer $ODY_API_KEY"const hm = await ody.hostedMedia.get();
console.log(hm.settings.enabled, hm.settings.expiryDays, hm.baseUrl);hm = client.hosted_media.get()
print(hm["settings"]["enabled"], hm["settings"]["expiryDays"], hm["baseUrl"]){
"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"
}#Switch it off or on, brand the page, change expiry and saving
/v1/api/hosted-mediaScope: 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.
enabledbooleandisplayNamestring | nulllogoKeynull — Onlynull(clear) is accepted from the API; upload a new logo in the app.accentstring | nullwebsiteUrlstring | nullexpiryDaysinteger —0(always) ·7·30·90allowSaveobject — any subset of{ photos, documents, video }trackOpensboolean
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.
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 }'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 linksclient.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{
"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"
}#Every send is a page — there is no per-send flag
With Hosted files on, POST /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.
{ "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 return410. - 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 and configure_hosted_files, gated by the same workspace:manage scope. send_message carries attachments the same way POST /messages does.