<!-- Workspaces & Projects — https://docs.warmerly.com/workspaces -->

# Workspaces & Projects

Every account belongs to one or more **workspaces** — the top-level tenant that owns
billing, members, and branding. Each workspace contains one or more **projects**, and
mailboxes, campaigns, and inbox data all attach to a project (selected via the
`X-Project-Id` header on other endpoints — see [Authentication](https://docs.warmerly.com/authentication)).

Workspace and project management endpoints below identify the workspace/project by ID
in the URL path, so they generally do **not** require `X-Project-Id`. The one
exception is listing/creating projects, which operates on your *active* workspace
(see [Projects](#projects) below).

## Roles

Workspace members have one of three roles:

| Role | Can do |
| --- | --- |
| `owner` | Everything, including deleting the workspace and managing billing. Every workspace has exactly one owner. |
| `admin` | Rename the workspace, manage members/invites and branding. Cannot delete the workspace, manage billing, or modify/remove the owner. |
| `member` | Read the workspace and use its projects. Cannot manage members, rename, or delete. |

Granting the `owner` role to another member **transfers ownership** — the caller must
already be the owner, and their own role is downgraded to `admin`.

## Workspaces

### List your workspaces

```
GET /workspaces
```

Returns every workspace you're a member of.

```json
{
  "workspaces": [
    {
      "id": "8f14e45f-ceea-4c6a-8c8d-6b1a5b5c9c1a",
      "name": "Acme Inc",
      "role": "owner",
      "status": "active",
      "logoUrl": "https://app.warmerly.com/api/images/...",
      "isOwner": true,
      "planName": "Agency",
      "billedThrough": null,
      "clientCount": 2,
      "deletionScheduledFor": null,
      "purgeAt": null
    }
  ]
}
```

`status` is `"active"`, `"pending"` (an extra workspace waiting for its own plan) or
`"deleted"` (in its 30-day recovery window). `billedThrough` is set on an Agency client
workspace and names the Agency workspace whose plan it shares; `planName` is then `null`.
`clientCount` is how many client workspaces share an Agency workspace's plan.

### Create a workspace

```
POST /workspaces
```

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | Yes | 1–80 characters. |

```bash
curl https://app.warmerly.com/api/v1/workspaces \
  -X POST \
  -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Acme Inc" }'
```

```json
{
  "workspace": {
    "id": "8f14e45f-ceea-4c6a-8c8d-6b1a5b5c9c1a",
    "name": "Acme Inc",
    "role": "owner",
    "status": "active",
    "logoUrl": null
  },
  "billedThrough": { "id": "3c59dc04-8f37-4b8b-9c0e-7d3a1f1e2b4d", "name": "My Agency" }
}
```

The caller becomes the workspace's `owner`. How it is paid for depends on your plan:

- **On Agency**, it is a client workspace: `status` is `"active"` straight away, it shares
  your Agency plan's allowances, and `billedThrough` names the Agency workspace.
- **Otherwise**, `status` is `"pending"` and `billedThrough` is `null` until a paid plan is
  chosen for it in the app. Free is not available for extra workspaces.

If you already have a pending workspace, that one is returned (renamed to `name`) instead
of creating a duplicate.

### Get a workspace

```
GET /workspaces/{id}
```

Returns the workspace, its members, and (if you're an `owner`/`admin`) its pending
invites.

```json
{
  "workspace": {
    "id": "8f14e45f-ceea-4c6a-8c8d-6b1a5b5c9c1a",
    "name": "Acme Inc",
    "logoUrl": null,
    "role": "owner",
    "ownerUserId": "3c2b1a09-..."
  },
  "members": [
    {
      "userId": "3c2b1a09-...",
      "email": "you@acme.com",
      "name": "Jordan",
      "image": null,
      "role": "owner",
      "createdAt": "2026-01-10T12:00:00.000Z"
    }
  ],
  "invites": [
    {
      "id": "b2f9...",
      "email": "new.hire@acme.com",
      "role": "member",
      "status": "pending",
      "createdAt": "2026-06-01T09:00:00.000Z",
      "expiresAt": "2026-06-08T09:00:00.000Z"
    }
  ]
}
```

`invites` is empty for members without `members:manage` permission (i.e. plain
`member` role).

### Rename a workspace

```
PATCH /workspaces/{id}
```

Requires `owner` or `admin`.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | Yes | 1–80 characters. |

```bash
curl https://app.warmerly.com/api/v1/workspaces/8f14e45f-ceea-4c6a-8c8d-6b1a5b5c9c1a \
  -X PATCH \
  -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Acme Incorporated" }'
```

```json
{ "workspace": { "id": "8f14e45f-ceea-4c6a-8c8d-6b1a5b5c9c1a", "name": "Acme Incorporated" } }
```

### Delete a workspace

```
DELETE /workspaces/{id}
```

Requires `owner`. What happens depends on the workspace, and `phase` says which:

| `phase` | When | What happens |
| --- | --- | --- |
| `removed` | A pending workspace that was never paid for | Deleted immediately. |
| `scheduled` | It has its own paid subscription | The subscription stops renewing; the workspace works until `effectiveAt`, then enters recovery. No refund. |
| `retention` | Anything else (Free, or an Agency client) | Locked now and kept until `purgeAt` (30 days), then permanently deleted with everything under it. |

```json
{
  "ok": true,
  "phase": "retention",
  "effectiveAt": "2026-09-23T10:00:00.000Z",
  "purgeAt": "2026-10-23T10:00:00.000Z",
  "nextWorkspaceId": "3c59dc04-8f37-4b8b-9c0e-7d3a1f1e2b4d"
}
```

`nextWorkspaceId` is the workspace you were switched to, when the deleted one stopped
being usable.

### Restore a workspace

```
POST /workspaces/{id}/restore
```

Requires `owner`. Undoes a deletion without a card: a `scheduled` deletion is called off
and the subscription carries on, and a workspace in recovery comes back into its Agency
plan, keeps its own subscription if it still has one, or otherwise returns on the Free plan.

```json
{ "outcome": "free" }
```

`outcome` is `deletion_cancelled`, `agency_pool`, `subscription_kept`, `free` or
`not_deleted`.

## Members

### Change a member's role

```
PATCH /workspaces/{id}/members/{userId}
```

Requires `owner` or `admin`.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `role` | `"owner"` \| `"admin"` \| `"member"` | Yes | New role for the member. |

```bash
curl https://app.warmerly.com/api/v1/workspaces/8f14e45f-ceea-4c6a-8c8d-6b1a5b5c9c1a/members/3c2b1a09-... \
  -X PATCH \
  -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "role": "admin" }'
```

```json
{ "ok": true }
```

Notes:

- Only an `owner` may set `role: "owner"` — doing so **transfers ownership**; the
  caller's own role becomes `admin`.
- An `admin` cannot change an `owner`'s role (`403 forbidden`).

### Remove a member

```
DELETE /workspaces/{id}/members/{userId}
```

Requires `owner` or `admin` to remove another member. A member may remove
*themselves* (leave the workspace) regardless of role, except the `owner` — the
owner must transfer ownership or delete the workspace first (`400 bad_request`).

An `owner` can never be removed by another member (`403 forbidden`).

```json
{ "ok": true }
```

## Invites

### List pending invites

```
GET /workspaces/{id}/invites
```

Requires `owner` or `admin`.

```json
{
  "invites": [
    {
      "id": "b2f9...",
      "email": "new.hire@acme.com",
      "role": "member",
      "status": "pending",
      "createdAt": "2026-06-01T09:00:00.000Z",
      "expiresAt": "2026-06-08T09:00:00.000Z"
    }
  ]
}
```

### Invite a member

```
POST /workspaces/{id}/invites
```

Requires `owner` or `admin`.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `email` | string | Yes | Invitee's email address. |
| `role` | `"admin"` \| `"member"` | Yes | Role granted on acceptance. You cannot invite someone directly as `owner`. |

```bash
curl https://app.warmerly.com/api/v1/workspaces/8f14e45f-ceea-4c6a-8c8d-6b1a5b5c9c1a/invites \
  -X POST \
  -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "email": "new.hire@acme.com", "role": "member" }'
```

```json
{
  "invite": {
    "id": "b2f9...",
    "email": "new.hire@acme.com",
    "role": "member",
    "status": "pending",
    "createdAt": "2026-06-01T09:00:00.000Z",
    "expiresAt": "2026-06-08T09:00:00.000Z"
  }
}
```

`201 Created` on success. If the email already belongs to a member, returns
`409 conflict`. A new invite replaces any existing pending invite for the same
email. Invites expire after 7 days. If transactional email is configured, an
invite email is sent automatically.

## Projects

A project hangs off a workspace and is what campaigns, mailboxes, and inbox data
attach to via `X-Project-Id`. Each workspace has exactly one project. Unlike the
workspace/member endpoints above, listing and creating projects operates on your
**active workspace** (the workspace currently selected in the dashboard/session)
rather than a workspace ID in the URL or header.

### List projects

```
GET /projects
```

```json
{
  "projects": [
    {
      "id": "2b6e1c3d-...",
      "name": "Default",
      "slug": "default",
      "description": null,
      "color": null,
      "icon": null,
      "createdAt": "2026-01-10T12:00:00.000Z",
      "campaignCount": 3,
      "accountCount": 5
    }
  ]
}
```

If your active workspace has no projects yet, a default one is created automatically
before the list is returned, so this endpoint never returns an empty array.

### Create a project

```
POST /projects
```

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | Yes | Project name. |
| `slug` | string | Yes | Lowercase letters, numbers, and hyphens only (`^[a-z0-9-]+$`). A random suffix is appended automatically if the slug is already taken. |
| `description` | string | No | Up to 2000 characters. |
| `color` | string | No | Up to 32 characters. |
| `icon` | string | No | Up to 64 characters. |
| `defaultTimezone` | string | No | Up to 64 characters. |

```bash
curl https://app.warmerly.com/api/v1/projects \
  -X POST \
  -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Q3 Outbound", "slug": "q3-outbound" }'
```

```json
{
  "project": {
    "id": "9d4f2a11-...",
    "name": "Q3 Outbound",
    "slug": "q3-outbound",
    "workspaceId": "8f14e45f-ceea-4c6a-8c8d-6b1a5b5c9c1a",
    "description": null,
    "color": null,
    "icon": null,
    "defaultTimezone": null,
    "createdAt": "2026-07-01T09:00:00.000Z"
  }
}
```

`201 Created` on success. Returns `409 conflict` if the active workspace already has a
project — a workspace can only ever have one.

### Get a project

```
GET /projects/{id}
```

Returns the project. Requires membership in the project's workspace.

```json
{
  "project": {
    "id": "9d4f2a11-...",
    "name": "Q3 Outbound",
    "slug": "q3-outbound",
    "workspaceId": "8f14e45f-ceea-4c6a-8c8d-6b1a5b5c9c1a",
    "description": null,
    "color": null,
    "icon": null,
    "defaultTimezone": null,
    "createdAt": "2026-07-01T09:00:00.000Z"
  }
}
```

### Project stats

```
GET /projects/{id}/stats
```

A rollup for one project — what the dashboard's project switcher shows.

```json
{
  "project": { "id": "…", "name": "SaaS Outreach", "slug": "saas-outreach-05adc35c", "workspaceId": "…", "userId": "…" },
  "campaigns": { "total": 3, "active": 2 },
  "accounts": { "total": 12 },
  "leads": { "total": 145 },
  "emails": { "sent": 300, "replied": 17, "replyRate": 5.7 }
}
```

`replyRate` is a percentage of real campaign sends — not a warmup figure (see
[How warmup works](https://docs.warmerly.com/how-warmup-works) for why the two must not be mixed).

## Workspace housekeeping

```
POST   /workspaces/{id}/activate
POST   /workspaces/{id}/logo
DELETE /workspaces/{id}/logo
DELETE /workspaces/{id}/invites/{inviteId}
```

`activate` switches which workspace is "current" for session-based (dashboard) use and
returns `{ "ok": true }`. **API-key callers rarely need it:** pass `X-Workspace-Id` on the
request instead, which scopes that one call without changing any stored state.

`logo` takes a `multipart/form-data` upload and returns `{ "logoUrl": "…" }`; `DELETE`
removes it. `DELETE /workspaces/{id}/invites/{inviteId}` revokes a pending invite that has
not been accepted.

## Errors

In addition to the standard [error envelope](https://docs.warmerly.com/#conventions), these endpoints return:

- `403 forbidden` — you're a member of the workspace/project but lack the required
  role for the action (e.g. a `member` trying to invite someone, or an `admin`
  trying to remove an `owner`).
- `404 not_found` — the workspace or project doesn't exist, or you're not a member of
  it (workspace/project membership checks return `404`, not `403`, to avoid leaking
  existence to non-members).
- `409 conflict` — inviting an email that's already a member.
- `400 bad_request` — invalid body (e.g. bad slug format), or an owner trying to
  leave their own workspace without transferring ownership first.
