Use CURLFile to send the local image as the file field at https://imgd.dev/v1/upload. Check the HTTP status separately from curl_exec, then validate the JSON. The complete script reports the API fix field and verifies the returned public URL.
imgd.dev charges a one-time $1 per GB of storage. The service has no free tier, so fund the account before the first upload. Every imgd.dev image is public. Never upload private, confidential, or access-controlled content.
Requirements
Use PHP 8 or later with the cURL extension. The script accepts JPEG, PNG, GIF, WebP, and AVIF files up to 20 MB.
The PHP manual states that curl_exec does not fail for an HTTP error status. The script therefore reads CURLINFO_RESPONSE_CODE after every request.
Complete PHP script
Save this file as examples/workflows/php/upload-image.php:
<?php
declare(strict_types=1);
const UPLOAD_URL = 'https://imgd.dev/v1/upload';
function stopWithError(string $message): void
{
fwrite(STDERR, $message . PHP_EOL);
exit(1);
}
function imageMime(string $path): string
{
$extension = strtolower((string) pathinfo($path, PATHINFO_EXTENSION));
return match ($extension) {
'jpg', 'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'webp' => 'image/webp',
'avif' => 'image/avif',
default => throw new InvalidArgumentException(
'Use a jpeg, png, gif, webp, or avif file extension.'
),
};
}
function uploadImage(string $path, string $key): array
{
$handle = curl_init(UPLOAD_URL);
if ($handle === false) {
throw new RuntimeException('Could not create the upload cURL handle.');
}
try {
$file = new CURLFile($path, imageMime($path), basename($path));
$configured = curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => ['file' => $file],
CURLOPT_HTTPHEADER => [
'Accept: application/json',
'Authorization: Bearer ' . $key,
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 60,
]);
if ($configured === false) {
throw new RuntimeException('Could not configure the upload cURL handle.');
}
$body = curl_exec($handle);
if ($body === false) {
throw new RuntimeException('Upload transport error: ' . curl_error($handle));
}
return [
'status' => (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE),
'content_type' => (string) curl_getinfo($handle, CURLINFO_CONTENT_TYPE),
'body' => $body,
];
} finally {
curl_close($handle);
}
}
function verifyPublicUrl(string $url): array
{
$handle = curl_init($url);
if ($handle === false) {
throw new RuntimeException('Could not create the verification cURL handle.');
}
try {
$configured = curl_setopt_array($handle, [
CURLOPT_HTTPHEADER => ['Accept: image/*'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 60,
]);
if ($configured === false) {
throw new RuntimeException('Could not configure the verification cURL handle.');
}
$body = curl_exec($handle);
if ($body === false) {
throw new RuntimeException('Verification transport error: ' . curl_error($handle));
}
return [
'status' => (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE),
'content_type' => (string) curl_getinfo($handle, CURLINFO_CONTENT_TYPE),
];
} finally {
curl_close($handle);
}
}
try {
if (!extension_loaded('curl')) {
throw new RuntimeException('Enable the PHP cURL extension.');
}
$key = getenv('IMGD_KEY');
if (!is_string($key) || trim($key) === '') {
throw new RuntimeException('Set the IMGD_KEY environment variable.');
}
$path = $argv[1] ?? null;
if (!is_string($path) || $path === '') {
throw new InvalidArgumentException(
'Usage: php upload-image.php /absolute/path/to/image.png'
);
}
if (!is_file($path) || !is_readable($path)) {
throw new InvalidArgumentException('The image path must be a readable file.');
}
$result = uploadImage($path, $key);
$payload = json_decode($result['body'], true, 512, JSON_THROW_ON_ERROR);
if (!is_array($payload)) {
throw new RuntimeException('The API response must be a JSON object.');
}
if ($result['status'] < 200 || $result['status'] >= 300) {
$error = is_string($payload['error'] ?? null) ? $payload['error'] : 'unknown_error';
$fix = is_string($payload['fix'] ?? null) ? $payload['fix'] : 'Read the response body.';
throw new RuntimeException(sprintf(
'Upload failed with HTTP %d and %s: %s',
$result['status'],
$error,
$fix
));
}
$url = $payload['url'] ?? null;
$hash = $payload['hash'] ?? null;
if (!is_string($url) || filter_var($url, FILTER_VALIDATE_URL) === false) {
throw new RuntimeException('The success response has no valid public URL.');
}
if (!is_string($hash) || preg_match('/^[0-9a-f]{64}$/', $hash) !== 1) {
throw new RuntimeException('The success response has no valid image hash.');
}
$verification = verifyPublicUrl($url);
if (
$verification['status'] < 200
|| $verification['status'] >= 300
|| !str_starts_with($verification['content_type'], 'image/')
) {
throw new RuntimeException(sprintf(
'Public URL verification failed with HTTP %d and Content-Type %s.',
$verification['status'],
$verification['content_type']
));
}
fwrite(STDERR, sprintf(
"Verified %s with HTTP %d and Content-Type %s.%s",
$url,
$verification['status'],
$verification['content_type'],
PHP_EOL
));
echo json_encode(
$payload,
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
) . PHP_EOL;
} catch (Throwable $error) {
stopWithError($error->getMessage());
}
Both cURL handles close in finally blocks. This cleanup occurs after success, an HTTP error, a JSON error, or a transport error.
Run the script
Store IMGD_KEY outside the repository. Do not put it in the script, the image URL, or a log.
cd examples/workflows/php
test -n "$IMGD_KEY"
php upload-image.php /absolute/path/to/image.png | tee upload.json
A new image returns HTTP 202 with this complete JSON shape:
{
"hash": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"url": "https://i.imgd.dev/i/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"status": "processing",
"mime": "image/png",
"bytes": 18452,
"width": 1200,
"height": 800,
"alt_text": null,
"unpublish_at": null,
"note": "the url works immediately, serving a blurred placeholder until the moderation check finishes"
}
An identical file can return HTTP 200. Its JSON also contains "deduplicated": true.
The URL works immediately. During moderation, it can serve a blurred image. The URL serves the original image after a live result.
Verify the result
The script fetches the URL and requires an image/* response. Also inspect the owner metadata:
HASH="$(jq -r '.hash' upload.json)"
curl --fail --silent --show-error \
-H "Authorization: Bearer $IMGD_KEY" \
"https://imgd.dev/v1/images/$HASH" | jq .
A completed response can contain this JSON:
{
"hash": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"url": "https://i.imgd.dev/i/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"status": "live",
"mime": "image/png",
"bytes": 18452,
"width": 1200,
"height": 800,
"alt_text": "A PHP terminal session that reports a verified image URL.",
"category": "safe",
"nsfw": false,
"violence": false,
"filename": "image.png",
"unpublish_at": null,
"published": true,
"created_at": 1787184000
}
A review result is terminal and stays blurred. A blocked result removes the bytes. Do not retry the same blocked file.
Recover by HTTP status
The API sends an error and a direct fix value for each 4xx response.
| Status | API error | Required action |
|---|---|---|
401 |
missing_api_key or invalid_api_key |
Set the correct IMGD_KEY. Do not retry an unchanged invalid key. |
402 |
payment_required |
Buy storage and confirm the account state, then retry. |
402 |
quota_exceeded |
Buy the suggested storage or delete unused images, then retry. |
413 |
file_too_large |
Reduce the file below 20 MB, then retry. |
415 |
unsupported_media_type |
Use JPEG, PNG, GIF, WebP, or AVIF with a matching type. |
429 |
rate_limited |
Wait for the next minute, then retry. |
A representative 415 response is:
{
"error": "unsupported_media_type",
"fix": "send jpeg, png, gif, webp, or avif and set the matching Content-Type",
"accepted": ["image/jpeg", "image/png", "image/gif", "image/webp", "image/avif"]
}
Do not use only curl_exec($handle) === false as the API error check. That check detects transport errors, not normal HTTP 4xx responses.
Security notes
- Keep
IMGD_KEYin an environment variable or a secret manager. - Keep TLS peer and host verification enabled.
- Do not enable verbose cURL output in a job that has the key.
- Do not put the key in a URL, repository, exception report, or log.
- Treat the returned URL as public and shareable.
- Delete stored bytes when the public image is no longer suitable.
When imgd.dev fits
imgd.dev fits PHP jobs that need a simple public image URL without a storage subscription. The content hash also gives identical bytes the same URL.
It does not fit private images, gated downloads, files above 20 MB, video, or a free trial. Select another host for those requirements.
Next guides
- Upload an image with Rust for an async typed client.
- Upload an image with Ruby for a standard-library client.
- Add the result to Markdown or a README.
For an agent, follow the integration procedure. For human setup, open the start page. Use the service evaluation before you select a host.