---
title: "Uploading an image"
description: "The three step presigned flow, accepted types and size limits."
canonical: "https://writavo.com/docs/media"
last-updated: "2026-09-28"
---

# Uploading an image

Three requests: reserve an upload, PUT the bytes straight to storage, then register the asset. The bytes never pass through the API.

## The three steps

1. **Reserve** `POST /media/upload-url` with the filename and the type you expect. You get a short lived presigned URL and an `upload_id`; the server picks the storage location inside your Site's namespace.
2. **Upload** `PUT` the raw bytes to that URL with no authorization header: the signature in the URL is the credential.
3. **Register** `POST /media` with the `upload_id`. Only now is the file in your library.

## The flow

**1. Reserve an upload** (returns 201)

```bash

curl -X POST https://api.writavo.com/v1/media/upload-url \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: b6f4b0e2-0000-4000-8000-0000000000aa" \
  -d '{
  "file_name": "hero.png",
  "content_type": "image/png",
  "size_bytes": 68
}'

```

You never name a storage location. The server chooses one inside your Site's namespace.

**2. PUT the bytes** (returns 200)

```bash

curl -X PUT https://uploads.example.com/presigned/EXAMPLE \
  -H "Content-Type: image/png" \
  --data-binary @hero.png

```

Send no authorization header. The signature inside the URL is the credential, and it expires.

**3. Register the asset** (returns 201)

```bash

curl -X POST https://api.writavo.com/v1/media \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: b6f4b0e2-0000-4000-8000-0000000000aa" \
  -d '{
  "upload_id": "3f1b0c7a-0000-4000-8000-000000000006",
  "alt_text": "A hero image"
}'

```

The server reads the real content type from the stored bytes here. The type you declared in step 1 is a hint and is never trusted.

## What the server checks

> The `content_type` in step 1 is a **hint only**. At registration the server reads the real type from the stored bytes. A file that is not an allowed image is rejected with `422 VALIDATION_FAILED` and deleted from storage.

## Abandoned and retried uploads

- An upload you never register is swept and its reservation expires. Nothing is charged.
- The presigned URL expires. If your `PUT` is slow or fails, request a new reservation rather than retrying.
- The URL is a live credential, so it is never replayed: a retry with the same `Idempotency-Key` returns the same `upload_id` with `upload_url: null` and `upload_url_replayable: false`. Request a fresh reservation.

## Deleting an asset

> Deleting an asset does **not** rewrite your articles. A published article that embeds it, or uses it as its `featured_image_url`, will show a broken image on your live blog.

- `GET /articles?fields=id,featured_image_url` finds featured uses.
- In body content the URL is plain text, so search your `content` for it.
- The catalog row and the stored bytes are both deleted. There is no undo.

## Alt text

- Send it: screen readers and search engines use it, and it cannot be generated for you. It is the only field you can edit later.
- To replace an image, upload a new one and repoint whatever used the old one; the bytes cannot change.
- A featured image takes its alt text from the article: send `featured_image_alt` beside `featured_image_url`. Without one, your blog uses the article title.
