---
title: Upload an Image With Ruby Net HTTP
description: Upload an image with Ruby Net HTTP multipart forms, TLS, JSON checks, and explicit status recovery.
slug: upload-image-ruby
date: 2026-08-20
updated: 2026-08-20
last_tested: 2026-08-20
summary: Use Ruby Net HTTP and set_form to upload an image without an external upload gem.
cluster: Languages
intent: how-to
sources:
  - title: Ruby Net HTTP class
    url: https://docs.ruby-lang.org/en/master/Net/HTTP.html
  - title: Ruby Net HTTPHeader set_form API
    url: https://docs.ruby-lang.org/en/master/Net/HTTPHeader.html
  - title: imgd.dev OpenAPI document
    url: https://imgd.dev/openapi.json
---

# Upload an Image With Ruby Net HTTP

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`:

```ruby
# 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.

```bash
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:

```json
{
  "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:

```bash
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:

```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:

```json
{
  "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

- [Upload an image with Rust](/blog/upload-image-rust/) for an async client.
- [Upload an image with PHP](/blog/upload-image-php/) for a cURL client.
- [Host a Puppeteer screenshot](/blog/puppeteer-screenshot-upload/) for browser automation.

For an agent, follow the [integration procedure](/integrate.md). For human setup, open [the start page](/#start). Read the [service evaluation](/evaluate.md) before you select a host.

## Sources

- [Ruby Net HTTP class](https://docs.ruby-lang.org/en/master/Net/HTTP.html)
- [Ruby Net HTTPHeader set_form API](https://docs.ruby-lang.org/en/master/Net/HTTPHeader.html)
- [imgd.dev OpenAPI document](https://imgd.dev/openapi.json)
