Use Net::HTTP::Post#set_form to send a multipart file field to https://imgd.dev/v1/upload. Ruby opens the HTTPS session, sends the file, parses the JSON, and checks each HTTP status. No external upload gem is required.

imgd.dev costs a one-time $1 per GB of storage. It has no free tier, so fund the account before the first upload. Every imgd.dev image is public. Do not send private, confidential, or access-controlled images.

How the standard-library upload works

Ruby Net::HTTPHeader#set_form accepts an open IO value for a multipart file field. The example keeps that file open only during the request.

The example uses these safeguards:

  1. Read IMGD_KEY from the environment.
  2. Require an accepted file extension.
  3. Enable TLS through the HTTPS URI.
  4. Set connection, read, and write timeouts.
  5. Close the file and HTTP session through blocks.
  6. Parse both success and error JSON.
  7. Report the server fix field without changing it.
  8. Verify the public image response.

Accepted files are JPEG, PNG, GIF, WebP, and AVIF. The maximum upload size is 20 MB.

Complete Ruby script

Save this file as examples/workflows/ruby/upload_image.rb:

# frozen_string_literal: true

require 'json'
require 'net/http'
require 'uri'

UPLOAD_URI = URI('https://imgd.dev/v1/upload')

class UploadFailure < StandardError; end

def required_env(name)
  value = ENV[name]
  raise UploadFailure, "Set the #{name} environment variable." if value.nil? || value.strip.empty?

  value
end

def image_mime(path)
  case File.extname(path).downcase
  when '.jpg', '.jpeg' then 'image/jpeg'
  when '.png' then 'image/png'
  when '.gif' then 'image/gif'
  when '.webp' then 'image/webp'
  when '.avif' then 'image/avif'
  else
    raise UploadFailure, 'Use a jpeg, png, gif, webp, or avif file extension.'
  end
end

def perform_request(uri, request)
  Net::HTTP.start(
    uri.host,
    uri.port,
    use_ssl: uri.scheme == 'https',
    open_timeout: 10,
    read_timeout: 60,
    write_timeout: 60
  ) do |http|
    http.request(request)
  end
end

def parse_json(body)
  payload = JSON.parse(body)
  raise UploadFailure, 'The API response must be a JSON object.' unless payload.is_a?(Hash)

  payload
rescue JSON::ParserError => e
  raise UploadFailure, "The API returned invalid JSON: #{e.message}"
end

def upload_image(path, key)
  response = File.open(path, 'rb') do |file|
    request = Net::HTTP::Post.new(UPLOAD_URI)
    request['Accept'] = 'application/json'
    request['Authorization'] = "Bearer #{key}"
    request.set_form(
      [['file', file, { filename: File.basename(path), content_type: image_mime(path) }]],
      'multipart/form-data'
    )
    perform_request(UPLOAD_URI, request)
  end

  payload = parse_json(response.body)
  unless response.is_a?(Net::HTTPSuccess)
    error = payload['error'].is_a?(String) ? payload['error'] : 'unknown_error'
    fix = payload['fix'].is_a?(String) ? payload['fix'] : 'Read the response body.'
    raise UploadFailure, "Upload failed with HTTP #{response.code} and #{error}: #{fix}"
  end

  payload
end

def validate_upload(payload)
  hash = payload['hash']
  url = payload['url']
  unless hash.is_a?(String) && hash.match?(/\A[0-9a-f]{64}\z/)
    raise UploadFailure, 'The success response has no valid image hash.'
  end
  raise UploadFailure, 'The success response has no public URL.' unless url.is_a?(String)

  uri = URI(url)
  unless uri.is_a?(URI::HTTPS) && uri.host == 'i.imgd.dev'
    raise UploadFailure, 'The success response has an unexpected public URL.'
  end

  uri
rescue URI::InvalidURIError => e
  raise UploadFailure, "The success response URL is invalid: #{e.message}"
end

def verify_public_url(uri)
  request = Net::HTTP::Get.new(uri)
  request['Accept'] = 'image/*'
  response = perform_request(uri, request)
  content_type = response['Content-Type'].to_s

  unless response.is_a?(Net::HTTPSuccess) && content_type.start_with?('image/')
    raise UploadFailure,
          "Public URL verification failed with HTTP #{response.code} and Content-Type #{content_type}."
  end

  warn "Verified #{uri} with HTTP #{response.code} and Content-Type #{content_type}."
end

begin
  key = required_env('IMGD_KEY')
  path = ARGV.fetch(0) do
    raise UploadFailure, 'Usage: ruby upload_image.rb /absolute/path/to/image.png'
  end
  unless File.file?(path) && File.readable?(path)
    raise UploadFailure, 'The image path must be a readable file.'
  end

  payload = upload_image(path, key)
  public_uri = validate_upload(payload)
  verify_public_url(public_uri)
  puts JSON.pretty_generate(payload)
rescue UploadFailure, SystemCallError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError => e
  warn e.message
  exit 1
end

File.open closes the image after the request. Net::HTTP.start closes each HTTP session after its block, including error paths.

Run the upload

Keep IMGD_KEY outside the repository. Never put the key in Ruby source, a URL, or a log message.

cd examples/workflows/ruby
test -n "$IMGD_KEY"
ruby upload_image.rb /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. That result also contains "deduplicated": true.

The URL works immediately. It can show a blurred image while moderation runs. It serves the original image after a live result.

Verify the owner metadata

The script checks the public response status and media type. Use the authenticated metadata endpoint for the moderation state and alt text:

HASH="$(jq -r '.hash' upload.json)"
curl --fail --silent --show-error \
  -H "Authorization: Bearer $IMGD_KEY" \
  "https://imgd.dev/v1/images/$HASH" | jq .

A live result 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 Ruby terminal session that shows a verified public image URL.",
  "category": "safe",
  "nsfw": false,
  "violence": false,
  "filename": "image.png",
  "unpublish_at": null,
  "published": true,
  "created_at": 1787184000
}

A review state is terminal and stays blurred. A blocked state removes the bytes. Do not upload identical blocked bytes again.

Recover from API errors

The script uses Net::HTTPSuccess for the success branch. It reads error and fix for every other HTTP response.

Status Meaning Recovery
401 The key is missing or invalid. Set the correct IMGD_KEY. Do not retry the same invalid value.
402 The account is unfunded. Buy storage, verify the account, and retry. There is no free tier.
402 The upload exceeds the quota. Buy the suggested storage or delete unused images, then retry.
413 The image exceeds 20 MB. Resize or compress the file, then retry.
415 The media type is not accepted. Use JPEG, PNG, GIF, WebP, or AVIF with the correct type.
429 The account exceeded the rate limit. Wait for the next minute, then retry.

A normal error response has this base structure:

{
  "error": "file_too_large",
  "fix": "file exceeds 20MB; resize the image below that and retry",
  "bytes": 24117248,
  "max_bytes": 20971520
}

Do not retry a 401, 413, or 415 response without a required change. Read and show the fix value first.

Security notes

  • Store IMGD_KEY in an environment variable or a secret manager.
  • Keep the default TLS certificate verification enabled.
  • Do not use Net::HTTP#set_debug_output with a secret request.
  • Do not put the key in the image URL or exception text.
  • Treat every returned image URL as public.
  • Delete stored bytes when you no longer want the public object.

When imgd.dev fits

imgd.dev fits Ruby scripts that need one public URL from an image upload. The standard library can perform the full request without an upload gem.

It does not fit private images, authorization gates, video, files above 20 MB, or a free evaluation upload. Choose another host for those needs.

Next guides

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

Sources