//SCHEMADocs
Console
How-to guides

Upload media

Files never pass through the API. You declare a file's SHA-256, upload it straight to storage, and then attach it. Here is the whole flow in code.

The flow#

  1. Hash the file locally: SHA-256, as 64 lowercase hex characters, plus its size in bytes.
  2. Request an upload with requestUpload.
    • If alreadyPresent is true, //SCHEMA has these bytes already. Skip to step 4.
  3. PUT the file to uploadUrl, sending every header in requiredHeaders exactly as given.
  4. Complete with completeUpload. The resource moves to stored.

You need media_write, and a resource to upload against — see addResource.

In Node.js#

import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { stat, readFile } from "node:fs/promises";

async function sha256(path) {
  const hash = createHash("sha256");
  for await (const chunk of createReadStream(path)) hash.update(chunk);
  return hash.digest("hex");
}

export async function upload(resourcePk, path) {
  const digest = await sha256(path);
  const { size } = await stat(path);

  const { data, errors } = await schema(
    `mutation ($pk: Int!, $sha: String!, $size: Int!) {
       requestUpload(resourcePk: $pk, sha256: $sha, byteSize: $size) {
         alreadyPresent uploadUrl requiredHeaders { name value }
       }
     }`,
    { pk: resourcePk, sha: digest, size },
  );
  if (errors) throw new Error(errors[0].message);
  const ticket = data.requestUpload;

  if (!ticket.alreadyPresent) {
    const headers = Object.fromEntries(ticket.requiredHeaders.map((h) => [h.name, h.value]));
    const put = await fetch(ticket.uploadUrl, {
      method: "PUT",
      headers,
      body: await readFile(path),
    });
    if (!put.ok) throw new Error(`upload failed: ${put.status} ${await put.text()}`);
  }

  const done = await schema(
    `mutation ($pk: Int!, $sha: String!) {
       completeUpload(resourcePk: $pk, sha256: $sha) { state }
     }`,
    { pk: resourcePk, sha: digest },
  );
  if (done.errors) throw new Error(done.errors[0].message);
  return done.data.completeUpload.state; // "stored"
}

This reads the file into memory. For multi-gigabyte masters, use an HTTP client that can stream a body with a known Content-Length — S3-compatible storage does not accept chunked uploads.

In a browser#

The GraphQL calls must go through your backend, which holds the API key. You can hash in the browser with crypto.subtle.digest("SHA-256", …) and send your backend the digest and size. Whether the browser can then PUT to storage directly depends on storage's CORS policy allowing your site's origin; unless //SCHEMA has confirmed yours, upload from your server.

What storage enforces#

  • The bytes must match the hash. The x-amz-checksum-sha256 header is part of the signature. Storage recomputes the digest and rejects a body that does not match, so a URL cannot be used to upload anything but the file you declared.
  • The headers must be sent exactly. Omitting or altering any header in requiredHeaders gets a 403.
  • The URL expires after expiresInSeconds (15 minutes). Request a new one if it lapses; that is always safe.
  • 5 GiB maximum. Larger files must be delivered over SFTP.

Deduplication#

Storage is addressed by content, so the same bytes are stored once however many releases use them. Reusing a cover across a single and its album, or re-uploading after a network failure, returns alreadyPresent: true and costs nothing. See Content-addressed media.

If something goes wrong#

SymptomCause
PUT returns 403A required header was missing or changed, or the URL expired.
PUT returns 400 with a checksum errorThe bytes do not match the SHA-256 you declared — often the hash was of a different file, or hex was sent where the header wants base64. Send the header value exactly as requiredHeaders gives it.
completeUpload: those bytes are not in the storeThe PUT did not succeed. Retry from requestUpload.
requestUpload: no resource … in this catalogThe resourcePk is wrong or belongs to another catalog.

Uploading for a delivery instead#

If you are delivering DDEX files rather than authoring a release, use requestDeliveryUpload, which names no resource. See Deliver DDEX through the API.