Create a temporary image URL by adding unpublish_after_hours to the upload request. After that time, the public URL returns HTTP 404. imgd.dev keeps the bytes, so the image still uses quota. Post the returned hash to the publish endpoint to restore the exact same URL.
Before you schedule the URL
imgd.dev costs $1 per GB as a one-time storage purchase. It has no free tier. Every imgd.dev image is public while its link is published. A scheduled unpublish does not make the stored bytes private.
Use a funded account, cURL, jq, and a safe image under 20 MB. Load IMGD_KEY from a secret manager. Do not put it in a script or URL.
Use unique image bytes for this test. Another account can publish the same content hash. Its active claim would keep the shared public URL available.
Run the complete lifecycle
This Bash script uses a short 0.02 hour window, which is about 72 seconds. Use a longer window for normal work.
#!/usr/bin/env bash
set -eu
: "${IMGD_KEY:?Set IMGD_KEY in your shell.}"
IMAGE_PATH="${1:-temporary.png}"
WORK_DIR="$(mktemp -d)"
UPLOAD_BODY="$WORK_DIR/upload.json"
UNPUBLISHED_BODY="$WORK_DIR/unpublished.json"
REPUBLISH_BODY="$WORK_DIR/republish.json"
trap 'rm -rf "$WORK_DIR"' EXIT
UPLOAD_STATUS="$(
curl --silent --show-error \
--output "$UPLOAD_BODY" \
--write-out '%{http_code}' \
--request POST 'https://imgd.dev/v1/upload' \
--header "Authorization: Bearer $IMGD_KEY" \
--form "file=@$IMAGE_PATH" \
--form 'unpublish_after_hours=0.02'
)"
case "$UPLOAD_STATUS" in
200|202)
;;
*)
jq . "$UPLOAD_BODY" >&2
exit 1
;;
esac
HASH="$(jq --exit-status --raw-output '.hash' "$UPLOAD_BODY")"
IMAGE_URL="$(jq --exit-status --raw-output '.url' "$UPLOAD_BODY")"
UNPUBLISH_AT="$(jq --exit-status --raw-output '.unpublish_at' "$UPLOAD_BODY")"
printf 'Scheduled URL: %s\nUnpublish time: %s\n' "$IMAGE_URL" "$UNPUBLISH_AT"
PUBLIC_BEFORE="$(
curl --silent --show-error \
--output /dev/null \
--write-out '%{http_code}' \
"$IMAGE_URL"
)"
test "$PUBLIC_BEFORE" = '200'
ACCOUNT_AFTER_UPLOAD="$(
curl --fail --silent --show-error \
'https://imgd.dev/v1/me' \
--header "Authorization: Bearer $IMGD_KEY"
)"
STORAGE_AFTER_UPLOAD="$(printf '%s' "$ACCOUNT_AFTER_UPLOAD" | jq --exit-status '.storage_bytes')"
sleep 75
UNPUBLISHED_STATUS='000'
for ATTEMPT in 1 2 3 4 5 6 7 8 9 10; do
UNPUBLISHED_STATUS="$(
curl --silent --show-error \
--output "$UNPUBLISHED_BODY" \
--write-out '%{http_code}' \
"$IMAGE_URL"
)"
if [ "$UNPUBLISHED_STATUS" = '404' ]; then
break
fi
sleep 3
done
test "$UNPUBLISHED_STATUS" = '404'
test "$(jq --exit-status --raw-output '.error' "$UNPUBLISHED_BODY")" = 'image_unpublished'
jq . "$UNPUBLISHED_BODY"
METADATA_AFTER_UNPUBLISH="$(
curl --fail --silent --show-error \
"https://imgd.dev/v1/images/$HASH" \
--header "Authorization: Bearer $IMGD_KEY"
)"
test "$(printf '%s' "$METADATA_AFTER_UNPUBLISH" | jq --exit-status '.published')" = 'false'
ACCOUNT_AFTER_UNPUBLISH="$(
curl --fail --silent --show-error \
'https://imgd.dev/v1/me' \
--header "Authorization: Bearer $IMGD_KEY"
)"
STORAGE_AFTER_UNPUBLISH="$(printf '%s' "$ACCOUNT_AFTER_UNPUBLISH" | jq --exit-status '.storage_bytes')"
test "$STORAGE_AFTER_UNPUBLISH" = "$STORAGE_AFTER_UPLOAD"
REPUBLISH_STATUS="$(
curl --silent --show-error \
--output "$REPUBLISH_BODY" \
--write-out '%{http_code}' \
--request POST \
"https://imgd.dev/v1/images/$HASH/publish" \
--header "Authorization: Bearer $IMGD_KEY" \
--header 'Content-Type: application/json' \
--data '{}'
)"
test "$REPUBLISH_STATUS" = '200'
RESTORED_URL="$(jq --exit-status --raw-output '.url' "$REPUBLISH_BODY")"
test "$RESTORED_URL" = "$IMAGE_URL"
test "$(jq --exit-status --raw-output '.published' "$REPUBLISH_BODY")" = 'true'
PUBLIC_AFTER="$(
curl --silent --show-error \
--output /dev/null \
--write-out '%{http_code}' \
"$RESTORED_URL"
)"
test "$PUBLIC_AFTER" = '200'
printf 'Restored the same URL: %s\n' "$RESTORED_URL"
The publish request uses an empty JSON object. This clears the expiry and makes the link permanent.
Read the scheduled upload response
A new upload returns HTTP 202. The response includes the resolved UTC time.
{
"hash": "0000000000000000000000000000000000000000000000000000000000000000",
"url": "https://i.imgd.dev/i/0000000000000000000000000000000000000000000000000000000000000000",
"status": "processing",
"mime": "image/png",
"bytes": 53248,
"width": 1200,
"height": 675,
"alt_text": null,
"unpublish_at": "2026-08-20T12:01:12.000Z",
"note": "the url works immediately, serving a blurred placeholder until the moderation check finishes and it sharpens to the real image"
}
Identical bytes can return HTTP 200 with deduplicated: true. The new upload request replaces the prior expiry for your claim.
Always send the expiry again when you re-upload the same bytes. An upload without an expiry makes your claim permanent.
Verify the 404 response
After the scheduled time, the public route returns this body with HTTP 404:
{
"error": "image_unpublished",
"fix": "this link is not currently published; if it is yours, republish it with POST https://imgd.dev/v1/images/0000000000000000000000000000000000000000000000000000000000000000/publish"
}
The owner metadata still exists. It reports published: false and keeps the same hash, URL, and byte count.
The script compares /v1/me before and after unpublish. storage_bytes does not decrease. Only DELETE /v1/images/:hash releases the account quota.
If the URL still returns 200, check two cases. Moderation can still have the processing state. Another account can also have an active claim for the same hash.
Read the republish response
The empty publish request removes the expiry. A successful response is valid JSON:
{
"hash": "0000000000000000000000000000000000000000000000000000000000000000",
"url": "https://i.imgd.dev/i/0000000000000000000000000000000000000000000000000000000000000000",
"published": true,
"unpublish_at": null,
"note": "this link is published with no expiry"
}
The restored URL equals the original URL because the SHA-256 content hash forms the address. The bytes do not move to a new link.
To restore the link for another limited time, send a positive unpublish_after_hours value to the publish endpoint.
Recover from upload errors
Each API error includes an error field and a fix field. Read fix before another request.
| Status | Cause | Action |
|---|---|---|
401 |
The key is missing or invalid. | Correct IMGD_KEY in the secret manager. Keep it out of logs. |
402 |
The account is unpaid or lacks quota. | Buy storage or delete an image. Then retry the upload. |
413 |
The image exceeds 20 MB. | Resize or compress the image below 20 MB. |
415 |
The media type is unsupported. | Use JPEG, PNG, GIF, WebP, or AVIF. |
An invalid expiry returns HTTP 400. unpublish_after_hours must be greater than zero and no more than 8760 hours.
A blocked image cannot be republished. The publish endpoint returns HTTP 403 for a blocked or error status.
Do not retry a failed request without the required change. You can safely retry after an uncertain network result because identical bytes keep one URL.
Security limits
A temporary public URL is not private storage. Anyone can fetch it before unpublish. A downstream cache can also retain content under its own policy.
Do not schedule sensitive images as a security control. Use a private storage service when access control is required.
Keep IMGD_KEY in the authorization header. Never add it to the image URL or the publish URL.
Decide if temporary publishing fits
This feature fits public previews, short-lived issue evidence, and links that must return later under the same address.
It does not fit confidential images, strict deletion deadlines, unsupported files, images above 20 MB, or free storage. Read the service evaluation guide before use.
Result
The URL publishes, returns 404 after its schedule, retains its storage quota, and returns under the same address after republish.
Learn the basic request in the cURL upload guide. Read the alt-text polling guide when you need the final metadata state.
Give an agent the integration procedure. A person can start from the home page.