Supercommerce API Docs
Webhooks

Storage — Presigned Upload

Cross-role utility endpoint that mints short-lived S3 presigned URLs for direct browser uploads. Used by both the admin and vendor-admin uploaders (product photos, return-evidence…

Cross-role utility endpoint that mints short-lived S3 presigned URLs for direct browser uploads. Used by both the admin and vendor-admin uploaders (product photos, return-evidence photos, banners, etc.) — the same handler serves any authenticated session because S3 access is gated by the presign signature, not by role. The storage module is registered globally (StorageModule.forRoot({...}).global = true) so other modules inject StorageService directly without re-importing.

Source: api-modules/storage/src/controllers/storage.controller.ts


Authentication

PropertyValue
GuardBetterAuthGuard (re-export of @sc/auth's AuthGuard)
RequiredAny valid session — admin or vendor or customer
Role checksNone at the route level. Authorization is implicit: the presign is bound to a randomly generated server-side key (assets/<year>/<month>/<hash><ext>) so the caller cannot overwrite an existing object or pick a path

S3 credentials live on the server (StorageModuleOptions, sourced from S3_* env vars in apps/api/src/app.module.ts) and are never sent to the client — only the time-limited presigned PUT URL is. Direct deletes happen through StorageService.deleteFile from other modules; there is no client-facing delete endpoint.


Endpoints

POST /storage/presigned — Mint a presigned upload URL

Body

{
  "fileName": "ada-avatar.jpg",
  "contentType": "image/jpeg"   // optional
}
FieldTypeConstraints
fileNamestringMin length 1. Only the extension is used (lowercased) to build the storage key; the rest of the name is discarded
contentTypestring?Falls back to mime-types.lookup(fileName), then application/octet-stream

Response 200

{
  "data": {
    "key": "assets/2026/5/k3jr8x2lp9a.jpg",
    "url": "assets/2026/5/k3jr8x2lp9a.jpg",
    "uploadUrl": "https://s3.example.com/supercommerce/assets/2026/5/k3jr8x2lp9a.jpg?X-Amz-Signature=...",
    "method": "PUT",
    "expiresIn": 300,
    "headers": {
      "Content-Type": "image/jpeg"
    }
  },
  "message": "Presigned URL generated successfully"
}
FieldTypeMeaning
keystringThe S3 object key the file will live at. Path pattern: assets/<year>/<month>/<random-hash><ext>
urlstringSame as key for v1 — both fields are returned for client compatibility. The public URL is composed downstream from S3_PUBLIC_URL + key
uploadUrlstringThe presigned PUT URL the client uploads the file body to directly
method"PUT"HTTP method to use against uploadUrl
expiresInnumberSeconds until the presigned URL expires (default 300, i.e. 5 minutes)
headers{ "Content-Type": string }Must be sent on the upload request — the signature is bound to this header

Side effects

None server-side at presign time. The S3 PutObject happens client-side directly against S3; no record is written. Persistence of the resulting key onto a domain entity (product image row, return photo row, etc.) happens on the downstream domain mutation that the client makes after a successful upload.


Idempotency / retry handling

Every call mints a fresh key — the random hash means even identical request bodies produce different keys. There is no dedup or upsert semantics at the storage layer; clients that retry the presign + upload will leave orphan objects on S3 if they don't reuse the first key.

The presigned PUT is single-shot but the URL itself is replayable until expiresIn lapses; S3 will overwrite the object on a repeat PUT to the same key (i.e. the upload step is idempotent on key, not on call).


Failure modes

StatusCodeWhen
400VALIDATION_ERRORBody fails zod (fileName missing or empty)
401UNAUTHORIZEDNo active session — BetterAuthGuard rejected

S3-side errors at upload time (signature mismatch, expiry, content-type mismatch) surface to the client directly from S3 and are not proxied through this endpoint.


On this page