API
Documentation / API Reference / Media Storage

Media storage — public and private files

2026-09-28. For the frontend team (Next.js) rendering any file the API returns: avatars, post images, chat attachments, voice notes, course files, assignment submissions.

Short version: public files are plain CDN URLs you can drop into src. Private files come back as an API URL that needs your bearer token, so you exchange it for a short-lived link first. Everything below is detail on that one rule.


1. Two kinds of URL

Every uploaded file lives in Cloudflare R2, in one of two buckets. Which bucket is fixed per upload type on the server — you never choose it, and you never need to know the bucket. You only need to recognise the URL shape.

Public file Private file
URL looks like https://cdn.trymeety.com/123/photo.jpg https://api.trymeety.com/api/v1/media/{uuid}
Who can read it Anyone with the URL Only viewers the API authorises, checked on every fetch
Works in <img src> / <video src> / <a href> as-is Yes No — the browser sends no bearer token, so it gets 401
Expires Never The URL in the response never expires; the link you exchange it for lasts 5 minutes
Cacheable Yes (CDN, 7 days) No — never cache the exchanged link past its expires_at

How to tell them apart

There is no separate is_private flag in responses. The URL itself is the signal: a private file's URL always points at the API's /media/{uuid} route. Anything else is public.

// lib/media.ts
const API_BASE = process.env.NEXT_PUBLIC_API_URL!; // e.g. https://api.trymeety.com/api/v1

const PRIVATE_MEDIA = /\/api\/v1\/media\/[0-9a-f-]{36}(\?|$)/i;

export function isPrivateMediaUrl(url: string | null | undefined): url is string {
  return !!url && url.startsWith(API_BASE) && PRIVATE_MEDIA.test(url);
}

Match on the path, not the host alone: the API host also serves JSON endpoints, and in local development API_BASE is a LAN address, not api.trymeety.com.

A private URL may carry a query string, e.g. …/media/{uuid}?conversion=thumb for a resized preview. Keep it exactly as given when you exchange it.


2. Which fields are private

Treat this as the current list, not a contract — the regex above is what your code should rely on, so a field that moves between buckets later keeps working without a client release.

Private (need the exchange)

Endpoint / resource Field
Chat messages (GET /chat/threads/{thread}/messages, sockets) attachments[].url, voice.url
Chat shared-media gallery (GET /chat/threads/{thread}/media) url
Course lessons (author, learner and public views) attachment_url, assignment_attachments[].url
Course materials url for uploaded files (a link material's url is the external link, not private); thumbnail_url when an image material has no poster
Assignment submissions file_url, submission_file_url

Public (use directly)

Avatars, cover photos, course thumbnails and OG images, lesson and material posters, feed post images / videos / thumbnails, feed and community comment attachments, community cover / icon / gallery, community post attachments and videos, payout method logos.

Mentor verification documents (ID card, certificates) are private too, but they are served through their own download_url endpoints — see verification-document-api.md.


3. The exchange

GET /api/v1/media/{uuid}
Authorization: Bearer {token}
Accept: application/json

200

{
  "status": "success",
  "message": "File link generated.",
  "data": {
    "url": "https://<account>.r2.cloudflarestorage.com/meety-private/481/voice-1790074948553.mp3?X-Amz-…",
    "expires_at": "2026-09-28T10:05:00+00:00"
  }
}

Put data.url on the element. It is a presigned R2 link: it needs no token, supports range requests (video/audio seeking, resumable downloads) and stops working at expires_at.

Errors

Code Body Meaning What to do
401 { "message": "Unauthenticated." } No or expired token Your normal re-login flow
403 envelope, "You do not have access to this file." Signed in, but not allowed — e.g. left the chat, no seat in the course Show a locked / unavailable placeholder. Do not retry.
404 envelope, "File not found." The file was deleted Show a "file removed" placeholder

Always send Accept: application/json. Without it the route answers with a 302 straight to the R2 link, which fetch follows and hands you the file bytes instead of the JSON.

Until the API deploy after 2026-09-28, a request without Accept: application/json and without a token returns 500 instead of 401. That is what an <img src> pointing at the raw private URL shows today. It is a server bug, fixed, but the answer for the client is the same either way: exchange the URL, never render it directly.


4. Next.js integration

Recommended: a hook that resolves any media URL

Public URLs pass straight through; private ones are exchanged and cached until shortly before they expire. Components never branch on public vs private.

// lib/media.ts (continued)
type Signed = { url: string; expiresAt: number };

const cache = new Map<string, Signed>();
const inflight = new Map<string, Promise<string>>();
const REFRESH_MARGIN_MS = 30_000;

export async function resolveMediaUrl(url: string, token: string): Promise<string> {
  if (!isPrivateMediaUrl(url)) return url;

  const hit = cache.get(url);
  if (hit && hit.expiresAt - REFRESH_MARGIN_MS > Date.now()) return hit.url;

  // Twenty bubbles rendering the same attachment make one request, not twenty.
  const pending = inflight.get(url);
  if (pending) return pending;

  const request = fetch(url, {
    headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
    cache: 'no-store',
  })
    .then(async (res) => {
      const body = await res.json();
      if (!res.ok) throw new MediaAccessError(res.status, body?.message);
      const signed = {
        url: body.data.url as string,
        expiresAt: Date.parse(body.data.expires_at),
      };
      cache.set(url, signed);
      return signed.url;
    })
    .finally(() => inflight.delete(url));

  inflight.set(url, request);
  return request;
}

export class MediaAccessError extends Error {
  constructor(public status: number, message?: string) {
    super(message ?? `Media request failed (${status})`);
  }
}

export function forgetMediaUrl(url: string) {
  cache.delete(url);
}
// hooks/useMediaUrl.ts
'use client';

import { useEffect, useState } from 'react';
import { isPrivateMediaUrl, resolveMediaUrl, MediaAccessError } from '@/lib/media';
import { useAuthToken } from '@/hooks/useAuthToken'; // however you already read the token

export function useMediaUrl(url: string | null | undefined) {
  const token = useAuthToken();
  const [src, setSrc] = useState<string | null>(
    url && !isPrivateMediaUrl(url) ? url : null,
  );
  const [error, setError] = useState<MediaAccessError | null>(null);

  useEffect(() => {
    if (!url) return setSrc(null);
    if (!isPrivateMediaUrl(url)) return setSrc(url);
    if (!token) return;

    let cancelled = false;
    setError(null);
    resolveMediaUrl(url, token)
      .then((signed) => !cancelled && setSrc(signed))
      .catch((e) => !cancelled && setError(e));

    return () => {
      cancelled = true;
    };
  }, [url, token]);

  return { src, error, isLoading: !!url && !src && !error };
}
// components/chat/AttachmentImage.tsx
'use client';

import { useMediaUrl } from '@/hooks/useMediaUrl';

export function AttachmentImage({ url, name }: { url: string; name: string }) {
  const { src, error, isLoading } = useMediaUrl(url);

  if (error?.status === 403) return <LockedFile name={name} />;
  if (error?.status === 404) return <RemovedFile name={name} />;
  if (isLoading || !src) return <ImageSkeleton />;

  return <img src={src} alt={name} loading="lazy" />;
}

next/image

  • Public files: use next/image normally. Allow the CDN host once:

    // next.config.ts
    images: {
      remotePatterns: [{ protocol: 'https', hostname: 'cdn.trymeety.com' }],
    },
    
  • Private files: pass unoptimized, or use a plain <img>. The Next image optimizer fetches and caches the image server-side under its own TTL; a presigned link is dead five minutes later and must not sit in that cache. If you use next/image anyway, also allow *.r2.cloudflarestorage.com in remotePatterns.

Audio and video

A presigned link is checked when each request starts. A voice note or video left paused longer than the link lives fails on the next seek or resume. Handle the element's error event: drop the cached link, resolve again, restore the position.

const { src } = useMediaUrl(voice.url);
const ref = useRef<HTMLAudioElement>(null);

async function onError() {
  const el = ref.current;
  if (!el || !isPrivateMediaUrl(voice.url)) return;
  const at = el.currentTime;
  forgetMediaUrl(voice.url);
  el.src = await resolveMediaUrl(voice.url, token);
  el.currentTime = at;
}

<audio ref={ref} src={src ?? undefined} onError={onError} controls />;

Downloads ("Save file")

<a href={privateUrl} download> cannot work — the browser sends no token. Resolve on click, then navigate:

async function download(url: string, token: string) {
  window.location.href = await resolveMediaUrl(url, token);
}

Resolve on click, not on render: a link resolved when the message list loaded may have expired by the time someone clicks it.

Alternative: a same-origin proxy route

If the web app keeps the API token in an httpOnly cookie, a Route Handler can do the exchange server-side and redirect, and then private URLs work in plain src attributes with no hook:

// app/media/[uuid]/route.ts
import { cookies } from 'next/headers';

export async function GET(req: Request, { params }: { params: Promise<{ uuid: string }> }) {
  const { uuid } = await params;
  const token = (await cookies()).get('meetyy_token')?.value;
  const query = new URL(req.url).search;

  const res = await fetch(`${process.env.API_URL}/media/${uuid}${query}`, {
    headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
    cache: 'no-store',
  });

  if (!res.ok) return new Response(null, { status: res.status });

  const { data } = await res.json();
  return Response.redirect(data.url, 302);
}

Then rewrite https://api.trymeety.com/api/v1/media/{uuid} to /media/{uuid} before rendering. Only use this if the token is already in a cookie; do not move it out of wherever it lives today just for this.


5. Rules of thumb

  • Never render a private URL directly. Always go through useMediaUrl / resolveMediaUrl.
  • Never persist the exchanged link (localStorage, IndexedDB, a service-worker cache, a message draft). Persist the original /media/{uuid} URL and exchange again.
  • Never cache an exchanged link past expires_at.
  • A 403 means "not allowed", not "try again". Show a placeholder.
  • Do not build URLs yourself from ids. Always use the URL the API returned — the query string may carry a conversion, and the host differs per environment.

6. Known gaps

  • Admin chat moderation (GET /admin/chat-threads/{threadId}/messages) returns chat attachment URLs that do not load: they point at the private bucket directly, and admins are not yet allowed through the media route for chat files. Being fixed server-side; nothing to do on the client until then.
  • Chat attachments uploaded before 2026-09-24 are still on the old local disk and have public-style URLs on the API host. They will be migrated to private storage; the regex in section 1 keeps working when they are.