> ## Documentation Index
> Fetch the complete documentation index at: https://docs.convertly.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# User asset buckets for SaaS

> Isolate customer and workspace media with server-side folder mapping, direct uploads, and signed delivery.

Use this pattern when your SaaS needs a private media area for every user, organization, or project. Convertly folders act as logical asset buckets inside a Convertly workspace. Your application remains the authority for which customer can access each folder.

<Warning>
  A standard Convertly API key is scoped to its Convertly workspace, not to one folder. Keep it on your server. Never send it to a browser, mobile app, customer, or third-party webhook.
</Warning>

## Choose the isolation boundary

| SaaS model                                            | Recommended Convertly layout                                                        |
| ----------------------------------------------------- | ----------------------------------------------------------------------------------- |
| Individual accounts                                   | One top-level folder per SaaS user                                                  |
| Organizations or teams                                | One top-level folder per SaaS organization, with optional child folders per project |
| Separate Convertly administration, quota, and billing | A separate Convertly workspace and API key                                          |

For most multi-tenant applications, use one Convertly workspace per environment and a top-level folder per tenant. Keep production and staging in separate Convertly workspaces or, at minimum, under separate top-level folders and API keys.

Convertly does not treat a folder name as an authorization boundary. The security boundary is your backend plus the folder ID mapping stored in your database.

## Architecture

```text theme={"system"}
Browser or mobile app
        |
        | Your session or access token
        v
Your SaaS API
  1. Authenticates the user
  2. Resolves the tenant from your database
  3. Loads that tenant's Convertly folder ID
        |
        | Server-only Convertly API key
        v
Convertly workspace
  customer A folder
  customer B folder
  customer C folder
```

Store a mapping similar to this in your application database:

| Column                | Purpose                                         |
| --------------------- | ----------------------------------------------- |
| `tenant_id`           | Your immutable user or organization ID          |
| `convertly_folder_id` | The top-level folder UUID returned by Convertly |
| `convertly_workspace` | Your own environment or workspace reference     |
| `created_at`          | Audit and lifecycle tracking                    |

Add a unique constraint on `(convertly_workspace, tenant_id)`. This prevents concurrent signup requests from creating multiple buckets for the same tenant.

### Example PostgreSQL schema

This example links each SaaS tenant to one Convertly folder and records every Convertly file owned by that tenant:

```sql theme={"system"}
create type asset_bucket_status as enum ('provisioning', 'ready', 'failed');

create table tenant_asset_buckets (
  id uuid primary key default gen_random_uuid(),
  tenant_id uuid not null references organizations(id) on delete cascade,
  convertly_workspace text not null,
  convertly_folder_id uuid,
  status asset_bucket_status not null default 'provisioning',
  lease_token uuid,
  lease_expires_at timestamptz,
  last_error text,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now(),
  unique (convertly_workspace, tenant_id),
  unique (convertly_workspace, convertly_folder_id),
  check (status <> 'ready' or convertly_folder_id is not null)
);

create table tenant_assets (
  id uuid primary key default gen_random_uuid(),
  tenant_id uuid not null references organizations(id) on delete cascade,
  convertly_workspace text not null,
  convertly_file_id uuid not null,
  convertly_folder_id uuid not null,
  created_at timestamptz not null default now(),
  unique (convertly_workspace, convertly_file_id)
);

create index tenant_assets_tenant_idx
  on tenant_assets (tenant_id, created_at desc);
```

The equivalent core relationship in Prisma is:

```prisma theme={"system"}
enum AssetBucketStatus {
  provisioning
  ready
  failed
}

model TenantAssetBucket {
  id                  String            @id @default(uuid()) @db.Uuid
  tenantId            String            @map("tenant_id") @db.Uuid
  convertlyWorkspace  String            @map("convertly_workspace")
  convertlyFolderId   String?           @map("convertly_folder_id") @db.Uuid
  status              AssetBucketStatus @default(provisioning)
  leaseToken          String?           @map("lease_token") @db.Uuid
  leaseExpiresAt      DateTime?          @map("lease_expires_at")
  lastError           String?           @map("last_error")
  createdAt           DateTime           @default(now()) @map("created_at")
  updatedAt           DateTime           @updatedAt @map("updated_at")
  assets              TenantAsset[]

  @@unique([convertlyWorkspace, tenantId])
  @@unique([convertlyWorkspace, convertlyFolderId])
  @@map("tenant_asset_buckets")
}

model TenantAsset {
  id                  String            @id @default(uuid()) @db.Uuid
  tenantId            String            @map("tenant_id") @db.Uuid
  convertlyWorkspace  String            @map("convertly_workspace")
  convertlyFileId     String            @map("convertly_file_id") @db.Uuid
  convertlyFolderId   String            @map("convertly_folder_id") @db.Uuid
  createdAt           DateTime          @default(now()) @map("created_at")
  bucket              TenantAssetBucket @relation(fields: [convertlyWorkspace, tenantId], references: [convertlyWorkspace, tenantId], onDelete: Cascade)

  @@unique([convertlyWorkspace, convertlyFileId])
  @@index([tenantId, createdAt(sort: Desc)])
  @@map("tenant_assets")
}
```

Use your own user table for individual accounts or your organization table for team accounts. `convertly_workspace` is an application-defined environment key such as `production` or `staging`; it is not a secret.

## 1. Create the logical bucket

Create the folder from a trusted server process during tenant provisioning. Use an opaque application identifier in your database mapping. Do not derive authorization from the folder name or expose customer email addresses in folder names.

```ts theme={"system"}
const CONVERTLY_API = "https://convertly.sh";

async function createTenantAssetFolder(label: string) {
  const response = await fetch(`${CONVERTLY_API}/api/folders`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.CONVERTLY_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      name: label,
      parentId: null,
      color: "#A855F7",
    }),
  });

  if (!response.ok) {
    throw new Error(`Convertly folder creation failed: ${response.status}`);
  }

  const { folder } = await response.json();
  return folder.id as string;
}
```

Persist the returned folder ID against the authenticated tenant. If provisioning is retried, read the existing mapping before creating another folder.

### Retry-safe provisioning

Creating a database row and calling an external API cannot be one atomic transaction. Use a short provisioning lease so only one worker creates the folder. Other requests should return `202 Accepted` or wait for the existing job instead of creating another folder.

```ts theme={"system"}
import { randomUUID } from "node:crypto";

async function provisionTenantBucket(tenantId: string) {
  const workspace = "production";
  const leaseToken = randomUUID();

  await db.query(`
    insert into tenant_asset_buckets (tenant_id, convertly_workspace)
    values ($1, $2)
    on conflict (convertly_workspace, tenant_id) do nothing
  `, [tenantId, workspace]);

  const claimed = await db.oneOrNone(`
    update tenant_asset_buckets
       set status = 'provisioning',
           lease_token = $3,
           lease_expires_at = now() + interval '2 minutes',
           last_error = null,
           updated_at = now()
     where tenant_id = $1
       and convertly_workspace = $2
       and convertly_folder_id is null
       and (lease_expires_at is null or lease_expires_at < now())
    returning *
  `, [tenantId, workspace, leaseToken]);

  if (!claimed) {
    return db.one(`
      select * from tenant_asset_buckets
       where tenant_id = $1 and convertly_workspace = $2
    `, [tenantId, workspace]);
  }

  try {
    // The immutable tenant UUID makes reconciliation possible after a crash.
    const label = `tenant-${tenantId}`;
    const folderId = await findExactRootFolder(label)
      ?? await createTenantAssetFolder(label);

    return await db.one(`
      update tenant_asset_buckets
         set convertly_folder_id = $4,
             status = 'ready',
             lease_token = null,
             lease_expires_at = null,
             updated_at = now()
       where tenant_id = $1
         and convertly_workspace = $2
         and lease_token = $3
      returning *
    `, [tenantId, workspace, leaseToken, folderId]);
  } catch (error) {
    await db.query(`
      update tenant_asset_buckets
         set status = 'failed',
             last_error = $4,
             lease_token = null,
             lease_expires_at = null,
             updated_at = now()
       where tenant_id = $1
         and convertly_workspace = $2
         and lease_token = $3
    `, [tenantId, workspace, leaseToken, String(error)]);
    throw error;
  }
}
```

`findExactRootFolder` should call `GET /api/folders?parentId=null&q=tenant-{tenantId}` and accept only an exact name match. This recovers safely if the worker created the Convertly folder but stopped before saving its UUID locally. Keep email addresses and other personal information out of the label.

## 2. Create a direct upload session

Your backend must select the folder. Do not accept an arbitrary `folderId` from the client.

```ts theme={"system"}
async function createTenantUpload(input: {
  tenantId: string;
  filename: string;
  contentType: string;
  sizeBytes: number;
}) {
  const bucket = await db.tenantAssetBucket.findUniqueOrThrow({
    where: {
      convertlyWorkspace_tenantId: {
        convertlyWorkspace: "production",
        tenantId: input.tenantId,
      },
    },
  });

  const response = await fetch(`${CONVERTLY_API}/api/uploads`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.CONVERTLY_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      filename: input.filename,
      contentType: input.contentType,
      sizeBytes: input.sizeBytes,
      folderId: bucket.convertlyFolderId,
    }),
  });

  if (!response.ok) throw new Error(`Upload session failed: ${response.status}`);
  return response.json();
}
```

Return only the signed `upload` instructions to the client. Save the returned `complete.body` on your server with a short-lived upload record. The client can then upload the bytes directly to the signed URL without receiving your Convertly API key.

```ts theme={"system"}
await fetch(upload.signedUrl, {
  method: upload.method,
  headers: { "Content-Type": file.type },
  body: file,
});
```

After the byte upload finishes, let the client send your own opaque upload ID back to your API. Your server should load the saved `complete.body`, verify that the upload belongs to the current tenant, and pass it unchanged to Convertly:

```ts theme={"system"}
const response = await fetch(`${CONVERTLY_API}/api/uploads/complete`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.CONVERTLY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify(savedUpload.completeBody),
});

if (!response.ok) throw new Error(`Upload completion failed: ${response.status}`);
const result = await response.json();
```

Do not trust a completion payload supplied by the browser. Keeping it server-side prevents a customer from changing the target folder or registering an object created for another tenant.

After Convertly returns the stored file, save its ID in `tenant_assets` before returning success:

```ts theme={"system"}
await db.query(`
  insert into tenant_assets (
    tenant_id,
    convertly_workspace,
    convertly_file_id,
    convertly_folder_id
  ) values ($1, $2, $3, $4)
  on conflict (convertly_workspace, convertly_file_id) do nothing
`, [tenantId, "production", result.file.id, bucket.convertlyFolderId]);
```

## 3. List and manage tenant assets

Resolve the folder ID from your database on every request:

```ts theme={"system"}
const url = new URL(`${CONVERTLY_API}/api/files`);
url.searchParams.set("folderId", bucket.convertlyFolderId);
url.searchParams.set("limit", "50");
url.searchParams.set("offset", "0");

const response = await fetch(url, {
  headers: { Authorization: `Bearer ${process.env.CONVERTLY_API_KEY}` },
});
```

Apply the same rule to rename, move, process, and delete operations. Before forwarding a file ID to Convertly, verify in your own data model that it belongs to the current tenant. Never authorize a request merely because the caller knows a Convertly file or folder UUID.

### Reusable authorization helpers

Derive the user from your verified session. The helper below checks organization membership, loads the ready bucket, and then verifies file ownership from the local mapping:

```ts theme={"system"}
async function requireTenantBucket(userId: string, tenantId: string) {
  const membership = await db.oneOrNone(`
    select 1
      from organization_members
     where organization_id = $1 and user_id = $2 and status = 'active'
  `, [tenantId, userId]);
  if (!membership) throw new Response("Forbidden", { status: 403 });

  const bucket = await db.oneOrNone(`
    select * from tenant_asset_buckets
     where tenant_id = $1
       and convertly_workspace = 'production'
       and status = 'ready'
  `, [tenantId]);
  if (!bucket) throw new Response("Asset bucket is not ready", { status: 409 });
  return bucket;
}

async function requireTenantAsset(
  userId: string,
  tenantId: string,
  convertlyFileId: string,
) {
  const bucket = await requireTenantBucket(userId, tenantId);
  const asset = await db.oneOrNone(`
    select * from tenant_assets
     where tenant_id = $1
       and convertly_workspace = 'production'
       and convertly_file_id = $2
  `, [tenantId, convertlyFileId]);
  if (!asset) throw new Response("Asset not found", { status: 404 });
  return { bucket, asset };
}
```

Call `requireTenantBucket` before listing or uploading. Call `requireTenantAsset` before reading, renaming, processing, moving, or deleting a file. Return `404` for an unknown cross-tenant file so the response does not confirm that another tenant owns it.

## 4. Deliver assets safely

Choose delivery based on the asset's visibility:

| Asset type                                         | Delivery approach                                                       |
| -------------------------------------------------- | ----------------------------------------------------------------------- |
| Public product images, avatars, or marketing media | A public CDN endpoint is appropriate                                    |
| Customer-private documents or media                | A signed CDN endpoint with short-lived URLs generated by your backend   |
| Temporary download from your application           | A temporary signed download URL returned through your authenticated API |

Folder placement does not make a public CDN URL private. For private media, require signed delivery and generate signatures only on your backend. Use short expirations, avoid logging complete signed URLs, and rotate signing keys by creating a replacement before deleting the old key.

See [Presets and signing](/docs/image-cdn/presets-and-signing) for signed CDN delivery.

## Security checklist

* Keep the Convertly API key and signing keys in a server-side secret manager.
* Derive `tenantId` from the authenticated session, never from an untrusted request body.
* Resolve folder IDs from your own database and reject cross-tenant file IDs.
* Validate filename, MIME type, and size before creating an upload session.
* Store upload completion data server-side and expire abandoned sessions.
* Add application-level per-user quotas and rate limits before Convertly usage limits are reached.
* Use separate workspaces or credentials for production and non-production data.
* Delete or export the tenant folder as part of account deletion and retention workflows.
* Record destructive actions in your audit log and make webhook processing idempotent.

## Storage quota and overage

Stored source files, generated outputs, and packaged streaming assets count toward HDAM storage. Free and Starter stop at their included storage limits. Pro and Business can continue when metered overage is enabled for the workspace.

| Plan       | Included HDAM storage |      Storage overage |
| ---------- | --------------------: | -------------------: |
| Free       |                   1GB |             Hard cap |
| Starter    |                  10GB |             Hard cap |
| Pro        |                  50GB |  \$0.10 per GB-month |
| Business   |                 500GB | \$0.085 per GB-month |
| Enterprise |            Contracted |           Contracted |

Convertly bills storage overage from the workspace's monthly peak usage above its included allowance. Review all current quotas on the [Limits](/limits#file-and-storage-limits) page.

<Card title="Files and Storage API" icon="database" href="/docs/files-and-storage">
  Upload sessions, folders, file operations, storage behavior, and direct-upload details.
</Card>
