Skip to content

Media upload flow

Every media asset — logos, token images, series asset bundles, generative code — goes through the same three-step flow:

  1. Create an upload session. The backend creates a pending MediaAsset and returns a signed URL.
  2. PUT the bytes to that URL.
  3. Poll until the asset’s status is Ready.

Files (images, videos, audio, metadata JSON) go PendingReady directly after upload. Directories (zips) go PendingProcessingReady as the backend extracts them.

Every upload declares a kind:

KindUse for
FileSingle images, videos, audio, metadata JSON
DirectoryZip archives for series asset bundles or generative code

The kind is declared, not inferred. A zip uploaded with kind: File stays a blob; you need Directory to trigger extraction.

import { MediaKind } from "@highlightxyz/sdk";
KindMax size
File (image)50 MB
File (audio)250 MB
File (video)500 MB
File (metadata)1 MB
Directory1 GB

Exceeding returns MediaTooLargeError at session creation time (before you waste bandwidth uploading).

import { readFileSync } from "node:fs";
import { MediaKind } from "@highlightxyz/sdk";
async function uploadFile(
client: ReturnType<typeof createHighlightClient>,
accessToken: string,
path: string,
mimeType: string,
kind: MediaKind,
): Promise<string> {
const buffer = readFileSync(path);
// 1. Create session
const session = (await client.media.createUploadSession({
kind,
fileName: path.split("/").pop()!,
mimeType,
fileSize: buffer.byteLength,
})).data!;
// 2. PUT bytes
const putRes = await fetch(session.upload.url, {
method: session.upload.method,
headers: {
...session.upload.headers,
authorization: `Bearer ${accessToken}`,
},
body: buffer,
});
if (!putRes.ok) {
throw new Error(`Upload PUT failed: ${putRes.status} ${await putRes.text()}`);
}
// 3. Poll until Ready
return waitForReady(client, session.media.id);
}
async function waitForReady(
client: ReturnType<typeof createHighlightClient>,
mediaId: string,
timeoutMs = 120_000,
): Promise<string> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const media = (await client.media.get({ mediaId })).data!;
if (media.status === "Ready") return mediaId;
if (media.status === "Failed") throw new Error(`Media ${mediaId} failed`);
await new Promise((r) => setTimeout(r, 2000));
}
throw new Error(`Media ${mediaId} did not become Ready within ${timeoutMs}ms`);
}

Series asset bundles and generative code are zip archives:

const codeMediaId = await uploadFile(
client,
tokens.accessToken,
"./generative-code.zip",
"application/zip",
MediaKind.Directory,
);

After upload, the backend extracts the archive. status transitions PendingProcessingReady. The waitForReady helper handles both cases — just give it enough timeout for extraction (~2 minutes is typical).

Once Ready, enumerate extracted files:

const children = (await client.media.listChildren({
mediaId: codeMediaId,
limit: 100,
})).data!;
for (const child of children.entries) {
console.log(child.key, child.size, child.mimeType);
}
// Fetch a specific child's full Entity (with a resolved URL)
const entry = (await client.media.getChild({
mediaId: codeMediaId,
path: "index.html",
})).data!;
console.log(entry.url);

The PUT handler runs synchronously for File kinds — most images are Ready on the next poll. Directory extraction runs in the background (zip decompression, per-file mime detection, storage writes for every child). Poll at 2-second intervals; don’t re-PUT on a slow response.

A Directory that ends up Failed (corrupt zip, extraction crash, etc.) can be retried:

await client.media.process({ mediaId });

process is idempotent — Ready assets come back unchanged; Processing assets keep going; Failed Directories retry.

Media lives on R2 by default. To freeze it permanently on Arweave:

await client.media.publish({ mediaId });

Files publish the blob; Directories publish a manifest over all children. Idempotent. After publish succeeds, the media has both R2 and Arweave storage locations.

For a collection’s metadata specifically, prefer client.collection.finalizeBaseUri(highlightId) — it archives the token-metadata directory and flips the on-chain baseURI in one call.

await client.media.delete({ mediaId });

Media in-use by a deployed contract or a generative collection’s code can’t be deleted — the call returns MediaInUseError (409). Arweave locations are immutable and stay on-chain even after deletion; only R2 objects are freed.