This is the abridged developer documentation for nitida # nitida > Hand it raw media. Get back every size, every format, every screen — crisp. Upload that survives a phone On **mobile**, a native background session (URLSession / WorkManager) carries the upload through backgrounding, screen lock and an OS kill. On the **web**, one atomic PUT with per-file progress — resume across a browser reload is **not** shipped; ask us if you need it. Compression in the browser A worker and `OffscreenCanvas` — the main thread never blocks. HEIC decodes natively where the engine can (**136 ms**), and only falls back to WASM where it cannot, so the 341 KB decoder stays unfetched for most users. Video, not just images Poster, transcode, an adaptive HLS ladder, and a cheap proxy built for machine analysis rather than for eyes. Multi-tenant from the first line Keys per tenant, isolation by prefix, and usage you can actually read. Running in production across five tenants. ## Two packages, one idea [Section titled “Two packages, one idea”](#two-packages-one-idea) **`@nitida/asset-client`** builds URLs. No network, no key, nothing to configure beyond a tenant id. If all your app does is *render* media, this is the only thing you need. **`@nitida/sdk`** adds the write half — upload, compress, register, poll — and re-exports every URL builder, so you never import both for the same job. ## The reference is generated from the types [Section titled “The reference is generated from the types”](#the-reference-is-generated-from-the-types) Every page under **API reference** is produced from the source on each build. Nothing there is transcribed by hand, which is the only way a reference stays true after a rename. The same content is published at [`/llms.txt`](/llms.txt) and [`/llms-full.txt`](/llms-full.txt) so an agent can read the whole surface in one fetch. # How this compares > nitida against Cloudinary, Cloudflare, Bunny, imgix, ImageKit and Vercel — every competitor number quoted from their own docs with the date, every row we could not verify marked as unverified, and the cases where you should use one of them instead. ## How this page is sourced [Section titled “How this page is sourced”](#how-this-page-is-sourced) Three different kinds of number appear below, and they do not deserve the same trust: * **Our numbers are measured, and the measurement is reproducible.** The transform figures come from [the transform benchmark](/guides/transform-benchmark/), which runs against the public API with an ordinary runtime key and writes its own JSON; what it does, step by step, is at the bottom of that page, so you can re-run it on your own tenant. * **Their numbers are quotes from their own documentation, with the date we read it.** Not from memory, not from a third-party comparison post, not from a blog. Every competitor row is stamped with the URL it came from and the day it was read. * **Rows we could not confirm say `no documentado`.** Not “no”, not a blank cell — a blank cell reads as absence and absence is a claim. Where a vendor’s documentation is silent, this page is silent too, and the footnote says what was tried. That is the entire credibility of the page, so it goes first rather than in a footer. A comparison table nobody can audit is worse than no table. ## Where nitida is the wrong choice [Section titled “Where nitida is the wrong choice”](#where-nitida-is-the-wrong-choice) Before any table, because this is the part a vendor’s own comparison page never writes. **We do not own a PoP network.** Delivery rides Cloudflare. If your requirement is a contractual edge footprint, or a specific set of points of presence, you are buying our opinion of someone else’s network. Bunny publishes 119 PoPs on its standard network and 10 on its volume network. **There is no SLA and the team is small.** No published uptime commitment, no support tier, no procurement paperwork. If an availability number has to appear in a contract, this is not the product. **No DAM UI.** There is no asset browser for a non-technical user to search, tag and organise in. ImageKit sells DAM storage as a metered line on every plan, which tells you it is a real product surface there and not here. **No AI auto-tagging, moderation or background removal in the ingest path.** Cloudinary wires auto-tagging, moderation (WebPurify, AWS Rekognition, Google AI Video Moderation, Perception Point), object detection and background removal into the same upload flow. Each of those would be a separate integration for you here. **No DRM.** Bunny Stream documents enterprise-grade multi-DRM. We have signed URLs, which is a different and weaker thing: a signed URL controls who can fetch the bytes, not what the player may do with them afterwards. **No official MCP server.** This matters because we claim to be agent-first, and on this specific axis three competitors are ahead of us: * Cloudinary ships **four** first-party MCP servers (Asset Management, Environment Config, Structured Metadata, Analysis) with OAuth on the remote ones, plus `llms.txt` and a `cloudinary_transformation_rules.md` written to keep a model from hallucinating transform syntax. * Cloudflare runs “a catalog of managed remote MCP servers which you can connect to using OAuth”, and its API server exposes “over 2,500 endpoints … through just two tools: `search()` and `execute()`”. * ImageKit’s “API MCP server lets an AI assistant manage your ImageKit media library on your behalf … covers all of ImageKit’s public APIs”, installed together with an agent skill bundle via `npx skills add imagekit-developer/skills --all`. What we have instead is a typed SDK, `llms.txt`, a `.md` route on every page of this site and the [agent files](/start/for-agents/). That is a real surface and it is not an MCP server. **We require an ingest step; four of the six do not.** imgix connects to an existing S3, GCS, Azure, DigitalOcean, Cloudflare R2, Wasabi or Linode bucket as a *Source* — “Connects to an existing Amazon S3 bucket with its own credentials” — and never takes ownership of your storage. ImageKit does both, trying its Media Library first and falling back to an attached external origin. Vercel proxies only. Bunny is proxy-first. If “we cannot re-upload 40 TB” is your constraint, that alone decides it. ## The architecture row: does it store your originals? [Section titled “The architecture row: does it store your originals?”](#the-architecture-row-does-it-store-your-originals) This is the row that actually separates these products, and most comparison tables never draw it. Everything downstream — whether you can add a variant later, whether there is a storage bill, whether “dedup” can even be defined — falls out of this answer. | Product | Stores your originals? | The quote | | -------------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Cloudinary** | **Both.** Storage is the primary model | `fetch` is the proxy mode, but “Fetched assets are cached in your Cloudinary product environment for performance reasons, and this storage counts against your quota” | | **Cloudflare Images** (stored) | **Yes** | “Images Stored and Images Delivered apply only to images that are stored in your Images bucket” | | **Cloudflare transformations** (your origin) | **No** | “If you optimize an image stored outside of Images, then you will be billed only for Images Transformed” | | **Bunny Optimizer** | **Proxy first** — Bunny Storage is a separate product with its own price | “Resize, crop, and modify images on the fly with simple url query parameters” … “without storing multiple file versions” | | **imgix** | **No — proxy only** | “The first step in working with Imgix is to create a **Source**, which connects Imgix to your asset storage” | | **ImageKit** | **Both** | “ImageKit always tries to fetch the file from the integrated Media Library first. If the file is not found, it tries to fetch it from the attached external storage (origins) sequentially” | | **Vercel** | **No — stores nothing but the output** | “**Only the optimized output is stored**, and the stored image is served like any other blob” | | **nitida** | **Yes — content-addressed by SHA-256** | The SHA *is* the asset; see [what this is](/start/what-is-this/) | **Cloudflare is two products and one row would lie about both.** The three meters — Images Transformed, Images Stored, Images Delivered — do not all apply at once: transforming an image on your own origin bills only the first. Splitting the row is not pedantry; it is a 3× difference in what you pay. ## Pricing shapes — and why there is no single dollar column [Section titled “Pricing shapes — and why there is no single dollar column”](#pricing-shapes--and-why-there-is-no-single-dollar-column) **These six bill in six incompatible units.** Credits, per-image, per-minute, per-GB, per-website, and per-transformation-plus-cache-read-plus-cache-write. A normalised “$ per month” column would require inventing a traffic mix, a hit rate, an asset count and a variant ladder, and then presenting the arithmetic on those invented inputs as a fact about the vendor. That is fabrication with a table around it, so this page does not do it. Each row gets its own unit and its own quote; the conversion to your bill is yours to do, on your numbers. | Product | Billing unit | Quoted rate | Free tier | | --------------------- | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | **Cloudinary** | **Credits**, fungible across three things | “1 credit = 1,000 transformations image and video processing **OR** 1GB managed storage for all digital assets **OR** 1GB video bandwidth high-performance CDN”. Plus “$99 Per month … 225 monthly credits” | “$0 Free forever … 25 monthly credits” | | **Cloudflare Images** | **Per image**, three meters | Transformed: “First 5,000 unique transformations included + **$0.50 / 1,000**” · Stored: “**$5 / 100,000** images stored / month” · Delivered: “**$1 / 100,000** images delivered / month” | “up to **5,000 unique transformations** each month for free”. Stored and Delivered have **no** free tier | | **Cloudflare Stream** | **Per minute** | “**$5 per 1,000 minutes stored**” with “no additional egress (traffic/bandwidth) fees” · “**$1 per 1,000 minutes delivered**” · “Ingress … and encoding are always free” | no documentado | | **Bunny** | **Per website**, flat — *plus* per-GB bandwidth | Optimizer “**$9.50/website**”, “Unlimited transformations” — but “**CDN bandwidth costs are applied separately**”: $0.01/GB EU+NA on the standard network, from $0.005/GB on the volume network. Storage $0.01/GB (HDD, one region) | **None permanent.** “14-day free trial”, “No credit card required” | | **imgix** | **Credits**, per GB | “Credits are a measure of capacity that are consumed by media management, delivery, and transformation” — management “2 credits / GB / month”, delivery “1 credit/GB”, transformation “Varies by feature”. “$25/mo” Starter (100 credits) → “$500/mo” Growth Plus. Overage $0.25 → $0.16 per credit | **None permanent.** “Get 30 days and 100 credits”, after which “they will expire” | | **ImageKit** | **Per GB** delivered + per GB stored + video units + extension units + users + purges | Lite “$9/mo”: “40 GB bandwidth” then “$0.5/GB”; “10 GB DAM storage” then “$0.1/GB”. Pro “$89/mo”: 225 GB / $0.45 per GB. Video billed in VPU = seconds × resolution units × codec units (**AV1 = 10**) | **Real and perpetual.** “Forever Free ($0/mo)”: “20 GB bandwidth”, “3 GB DAM storage”, 500 video units, 2 users | | **Vercel** | **Three** units, none of them per-GB of source | “Image transformations 5K/month $0.05 - $0.0812 per 1K” · “Image cache reads 300K/month $0.40 - $0.64 per 1M” · “Image cache writes 100K/month $4.00 - $6.40 per 1M” — *plus* “charges apply for **Fast Data Transfer and Edge Requests**” on delivery | Hobby: 5K transformations, 300K reads, 100K writes — “Hobby teams are restricted to **non-commercial personal use only**” | | **nitida** | No published price list | Onboarding is a conversation; you get an endpoint, a tenant id and keys. See [credentials](/start/credentials/) | — | ### Three ways this table is usually wrong [Section titled “Three ways this table is usually wrong”](#three-ways-this-table-is-usually-wrong) Stated explicitly, because each of these is a mistake we would have made from memory: 1. **Cloudflare never bills per GB.** Images are billed *per image*, video *per minute*. Any “$/GB” figure in a Cloudflare Images column is fabricated, however plausible it looks next to the other rows. 2. **Bunny’s “unlimited” is a billing statement, not a technical ceiling — and it excludes bandwidth.** The $9.50/website subscription covers “Unlimited requests · Unlimited optimizations · Unlimited transformations”, and then “CDN bandwidth costs are applied separately”. Quoting the $9.50 alone overstates the saving by however much traffic you serve. 3. **Vercel’s pricing model changed.** It is no longer counted in “source images”; it is transformations + cache reads + cache writes. Older knowledge is confidently wrong here. Their *legacy* pricing page also renders two figures as `$5000000.00` — a documentation bug. Those two numbers are not quoted anywhere on this page. ### One more counting rule worth reading before you model anything [Section titled “One more counting rule worth reading before you model anything”](#one-more-counting-rule-worth-reading-before-you-model-anything) Cloudinary does not count one URL as one transformation, in either direction: > “Upload processing: The upload of each image and video asset … counts as one transformation.” > > “Since transformations are counted when a **new** derived asset is **generated**, multiple requests to the identical transformation URL **do not** affect transformation counts.” > > “Default optimizations count towards your usage, even though the delivery URL does not appear to be different from delivering an original asset.” Video is priced per second of *output*: SD 2 · HD 4 · 4K 8 credits per second, and adaptive bitrate with `sp_auto` runs 8/s to 1080p, 12/s at 1440p, 24/s at 2160p. “Parts of seconds are counted as one second.” Vercel’s three units also have precise triggers: transformations and cache writes are “billed for every cache MISS and STALE”, while a cache read “is *not* billed for every cache HIT, only when the image needs to be retrieved from the shared global cache.” ## Feature matrix [Section titled “Feature matrix”](#feature-matrix) `no documentado` means the vendor’s own documentation does not state it and we did not find it — see the footnotes for what was searched. It is not a “no”. | | Cloudinary | Cloudflare | Bunny | imgix | ImageKit | Vercel | nitida | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | **Video** | Yes | Yes (Stream, billed separately) | Yes (Stream) | Yes (Video API) | Yes | **Images only** [1](#user-content-fn-1) | Yes | | **Adaptive bitrate** | HLS **and** DASH from one original, `sp_auto` | Multi-bitrate ladder included in the per-minute price | Yes; “No Transcoding Fees” | HLS/DASH as URL parameters; 6 credits per minute of output | HLS **and** DASH via one `sr-` parameter; first request answers “202 Accepted” | — | HLS ladder | | **Signed URLs** | Yes, `/s--SIGNATURE--/` [2](#user-content-fn-2) | Yes — **stored Images only**, and “Images with custom ID paths cannot be made private using signed URL tokens” | Yes, at the CDN layer (MD5 basic, SHA256 advanced with geo and rate limits) | Yes, MD5, per-Source token; an altered URL returns “**403 Forbidden**” | Yes, with expiry (`ik-t`, 401 on expiry) [3](#user-content-fn-3) | **no documentado** [4](#user-content-fn-4) | Yes | | **Content dedup** | no documentado | no documentado | no documentado | no documentado | no documentado | Partial: “For local images … **the content hash is used**”; “Remote images use an absolute url” | Yes — SHA-256 *is* the address | | **Per-tenant metering** | Per account: `GET /usage` returns storage, bandwidth, requests, derived resources. **Per API key: no documentado** [5](#user-content-fn-5) | Partial — per-image metadata via the Workers binding. A bytes-and-file-count-by-key endpoint: **no documentado** [6](#user-content-fn-6) | **Per zone, and it is good:** `GET /storagezone/{id}` returns `StorageUsed` and `FilesStored` in one call | Per Source, daily CSV, “retained for **90 days**” | Per account: `GET /v1/accounts/usage` returns `bandwidth_bytes` and `media_library_storage_bytes`. Per-key breakdown: **no documentado** | **No — “Vercel tracks events at the team level, counting them across all projects in the team”** | Per tenant *and* per key: `usage.snapshot()` returns `storage.totalBytes` + `storage.assetCount`; `usage.keys()` attributes it | | **Agent / MCP surface** | **4 first-party MCP servers**, OAuth, `llms.txt`, `.md` per doc page, a transformation-rules file for LLMs | Managed remote MCP catalog with OAuth; “over 2,500 endpoints … through just two tools” | **Deliberately no MCP:** “No MCP server to configure … The CLI you use is the same interface your agent uses” [7](#user-content-fn-7) | **no documentado** [8](#user-content-fn-8) | Official MCP over the whole public API + an installable skill bundle + `llms.txt` / `llms-full.txt` | The write-time API is agent-shaped: `vercel blob put-image` “prints the URL … so scripts and agents can read the result back” | Typed SDK, `llms.txt`, `.md` on every page, [agent files](/start/for-agents/) — **no MCP server** | | **Hard limits** | 100 MB before `upload_large` is required; async above 20 GB. Per-plan maxima: **no documentado** [9](#user-content-fn-9) | Remote origin **100 MB** / hosted Images **10 MB**; 100 MP; 12,000 px (1,200 px for AVIF). Rate limits: **no documentado** | **no documentado** [10](#user-content-fn-10) | Canvas **8192×8192**; inputs under **100 MB**, soft limit 500 MB. Rate limits: no documentado | Transform input 20 MB free / 40 MB paid, “adjustable upon request”; video 100 MB / 2 GB; max transform dimension **65535 px** (WebP 16383). Reads “near or below **40 requests/second**”, then 429 | Output max **10 MB**; source max **8192 px** per side. Source *file size*: no documentado | See [variants and presets](/concepts/variants-and-presets/) | ## ⭐ The dedup row, worded precisely [Section titled “⭐ The dedup row, worded precisely”](#-the-dedup-row-worded-precisely) **Content deduplication is the one row where being sloppy would make this whole page dishonest**, because it is the row where nitida looks best. **None of the six documents it.** Not one of Cloudinary, Cloudflare, Bunny, imgix, ImageKit or Vercel states that byte-identical uploads collapse to a single stored, billed asset. **That is not the same as saying none of them do it**, and the difference is the whole point. “Not documented” is a fact about their documentation; “does not happen” would be a claim about their storage engines, which we have not tested and cannot see. Content addressing is therefore *undisputed by their docs* as a nitida differentiator — not *proven absent* in theirs. What each vendor actually says, so you can judge the gap yourself: * **Cloudinary** — identity is the `public_id`, not the bytes; `unique_filename` defaults to `true` and “appends random characters to the end of the filename to guarantee its uniqueness”. Duplicate detection exists as an **opt-in add-on in Beta**, and it works by *similarity*: “images do not need to be identical”. * **Cloudflare** — nothing states that identical bytes collapse. The closest documentation concerns *ID reuse*. Billing per uploaded image is suggestive, but suggestion is not a source. * **Bunny** — storage is a path-indexed, filesystem-shaped zone, which argues against it. Bunny says neither. * **imgix** — the closest thing is one-origin-many-derivatives, which is not deduplication. * **ImageKit** — duplicates are a *search* feature, not a storage guarantee: “Any duplicate files will appear at the start of the search results.” Upload naming is by name, not by hash. * **Vercel** — the only documented content addressing among the six, and it is partial: “For local images (`/assets/me.png`) **the content hash is used** instead”; “Remote images use an absolute url”. The key is also scoped by Project ID, so there is no dedup across projects, and the same bytes behind two URLs are two entries and two transformations. On our side the claim is narrow and checkable: the SHA-256 *is* the address, so the same bytes are the same asset regardless of who uploaded them or what they named the file. The [transform benchmark](/guides/transform-benchmark/) relies on it — it uploads the same photograph twice on purpose and the second upload deduplicates. ## If you need X, use Y [Section titled “If you need X, use Y”](#if-you-need-x-use-y) Two of these route away from us on purpose. A routing table that always ends in “use ours” is a sales page wearing a table’s clothes. | If this is your constraint | Use | Why | | ------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **You cannot re-upload your library** — the originals live in S3/GCS/Azure/R2 and must stay there | **imgix**, or **Cloudflare transformations** over your own origin | imgix connects to an existing bucket as a Source and never takes ownership. Cloudflare bills only the transform meter when the original lives outside Images. We require an ingest step; there is no way around it | | **You are already on Vercel and want `next/image` to keep working** | **Vercel** | Zero integration cost by construction — no SDK, no tenant, no second bill. It stores nothing, so there is no storage line at all | | **You need DRM, or AI auto-tagging and moderation in the upload path** | **Bunny Stream** (DRM) · **Cloudinary** (tagging, moderation, background removal) | Both are documented product surfaces there and are absent here | | **You need a genuinely free production tier** | **ImageKit** | “Forever Free”: 20 GB bandwidth, 3 GB DAM storage. Bunny and imgix have trials only; Cloudflare’s free tier covers transformations but not storage or delivery | | **You need a flat, predictable per-site bill** | **Bunny Optimizer** | $9.50/website with unlimited transformations — as long as you budget CDN bandwidth separately, which is the trap in trap #2 above | | **You need an MCP server today** | **ImageKit**, **Cloudinary** or **Cloudflare** | All three ship one; we do not | | **You need per-key usage attribution inside one tenant** | **nitida** | `usage.keys()` attributes storage and traffic to the key that caused it. Cloudinary and ImageKit report per *account*; imgix per *Source*; Vercel’s finest grain is the team | | **You need storage bytes and file count as a first-class number per tenant** | **nitida** or **Bunny** | Bunny returns `StorageUsed` + `FilesStored` from one authenticated GET per zone — genuinely good, and the closest competitor on this axis | | **You want one URL per asset that survives every resize, format and crop** | **nitida** | Content addressing is the whole design: the SHA is the asset and every URL is a function of it | ## What this page does not claim [Section titled “What this page does not claim”](#what-this-page-does-not-claim) * It does not compare **latency or throughput**. Nobody ran a delivery benchmark across these six. * It does not compare **image quality at equal settings**. Our own encoder is measured in [the transform benchmark](/guides/transform-benchmark/); theirs is not, and a cross-vendor quality claim without a measurement is the same fabrication as a normalised price column. * It does not compare **support, SLA or contract terms** beyond noting that we have none. *** **Every competitor claim on this page was verified on 2026-08-17** against the vendor’s own documentation, at the URL stamped next to it in this page’s source. Freshness is enforced mechanically, not by good intentions: A freshness check parses every stamp on every CI run and fails the build when one is older than 90 days, carries no date, carries no URL, or is dated in the future. It deliberately does **not** fetch the competitor URLs: a `200` proves the page still exists, not that the sentence we quoted is still on it — so an automated fetch would report a safety it never verified. Re-reading is a human step, on purpose. ## Footnotes [Section titled “Footnotes”](#footnote-label) 1. Vercel never writes the sentence “no video”. What it writes is the format allow-list: “A source image must be one of the following formats to be optimized: `image/jpeg`, `image/png`, `image/webp`, `image/avif`. **Other formats will be served as-is**”. So the honest phrasing is *images only, and everything else passes through untouched* — not “video is unsupported”, which is an inference we would be putting in their mouth. [↩](#user-content-fnref-1) 2. With a caveat Cloudinary states itself, for `authenticated` assets: “You cannot create on-the-fly transformations; only pre-generated eager transformations work.” [↩](#user-content-fnref-2) 3. The canonical page `imagekit.io/docs/security/signed-urls` returns **404**. The quoted behaviour is official content reached through their own search, but the exact URL is unverified, so it is not stamped as a source. [↩](#user-content-fnref-3) 4. Searched for a signing mechanism; found only the `remotePatterns` / `localPatterns` allow-list. An allow-list is access control over *sources*, not a signature over a URL, and the two are not interchangeable. [↩](#user-content-fnref-4) 5. Searched “api key” and “per user” across the full 394 KB of `admin_api.md`. Credentials appear; attribution does not. Cloudinary sells tenant separation as *separate accounts* (“1 Account” / “2 Accounts” / “3 Accounts”), not as per-key metering. [↩](#user-content-fnref-5) 6. Searched the Images and Workers-binding documentation for a bytes-plus-file-count-by-key endpoint: **zero results**. [↩](#user-content-fnref-6) 7. A community `bunnycdn-mcp` exists. It is **not** official and is therefore not counted as a Bunny feature here. [↩](#user-content-fnref-7) 8. Searched imgix.com and docs.imgix.com for an MCP server and for `llms.txt`; found neither. Their “Generative AI Endpoint” is media *generation*, not an agent surface, and conflating the two would be the easy mistake. [↩](#user-content-fnref-8) 9. A `GET /usage` on a Basic plan returns `image_max_size_bytes: 157286400`, `video_max_size_bytes: 3145728000`, `image_max_px: 100000000` — but those are *that plan’s* values, read programmatically. The Free and Plus figures are published only in a support article that returns **403**, so they are not stated here. [↩](#user-content-fnref-9) 10. Bunny publishes the opposite — “Unlimited requests · Unlimited optimizations · Unlimited transformations”. That is a statement about billing, not a technical ceiling, and reading it as “no limits” is exactly the error this footnote exists to prevent. [↩](#user-content-fnref-10) # Access and privacy — what a URL means > A public URL is the authorisation, and that is a deliberate trade. Private assets close every public door and hand access back through a signed, expiring URL your backend mints. Every asset has a `visibility`, and the two values mean genuinely different things. Read the one that applies to you. > **`public`** — whoever has the URL has the bytes. **`private`** — every public URL answers **404**, and the bytes come back only through a signed URL that expires. `public` is the default, and it is what almost everything should be. ## Public: the URL *is* the authorisation [Section titled “Public: the URL is the authorisation”](#public-the-url-is-the-authorisation) There is no per-request identity in the delivery path of a public asset. The CDN does not know who you are, does not ask, and cannot be told. A URL is not a *pointer* to an asset you then get authorised for — the URL **is** the authorisation. That is deliberate, and it is what makes delivery cost what it costs: a response that depends on *who is asking* cannot be cached by URL alone, and public delivery is cached by URL alone. Same trade every URL-addressed media CDN makes. In practice: * **No credential is checked on delivery.** Your runtime key authorises *uploads and reads of metadata*. It has nothing to do with fetching bytes. * **Links do not expire.** * **There is no per-asset access log.** We can tell you aggregate usage; we cannot tell you who fetched a particular file. ### The part that surprises people: the URL is derived from the content [Section titled “The part that surprises people: the URL is derived from the content”](#the-part-that-surprises-people-the-url-is-derived-from-the-content) The `sha` in every URL is the **first 16 hex characters of the SHA-256 of the uploaded bytes**. Not a secret — but the consequence is worth stating outright: ```bash # no key, no cookie, no header of any kind $ curl -sO https://8ok.uk/d/v/818105b8e773f53c-o.jpg $ shasum -a 256 818105b8e773f53c-o.jpg 818105b8e773f53cd0e0063c311dc100aeedbc7be1989cdbb6e96c2d13945c71 └──────┬───────┘ the sha in the URL ``` So a public URL is not merely *hard to guess*: **it is computable by anyone who already holds a copy of the same file.** Given a candidate document, a third party can ask whether you host it and get a yes or no, with no credential. For listing photographs, a product catalogue or a restaurant menu this is irrelevant — those are meant to be seen. For a signed contract, an ID scan or a medical record, *the existence of the file is itself the secret*. That is what `private` is for. ## Private: the sha stops being enough [Section titled “Private: the sha stops being enough”](#private-the-sha-stops-being-enough) Set `visibility: "private"` and every public door closes — the stored variants, the raw original, the HLS ladder, and the on-the-fly `/t/` transform route. All of them answer **404**, to anyone, with the sha and nothing else. ```bash curl -X PATCH https://api.nitida.gofuture.space/assets//visibility \ -H "Authorization: Bearer $NITIDA_API_KEY" \ -H "X-Tenant-Code: your-tenant" \ -H "Content-Type: application/json" \ -d '{"visibility":"private"}' ``` Nothing moves. One row changes, the edge cache for that asset is purged, and a 1 GB video flips as fast as a thumbnail — it is the same single copy behind both doors. 404 on a private asset is the feature, not a missing file Everywhere else on this platform a 404 means the object was never written. On a private asset it means the door is closed. If you are debugging a 404, check `visibility` on the DTO **before** you check R2. ### Getting the bytes back: a signed URL your backend mints [Section titled “Getting the bytes back: a signed URL your backend mints”](#getting-the-bytes-back-a-signed-url-your-backend-mints) ```ts import { getPrivateAssetUrl } from "@nitida/sdk"; // On YOUR server, once you have decided this viewer may see it: const url = await getPrivateAssetUrl(asset, "lg", tenantSigningKey, { expiresInSeconds: 300, }); // → https://8ok.uk/a/5/v/-l.webp?exp=…&sig=… ``` The division of labour is the whole design: **your backend knows who the viewer is and decides; we verify the signature and serve.** We never learn who your end user is, and we do not want to. * The signed tree is `/a/{tenant}/…`, mirroring the public paths. * It covers stored variants, the raw original, the whole HLS ladder — the master playlist’s children are re-signed on the way out, so a player just works — and **on-the-fly transforms**, via `getPrivateTransformUrl`: ```ts const url = await getPrivateTransformUrl( asset, { width: 1280, format: "webp" }, tenantSigningKey, { expiresInSeconds: 300 }, ); ``` A private asset servable only at the sizes somebody already generated would barely be a product, so the private tree resizes too. The signature covers the transform itself, so one signed URL authorises one rendition — not every width. * **`exp` is mandatory.** `getPrivateAssetUrl` refuses to build a URL without one. A signed URL that never expires is a public URL the moment somebody forwards it. * Signed responses are `Cache-Control: private`. They are not shared-cached, so they cost more per request than public ones. That is the trade. The signing key is a backend secret It mints URLs for **every** private asset the tenant owns. Shipping it to a browser hands a visitor the whole library. It belongs beside your database password, not in a bundle. ### The SDK refuses rather than hand you a doomed URL [Section titled “The SDK refuses rather than hand you a doomed URL”](#the-sdk-refuses-rather-than-hand-you-a-doomed-url) ```ts getAssetUrl(privateAsset, "lg"); // Error: getAssetUrl: this asset is private, so a public CDN URL for it will // answer 404 — that is the feature, not a missing file. Mint a signed URL on // your BACKEND instead: await getPrivateAssetUrl(...) ``` Every public URL builder does this — `getAssetUrl`, `getAssetSrcSet`, `getTransformUrl`, `getTransformSrcSet`, `getVideoTransformUrl`, `getHlsStreamingUrl` — whenever the value you pass carries `visibility`. Pass only `{ sha }` and there is nothing to check, so nothing is refused. ### Revocation, and its one honest caveat [Section titled “Revocation, and its one honest caveat”](#revocation-and-its-one-honest-caveat) Flipping back to `private` **is** revocation: URLs handed out while the asset was public stop working. The edge cache is purged on the flip, so this is real and not merely a database opinion. The caveat, stated rather than buried: propagation is not instantaneous. The CDN refreshes the list of private assets on a **60-second** TTL, so a flip can take up to a minute to reach every location. Plan a revocation as “within a minute”, not “this instant”. ### One consequence of content-addressed storage [Section titled “One consequence of content-addressed storage”](#one-consequence-of-content-addressed-storage) Storage is deduplicated by content hash. If you mark your copy private but another tenant holds the **same bytes** and publishes them, the bytes stay reachable — through *their* public URL, because they published them, not through any door of yours. This is inherent to deduplicating by hash and every platform that does it shares it. If the existence of a file is the secret and the file is one somebody else might also hold, that is worth knowing before you upload. ## What the transform `?sig=` is not [Section titled “What the transform ?sig= is not”](#what-the-transform-sig-is-not) Some transform URLs carry a `?sig=` that has nothing to do with access: | | | | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | **`?sig=` on `/t/…`** | authorises an image width **outside** the standard ladder. No expiry, no access meaning. Treat such a URL as publicly as an unsigned one | | **`?exp=…&sig=…` on `/a/…`** | authorises **access**, until `exp` | They come from the same tenant signing key but not the same key material — the access signature is computed under a separately derived key, so a signature minted to resize can never be replayed as one to enter. ## Keep the runtime key server-side [Section titled “Keep the runtime key server-side”](#keep-the-runtime-key-server-side) This is the access rule that costs money if you get it wrong. An `amk_rt_*` runtime key is a **write** credential for a paid platform; shipping it in a browser bundle hands a stranger your upload quota. Uploads from a browser or a phone go one of two ways, and neither ships the key: the browser posts to **your** route and your route calls us, or your server asks us for a **presigned URL scoped to that one upload** and hands the browser only that. `@nitida/sdk/web` exports a client whose *type* rejects `apiKey`, so the wrong shape does not compile. See [Credentials](/start/credentials/) and [Browser-direct upload](/examples/browser-direct/). ## What we will not do [Section titled “What we will not do”](#what-we-will-not-do) We will not become an identity provider. We will never know who your end user is. Your backend already does, and that is where the decision belongs — you decide yes or no and mint a signed URL; we check the signature and hand over the bytes. # Beyond images — palette, AI proxies, probes > The artifacts the platform produces that are not pictures, and the seam between what we generate and what you decide. Three things come out of an upload that are not a resized image. All three follow the same rule, and it is the question people ask first: > **We produce the artifact. You decide what it means.** Nothing here runs a model on your behalf, ships a UI, or writes to your database. It hands you something cheap that would be expensive for you to compute, at the moment it is cheapest to compute — while the bytes are already open on our machine. ## The colour palette [Section titled “The colour palette”](#the-colour-palette) Extracted **synchronously during processing** — from the image itself, or from a video’s poster frame. Not a job, not a webhook you wait for: by the time the asset is `ready`, its palette is already on it. A dominant colour plus up to six vibrant/muted swatches — the Spotify / Apple-Music shape. **There is no getter.** The palette is a field on the asset DTO, already in the response the upload returned, so nothing fetches it: ```ts import { bestTextContrast, iteratePaletteSwatches, pickAmbientBackground, } from "@nitida/asset-client"; asset.palette; // → { d: "#683600", v: "#C2AA48", m: "#8A7B4E", dv: "#683600", … } | null // compact keys on the wire: d v m · lv dv · lm dm // The helpers read that shape so you never touch the keys yourself: const ambient = pickAmbientBackground(asset.palette); // → { hex: "#A7C2C6", textColor: "#000000" } | null for (const { label, hex } of iteratePaletteSwatches(asset.palette)) { const { color, ratio, passesAA } = bestTextContrast(hex); // label is the long name — "dominant", "lightMuted", … } ``` See [the palette example](/examples/palette/) for the full helper set on five real photographs, including the one where the ambient helper picks badly. `palette` is **`null` on any asset that has none.** Branch on it; the type says `AssetPalette | null` for a reason. Measured across the platform, 2026-08-22: | kind | with a palette | without | | ------------------------ | -------------- | ------- | | image | 24 199 | **788** | | video | 1 522 | **585** | | audio · document · other | 0 | **383** | A video gets its palette from its **poster frame**, so it has one whenever a poster was generated. Audio, documents and `other` never do, and that is a rule, not a gap. The video gap is **not** a backlog waiting to clear: those 585 have **no poster at all**, and a poster cannot be produced without decoding the video again. So the palette is recoverable only at the price of a fresh transcode — a cost decision, not a pending job. Six stragglers are a different story: their poster is *registered* but its object is missing from the CDN, a legacy row whose bytes were never migrated. This paragraph used to be false Until 2026-08-22 it claimed a video had a palette whenever it had a poster, while the table printed four lines below it showed 879 videos without one. Both were true at once because the **Cloud Run job path** generated the poster and never derived the palette from it — only the inline path did. Regenerating a poster therefore could not fix it, which is exactly how an outside reader disproved the sentence. The job path now derives it, and the assets that had already landed were backfilled. **What it is for:** an ambient background behind a product image, a card tint, a placeholder colour while the image loads that is not grey. It ships on the DTO in a compact form deliberately — an earlier, verbose shape was **62% of the entire asset payload**. **What it is not:** a design system. It tells you what colours are *in* the image; whether that should become a gradient is your call. *How it behaves, since people ask:* the image is downscaled to 100×100 and the swatches come from a small k-means plus HSL bucketing. It is **deterministic** — the same bytes always yield the same palette — and it costs no extra request: the palette ships on the DTO the upload already returns. ## The AI proxy — and where that logic lives [Section titled “The AI proxy — and where that logic lives”](#the-ai-proxy--and-where-that-logic-lives) This is the seam the question is really about. ```plaintext nitida YOUR APP ────── ──────── upload a video │ ├──▶ poster ─────────────────────────▶