---
title: "Add members"
method: POST
path: "/v1/lists/{id}/members"
tags: ["Lists"]
---

# Add members

`POST /v1/lists/{id}/members`

## Request

- Base URL: `https://api.trayo.ai`
- URL: `https://api.trayo.ai/v1/lists/{id}/members`
- Auth: HTTP bearer

**Required permissions:** `people:write`.

Add people and/or whole accounts, up to 200 per call. Each row is either `{ personId }` (pin a person) or `{ accountId }` (add the whole account as a dynamic entry) — the kind is derived from which id you send. Set `via` to say how they got here: `find` for a `POST /v1/find` result, `signal` when an event suggested them, `import` for a bulk load, `manual` (default) otherwise.

**Partial success, and duplicates are skips.** A row that is already on the list — from this call or an earlier one — lands in `skipped` with a reason, not in an error, so the same batch can be sent twice without doubling anything. Read `skipped` every time; `200` means the call ran, not that every row was added.

The typical shape: `POST /v1/find` → `POST /v1/accounts/batch` (or `/v1/people/batch`) → this route with the ids that came back and `via: "find"`.

## Path parameters

- `id` string, uuid, required

## Request body

- AddListMembersDto
  - `members` object[], required: 1 to 200 members. Over the cap is an error, not a clamp.
    - `personId` string, uuid: Pin this person (a `GET /v1/people` id). Mutually exclusive with `accountId`.
    - `accountId` string, uuid: Add this whole account (a `GET /v1/accounts` id) as a dynamic entry — "Acme, best contact" — whose concrete people the app resolves from the live roster whenever the list is opened. Mutually exclusive with `personId`.
    - `via` 'manual' | 'find' | 'signal' | 'import': How this member got here, recorded on the row and shown in the app. `find` for a `POST /v1/find` result, `signal` when an event suggested them, `import` for a bulk load, `manual` (the default) otherwise. This is provenance for humans reading the list later — set it honestly.
    - `eventId` string, uuid: The event (`GET /v1/events` id) that suggested this member, if one did. Verified in your workspace like the member itself: an unknown id skips the row, and its `reason` says the event was not found.

## Response `200`

The request was processed. Check `skipped` — a `200` does not mean every row landed.

- AddListMembersResultDto: **Partial success is the contract.** Adding a member who is already on the list is reported in `skipped`, not raised as an error, so re-sending the same batch is safe: nothing duplicates, and `added` tells you what was new.
  - `added` object[], required: The rows that were added, in submission order.
    - `id` string, uuid, required
    - `listId` string, uuid, required
    - `kind` 'person' | 'account', required
    - `personId` string, nullable, required: Set on `kind: person`. Null on an account entry.
    - `accountId` string, nullable, required: On `kind: account`, THE member. On `kind: person`, provenance — the account the person belonged to when pinned — and may be null.
    - `via` 'manual' | 'account' | 'signal' | 'find' | 'import', required
    - `eventId` string, nullable, required
    - `per` integer, nullable, required: Account entries only: how many contacts the app resolves for this account (1 = best contact). Members added here get 1. Null on person rows.
    - `excluded` boolean, required: A negative membership set in the app — "not this person" against a re-resolving account entry. Read-only here; excluded rows are not counted in `memberCount` and are omitted from `GET …/members`.
    - `createdAt` string, date-time, required
    - `person` object, nullable: On the members listing: who the person is. Null on an account entry.
      - `fullName` string, required
      - `title` string, nullable, required
      - `company` string, nullable, required: The person's current account name, when they have one.
    - `account` object, nullable: On the members listing: the account the row names, as `accountId` does (the member on `kind: account`, where the person worked on `kind: person`). Null when `accountId` is, or when that account has been removed.
      - `name` string, required
      - `website` string, required: The account's website URL.
  - `skipped` object[], required: Rows that were NOT added: malformed, unknown or another workspace's id, or ALREADY A MEMBER. Always inspect this — a `200` means the call succeeded, not that every row landed.
    - `index` integer, required: 0-based position in the submitted `members`.
    - `reason` string, required: Why this row was not added.

## Other responses

- `400`: The ENVELOPE was wrong: `members` missing, not an array, empty, or over 200 items. A bad ROW never produces this status — it is reported in the `200` response's `skipped` instead. `validation_failed` (`retry: fix_input`) — The request did not pass input validation: a missing or malformed body field, a bad `limit`, a path parameter that is not a UUID, a `signalKey` that already exists or is reserved. `message` lists every failed constraint; `param` names the first offending field when one can be identified.
- `401`: Missing, malformed, or incorrect bearer token. Every way authentication can fail returns the identical body, so do not try to infer which one it was, and do not repeat the call with the same key. `unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.
- `403`: The key grant and its owner's current workspace role must both include every scope required by the operation. `insufficient_scope` (`retry: do_not_retry`) — Scope denial details distinguish missing_key_scope (use a key granting every missing scope), owner_permission_limit (the owner needs the missing workspace permissions), and invalid_key_scope (replace the key with supported scopes). Membership and undeclared-route denials remain generic.
- `404`: `list_not_found` (`retry: do_not_retry`) — No such list for this key. Lists are scoped to the user your key acts as, so a list another user created in the same workspace is indistinguishable from a non-existent one.
- `429`: Every route is rate limited per workspace across REST, MCP, all keys and OAuth users, `GET /v1/whoami` included, so a balance poll competes with the work it is watching. For `rate_limited`, wait `Retry-After` seconds (also `details.retryAfterSeconds`), then send the same request once; do not retry sooner. `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` describe the workspace's bucket, and they come back on successful responses too: read them and slow down BEFORE you are refused. A burst from one network can also be refused before the key is checked; that 429 carries `Retry-After` and no `RateLimit-*` headers. `rate_limited` (`retry: retry_later`) — The workspace or source network exhausted its request budget for one bucket. `details.retryAfterSeconds` (and `Retry-After` on REST) is the wait: wait that long, then retry the same request once. Do not poll or retry sooner; an early retry is refused again. If you cannot wait, stop and tell the user when to retry; do not fill the wait with other calls. `details.limit` and `details.bucket` name the exhausted budget. When present, RateLimit-* headers describe the active budget and reset.
- `500`: Something failed on our side. Nothing about the request needs changing. `internal_error` (`retry: retry_later`) — Something failed on our side. `message` never describes it — quote `requestId` when reporting. Retry with exponential backoff and GIVE UP after a few attempts: `retry_later` here does not mean the failure is known to be transient, only that nothing about your request needs changing. A request that fails this way repeatedly will not start working.

---

[API](https://skmtc.dev/trayo/apis/trayo-api.md) · [All operations](https://skmtc.dev/trayo/apis/trayo-api/llms.txt) · [OpenAPI document](https://skmtc.dev/trayo/apis/trayo-api/revisions/0af452dce6de?raw)
