The rest of the surface
The pages before this cover what almost every integration needs. This one covers what exists beyond that, so nothing is a surprise you discover from a stack trace.
Signed transform URLs — the only way to a custom width
Section titled “Signed transform URLs — the only way to a custom width”Unsigned transforms accept only widths on the fixed ladder. Anything else is
400 at the edge, and that is a denial-of-service guard, not an oversight: an
open resize endpoint lets anyone mint unlimited unique renders on your bill.
A signed URL earns the bypass, because you vouched for it:
import { getSignedTransformUrl } from "@nitida/asset-client";
// width is a plain number here — any number.// The signing key is the THIRD argument and it is required.const url = await getSignedTransformUrl( { sha }, { width: 733, format: "webp" }, process.env.NITIDA_SIGNING_KEY!,);// or, with the client holding the key:// const nt = new NitidaClient({ …, signingKey: process.env.NITIDA_SIGNING_KEY });// await nt.transform(asset, { width: 733 }, { sign: true });The types enforce the rule so you cannot get it wrong by accident:
TransformOptions.width is the ladder union, while SignedTransformOptions.width
is number — and only the signing helpers accept the latter.
Getting the key
Section titled “Getting the key”The signing key is not part of the credential set you are issued, and none
of your own keys can read it — a tenant admin key gets 403 SYSTEM_KEY_REQUIRED. It is minted by:
POST /admin/projects/:code/rotate-signing-keywhich requires a system-scope credential held by the platform operator, and returns the key once. So: ask for it, then keep it in your secret manager next to the API key.
Rotating invalidates every URL signed with the previous key immediately, so coordinate it with whatever pre-signs — a build-time cache, a BFF route.
If you already hold a key and a signed URL still answers 401 invalid_signature, the key is wrong or rotated, not the URL shape: an
off-ladder width that answers 400 unsigned and 401 signed proves the
signature path is reached and the bypass works.
Upscaling
Section titled “Upscaling”An AI upscale of an existing asset, through a configured provider.
POST /assets/:id/upscale start (or return an existing one)GET /assets/:id/upscales everything upscaled for this shaPOST /assets/upscale/:id/poll manual finalize, when a webhook was missedIdempotent on (tenant, sha, preset, provider) — asking twice does not pay
twice. It is asynchronous and provider-backed, so treat it like the video path:
kick it off, poll or wait for the webhook, and have a fallback for the missed
one. That is what /poll is for.
This is metered separately from storage — the upscale op is its own scope on
the runtime key.
Usage and metering
Section titled “Usage and metering”await nt.usage.snapshot(); // current periodawait nt.usage.timeseries(30); // daily pointsawait nt.usage.keys(); // per-key breakdownThe Cloudinary-style consumption view: what you have stored, transformed and
served. keys() is the one worth wiring early — when usage jumps, “which key
did this” is the first question, and per-key attribution is the only cheap
answer.
OG cards — a deliberate bypass
Section titled “OG cards — a deliberate bypass”Social preview images have a different shape from user uploads: they are pre-rendered at deploy time, already the exact size, and they must not go through a resize ladder that would only re-encode them.
POST /og-cards/upload service-to-service, Bearer OG_UPLOAD_API_KEYBytes go base64 in a JSON envelope, because the callers are build-time jobs (Vercel, CI) where a simple wire format beats multipart.
Use this only for that case. If a human uploaded it, it belongs on the normal path.
Video compositions
Section titled “Video compositions”Two processors that build a new video from pieces you already uploaded:
- Slideshow — stills into a clip.
- Marketing composition — stitch segments into one MP4.
Both follow the same contract as any long video job: a processing row comes
back immediately, and it flips to ready or failed when the job finishes. Poll
with waitReady and budget minutes, not seconds — a multi-segment kit is a
10-minute timeout, not a 5-minute one.
External systems in the path
Section titled “External systems in the path”Worth knowing, because they explain latency and failure modes:
| system | role | what happens if it is down |
|---|---|---|
| Object storage | the bytes | uploads fail — this is the hard dependency |
| CDN | delivery | serving degrades; uploads unaffected |
| The video job runner | video transcode, HLS | video stays processing; images unaffected |
| Upscale provider | AI upscaling | that upscale fails; nothing else does |
| Your webhooks | palette / asset-ready | nothing. Fire-and-forget by design |
That last row is a deliberate choice: an upload does not fail because a downstream consumer was unavailable. You lose a notification, not an asset — reconcile with a retry or a backfill. The alternative trades a recoverable gap for an unrecoverable one.