---
title: "How to Create a Temporary Image URL and Restore the Same Link"
description: "Schedule an imgd.dev URL to unpublish, verify its 404 response, and republish the same content-addressed link."
slug: "temporary-image-url-api"
date: 2026-08-20
updated: 2026-08-20
last_tested: 2026-08-20
summary: "Upload with unpublish_after_hours, confirm the public URL becomes a 404, then publish the hash again to restore the same URL."
cluster: Getting started
intent: how-to
sources:
  - title: "imgd.dev OpenAPI specification"
    url: "https://imgd.dev/openapi.json"
  - title: "imgd.dev agent reference"
    url: "https://imgd.dev/llms.txt"
  - title: "imgd.dev integration procedure"
    url: "https://imgd.dev/integrate.md"
---

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.

```bash
#!/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.

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

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

```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](/evaluate.md) 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](/blog/upload-image-curl-public-url/). Read the [alt-text polling guide](/blog/image-upload-alt-text-api/) when you need the final metadata state.

Give an agent the [integration procedure](/integrate.md). A person can [start from the home page](/#start).
