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

## Why three steps

A presigned upload keeps image bytes off the API path, so a large file does not occupy a request slot and a slow connection does not hold one open. It also means you never name a storage location. The server chooses one inside your Site's namespace, and there is no request field anywhere that could point an upload somewhere else.

1. **Reserve** `POST /media/upload-url` with the filename and the type you believe you are uploading. You get a short lived presigned URL and an `upload_id`.
2. **Upload** `PUT` the raw bytes to that URL. Send no authorization header: the signature inside the URL is the credential.
3. **Register** `POST /media` with the `upload_id`. Only now is the file part of 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, and when

> The `content_type` you declare in step 1 is a **hint only**. At registration the server reads the stored bytes and takes the real type from them. A file whose actual type is not an allowed image is rejected with `422 VALIDATION_FAILED` and deleted from storage, so a rejected upload leaves nothing behind.

## If you never register

An uploaded object with no `media_assets` row is not part of your library. It is swept and the reservation expires. Nothing is charged and nothing is left dangling, so an abandoned upload is safe to abandon.

The presigned URL expires on its own too. If your `PUT` is slow or fails, request a new reservation rather than retrying an expired signature.

## Retrying a reservation

> The presigned URL is a bearer credential, so it is deliberately **not replayed**. A retry with the same `Idempotency-Key` returns the same `upload_id` with `upload_url: null` and `upload_url_replayable: false`. That is the honest answer: the retry has learnt that the reservation exists, and no live credential has been written to a table that gets backed up. Request a fresh reservation.

## Deleting an asset

> Deleting a media asset does **not** rewrite your articles. If a published article embeds the image, or uses it as its `featured_image_url`, that reference becomes 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 both go. There is no undo.

## Alt text

Worth sending, and the only field you can edit after the fact. It is what screen readers announce and what search engines read, and there is no way to generate it for you. The bytes themselves are immutable: to replace an image, upload a new one and repoint whatever referenced the old one.
