# Property Images API

Upload, manage, and reorder property listing images. Images are stored on Cloudflare R2 and served via Cloudflare Image Transformations.

All endpoints support the standard `Idempotency-Key` header on writes — see [Authentication](/docs/api-reference/authentication).

## Upload Flow

Direct-to-R2 upload is a **three-step process**. **The confirm step is required** — without it the file lives orphaned in R2, never appears in `GET /images`, and does not count toward your image quota.

1. **Presign** — `POST .../images/presign` returns a presigned URL and an `r2Key`.
2. **Upload** — `PUT` the file bytes directly to that URL. Send `Content-Type` matching what you presigned. Wait for a `2xx` response.
3. **Confirm** — `POST .../images/confirm` with the `r2Key` to create the DB row and make the image visible.

For uploading multiple files at once, use the [batch endpoints](#batch-upload).

```bash
# 1. Presign
curl -X POST https://rentalot.ai/api/v1/properties/$PROP/images/presign \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"fileName":"living-room.jpg","contentType":"image/jpeg","sizeBytes":2048576}'

# 2. Upload to R2 (use uploadUrl from step 1)
curl -X PUT "$UPLOAD_URL" \
  -H "Content-Type: image/jpeg" \
  --data-binary @living-room.jpg

# 3. Confirm
curl -X POST https://rentalot.ai/api/v1/properties/$PROP/images/confirm \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"r2Key":"images/...","contentType":"image/jpeg","sizeBytes":2048576,"altText":"Living room"}'
```

## List Images

```
GET /api/v1/properties/:id/images
```

Returns all images for a property, ordered by display order.

**Response (`200 OK`):**

```json
{
  "data": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "url": "https://images.rentalot.ai/images/user123/prop456/img789.jpg",
      "altText": "Living room with hardwood floors",
      "order": 0
    }
  ]
}
```

## Get Presigned Upload URL

```
POST /api/v1/properties/:id/images/presign
```

Generates a single presigned PUT URL. Honors `Idempotency-Key` so a retry returns the same `r2Key` instead of burning through your image quota.

**Request body:**

```json
{
  "fileName": "living-room.jpg",
  "contentType": "image/jpeg",
  "sizeBytes": 2048576
}
```

| Field | Type | Description |
|-------|------|-------------|
| `fileName` | string | Original filename, used to derive the file extension. Max 255 chars. |
| `contentType` | string | One of `image/jpeg`, `image/png`, `image/webp`, `image/heic`. |
| `sizeBytes` | integer | File size in bytes. Max 10 MB (10 \* 1024 \* 1024). |

**Response (`200 OK`):**

```json
{
  "data": {
    "uploadUrl": "https://r2.example.com/presigned-put-url...",
    "r2Key": "images/user123/prop456/abc-def.jpg",
    "maxImages": 20
  }
}
```

After this, `PUT` the file bytes to `uploadUrl` with the same `Content-Type` you presigned, then call **Confirm Upload** below.

**Errors:**

- `422` — property not found, `contentType` not allowed, or your plan's `imagesPerProperty` limit is reached.

## Confirm Upload

```
POST /api/v1/properties/:id/images/confirm
```

Creates the database record after a successful `PUT` to R2.

**Request body:**

```json
{
  "r2Key": "images/user123/prop456/abc-def.jpg",
  "contentType": "image/jpeg",
  "sizeBytes": 2048576,
  "altText": "Living room with hardwood floors"
}
```

| Field | Type | Description |
|-------|------|-------------|
| `r2Key` | string | The `r2Key` returned from presign. Must start with `images/{userId}/{propertyId}/`. |
| `contentType` | string | Must match an allowed image type. |
| `sizeBytes` | integer | File size in bytes. |
| `altText` | string? | Optional alt text. Max 500 chars. |

**Response (`201 Created`):** Includes a `Location: /api/v1/properties/:id/images/:imageId` header.

```json
{
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "url": "https://images.rentalot.ai/images/user123/prop456/abc-def.jpg",
    "altText": "Living room with hardwood floors",
    "order": 1
  }
}
```

Emits the `property.updated` webhook event.

## Batch Upload

For uploading 2–20 files at once, use the batch endpoints. The flow is the same three steps, but each call accepts an array.

### Presign Batch

```
POST /api/v1/properties/:id/images/presign-batch
```

**Request body:**

```json
{
  "images": [
    { "fileName": "kitchen.jpg", "contentType": "image/jpeg", "sizeBytes": 1500000 },
    { "fileName": "bath.png",    "contentType": "image/png",  "sizeBytes": 800000 }
  ]
}
```

Up to 20 entries per call. Will reject with `422` if `current + requested > maxImages`.

**Response (`200 OK`):**

```json
{
  "data": [
    { "fileName": "kitchen.jpg", "uploadUrl": "https://r2.example.com/...", "r2Key": "images/user123/prop456/k.jpg" },
    { "fileName": "bath.png",    "uploadUrl": "https://r2.example.com/...", "r2Key": "images/user123/prop456/b.png" }
  ]
}
```

### Confirm Batch

```
POST /api/v1/properties/:id/images/confirm-batch
```

**Request body:**

```json
{
  "images": [
    { "r2Key": "images/user123/prop456/k.jpg", "contentType": "image/jpeg", "sizeBytes": 1500000, "altText": "Kitchen" },
    { "r2Key": "images/user123/prop456/b.png", "contentType": "image/png",  "sizeBytes": 800000 }
  ]
}
```

**Response (`201 Created`):**

```json
{
  "data": [
    { "id": "...", "url": "https://images.rentalot.ai/...", "altText": "Kitchen", "order": 1 },
    { "id": "...", "url": "https://images.rentalot.ai/...", "altText": null, "order": 2 }
  ]
}
```

Quota is re-checked on confirm (not just on presign) to guard against races. Emits one `property.updated` webhook event.

## Import Images by URL

```
POST /api/v1/properties/:id/images/import
```

Imports images from public URLs. The server downloads, validates, and uploads each URL to R2, then creates DB records. Processing happens **asynchronously** — the endpoint returns immediately with a `jobId` for polling.

**Request body:**

```json
{
  "urls": [
    "https://example.com/listing-photo-1.jpg",
    "https://example.com/listing-photo-2.jpg"
  ]
}
```

Up to 20 URLs per call, each up to 2048 chars.

**Response (`202 Accepted`):**

```json
{
  "data": {
    "jobId": "9f1e1f2a-...",
    "status": "pending",
    "totalUrls": 2
  }
}
```

**Errors:**

- `422` — property not found, monthly import budget exhausted, or per-property image limit reached.

### Poll Import Job

```
GET /api/v1/properties/:id/images/import/:jobId
```

**Response (`200 OK`):**

```json
{
  "data": {
    "jobId": "9f1e1f2a-...",
    "status": "completed",
    "totalUrls": 2,
    "imported": 2,
    "failed": 0,
    "totalBytes": 2300000,
    "errors": [],
    "createdAt": "2026-05-08T12:00:00.000Z",
    "completedAt": "2026-05-08T12:00:04.123Z"
  }
}
```

`status` is one of `pending`, `processing`, `completed`, `failed`.

## Delete Images

```
DELETE /api/v1/properties/:id/images
```

Delete one or more images by ID. Removes from both the database and R2 storage.

**Request body:**

```json
{
  "imageIds": ["550e8400-e29b-41d4-a716-446655440000"]
}
```

Up to 20 IDs per call.

**Response (`200 OK`):**

```json
{
  "data": { "deleted": 1 }
}
```

Emits the `property.updated` webhook event. IDs that don't belong to your account are silently skipped.

## Reorder Images

```
PATCH /api/v1/properties/:id/images/reorder
```

Sets the display order. The first ID in the array gets `order = 0`, the second `1`, etc.

**Request body:**

```json
{
  "imageIds": [
    "img-id-for-order-0",
    "img-id-for-order-1",
    "img-id-for-order-2"
  ]
}
```

Up to 20 IDs per call.

**Response (`200 OK`):**

```json
{ "ok": true }
```

Emits the `property.updated` webhook event.

## Notes

- **Tier-limited** — images per property is a plan limit. Check `maxImages` in the presign response or your tier in [Settings](/docs/api-reference/settings).
- **Allowed types:** `image/jpeg`, `image/png`, `image/webp`, `image/heic`. Max 10 MB per image.
- **Content-Type matching:** the `Content-Type` you send on the R2 `PUT` must match the `contentType` you sent to presign — R2 will reject mismatches.
- **CDN delivery:** images are served via Cloudflare Image Transformations for automatic resizing and format optimization.
