Full API reference: https://api.mailoo.app/docs/v1
Mailoo stores user-owned images in S3-compatible object storage (AWS S3, MinIO, etc.). All access goes through the Mailoo API; integrators and browsers never receive S3 credentials or internal object keys.
This page is for teams embedding Mailoo integrations (blog, forms, chat, etc.) who need to understand how article images and other user assets are stored and how to read them safely.
Overview
- Namespace: Each dashboard user gets a dedicated key prefix
users/{userId}/in a shared bucket (userIdis the internal Mailoo user id from the database, a CUID). - Public identifier: Clients use the media id (CUID) returned by the API. S3 keys are internal only.
- Initialization: The API creates an internal marker object under that prefix on first authenticated API activity (idempotent). Creation is logged server-side.
- Deletion: When a user account is removed via the API, objects under
users/{userId}/are deleted from storage as part of that flow. Project / integration / article deletes enqueue background S3 cleanup for associated media keys. - Upload / delete / derive: Bearer-authenticated dashboard flows (via your session / BFF). Do not expose S3 keys or bucket credentials to browsers or third-party sites.
- Originals vs article assets: Upload allows larger originals (up to 25 MiB before processing);
POST /images/deriveproduces resized / re-encoded copies linked to the same scope as the source. - Validation: File type is verified with magic bytes; EXIF orientation is applied and metadata is stripped server-side. Animated GIFs are rejected.
- Canonical embed URLs: Saved markdown / HTML use Mailoo HTTP URLs with ``?id={mediaId}`` on
/api/v1/images/public/content. That URL is an identifier, not an anonymous byte URL. Browsers and social crawlers cannot send an API key; the integrator host must proxy (see below) and put the host URL in<img>andog:image. - External read (API key):
GET /api/v1/images/public/content?id=β¦with X-API-Key and scopeimage.external-read(or a FULL key) returns bytes only for READY media owned by the API key user. - ``S3_PUBLIC_BASE_URL``: Optional internal API detail only; it is not exposed in JSON responses and must not be used as the public image contract.
Authentication summary
Dashboard image routes use a signed-in Bearer session. Integrator reads use X-API-Key with image.external-read (or FULL). Full schemas: https://api.mailoo.app/docs/v1.
Operation Auth
List images (cursor) Bearer; optional projectUid / integrationId / articleId for visibility context
Upload image (multipart) Bearer; form fields file, scope, optional context ids
Derive image (resize / format / compress) Bearer; JSON sourceId and at least one of maxWidth, maxHeight, format
Get image bytes (owner) Bearer; GET /images/{id}/content
Patch / delete by id Bearer
Get image bytes (integrator / server) X-API-Key with image.external-read (or FULL); id must refer to READY media owned by the key user
Profile avatar upload / clear Bearer; POST / DELETE /api/v1/me/avatar (not the library routes above)
Public profile avatar bytes No auth; GET /api/v1/public/avatars/{mediaId} --- only if mediaId is some user's current avatarMediaId
Profile avatars
Every user always has a public avatar: either a photo uploaded via POST /api/v1/me/avatar or a generated initials image created on signup / login (ensureUserAvatar). Compressed size is fixed (256Γ256 WebP), stored once under users/{userId}/avatar/{mediaId}.webp.
- Existing library endpoints (
/images/{id}/content,/images/public/content) stay closed as documented above; avatars do not use them. GET /api/v1/public/avatars/{mediaId}streams the pre-compressed object (no on-the-fly resize). Cache:Cache-Control: public, max-age=31536000, immutable.DELETE /api/v1/me/avatarremoves an uploaded photo and regenerates initials --- the user is never left without an image.- Chat widget
operators[].imageuses this public URL (absoluteAPI_PUBLIC_URL). - mailoo.app UI: profile, project members, JSBOX chips, and blog author chips load avatars via the same-origin BFF (
/api/v1/public/avatars/{mediaId}) andnext/image(fixed display size). External blogauthor.avataris the author profile photo URL when set, otherwise the absolute public avatar URL fromUser.avatarMediaId--- not Auth.jsUser.image.
Security model (normative)
- Owner is the authenticated Mailoo user id from JWT or API key --- never a client-supplied user id.
- ``projectUid`` / ``integrationId`` / ``articleId`` are context inside that owner's data; the API resolves and checks the chain.
- S3 keys are never returned to clients.
- RESTRICTED API keys must include
image.external-readto call the public read endpoint.
Scopes (visibility)
Media is stored with one of USER, PROJECT, INTEGRATION, or ARTICLE scope. Listing with context returns READY items visible for that editor context.
Stock photos (Blog dashboard)
Each Blog integration can store its own Unsplash and Pexels API keys under Connection & settings (encrypted in integration.config.stockPhotos; never returned in plaintext). There are no platform-wide stock API keys.
In the article editor image panel, the Stock tab searches with that integration's keys. Choosing a photo imports the bytes into UserMedia (same scopes and public embed URLs as a normal upload). Unsplash download tracking uses the integration's Unsplash key when importing.
Dashboard API (Bearer, project editor):
GET /api/v1/projects/{uid}/integrations/{id}/stock/search?provider=unsplash|pexels&q=β¦POST /api/v1/projects/{uid}/integrations/{id}/stock/import--- body includesprovider,externalId,scope, and optional hierarchy ids
API reference (Images)
Base path: {apiBase}/api/v1/images.
List (Bearer, cursor)
GET /api/v1/images --- query: limit (1--100, default 24), cursor, optional filters projectUid, integrationId, articleId, scope, includeNonReady=true. Response: items, nextCursor, imagePublicEmbedBaseUrl.
Upload (Bearer)
POST /api/v1/images --- multipart: file, scope (USER | PROJECT | INTEGRATION | ARTICLE), plus context fields per scope (projectUid, integrationId, articleId as required). Response includes id, embedUrl, imagePublicEmbedBaseUrl, metadata --- no S3 key.
Derive image (Bearer)
POST /api/v1/images/derive --- JSON: sourceId, optional maxWidth, maxHeight, format, quality. At least one of maxWidth, maxHeight, or format is required.
Get content (Bearer)
GET /api/v1/images/{id}/content --- raw bytes for READY media.
Optional on-the-fly resize / transcode (same sharp pipeline as derive): query parameters ``w``, ``h``, ``fit`` (inside | cover | fill, default inside), ``format`` (jpeg | jpg | png | webp), ``q`` (1--100, default 85). At least one of ``w``, ``h``, or ``format`` must be present to enable processing; otherwise the object is streamed as stored.
Patch / delete (Bearer)
PATCH /api/v1/images/{id}--- JSONname,altText.DELETE /api/v1/images/{id}--- single-media delete with S3 retry outbox on failure.
Get content (API key)
GET /api/v1/images/public/content?id={mediaId} --- X-API-Key; for server-side proxies only. Do not use this URL as og:image or as a browser <img src>. The same optional transform query parameters as the Bearer content route (``w``, ``h``, ``fit``, ``format``, ``q``) apply when at least one of ``w``, ``h``, or ``format`` is set.
Pre-generated variants via ``POST /api/v1/images/derive`` remain supported for stable URLs (a new media id per variant).
Integrator proxy and blog HTML
Blog API article payloads include ``imagePublicEmbedBaseUrl`` (prefix ending with ?id=). The article list response also repeats the same prefix at the top level (next to data / pagination). Category description responses include ``imagePublicEmbedBaseUrl`` in data when description HTML may reference Mailoo images. Stored ``htmlContent`` uses absolute canonical public URLs under ``API_PUBLIC_URL``; replace those URLs in HTML with your site's proxy URL using the same media id.
Use ``rewriteMailooPublicImageUrls`` or ``rewriteMailooBlogMediaUrls`` from ``@mailoo/images`` (also re-exported for hosts that already depend on that package). rewriteMailooBlogMediaUrls rewrites ogImageUrl, htmlContent, content, and an author avatar that is itself a canonical embed URL. It does not change /api/v1/public/avatars/ URLs. See nextjs-packages{.interpreted-text role="doc"}.
createMailooBlogClient(config, { imageProxyPrefixWithIdEquals }) from ``@mailoo/blog`` applies that rewrite on list, slug, category description, and JSON-LD image URLs. The canonical prefix is the response imagePublicEmbedBaseUrl (not a hardcoded API host). Omit the option to keep canonical URLs for server-only callers. The blog API key used by the proxy must include ``image.external-read`` as well as ``blog.external-read``.
Operator configuration (API only)
Set on the API host (not the browser):
Variable Purpose
API_PUBLIC_URL Public API base (no trailing slash); used for canonical embed URLs
S3_BUCKET Bucket name (required)
S3_REGION Region (default us-east-1 if unset)
S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY Credentials (required)
S3_ENDPOINT Custom endpoint for MinIO / S3-compatible (optional)
S3_FORCE_PATH_STYLE true for many MinIO setups (optional; defaults to path-style when S3_ENDPOINT is set)
S3_PUBLIC_BASE_URL Optional; internal to the API only --- not part of the public integration contract
These variables belong on the API deployment only (never in the browser).
Further reading
nextjs-packages{.interpreted-text role="doc"} ---@mailoo/imagesproxy factoryblog-headless-cms{.interpreted-text role="doc"}website-forms{.interpreted-text role="doc"}- OpenAPI:
https://api.mailoo.app/docs/v1