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

# Migrate to the new Teams API

> How to migrate from the legacy Teams API (/teams) to the new Teams API (/external/v1/teams).

export const RememberCallout = ({title, children}) => <div className="callout-box callout-remember">
    <div className="callout-row">
      <span className="callout-icon">
        <svg width="22" height="22" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M229.66,98.34,172.39,155.8c11.46,22.93-1.72,45.86-10.11,57a8,8,0,0,1-12,.83L42.34,105.76A8,8,0,0,1,43,93.85c29.65-23.92,57.4-10,57.4-10l57.27-57.46a8,8,0,0,1,11.31,0L229.66,87A8,8,0,0,1,229.66,98.34Z" opacity="0.2" /><path d="M235.32,81.37,174.63,20.69a16,16,0,0,0-22.63,0L98.37,74.49c-10.66-3.34-35-7.37-60.4,13.14a16,16,0,0,0-1.29,23.78L85,159.71,42.34,202.34a8,8,0,0,0,11.32,11.32L96.29,171l48.29,48.29A16,16,0,0,0,155.9,224c.38,0,.75,0,1.13,0a15.93,15.93,0,0,0,11.64-6.33c19.64-26.1,17.75-47.32,13.19-60L235.33,104A16,16,0,0,0,235.32,81.37ZM224,92.69h0l-57.27,57.46a8,8,0,0,0-1.49,9.22c9.46,18.93-1.8,38.59-9.34,48.62L48,100.08c12.08-9.74,23.64-12.31,32.48-12.31A40.13,40.13,0,0,1,96.81,91a8,8,0,0,0,9.25-1.51L163.32,32,224,92.68Z" /></svg>
      </span>
      <div>
        {title && <div className="callout-title">
            {title}
          </div>}
        <div className="callout-body">
          {children}
        </div>
      </div>
    </div>
  </div>;

This guide is for integrators calling legacy internal Teams endpoints (`/teams`) who are
moving to the new Teams API (`/external/v1/teams`).

## New to the Teams API?

<CardGroup cols={3}>
  <Card title="API Overview" icon="https://mintcdn.com/pleo-61d4d38b/earhx-WV21nRUBYy/images/current/icons/pleo-mcp-overview.svg?fit=max&auto=format&n=earhx-WV21nRUBYy&q=85&s=f999d6d938e4ad0d24ae6f1882c437b5" href="/reference/teams/teams-api-overview" width="256" height="256" data-path="images/current/icons/pleo-mcp-overview.svg">
    <div className="text-sm mt-2">What the Teams API returns and how it's structured.</div>
  </Card>

  <Card title="API Scopes" icon="https://mintcdn.com/pleo-61d4d38b/earhx-WV21nRUBYy/images/current/icons/access-permissions.svg?fit=max&auto=format&n=earhx-WV21nRUBYy&q=85&s=5fdc4d93616eb532eb1b1e921e7775d7" href="/reference/teams/teams-api-scopes" width="256" height="256" data-path="images/current/icons/access-permissions.svg">
    <div className="text-sm mt-2">Scopes required to access each Teams API endpoint.</div>
  </Card>

  <Card title="OAuth 2.0 Guide" icon="https://mintcdn.com/pleo-61d4d38b/earhx-WV21nRUBYy/images/current/icons/data-security.svg?fit=max&auto=format&n=earhx-WV21nRUBYy&q=85&s=da63c5719bf5fffaf0fe33c2d69c42ec" href="/docs/current/guides/oauth-workflow-guide" width="256" height="256" data-path="images/current/icons/data-security.svg">
    <div className="text-sm mt-2">End-to-end OAuth 2.0 workflow guide for Pleo integrations.</div>
  </Card>

  <Card title="OAuth 2.0 with Postman" icon="https://mintcdn.com/pleo-61d4d38b/earhx-WV21nRUBYy/images/current/icons/oauth-with-postman.svg?fit=max&auto=format&n=earhx-WV21nRUBYy&q=85&s=4007b8f53e4ed869b339e65c60681a28" href="/docs/current/guides/oauth-workflow-guide-postman" width="256" height="256" data-path="images/current/icons/oauth-with-postman.svg">
    <div className="text-sm mt-2">How to configure Postman to authenticate using OAuth 2.0 and call the Pleo API.</div>
  </Card>

  <Card title="Standalone API Key Guide" icon="https://mintcdn.com/pleo-61d4d38b/earhx-WV21nRUBYy/images/current/icons/permissions-access.svg?fit=max&auto=format&n=earhx-WV21nRUBYy&q=85&s=436e53e35075566c61df866d1fc39a68" href="/docs/current/guides/standalone-api-keys-workflow-guide" width="256" height="256" data-path="images/current/icons/permissions-access.svg">
    <div className="text-sm mt-2">End-to-end guide for authenticating with standalone API keys.</div>
  </Card>

  <Card title="Standalone API Keys with Postman" icon="https://mintcdn.com/pleo-61d4d38b/earhx-WV21nRUBYy/images/current/icons/oauth-with-postman.svg?fit=max&auto=format&n=earhx-WV21nRUBYy&q=85&s=4007b8f53e4ed869b339e65c60681a28" href="/docs/current/how-tos/api-keys/how-to-make-an-api-call-using-standalone-api-keys-postman" width="256" height="256" data-path="images/current/icons/oauth-with-postman.svg">
    <div className="text-sm mt-2">How to make API calls using standalone API keys in Postman.</div>
  </Card>
</CardGroup>

## Key Differences

* Authentication moves from a logged-in user session (JWT) to OAuth 2.0 client credentials.
* Authorisation moves from a company `OWNER` role check on the caller to a token-scoped permission check (`team:read` / `team:write` / `team:delete`) against the team's company or organisation.
* List responses use cursor-based pagination instead of offset-based pagination.
* Single-resource responses are wrapped in a `data` envelope.
* The `reviewers` field has been removed from the team response.
* On create, `companyId` is derived from the access token rather than the request body, giving the same caller experience as before, just with a different token type. A team can also be created directly under an `organizationId`, which takes precedence over `companyId` if both are provided. See [Creating a Team](#creating-a-team).
* Team name uniqueness is now validated on create. See [Behaviour Change: Team Names Must Be Unique](#behaviour-change-team-names-must-be-unique).
* Updating a team moves from `PUT` (all fields required) to `PATCH` (all fields optional). See [Breaking Change: PUT → PATCH, All Fields Now Optional](#breaking-change-put-patch-all-fields-now-optional).
* The sentinel for deleting a team's code changes from `null` to an empty string `""`. See [Breaking Change: Deleting the Code](#breaking-change-deleting-the-code-null-vs-empty-string).
* Adding and removing team members moves to dedicated `/members` endpoints and no longer accepts multiple employee IDs via `PUT`. See [Managing Team Members](#managing-team-members).

## Endpoint Mapping

| Legacy endpoint | New endpoint | Notes |
| :- | :- | :- |
| `POST /teams` | `POST /external/v1/teams` | `companyId` is now an optional body field, defaulting to the OAuth 2.0 token's company if omitted; an `organizationId` field is also available. See [Creating a Team](#creating-a-team). |
| `GET /teams` | `GET /external/v1/teams` | Now cursor-paginated. Supports filtering by `companyId`, `organizationId`, `employeeId`, `teamId`, `query` (partial name match), and `includeCompanyTeams` (only valid together with `organizationId`, and mutually exclusive with `companyId`). |
| `GET /teams/:teamId` | `GET /external/v1/teams/{teamId}` | Response is now wrapped in a `data` envelope. |
| `DELETE /teams/:teamId` | `DELETE /external/v1/teams/{teamId}` | Returns `204 No Content` on success. |
| `PUT /teams/:teamId` | `PATCH /external/v1/teams/{teamId}` | Method changes from `PUT` to `PATCH`, fields become optional, and the code-deletion sentinel changes from `null` to `""`. See [Updating a Team](#updating-a-team). |
| `PUT /teams/:teamId/employees/:employeeId` | `POST /external/v1/teams/{teamId}/members` | Method changes from `PUT` to `POST`, and the request body takes an `employeeIds` array to add one or more Employees in a single call. See [Adding Team Members](#adding-team-members). |
| `DELETE /teams/:teamId/employees/:employeeId` | `DELETE /external/v1/teams/{teamId}/members/{employeeId}` | Same shape as before, but now idempotent. See [Removing a Team Member](#removing-a-team-member). |

The following sections cover each change in detail.

## Authentication and Permission Scopes

**Before:** the legacy endpoints authenticated the caller as a logged-in Pleo user and required
the `OWNER` role on the target company.

**After:** the new API authenticates callers as OAuth 2.0 clients. Access is granted
per-scope, and the token must carry access to the company or organisation the team belongs to:

| Operation | Scope |
| :- | :- |
| Create Team | `team:write` |
| Search Teams | `team:read` |
| Get Team by ID | `team:read` |
| Update Team | `team:write` |
| Delete Team by ID | `team:delete` |

<RememberCallout title="Remember">
  A request also validates that the `companyId` or `organizationId` on the team is one the OAuth 2.0 token has been granted access to. A valid scope alone is not sufficient: a `403 Forbidden` is returned if the token isn't authorised for that company or organisation.
</RememberCallout>

There is no longer a concept of a per-user role (e.g. `OWNER`) on the request. Access is entirely
determined by what the client's token is scoped to.

## Creating a Team

`POST /external/v1/teams` creates a new team for a company or an organisation the OAuth 2.0 token
has access to.

### `companyId` and `organizationId` Are Optional Body Fields

**Before:** the legacy endpoint extracted `companyId` from the caller's JWT server-side (via
`preload: jwtCompanyId`), so it was never part of the request body:

```http theme={null}
POST https://openapi.pleo.io/teams
Content-Type: application/json

{
  "name": "Sales",
  "code": "SALES-DK"
}
```

**After:** the new endpoint accepts optional `organizationId` and `companyId` fields in the
body:

* If `organizationId` is supplied, it takes precedence: the team is created directly under that
  organisation, and the token must be scoped to it.
* Otherwise, `companyId` is used if supplied (the token must have access to that company), and
  defaults to the company the token is scoped to if omitted.

For the common case of a company-scoped token creating a team for its own company, the request is
unchanged from before: `companyId` can still be omitted:

```http theme={null}
POST https://external.pleo.io/external/v1/teams
Content-Type: application/json

{
  "name": "Sales",
  "code": "SALES-DK"
}
```

An organisation-scoped token can create a team for one of its companies by supplying that
company's `companyId` explicitly, or create an organisation-level team by supplying
`organizationId` instead:

```http theme={null}
POST https://external.pleo.io/external/v1/teams
Content-Type: application/json

{
  "name": "Sales",
  "organizationId": "9f8e7d6c-5b4a-4c3d-2e1f-0a9b8c7d6e5f"
}
```

<RememberCallout title="Remember">
  An organisation-scoped access token must supply either `companyId` (a company accessible through that organisation) or `organizationId` in the request body. Omitting both returns a `403 Forbidden`. A company-scoped token can omit both and defaults to its own company.
</RememberCallout>

### Behaviour Change: Team Names Must Be Unique

Unlike the legacy endpoint, which did not validate team name uniqueness, the
new endpoint enforces the same validation as the rest of Teams API: a team name
must be unique (case-insensitively) within the company and within its organisation. Creating a
team with a name that already exists in the company or organisation now returns a `400 Bad
Request`, where it would previously have succeeded.

If your integration relies on being able to create teams with duplicate names, you'll need to
adjust for this before migrating.

## Updating a Team

`PATCH /external/v1/teams/{teamId}` partially updates a team's `name` and/or `code`. It requires a
**company- or organisation-scoped** access token authorised for the team, and the `team:write`
scope, the same scope used by [Create Team](#creating-a-team).

### Breaking Change: PUT → PATCH, All Fields Now Optional

**Before:** the legacy endpoint is a `PUT` and, per standard PUT semantics, expects the full
resource on every call: both `name` and `code` are required fields:

```http theme={null}
PUT https://openapi.pleo.io/teams/b6f2b2e0-1a3a-4e2f-9c0e-7a4b8f2c1d3e
Content-Type: application/json

{
  "name": "Sales",
  "code": "SALES-DK"
}
```

**After:** the new endpoint is a `PATCH`. Both fields are optional, and any field omitted from
the request body is left unchanged:

```http theme={null}
PATCH https://external.pleo.io/external/v1/teams/b6f2b2e0-1a3a-4e2f-9c0e-7a4b8f2c1d3e
Content-Type: application/json

{
  "name": "Sales"
}
```

<RememberCallout title="Remember">
  This is a breaking change under strict HTTP semantics: `PUT` and `PATCH` are different methods with different contracts. Functionally, if your integration already sends both `name` and `code` on every update (as `PUT` requires), the two are equivalent. If your client only sends the field it intends to change, you must switch from `PUT` to `PATCH` before migrating, or you will unintentionally clear the field you omit.
</RememberCallout>

### Breaking Change: Deleting the Code (`null` vs Empty String)

**Before:** send `code: null` to delete a team's code:

```json theme={null}
{
  "name": "Sales",
  "code": null
}
```

**After:** send `code: ""` (empty string) to delete it. Sending `null`, or omitting `code` entirely,
leaves the existing code unchanged:

```json theme={null}
{
  "code": ""
}
```

<RememberCallout title="Remember">
  `null` and "omitted" mean the same thing on the new endpoint: "don't touch this field." Only an empty string clears it. If your integration sends `code: null` to delete a code, update it to send `code: ""` before migrating, or the delete will silently no-op.
</RememberCallout>

## Managing Team Members

Adding and removing team members moves from a single `PUT`/`DELETE` pair on `/teams/:teamId/employees/:employeeId`
to two dedicated endpoints under `/members`, documented in OpenAPI schema as
`Add Team Members` and `Remove Team Member`.

### Adding Team Members

`POST /external/v1/teams/{teamId}/members` adds one or more Employees to a Team. It requires the
same `team:write` scope as [Create Team](#creating-a-team) and [Update Team](#updating-a-team).

```http theme={null}
POST https://external.pleo.io/external/v1/teams/b6f2b2e0-1a3a-4e2f-9c0e-7a4b8f2c1d3e/members
Content-Type: application/json

{
  "employeeIds": ["d1e2f3a4-b5c6-47d8-9e0f-1a2b3c4d5e6f"]
}
```

The response is `200 OK` with a `data` array containing one result per requested Employee, rather
than failing the whole call if one Employee can't be added:

```json theme={null}
{
  "data": [
    {
      "employeeId": "d1e2f3a4-b5c6-47d8-9e0f-1a2b3c4d5e6f",
      "success": true
    }
  ]
}
```

<RememberCallout title="Remember">
  As with the legacy endpoint it replaces, an Employee can only be an active member of one Team at a time. Adding an Employee who is already a member of a Team, whether this one or another, does not move them: that Employee's entry in the response has `"success": false` and `"error": "EMPLOYEE_ALREADY_IN_TEAM"`, while any other Employees in the same request are still processed normally.
</RememberCallout>

### Removing a Team Member

`DELETE /external/v1/teams/{teamId}/members/{employeeId}` removes a single Employee from a Team. It
requires the `team:write` scope.

If the Employee is not currently a member of the Team, this is a no-op rather than an error: the
call still returns `200 OK`, with `false` as the response body (`true` if an Employee was actually
removed).

<RememberCallout title="Remember">
  Don't treat a `false` response body as a failure: it only means the Employee wasn't a member of the Team. Check for it only if your integration needs to distinguish "nothing to remove" from "removed".
</RememberCallout>

## Response Structure Changes

### Envelope

`GET /external/v1/teams/{teamId}` and `PATCH /external/v1/teams/{teamId}` now wrap the team in a
`data` object, rather than returning the team as the top-level response body:

```json theme={null}
{
  "data": {
    "id": "b6f2b2e0-1a3a-4e2f-9c0e-7a4b8f2c1d3e",
    "name": "Sales",
    "companyId": "1a2b3c4d-5e6f-4a1b-9c8d-7e6f5a4b3c2d",
    "organizationId": "9f8e7d6c-5b4a-4c3d-2e1f-0a9b8c7d6e5f",
    "code": "SALES-DK",
    "employees": [
      "d1e2f3a4-b5c6-47d8-9e0f-1a2b3c4d5e6f"
    ],
    "createdAt": "2024-01-15T09:00:00Z",
    "updatedAt": "2024-06-02T14:32:11Z"
  }
}
```

`GET /external/v1/teams` returns a cursor-paginated response, replacing the previous offset-based
pagination (`limit`/`offset` query params) used on `GET /teams`.

### `reviewers` Field Removed

The `reviewers` field (the list of employee IDs assigned as a team's reviewers) has been removed
from the team response entirely. It is no longer present on any new Teams API response:

```diff theme={null}
 {
   "id": "b6f2b2e0-1a3a-4e2f-9c0e-7a4b8f2c1d3e",
   "name": "Sales",
   "companyId": "1a2b3c4d-5e6f-4a1b-9c8d-7e6f5a4b3c2d",
   "organizationId": "9f8e7d6c-5b4a-4c3d-2e1f-0a9b8c7d6e5f",
   "code": "SALES-DK",
   "employees": ["d1e2f3a4-b5c6-47d8-9e0f-1a2b3c4d5e6f"],
-  "reviewers": ["c9d8e7f6-a5b4-4c3d-2e1f-0a9b8c7d6e5f"],
   "createdAt": "2024-01-15T09:00:00Z",
   "updatedAt": "2024-06-02T14:32:11Z"
 }
```

If your integration reads reviewer assignments from the Teams API response, that data is no longer
available on this resource and you'll need to source it elsewhere.

## Errors

| Status | Meaning |
| :- | :- |
| `400 Bad Request` | Invalid combination of search parameters (e.g. `companyId` combined with `includeCompanyTeams`, or `includeCompanyTeams` without `organizationId`), an invalid team name, or a team name that already exists in the company/organisation. |
| `403 Forbidden` | The token is missing the required scope, is not authorised for the team's company or organisation, or (for create) is organisation-scoped and supplied neither `companyId` nor `organizationId` in the request body. |
| `404 Not Found` | No team exists with the given `teamId`. |

## No Impact on Existing Integrations

This change doesn't affect any webhooks or events your integration relies on. Team creation behaves the same whether
you use the legacy or new endpoint: no new event types, and no changes to existing event payloads.

### Team Update Events

Updating a team now publishes two separate update events instead of one, compared to the legacy endpoint.

* **Legacy:** a single update event fires whenever the team's `name` or `code` changes, carrying both fields.
* **New:** two independent events fire instead: one when `name` changes, one when `code` changes. Updating both fields in a single request triggers both events. This isn't new behaviour introduced by this migration: the new endpoint has always emitted these two separate events.

<RememberCallout title="Remember">
  If your integration was built against the legacy single update event, you'll need to handle the two separate events instead. Confirm your integration handles both before routing update traffic through the new endpoint.
</RememberCallout>
