POST
/
api
/
v1
/
uploads
/
presign
Presign upload URLs
curl --request POST \
  --url https://api.example.com/api/v1/uploads/presign
import requests

url = "https://api.example.com/api/v1/uploads/presign"

response = requests.post(url)

print(response.text)
const options = {method: 'POST'};

fetch('https://api.example.com/api/v1/uploads/presign', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));
<?php

$curl = curl_init();

curl_setopt_array($curl, [
CURLOPT_URL => "https://api.example.com/api/v1/uploads/presign",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
]);

$response = curl_exec($curl);
$err = curl_error($curl);

curl_close($curl);

if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}
package main

import (
"fmt"
"net/http"
"io"
)

func main() {

url := "https://api.example.com/api/v1/uploads/presign"

req, _ := http.NewRequest("POST", url, nil)

res, _ := http.DefaultClient.Do(req)

defer res.Body.Close()
body, _ := io.ReadAll(res.Body)

fmt.Println(string(body))

}
HttpResponse<String> response = Unirest.post("https://api.example.com/api/v1/uploads/presign")
.asString();
require 'uri'
require 'net/http'

url = URI("https://api.example.com/api/v1/uploads/presign")

http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true

request = Net::HTTP::Post.new(url)

response = http.request(request)
puts response.read_body

Overview

Issues short-lived presigned PUT URLs for direct browser/app uploads to DigitalOcean Spaces. The client never streams bytes through the API — files go directly to object storage using the returned URL and headers, then are confirmed via POST /uploads/confirm. The request specifies a single fileType (TENANCY_DOC, HOME_PHOTO, or AVATAR) and a list of files, each with its mimeType and sizeBytes. The server validates per-file limits (see below), builds an object key of the form tenancy-docs/{userId}/{ulid}.{ext} (private bucket), listings/{userId}/{ulid}.{ext} (public bucket), or avatars/{userId}/{ulid}.{ext} (public bucket), and signs a PUT URL valid for 10 minutes. Per-file validation:
  • TENANCY_DOC: mimeType; sizeBytes ≤ 10,000,000.
  • HOME_PHOTO: mimeType; 50,000 ≤ sizeBytes ≤ 10,000,000.
  • AVATAR (profile photo): mimeType; sizeBytes ≤ 10,000,000 (no minimum). After confirming, pass the returned uploadId as avatarUploadId to PATCH /users/me.
  • CHAT_MEDIA (chat attachment): mimeType ∈ images , video , docs ; sizeBytes50,000,000 (no minimum). Public bucket, key chat-media/{userId}/{ulid}.{ext}. Post the returned CDN url back on a chat message’s media.url — see send-message.
The client MUST send the upload with the exact headers returned in headers (Content-Type and any Content-Length-Range / signature headers) — DigitalOcean rejects the PUT otherwise. See Upload flow for the full pipeline.

Authentication

Bearer <accessToken> required. Scope: user.

Path parameters

None.

Query parameters

None.

Request body

FieldTypeRequiredAllowed values / ConstraintsExample
fileTypeenumyesTENANCY_DOC, HOME_PHOTO, AVATAR, CHAT_MEDIAHOME_PHOTO
filesobject[]yes1..10 entries. See File object.

File object

FieldTypeRequiredNotes / ConstraintsExample
mimeTypestringyes1..100 chars. Allowlisted per fileType (see Overview).image/jpeg
sizeBytesintegeryesPositive integer. Allowlisted per fileType (see Overview).2_500_000

Example payload

{
  "fileType": "HOME_PHOTO",        // enum: "TENANCY_DOC" | "HOME_PHOTO"
  "files": [
    {
      "mimeType": "image/jpeg",    // HOME_PHOTO: "image/jpeg" | "image/png" | "image/webp"; TENANCY_DOC: "application/pdf" | "image/jpeg" | "image/png"
      "sizeBytes": 2500000          // integer; HOME_PHOTO: 50_000..10_000_000; TENANCY_DOC: 1..10_000_000
    },
    {
      "mimeType": "image/png",
      "sizeBytes": 1800000
    }
  ]
}

Response — 200 OK

FieldTypeNotesExample
filesobject[]One entry per input file, in the same order. See PresignedFile object.

PresignedFile object

FieldTypeNotes / Allowed valuesExample
uploadUrlstringPresigned HTTPS PUT URL, valid for 10 minutes.https://swappr-public.lon1.digitaloceanspaces.com/listings/...?X-Amz-Signature=...
fileKeystringServer-generated object key. Pass back to /uploads/confirm.listings/66400a8f1c2b4d5e6f7a8b00/01HZQ7K3M4N5P6Q7R8S9T0V1W2.jpg
headersobjectRequired HTTP headers for the PUT. Keys are header names, values are strings. Must be sent verbatim.{ "Content-Type": "image/jpeg" }

Example response

{
  "files": [
    {
      "uploadUrl": "https://swappr-public.lon1.digitaloceanspaces.com/listings/66400a8f1c2b4d5e6f7a8b00/01HZQ7K3M4N5P6Q7R8S9T0V1W2.jpg?X-Amz-Signature=abcd1234...",
      "fileKey": "listings/66400a8f1c2b4d5e6f7a8b00/01HZQ7K3M4N5P6Q7R8S9T0V1W2.jpg",
      "headers": {
        "Content-Type": "image/jpeg"
      }
    },
    {
      "uploadUrl": "https://swappr-public.lon1.digitaloceanspaces.com/listings/66400a8f1c2b4d5e6f7a8b00/01HZQ7K3M4N5P6Q7R8S9T0V1W3.png?X-Amz-Signature=efgh5678...",
      "fileKey": "listings/66400a8f1c2b4d5e6f7a8b00/01HZQ7K3M4N5P6Q7R8S9T0V1W3.png",
      "headers": {
        "Content-Type": "image/png"
      }
    }
  ]
}

Error responses

StatusCodeMeaning
400VALIDATION_FAILEDBody malformed; files empty or > 10 entries; per-file MIME not allowed for the chosen fileType; sizeBytes not a positive integer or outside the allowed range.
401UNAUTHENTICATEDMissing, malformed, or expired access token.
503STORAGE_NOT_CONFIGUREDDigitalOcean Spaces credentials are not provisioned in this environment (dev-only).

Example error — 400

{
  "type": "https://api.swappr.co.uk/errors/validation-failed",
  "title": "Validation failed",
  "status": 400,
  "code": "VALIDATION_FAILED",
  "detail": "HOME_PHOTO sizeBytes must be >= 50000 bytes",
  "instance": "/api/v1/uploads/presign",
  "requestId": "01HZQ7K3M4N5P6Q7R8S9T0V1W2"
}

Side effects

  • No database writes yet — the uploads row is created on confirm.
  • No bytes are streamed through the API; the response is purely cryptographic (SigV4 signature) plus the generated object key.

See also

curl

curl -X POST https://api.swappr.co.uk/api/v1/uploads/presign \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "fileType": "HOME_PHOTO",
    "files": [
      { "mimeType": "image/jpeg", "sizeBytes": 2500000 },
      { "mimeType": "image/png",  "sizeBytes": 1800000 }
    ]
  }'