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:
- Read
IMGD_KEYfrom the environment. - Require an accepted file extension.
- Enable TLS through the HTTPS URI.
- Set connection, read, and write timeouts.
- Close the file and HTTP session through blocks.
- Parse both success and error JSON.
- Report the server
fixfield without changing it. - 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_KEYin an environment variable or a secret manager. - Keep the default TLS certificate verification enabled.
- Do not use
Net::HTTP#set_debug_outputwith 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
- Upload an image with Rust for an async client.
- Upload an image with PHP for a cURL client.
- Host a Puppeteer screenshot for browser automation.
For an agent, follow the integration procedure. For human setup, open the start page. Read the service evaluation before you select a host.