Upload an image with JavaScript by reading its bytes, creating a typed Blob, and appending that blob to FormData as file. Send the form to https://imgd.dev/v1/upload with built-in Node.js fetch. Accept HTTP 202 for new bytes and HTTP 200 for deduplicated bytes.
Before you upload
imgd.dev costs $1 per GB as a one-time storage purchase. It has no free tier. Every imgd.dev image is public to anyone with its URL. Do not upload a private, confidential, or secret image.
Use Node.js 20 or later. This example uses only Node.js built-ins. It does not install an upload library.
Load IMGD_KEY from a secret manager before you start. Confirm that the variable exists without printing its value:
test -n "$IMGD_KEY"
Complete JavaScript example
Save this code as examples/core/javascript/upload-image.mjs.
import { readFile } from "node:fs/promises";
import { basename, extname } from "node:path";
const UPLOAD_URL = "https://imgd.dev/v1/upload";
const METADATA_URL = "https://imgd.dev/v1/images";
const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
const REQUEST_TIMEOUT_MS = 60_000;
const MIME_TYPES = Object.freeze({
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".avif": "image/avif",
});
/**
* @typedef {object} UploadSuccess
* @property {string} hash
* @property {string} url
* @property {string} status
* @property {string} mime
* @property {number} bytes
* @property {number|null} width
* @property {number|null} height
* @property {string|null} alt_text
* @property {string|null} unpublish_at
* @property {boolean} [deduplicated]
* @property {string} note
*/
function isRecord(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isUploadSuccess(value) {
return isRecord(value)
&& typeof value.hash === "string"
&& typeof value.url === "string"
&& typeof value.status === "string"
&& typeof value.mime === "string"
&& typeof value.bytes === "number";
}
async function readJson(response) {
const text = await response.text();
try {
return JSON.parse(text);
} catch {
throw new Error(`The server returned invalid JSON with HTTP ${response.status}.`);
}
}
function responseError(response, payload) {
const guidance = {
401: "Load a valid IMGD_KEY from the secret manager.",
402: "Buy storage or delete unused images, then retry.",
413: "Reduce the image below 20 MB, then retry.",
415: "Use JPEG, PNG, GIF, WebP, or AVIF with the correct media type.",
};
const errorCode = isRecord(payload) && typeof payload.error === "string"
? payload.error
: "unknown_error";
const fix = isRecord(payload) && typeof payload.fix === "string"
? payload.fix
: guidance[response.status] ?? "Read the response and correct the request.";
return new Error(`Request failed with HTTP ${response.status} (${errorCode}): ${fix}`);
}
async function uploadImage(imagePath, apiKey) {
const extension = extname(imagePath).toLowerCase();
const mediaType = MIME_TYPES[extension];
if (!mediaType) {
throw new Error("Use a JPEG, PNG, GIF, WebP, or AVIF file name.");
}
const bytes = await readFile(imagePath);
if (bytes.byteLength === 0) {
throw new Error("The image file is empty.");
}
if (bytes.byteLength > MAX_UPLOAD_BYTES) {
throw new Error("The image is larger than the 20 MB upload limit.");
}
const form = new FormData();
form.append("file", new Blob([bytes], { type: mediaType }), basename(imagePath));
const response = await fetch(UPLOAD_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
},
body: form,
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
const payload = await readJson(response);
if (!response.ok) {
throw responseError(response, payload);
}
if (response.status !== 200 && response.status !== 202) {
throw new Error(`The upload returned unexpected HTTP ${response.status}.`);
}
if (!isUploadSuccess(payload)) {
throw new Error("The upload response does not match the expected success contract.");
}
return payload;
}
async function verifyUpload(upload, apiKey) {
const metadataResponse = await fetch(`${METADATA_URL}/${upload.hash}`, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
const metadata = await readJson(metadataResponse);
if (!metadataResponse.ok) {
throw responseError(metadataResponse, metadata);
}
if (!isRecord(metadata) || metadata.hash !== upload.hash) {
throw new Error("The metadata response did not confirm the upload hash.");
}
const publicResponse = await fetch(upload.url, {
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!publicResponse.ok) {
throw new Error(`The public URL returned HTTP ${publicResponse.status}.`);
}
const contentType = publicResponse.headers.get("content-type") ?? "";
if (!contentType.startsWith("image/")) {
throw new Error(`The public URL returned the unexpected type '${contentType}'.`);
}
const publicBytes = await publicResponse.arrayBuffer();
if (publicBytes.byteLength === 0) {
throw new Error("The public URL returned an empty image body.");
}
}
const imagePath = process.argv[2];
const apiKey = process.env.IMGD_KEY;
if (!imagePath) {
process.stderr.write("Usage: node upload-image.mjs IMAGE_PATH\n");
process.exitCode = 64;
} else if (!apiKey) {
process.stderr.write("Set IMGD_KEY in the environment.\n");
process.exitCode = 78;
} else {
try {
const upload = await uploadImage(imagePath, apiKey);
await verifyUpload(upload, apiKey);
process.stdout.write(`${JSON.stringify(upload, null, 2)}\n`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exitCode = 1;
}
}
Run the example
Pass a JPEG, PNG, GIF, WebP, or AVIF path:
node examples/core/javascript/upload-image.mjs ./image.png
The program sets the multipart field name to file. It also sets the media type from the file extension.
Expected JSON
A new PNG can produce this output after both verification requests succeed:
{
"hash": "0000000000000000000000000000000000000000000000000000000000000000",
"url": "https://i.imgd.dev/i/0000000000000000000000000000000000000000000000000000000000000000",
"status": "processing",
"mime": "image/png",
"bytes": 48211,
"width": 1280,
"height": 720,
"alt_text": null,
"unpublish_at": null,
"note": "the url works immediately, serving a blurred placeholder until the moderation check finishes and it sharpens to the real image"
}
The URL works immediately. During processing, the public request verifies a blurred image placeholder. The original becomes available after a live result.
A deduplicated upload returns HTTP 200 and adds this field:
{
"deduplicated": true
}
How the validation works
isUploadSuccess checks required values before the program uses them. The JSDoc type records the response convention for editors and readers.
The program rejects an empty file, an unsupported extension, and a local file above 20 MB. The server remains the final contract.
verifyUpload checks the authenticated metadata route. It then downloads the immediate public image response and checks its media type and size.
The upload has a 60-second timeout. A network timeout produces a nonzero process exit code.
Recover from API errors
The program reads both error and fix from non-2xx JSON. It uses a local instruction only when the server omits fix.
| Status | Cause | Action |
|---|---|---|
401 |
IMGD_KEY is missing or invalid. |
Load the correct key from the secret manager. Do not print it. |
402 |
The account has zero storage or full storage. | Buy storage or delete unused images. Then run the command again. |
413 |
The server received more than 20 MB. | Reduce the image below 20 MB. |
415 |
The server received an unsupported type. | Use JPEG, PNG, GIF, WebP, or AVIF with the matching extension. |
Do not retry these responses without a change. You can retry a timeout with the same bytes. Content addressing returns the same hash and URL.
A typical error is valid JSON:
{
"error": "file_too_large",
"fix": "file exceeds 20MB; resize the image below that and retry",
"bytes": 22020096,
"max_bytes": 20971520
}
Security rules
Keep IMGD_KEY in the process environment. Do not add it to source code, a URL, an exception, or normal output.
The program prints the upload response. That response contains a public URL, not the key.
Inspect image content before upload. Public storage does not protect private text, faces, or production data.
Decide if this example fits
This example fits Node.js scripts that need a direct public image URL and no upload dependency. It also fits safe upload retries.
It does not fit private images, access control, video, PDFs, files above 20 MB, or a free tier. Read the service evaluation guide before use.
Result
The program uploads one image, validates the response, verifies metadata, and verifies the immediate public image body.
Compare it with the TypeScript upload guide or start from the cURL upload guide.
Give an agent the integration procedure. A person can start from the home page.