Guides

Uploading

Upload images through the HTTP API, URL fetch or the admin console, plus format and size limits, EXIF handling, idempotency keys, how duplicates are handled and what a successful upload guarantees.

Upload entry points

Entry pointAuthenticationGood for
POST /api/upload (multipart form)Upload tokenScripts, PicGo / PicList custom uploaders
POST /api/upload/url (JSON)Upload tokenLetting the server fetch an image from another address
"Upload" on the Images page of the admin consoleConsole sessionDrag and drop, file picker, clipboard paste, fetch from URL
S3 protocol /s3/…S3 credential derived from an upload tokenClients that can upload to S3, see Upload clients

All four go through the same upload pipeline: format check, pixel budget, quota pre-check, EXIF handling, channel writes, then the database record. They differ only in where the bytes come from and which options they accept: the S3 entry point cannot set the lifetime per request, does no EXIF processing, and has no idempotency key.

Upload tokens are issued on the Upload tokens page of the console, see Admin console.

Uploading through the API

Terminal
curl -H "Authorization: Bearer imgy_xxxxxxxx" \
  -F "file=@photo.jpg" \
  https://img.example.com/api/upload

With every optional parameter:

Terminal
curl -H "Authorization: Bearer imgy_xxxxxxxx" \
  -H "Idempotency-Key: 7d0c6a2e-photo-0001" \
  -F "file=@photo.jpg" \
  -F "ttl=604800" \
  -F "exif_policy=strip" \
  https://img.example.com/api/upload
Form fieldRequiredMeaning
fileYesThe image file. The file name is metadata only (shown and searchable in the console); control characters are removed and it is truncated to 255 bytes. Only the first file part with a file name is used
ttlNoLifetime in whole seconds (0 to 3153600000) or never. Both 0 and never mean the image never expires. When omitted, the per-user default applies, then the global default; see Lifecycle and deduplication
exif_policyNostrip or passthrough. When omitted, the global default applies (passthrough out of the box); see EXIF handling

ttl and exif_policy are read only from multipart form fields; putting them in the URL query string has no effect. Each field is at most 1 KiB, and a longer one returns 400. Any other form fields are ignored. The Idempotency-Key request header is optional, see Idempotency keys.

A successful upload returns 200:

{
  "id": "R0RjUyP1UW",
  "url": "https://img.example.com/i/R0RjUyP1UW"
}
  • id is a 10-character short ID drawn from 0-9A-Za-z.
  • url is always absolute. If the Public site address (base_url) is set, it is used; otherwise the address is derived from the request: the scheme from whether the connection uses TLS, the host from the Host header. When the request arrives through a trusted reverse proxy, X-Forwarded-Proto and X-Forwarded-Host are used instead (which proxies are trusted is set by the IMGYOUNG_TRUSTED_PROXIES environment variable). If the public domain behind a reverse proxy or CDN differs from the host in the request, you must set base_url; see Configuration and Reverse proxy.

Errors come back as {"error": "…"}; see Status codes. The full API definition is in API reference: data plane.

Idempotency keys

On a flaky network a client may send the same upload twice. Sending an Idempotency-Key header keeps the retry from creating a second image:

  • Keys are scoped per user; two users can use the same key without affecting each other, so nobody can use an idempotency key to probe for or retrieve someone else's image.
  • Within the retention period, a request from the same user with the same key returns the id and url of the first successful upload and writes nothing to the channels. The retention period is idempotency_key_ttl_hours, 24 hours out of the box, counted from that first success.
  • A hit does not compare the request content: if you reuse a key with a different file, you still get the first image back. Use a different key for every distinct image.
  • Only successful uploads record their key, so a failed request can safely be retried with the same key.
  • Of several concurrent requests with the same key, only one is stored; the others return the same result.
  • Keys past their retention period are reclaimed by the cleanup sweep, and deleting an image also deletes the keys that point to it. After that, the same key is treated as a new upload.

Both /api/upload and /api/upload/url honor idempotency keys; the S3 entry point does not.

Fetching from a URL

Give the server an image address and it downloads and stores the image for you, which is handy for pulling images back from other image hosts. The fetched bytes go through the same upload pipeline as a direct upload, so the format allowlist, pixel budget, EXIF handling and quota all apply; the server never copies a remote file straight into a channel.

Terminal
curl -H "Authorization: Bearer imgy_xxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/photo.png", "ttl": "never"}' \
  https://img.example.com/api/upload/url

The body is a JSON object of at most 8 KiB containing only the fields below (any other field returns 400):

FieldRequiredMeaning
urlYesImage address, http / https only
file_nameNoFile name; defaults to the last segment of the URL path, or fetched if there is none
exif_policyNoSame as the form upload
ttlNoSame as the form upload; the number 3600 and the strings "3600" and "never" are all accepted

The response and the use of Idempotency-Key are the same as for the form upload. The format of the fetched bytes is determined from their content; the Content-Type declared by the remote server plays no part.

Fetch restrictions

This entry point makes the server issue an outbound request on the caller's behalf, so fetching is bound by a fixed set of rules that keep it from being used to probe your internal network:

RuleDetailWhen violated
SchemeOnly http / https, checked again on every redirect hop400
DestinationPublic addresses only. The check runs right before each connection, against the IP actually resolved, so DNS rebinding and redirects into private networks are both caught400
RedirectsAt most 5502
SizeSame as the per-image limit (max_upload_mb). A declared Content-Length over the limit is rejected up front; without one, reading stops once the limit is exceeded413
Time30 seconds for the whole fetch; establishing the TCP connection, the TLS handshake and waiting for response headers are each capped at 10 seconds502
Remote status2xx only502

Rejected address ranges:

  • IPv4: 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16 (including the cloud metadata address 169.254.169.254), 172.16.0.0/12, 192.0.0.0/24, 192.168.0.0/16, 198.18.0.0/15, 224.0.0.0/4, 240.0.0.0/4.
  • IPv6: ::/128, ::1/128, ::/96, 64:ff9b::/96, 64:ff9b:1::/48, 2002::/16, fc00::/7, fe80::/10, ff00::/8. IPv4-mapped addresses (such as ::ffff:127.0.0.1) are judged by the IPv4 address they contain.

A few more details:

  • The server connects to the target directly and ignores proxy environment variables such as HTTP_PROXY.
  • The request carries no Referer, and its User-Agent is the Go standard library default. Sources with hotlink protection or bot blocking may refuse it, which shows up as a 502.
  • A fetch occupies one upload concurrency slot while it runs (see Formats and limits).

Uploading in the admin console

On the Images page, click Upload. The dialog has two tabs:

  • Local files: click to pick files or drag images onto the dashed area; multiple files are fine. While the dialog is open, Ctrl / ⌘ + V pastes images straight from the clipboard. At most two files upload at the same time; the rest wait in a queue.
  • Fetch from URL: enter an image address and click Fetch; this is the same as fetching from a URL.

The Expiry (Default / Never / Custom hours) and EXIF (Default / Strip / Keep) options below apply to files added afterwards; Default means the field is not sent. Closing the dialog does not interrupt uploads in progress; each finished upload shows its link with a copy button.

Console uploads belong to the signed-in account and need no token; the image details show the source as "Console upload / token removed". They share the rate limit and the concurrency gate with token uploads.

Formats and limits

Only JPEG, PNG, GIF and WebP are accepted. The format is determined from the file content (magic bytes) and cross-checked against what the decoder recognizes; file extensions and the type declared by the client are ignored.

  • SVG is not accepted (it can embed scripts).
  • Animated GIFs can be uploaded; they are stored as is and always served as the original, so the animation is kept.
  • Animated WebP is not supported; the upload returns 400.
LimitSettingDefaultWhen exceeded
Image sizemax_upload_mb50 MB413. Only the file itself counts, so a file exactly at the limit is accepted; the request body as a whole is capped separately at the limit + 64 KiB, and exceeding that is also 413
Pixel budgetmax_pixels_mp50 megapixels (width × height)422. Judged from the image header alone, so an oversized image is never fully decoded
Concurrent uploadsmax_concurrent_uploads8429 with Retry-After: 1
Upload rateupload_rate_per_min0 (off)429 with Retry-After: 60
Storage quotaSet per userUnlimited403
  • The concurrency gate is a single global gate shared by data plane uploads, URL fetches, console uploads and every S3 request (including GET / HEAD / DELETE). It caps how many requests are processed at once. Uploads of any size are processed in memory and never written to temporary files, so this number bounds the peak memory of uploading. When it is full, requests get 429 immediately instead of queueing.
  • The pixel budget guards decoded memory, which depends on resolution rather than file size: a 20-megapixel image decodes to about 80 MB of RGBA. That is why only the header is read first, and an oversized image never reaches the decoder.
  • The rate limit is per user, and uploads by the same user through tokens, the console and S3 PutObject share one allowance. It is a token bucket: up to N uploads can go through back to back (N being the per-minute allowance), after which one more is allowed every 60/N seconds. The buckets live in process memory and reset on restart.
  • The quota counts every reference in full: each image record counts the full size of its physical file against its owner, and background deduplication does not reduce usage. The pre-check uses the size of the uploaded bytes.

All of these are changed under Settings → Runtime settings and take effect on save, see Configuration. Per-user quotas are set on the Users page.

Status codes

StatusMeaning
200Success (an idempotency key hit is also 200)
400Missing file field or unreadable body; image content cannot be decoded (including animated WebP); an invalid ttl or exif_policy, or one longer than 1 KiB; for URL fetches, an invalid body, a scheme other than http/https, or a destination that is not a public address
401Token missing, invalid, revoked or expired, or its owner is disabled
403Token lacks the upload scope, or the storage quota is full
413The file is over the per-image size limit, or the request body is over the limit + 64 KiB (for URL fetches, the remote content is too large)
415Not a supported image format
422Resolution exceeds the pixel budget
429The concurrency gate is full or the user is uploading too fast; retry after Retry-After
502URL fetch only: the remote server returned non-2xx, redirected too often, timed out or could not be reached
503No healthy channel to write to, or fewer synchronous copies than the minimum succeeded

EXIF handling

PolicyBehavior
passthroughStored byte for byte; no metadata is touched. The global default out of the box
stripMetadata is removed before storing, as listed below

The default is passthrough: keeping storage providers from seeing the original is the job of channel encryption, while whether to strip metadata from what visitors with the link can see is the uploader's own choice, so by default the original is left untouched.

The policy for an upload is the form (or JSON) field if given, otherwise the global default exif_policy. S3 uploads skip EXIF processing and are stored byte for byte: S3 clients check the upload by size and ETag, so the stored bytes must match what was sent. spoof (fake metadata) is not available yet; sending it returns 400, and it cannot be the global default either.

strip works at the byte level: nothing is re-encoded and no pixel changes:

FormatRemovedKept
JPEGEvery APPn segment except APP0 (JFIF), APP2 (ICC color profile) and APP14 (Adobe), which covers EXIF (GPS, camera model, capture time and so on), XMP, IPTC and vendor segments; COM comment segments; any data after EOI (such as the video in a Motion Photo)Entropy-coded data is kept untouched. If the original has a non-default orientation tag (Orientation), a minimal EXIF segment holding only the orientation is added back, so the photo still displays upright. Segments between the scans of a progressive JPEG are not inspected
PNGeXIf, tEXt, zTXt and iTXt chunks; any data after IENDgAMA, iCCP and other chunks that affect color rendering
WebPEXIF and XMP chunks, clearing the matching flags in the VP8X header and fixing the RIFF length; any data beyond the declared RIFF lengthPixel chunks (including animation frames)
GIFNothingGIF has no standard container for EXIF / XMP
  • An image's SHA-256 (the hash shown in the console and the basis of deduplication) is computed over the bytes actually stored, after stripping.
  • With passthrough, GPS and other metadata in the original can be read by anyone who has the link and fetches the original bytes. The variants produced by on-demand processing are re-encoded and carry no metadata, but the original itself stays reachable.

Why processing happens at upload time

The EXIF policy takes effect at the moment of upload, and the processed bytes are what gets stored; the original is not reprocessed on access according to whatever the current setting is.

The reason is that caching on the access side rests on "the bytes behind a given /i/ link never change": responses carry Cache-Control: immutable, and browsers and CDNs can safely cache them for a year. If processing happened on access instead, changing the global policy would change the bytes served by the same link: the CDN cache would have to be purged wholesale, and old bytes already cached in visitors' browsers could not be invalidated at all.

The main benefit of processing on access would be keeping the original untouched in the channel and sanitizing only on the way out. Privacy towards storage providers is already handled by channel encryption, and processing at upload with passthrough as the default covers that need, so it is not worth giving up immutable caching for.

Uploading the same image more than once

Uploads are not deduplicated on the way in: uploading the same file twice gives two short IDs, and two copies are written to the channels at first.

This is deliberate: the upload path has no branch such as "instant upload of a known file"; every upload goes fully through validation, channel writes and recording, which keeps the surface for failures as small as possible. Deduplication only needs the hashes computed during upload, so in the background it is a pure database operation with no need to decode images again.

The background deduplication loop (every 300 seconds out of the box, dedup_scan_seconds) merges physical files whose stored bytes are identical into one, and removes the extra copy after the delete grace period. After a merge:

  • Each upload keeps its own short ID, lifetime, access count and owner, and every link keeps working.
  • Quotas count every reference in full, so a merge does not reduce usage.

The same photo uploaded with different EXIF policies is stored as different bytes and is not merged automatically; its perceptual hashes are close, so the pair lands in Duplicates for an administrator to decide. See Lifecycle and deduplication. Duplicates caused by client retries are best avoided at the source with idempotency keys.

What a successful upload means

Before returning, an upload writes to healthy channels one after another in priority order until the number of successful writes reaches min_success (Minimum synced replicas, 1 out of the box):

  1. If a write fails or times out (each write gets at most 10 minutes), the next healthy channel is tried; the first channel that succeeds is the primary channel for this upload.
  2. Once enough copies exist, the record is written to the metadata database and the upload returns. The remaining channels are marked as pending and filled by background replication, which does not slow the upload down.
  3. If there are not enough healthy channels, or not enough writes succeed after trying them all, the upload returns 503. Objects already written become orphans, which reconciliation reclaims after a 48-hour grace period.

With min_success above 1, an upload waits until several channels have the bytes before returning, in exchange for having multiple replicas from the start; if fewer channels are healthy than that number, every upload returns 503. Channel priority, health checks and replication are covered in Storage channels.

On this page