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_KEY in 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

For an agent, follow the integration procedure. For human setup, open the start page. Use the service evaluation before you select a host.

Sources