Userz

POST /v1/feedback/upload-url

Request a short-lived presigned PUT URL the browser can upload an attachment directly to our object storage with. Pass the resulting key into POST /v1/feedback under attachments[].

POST/v1/feedback/upload-urlAuth: publicKey + optional userToken

Why presign?

Screenshots are 100s of KB to a few MB. Routing them through our API would base64-bloat the JSON path, double our ingress cost, and blow the 8 MB submission body cap. Direct browser-to-storage upload sidesteps all three.

Body

NameTypeDescription
contentType*stringMIME type. Must match the regex /^[a-z]+\/[a-z0-9.+-]+$/i.
byteSize*numberObject size in bytes. ≤ 15 MB.
kind*"screenshot" | "annotated" | "attachment"Categorical tag we use for storage prefix routing and lifecycle policy.

Response (200)

JSON
{
  "key": "orgs/69e2.../screenshots/d1c1f9b2-3a8d-4f8a-bb16-9a7d5d09b7cd.png",
  "uploadUrl": "https://<storage-endpoint>/userz-private/orgs/.../...png?X-Amz-Algorithm=...",
  "expiresInSeconds": 300
}

The uploadUrl accepts a single PUT with the same contentType you requested. The key is what you reference in attachments[].key on the subsequent submission.

Browser upload example

TypeScript
async function uploadAndSubmit(blob: Blob, feedbackText: string) {
  // 1. Presign
  const { key, uploadUrl } = await fetch('https://api.userz.ai/v1/feedback/upload-url', {
    method: 'POST',
    headers: {
      authorization: 'Bearer pub_...',
      'content-type': 'application/json',
    },
    body: JSON.stringify({
      contentType: blob.type,
      byteSize: blob.size,
      kind: 'screenshot',
    }),
  }).then((r) => r.json());

  // 2. PUT to the presigned URL directly. MUST send the same Content-Type.
  await fetch(uploadUrl, {
    method: 'PUT',
    body: blob,
    headers: { 'content-type': blob.type },
  });

  // 3. Submit, referencing the key.
  await fetch('https://api.userz.ai/v1/feedback', {
    method: 'POST',
    headers: {
      authorization: 'Bearer pub_...',
      'content-type': 'application/json',
    },
    body: JSON.stringify({
      text: feedbackText,
      attachments: [
        { key, kind: 'screenshot', contentType: blob.type, byteSize: blob.size },
      ],
    }),
  });
}

Lifecycle

Objects PUT but never referenced from a Feedback document are swept by the cleanup worker after 24 hours. Attachments referenced from a Feedback document follow the retention policy of the parent feedback (default 30 days; configurable per Org plan).

The presigned URL has a 5-minute TTL. Don't cache it across pages or sessions, request a fresh one for every upload.

Errors

NameTypeDescription
400invalid_bodyBad contentType regex or oversize byteSize.
401(see /v1/feedback)Same auth errors as the submission endpoint.
429rate_limitedPer-IP per-App presign cap exceeded.