---
title: Images and Branding
description: Upload medicine photos and the org logo through presigned S3 URLs with type and size validation.
---

You store images in S3 through presigned URLs so the browser uploads bytes directly. The routes live in `apps/server/src/features/images/route.ts`, the business writes live in `apps/server/src/features/images/service.ts`, and the S3 helpers live in `apps/server/src/core/storage/storage.ts`. Every route requires authentication.

### Configuring storage

You set `AWS_S3_BUCKET`, `AWS_ENDPOINT_URL_S3`, `AWS_ACCESS_KEY_ID`, and `AWS_SECRET_ACCESS_KEY` before any upload works, as listed in `apps/server/.env.example`. You may also set `AWS_REGION`, which defaults to `us-east-2`, and `AWS_S3_PUBLIC_BASE`; without the public base, public URLs fall back to the endpoint plus bucket. Missing config returns 500 with `Image storage is not configured`.

### Accepted types and size limits

You may upload JPEG, PNG, and WebP only. Medicine images cap at 5 MB and branding images cap at 2 MB, enforced in `validateImageUpload` inside `apps/server/src/core/storage/storage.ts`. Keys must start with `medicines/` or `branding/` respectively, carry a matching image extension, and never contain `..` or backslashes, checked by `isValidMedicineImageKey` and `isValidBrandingImageKey` in the same file.

### Uploading a medicine photo

You need the `admin`, `inventory_manager`, or `branch_manager` role. You ask for a presigned URL, PUT the bytes to it, then attach the key to the medicine.

```bash
curl -X POST http://localhost:8000/images/medicines/upload-url \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"content_type":"image/jpeg","size_bytes":412000}'
```

You get `key`, `uploadUrl`, and `publicUrl`. Upload with a plain PUT that matches the declared content type:

```bash
curl -X PUT "$UPLOAD_URL" \
  -H "Content-Type: image/jpeg" \
  --data-binary @photo.jpg
```

Then you attach the key. The server verifies the object exists with a HEAD request via `getMedicineImageStatus`, rejects missing or oversized uploads with 400, and writes `image_key` plus `image_url` onto the medicine row.

```bash
curl -X POST http://localhost:8000/images/medicines/MEDICINE_ID/image \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"image_key":"medicines/abc123.jpg"}'
```

Attaching emits a `medicines` sync change marked `global` and logs a medicine `IMAGE_UPDATE` audit row, so every device picks up the new photo on its next pull.

### Setting the org logo

Branding is admin-only. You follow the same two-step flow against the branding prefix, then set the logo with `PUT /images/branding/logo`. The server checks the object with `getBrandingImageStatus` and rejects anything missing or over 2 MB.

```bash
curl -X POST http://localhost:8000/images/branding/upload-url \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"content_type":"image/png","size_bytes":180000}'
```

```bash
curl -X PUT http://localhost:8000/images/branding/logo \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"image_key":"branding/abc123.png"}'
```

The logo lands in the single `org_settings` row with id `org`, anchored by `SETTINGS_ID` in `apps/server/src/features/settings/queries.ts` and created when missing by `setBrandLogo` inserting through `insertOrgSettings` in `apps/server/src/features/images/mutations.ts`. You read the logo back with `GET /settings`, whose `logo_url` is resolved by `resolveLogoUrl` in `apps/server/src/features/settings/queries.ts`. Every logo change logs an `org_settings` `LOGO_UPDATE` audit row.

### Handling failures

You retry the attach step when you see `Image upload not found or larger than 5 MB` or `Logo upload not found or larger than 2 MB`: the PUT either has not finished or the declared size was wrong. You treat `Invalid image key` and `Invalid branding image key` as a client bug, since the key must come from the upload-url response. Presigned URLs expire after 900 seconds, so you request a fresh one when the PUT returns an S3 expiry error.

### Next steps

Sync the new image rows to devices in /platform/sync-protocol, trace logo changes in /platform/audit-log, and review the stock behind the photos in /inventory/medicines-and-batches.
