POST
/
api
/
v1
/
uploads
/
confirm
Confirm uploads
curl --request POST \
  --url https://api.example.com/api/v1/uploads/confirm
import requests

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

response = requests.post(url)

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

fetch('https://api.example.com/api/v1/uploads/confirm', 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/confirm",
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/confirm"

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/confirm")
.asString();
require 'uri'
require 'net/http'

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

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

Confirms that the client successfully PUT each presigned object to DigitalOcean Spaces. For every fileKey the server:
  1. Verifies the key prefix matches the caller (tenancy-docs/{userId}/... or listings/{userId}/...) — otherwise 403 FORBIDDEN.
  2. HEADs the object in storage to confirm presence and read Content-Length/Content-Type.
  3. Cross-checks the stored Content-Type against the extension-implied MIME — if they disagree, 400 VALIDATION_FAILED. (The full byte-level magic-bytes sniff lands with the Phase 7 image worker — until then the presign-time Content-Type pin and this cross-check are the spoof defence.)
  4. Inserts a row into the uploads collection.
  5. For HOME_PHOTO keys, enqueues an image.process BullMQ job (Phase 7 will run the worker — resize variants, EXIF GPS strip, blurhash).
  6. Returns { uploadId, fileKey, cdnUrl }. cdnUrl is null for TENANCY_DOC (private bucket; admins fetch via a separate signed-download endpoint).
The returned uploadId is what onboarding and current-home endpoints reference (uploadId, photoUploadIds).

Authentication

Bearer <accessToken> required. Scope: user.

Path parameters

None.

Query parameters

None.

Request body

FieldTypeRequiredAllowed values / ConstraintsExample
fileKeysstring[]yes1..10 entries. Each is a fileKey returned by POST /uploads/presign. 1..500 chars each. Must start with tenancy-docs/{callerUserId}/ or listings/{callerUserId}/.["listings/66400a8f1c2b4d5e6f7a8b00/01HZQ7K3M4N5P6Q7R8S9T0V1W2.jpg"]

Example payload

{
  "fileKeys": [
    "listings/66400a8f1c2b4d5e6f7a8b00/01HZQ7K3M4N5P6Q7R8S9T0V1W2.jpg",
    "listings/66400a8f1c2b4d5e6f7a8b00/01HZQ7K3M4N5P6Q7R8S9T0V1W3.png"
  ]
}

Response — 200 OK

FieldTypeNotesExample
filesobject[]One entry per input fileKey, same order. See ConfirmedFile object.

ConfirmedFile object

FieldTypeAllowed valuesExample
uploadIdstring24-char MongoDB ObjectId. The id to pass to onboarding/current-home endpoints.66400a8f1c2b4d5e6f7a8b91
fileKeystringThe key echoed back.listings/66400a8f1c2b4d5e6f7a8b00/01HZQ7K3M4N5P6Q7R8S9T0V1W2.jpg
cdnUrlstring | nullPublic CDN URL for HOME_PHOTO; null for TENANCY_DOC (private bucket).https://cdn.swappr.co.uk/listings/66400a8f1c2b4d5e6f7a8b00/01HZQ7K3M4N5P6Q7R8S9T0V1W2.jpg

Example response

{
  "files": [
    {
      "uploadId": "66400a8f1c2b4d5e6f7a8b91",
      "fileKey": "listings/66400a8f1c2b4d5e6f7a8b00/01HZQ7K3M4N5P6Q7R8S9T0V1W2.jpg",
      "cdnUrl": "https://cdn.swappr.co.uk/listings/66400a8f1c2b4d5e6f7a8b00/01HZQ7K3M4N5P6Q7R8S9T0V1W2.jpg"
    },
    {
      "uploadId": "66400a8f1c2b4d5e6f7a8b92",
      "fileKey": "listings/66400a8f1c2b4d5e6f7a8b00/01HZQ7K3M4N5P6Q7R8S9T0V1W3.png",
      "cdnUrl": "https://cdn.swappr.co.uk/listings/66400a8f1c2b4d5e6f7a8b00/01HZQ7K3M4N5P6Q7R8S9T0V1W3.png"
    }
  ]
}

Error responses

StatusCodeMeaning
400VALIDATION_FAILEDBody malformed; fileKeys empty or > 10; an object has an invalid size; the stored Content-Type does not match the extension-implied MIME (spoof attempt).
401UNAUTHENTICATEDMissing, malformed, or expired access token.
403FORBIDDENA fileKey does not start with tenancy-docs/{callerUserId}/ or listings/{callerUserId}/ — i.e. it belongs to a different user.
404NOT_FOUNDThe object was not found in storage (the client never completed the PUT, or the presign URL expired before upload).
503STORAGE_NOT_CONFIGUREDDigitalOcean Spaces credentials are not provisioned in this environment (dev-only).

Example error — 404

{
  "type": "https://api.swappr.co.uk/errors/not-found",
  "title": "Not found",
  "status": 404,
  "code": "NOT_FOUND",
  "detail": "object listings/66400a8f1c2b4d5e6f7a8b00/01HZQ7K3M4N5P6Q7R8S9T0V1W2.jpg not found in storage",
  "instance": "/api/v1/uploads/confirm",
  "requestId": "01HZQ7K3M4N5P6Q7R8S9T0V1W2"
}

Side effects

  • Inserts one row per fileKey into the uploads collection with userId, fileType (inferred from the key prefix), mimeType (extension-derived), fileSizeBytes, fileKey, and url (CDN URL for HOME_PHOTO, empty string for TENANCY_DOC).
  • For each HOME_PHOTO, enqueues an image.process BullMQ job (Phase 2 records the call; Phase 7 runs the worker — resize, EXIF GPS strip, blurhash).
  • No write happens for any file in the batch if an earlier file fails — failures are surfaced in order.

See also

curl

curl -X POST https://api.swappr.co.uk/api/v1/uploads/confirm \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "fileKeys": [
      "listings/66400a8f1c2b4d5e6f7a8b00/01HZQ7K3M4N5P6Q7R8S9T0V1W2.jpg",
      "listings/66400a8f1c2b4d5e6f7a8b00/01HZQ7K3M4N5P6Q7R8S9T0V1W3.png"
    ]
  }'