# The rest of the surface

> Signed URLs, upscaling, usage metering, OG cards, compositions — the features that exist but rarely come up first.

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

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:

```ts
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.

:::danger[The signing key is server-side, like the API key]
Sign in a route handler or at build time. A signing key in the browser is an
open resize endpoint with extra steps.
:::

### 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-key
```

which 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

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 sha
POST /assets/upscale/:id/poll     manual finalize, when a webhook was missed
```

**Idempotent 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

```ts
await nt.usage.snapshot();       // current period
await nt.usage.timeseries(30);   // daily points
await nt.usage.keys();           // per-key breakdown
```

The 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

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_KEY
```

Bytes 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

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

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.