# Assets > Direct-to-storage uploads, asset states, cacheable delivery, and deletion rules. Assets — images, PDFs, documents, audio, video — are uploaded directly to S3-compatible storage with presigned requests, verified by Myna, and delivered from a dedicated host with aggressive caching for published files. Like entries, assets are drafts until published, and their publication rides on the [change set](/concepts/change-sets) workflow. ## Upload flow Uploads never proxy file bytes through the API. Three steps, all requiring `assets:write` (see [scopes](/platform/scopes)): ```bash # 1. Request an upload slot. $ curl -X POST https://api.myna.sh/v1/projects/my-site/assets/uploads \ -H "Authorization: Bearer myna_sk_..." \ -d '{"filename":"hero.png","contentType":"image/png","byteSize":48211,"checksum":"9e107d9d..."}' # → { uploadId, storageKey, method: "PUT", url, headers, expiresAt } # 2. PUT the bytes to the presigned URL (valid 15 minutes). $ curl -X PUT "" -H "content-type: image/png" --data-binary @hero.png # 3. Complete — Myna HEADs the object and creates the asset. $ curl -X POST https://api.myna.sh/v1/projects/my-site/assets/uploads/upl_01.../complete \ -H "Authorization: Bearer myna_sk_..." ``` On complete, Myna verifies the stored object with a HEAD request: byte size must match exactly, the content type must match, and the checksum is compared best-effort (a single-part PUT's S3/MinIO ETag equals the MD5 hex digest). Any mismatch fails with `VALIDATION_FAILED` and no asset is created. Successful completion yields an asset in the `draft` state. Pending uploads that are never completed expire after 24 hours. `myna assets upload ` wraps all three steps — see [CLI commands](/cli/commands). Storage keys are immutable and never derived solely from user filenames. Assets with identical checksums may be deduplicated within a project. ### Finding duplicates A checksum answers "are these the same bytes?", which is not the duplicate a media library accumulates. The same photograph exported again at another quality, or resized for a hero slot, is new bytes and an old picture. Every uploaded or replaced image therefore also gets a `perceptualHash`: a 64-bit difference hash, 16 hex characters, computed from a 9×8 greyscale reduction of the image after its EXIF rotation is applied. Re-encoding, rescaling, and mild recompression leave it alone. ```bash $ myna assets similar ast_01... ID FILENAME DISTANCE STATE ast_01H... hero-2400.png 1 published ``` `GET /v1/projects/:project/assets/:asset/similar` returns `{ similar: [{ asset, distance }] }`, ordered nearest first. `distance` is the Hamming distance in bits out of 64: 0–2 is the same image re-encoded, and by 10 distinct photographs of the same subject start appearing. `?maxDistance=` defaults to 5 and is capped at 16; `?limit=` pages the result. Reading it needs `assets:read`. `myna assets upload` prints any match it finds, and `myna_upload_asset` returns them in `similar`, because the moment to notice a duplicate is before it is referenced. Filter by exact `checksum` on `GET /assets` when you want byte-identical matches instead. Two boundaries worth knowing: the fingerprint covers images Myna can decode (JPEG, PNG, WebP, AVIF) and is null for everything else, and it is computed at upload, so assets that predate it never match. Re-upload or `myna assets replace` gives an older asset a fingerprint. ### Blocked types Executables and active documents are rejected at upload time by both content type and extension: `html`, `htm`, `xhtml`, `shtml`, `svg`, `svgz`, `xml`, `xsl`, `xslt`, `js`, `mjs`, `cjs`, `php`, `phtml`, `exe`, `dll`, `so`, `dylib`, `bat`, `cmd`, `sh`, `com`, `msi`, `scr`, `jar`, `app`. HTML and SVG are excluded because a browser executes script in them, and assets are served from a host you may put behind your own domain. Use raster formats for images. Content types are compared after stripping parameters, so `image/svg+xml; charset=utf-8` is refused exactly like `image/svg+xml`. The check runs twice — once when the upload slot is issued against the declared type, and again on completion against the type the stored object actually carries. ### Delivery safety Delivery is governed by a separate, narrower allowlist. Images, audio, video, PDF, plain text, CSV, Markdown, and web fonts are served inline under their own content type. **Anything else is served as `application/octet-stream` with `Content-Disposition: attachment`**, so an unusual file can still be stored and downloaded but can never render as a document. Every asset response also carries `X-Content-Type-Options: nosniff` and `Content-Security-Policy: default-src 'none'; sandbox`, which keeps even an inline PDF from reaching the origin it was served from. ## States | State | Meaning | |---|---| | `pending` | Upload slot issued; bytes not yet verified | | `draft` | Verified and stored; not publicly delivered | | `published` | Publicly delivered and cacheable | | `quarantined` | Withdrawn from public delivery; treated like a draft (credential required) | | `deleted` | Soft-deleted; not delivered | An asset becomes `published` when a change set containing it as an item publishes, or automatically when a publishing entry's revision references it — referenced `pending`/`draft` assets are promoted in the same transaction as the entry pointers. A reference is any `ast_` id in the entry's data, whether it is the whole value of an asset field or embedded in a longer string. The second case is what a rich-text or markdown body holds: an image in prose is stored as a delivery URL, and the id is a substring of it. Both count — for auto-publishing here, for the `altText` [collection policy](/concepts/change-sets#collection-policy), for [orphan detection](#organization-and-cleanup), and for the deletion block below. An image on a published page is never an orphan and never silently left in `draft`. ## Delivery and caching Every asset has one canonical URL: ```text https://api.myna.sh/assets/:project/:asset/:filename ``` `:project` accepts the project id or slug; `:filename` is the display filename — it is cosmetic, because delivery resolves on project and asset id, so a URL built from an id alone works. Read the exact URL from an asset's `url` field, or build one with [`images.url`](/sdk/public-client); do not assemble it by hand. Delivery is a path on the API rather than a host of its own. A dedicated host is a supported deployment shape — point one at the API and it serves delivery at its root, without the `/assets` prefix — but myna.sh does not run one, so `https://api.myna.sh/assets` is the base to use and the SDK's default. Delivery semantics depend on state: - **Published** assets send `cache-control: public, max-age=31536000, immutable`, an `ETag` derived from the checksum (`If-None-Match` → `304`), `accept-ranges: bytes` with byte-range requests answered `206`, `content-disposition: inline`, and `x-content-type-options: nosniff`. Delivered bandwidth is metered per organization. - **Draft** (and quarantined) assets return `401` unless the request carries a project-bound [preview token](/concepts/previews) (as a bearer token or `?token=` query parameter) or a management credential with `assets:read`. Draft responses send `cache-control: private, no-store`. ## Image transformations Image assets can be resized and re-encoded on delivery, so a page requests the size it is about to render instead of the full-resolution original: | Parameter | Meaning | | --- | --- | | `w` | Target width in CSS pixels. Snapped up to the next supported width. | | `format` | `auto` (default), `webp`, `avif`, `jpeg`, or `png`. `auto` negotiates from the browser's `Accept` header. | | `q` | Quality. Snapped up to the next supported step: `50`, `65`, `80`, `95`. Defaults to `80`. | ```text https://api.myna.sh/assets/my-site/ast_01j9.../hero.png?w=640 https://api.myna.sh/assets/my-site/ast_01j9.../hero.png?w=1200&format=webp&q=70 ``` Supported widths are `16, 32, 48, 64, 96, 128, 256, 384, 640, 750, 828, 1080, 1200, 1920, 2048, 3840`. A request for 500 is served at 640 — the parameter space is deliberately closed so an asset can only ever have a small, fixed number of derivatives: 16 widths × 4 formats × 4 quality steps, and no more. Images are never upscaled: asking for more pixels than the original has returns the original's size. Rendering a derivative that is not yet cached is rate-limited per client address; serving one that is cached is not. A page requesting a normal set of sizes never notices, but walking the parameter space to force re-encodes answers `429`. Transformations apply to `image/jpeg`, `image/png`, `image/webp`, and `image/avif`. Other content types, including SVG (which is [refused at upload](#blocked-types)), are served unchanged. An unsupported `format` or a non-numeric `w` answers `400`. Each derivative is rendered once and written back to object storage, so the second request for the same URL is a read. Derivatives carry their own `ETag` and, because `format=auto` varies per browser, `Vary: accept`. Draft assets are transformable too, under the same preview-token gate as the original. The SDK builds these URLs for you: ```ts myna.images.url(post.fields.cover, { width: 640 }); myna.images.srcSet(post.fields.cover, [640, 1200, 1920]); ``` Intrinsic pixel dimensions are recorded at upload and returned as `width` and `height` on the asset, so a page can reserve layout space before the image loads. ## Limits Per-file upload size and total storage are plan entitlements — 50 MB per file and 1 GB storage on Free, 500 MB and 25 GB on Pro; see [plans](/platform/plans). When an organization exceeds its storage allowance, new uploads are blocked with `PLAN_LIMIT_REACHED` (HTTP 402) but existing published assets keep serving — public sites fail conservatively. ## Deletion `DELETE /v1/projects/:project/assets/:asset` (scope `assets:delete`) is soft: the asset stops being delivered but is recoverable for 30 days. Deletion is **blocked** while any current draft or published entry [revision](/concepts/revisions) references the asset — the request fails with `CONFLICT` listing the referencing `entryIds` (see [error codes](/management-api/errors)). Check references first with `GET /v1/projects/:project/assets/:asset?usage=true`. Staging an asset `delete` item on a change set defers the deletion until the change set publishes. ## Metadata and alt text `myna assets update --alt "…"` and `PATCH /v1/projects/:project/assets/:asset` edit `displayFilename` (the delivery filename; `originalFilename` is preserved), `caption`, `defaultAlt`, and `tags`. `defaultAlt` is the asset's project-wide default alt text; individual usages can override it — an entry's asset field value may carry its own usage-level alt text, which takes precedence over the default when rendering that entry. Set a `defaultAlt` on upload-adjacent workflows so every future usage has a sensible fallback. A collection that declares `policy.altText()` refuses to publish an entry whose images lack one — see [collection policy](/concepts/change-sets#collection-policy). ## Organization and cleanup - **Tags** — lightweight labels on each asset (`tags: ["brand", "hero"]` via `PATCH`); filter listings with `GET /v1/projects/:project/assets?tag=brand`. - **Duplicate detection** — every asset stores a `checksum`; `?checksum=` finds existing copies before uploading the same file again. - **Orphan cleanup** — `?orphaned=true` lists assets no current draft or published entry revision references, ready for review and deletion. An image embedded in a body counts as referenced, so a picture a page displays never appears here. ## Replacing an asset `myna assets replace --confirm-replace`, or `POST /v1/projects/:project/assets/:asset/replace` with `{"uploadId": "upl_..."}`, swaps the asset's binary for a newly uploaded one (same two-step upload flow, but finish with `replace` instead of `complete`). The asset keeps its identity: the `ast_` id, alt text, caption, tags, state, and every entry reference stay intact, and the delivery URL now serves the new file.