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#
- Hash the file locally: SHA-256, as 64 lowercase hex characters, plus its size in bytes.
- Request an upload with requestUpload.
- If
alreadyPresentistrue, //SCHEMA has these bytes already. Skip to step 4.
- If
- PUT the file to
uploadUrl, sending every header inrequiredHeadersexactly as given. - 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-sha256header 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
requiredHeadersgets a403. - 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#
| Symptom | Cause |
|---|---|
PUT returns 403 | A required header was missing or changed, or the URL expired. |
PUT returns 400 with a checksum error | The 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 store | The PUT did not succeed. Retry from requestUpload. |
requestUpload: no resource … in this catalog | The 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.