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

# Contact Lists API: static audiences you curate by hand

> Create and maintain contact lists, add members by contact id or by phone/email identifier, read membership back, and export a list as CSV — every call shown in curl and through each SDK's escape hatch.

# Contact Lists API

A contact list is a static audience you curate explicitly: you create the list, then add each member yourself, one at a time or in bulk. Membership changes only when you change it. That is the difference from [segments](/api-reference/segments), which are rule-defined: a segment's members are recomputed from criteria (`lifecycle_stage:active`, `tags:vip`, …) and shift as contacts change. Use a list when a human decides who is on it (a field-ops call sheet, a VIP list for the launch message); use a segment when a rule decides. The two models side by side are walked through in [List vs segment membership models](/concepts/list-vs-segment-membership-models).

Lists live under `/api/v1/contacts/lists`, with membership as the `/members` sub-resource and a per-contact reverse read at `/api/v1/contacts/{id}/lists`. The endpoint-level reference for every operation on the resource (update, delete, duplicate, stats) lives under [Contacts endpoints](/api-reference/endpoints/contacts).

**Base path:** `/api/v1/contacts`

**Authentication:** An organization API key (sent as `X-API-Key` or `Authorization: Bearer dv_live_sk_…`). The CSV export additionally requires an owner, admin, or developer role.

## Using the SDKs

This page's endpoints have no typed helper yet, so each language reaches them through the same generic escape hatch, which keeps auth, retries, and the response envelope identical to the typed resources. The samples below run the same calls as the curl further down the page.

* **Node.js (`@devotel-orbit/node`)** — `orbit.request<T>(method, path, body?)`
* **Python (`orbit_sdk`)** — `client.request(method, path, json_body={...})`
* **Go (`orbit-go`)** — `client.Request(ctx, method, path, query, body, out, idempotencyKey)`
* **Ruby (`orbit_sdk`)** — `client.request(method, path, json_body: {...})`
* **PHP (Devotel\Orbit)** — `$client->request(method: ..., path: ..., jsonBody: [...])`

<Tabs>
  <Tab title="Node.js">
    Create a list, then add a member by phone number. The escape hatch returns the
    unwrapped `data` object, so type it to the shape this endpoint returns (see
    "Response shapes" below):

    ```typescript theme={null}
    import { Orbit } from "@devotel-orbit/node";

    const orbit = new Orbit({ apiKey: process.env.ORBIT_API_KEY! });

    interface ContactList {
      id: string;
      name: string;
      description: string | null;
      contact_count: number;
      created_at: string;
      updated_at: string;
    }

    const list = await orbit.request<ContactList>(
      "POST",
      "/api/v1/contacts/lists",
      {
        name: "Field ops",
        description: "Phone-only contacts for the field team",
      }
    );

    // Members accept a canonical contact id or a free-form identifier
    // (phone, email) that the API resolves for you.
    const added = await orbit.request<{
      list_id: string;
      contact_id: string;
      already_member: boolean;
    }>("POST", `/api/v1/contacts/lists/${list.id}/members`, {
      identifier: "+451594424717",
    });

    console.log(list.id, added.contact_id, added.already_member);
    ```
  </Tab>

  <Tab title="Python">
    The same two calls through `client.request` with the `json_body` keyword:

    ```python theme={null}
    from orbit_sdk import OrbitClient

    client = OrbitClient.from_env()  # reads ORBIT_API_KEY

    list_row = client.request(
        "POST",
        "/api/v1/contacts/lists",
        json_body={
            "name": "Field ops",
            "description": "Phone-only contacts for the field team",
        },
    )

    added = client.request(
        "POST",
        f"/api/v1/contacts/lists/{list_row['id']}/members",
        json_body={"identifier": "+451594424717"},
    )

    print(list_row["id"], added["contact_id"], added["already_member"])
    ```
  </Tab>

  <Tab title="Go">
    Create the list through the low-level
    `client.Request(ctx, method, path, query, body, out, idempotencyKey)` escape
    hatch:

    ```go theme={null}
    package main

    import (
        "context"
        "fmt"
        "os"

        "github.com/devotel/orbit-go/orbit"
    )

    func main() {
        client, err := orbit.NewClient(os.Getenv("ORBIT_API_KEY"))
        if err != nil {
            panic(err)
        }

        body := map[string]any{
            "name":        "Field ops",
            "description": "Phone-only contacts for the field team",
        }

        var list map[string]any
        if err := client.Request(context.Background(), "POST", "/api/v1/contacts/lists", nil, body, &list, ""); err != nil {
            panic(err)
        }
        fmt.Printf("Created list %v\n", list["id"])
    }
    ```
  </Tab>

  <Tab title="Ruby">
    Create the list via the generic `json_body` keyword:

    ```ruby theme={null}
    require "orbit_sdk"

    client = OrbitSdk::Client.from_env

    list = client.request(
      "POST",
      "/api/v1/contacts/lists",
      json_body: {
        "name" => "Field ops",
        "description" => "Phone-only contacts for the field team",
      }
    )

    added = client.request(
      "POST",
      "/api/v1/contacts/lists/#{list["id"]}/members",
      json_body: { "identifier" => "+451594424717" }
    )

    puts "#{list["id"]} #{added["contact_id"]} #{added["already_member"]}"
    ```
  </Tab>

  <Tab title="PHP">
    Create the list via the generic `jsonBody` escape hatch:

    ```php theme={null}
    <?php
    use Devotel\Orbit\OrbitClient;

    $client = OrbitClient::fromEnv();

    $list = $client->request(
        method: "POST",
        path: "/api/v1/contacts/lists",
        jsonBody: [
            "name" => "Field ops",
            "description" => "Phone-only contacts for the field team",
        ],
    );

    $added = $client->request(
        method: "POST",
        path: "/api/v1/contacts/lists/{$list["id"]}/members",
        jsonBody: ["identifier" => "+451594424717"],
    );

    echo $list["id"] . " " . $added["contact_id"] . PHP_EOL;
    ```
  </Tab>
</Tabs>

## Worked use case: a field-ops list, two members, one CSV

The run-through that ties the three sub-resources together: create a phone-only
list, patch two members onto it, then pull the whole list back out as a CSV.

### 1. Create the list

<RequestExample>
  ```bash theme={null}
  curl -X POST 'https://api.orbit.devotel.io/api/v1/contacts/lists' \
    -H 'X-API-Key: dv_live_sk_your_key_here' \
    -H 'Content-Type: application/json' \
    -d '{
      "name": "Field ops",
      "description": "Phone-only contacts for the field team"
    }'
  ```
</RequestExample>

<ResponseExample>
  ```json 201 theme={null}
  {
    "data": {
      "id": "lst_01K2FOPS…",
      "name": "Field ops",
      "description": "Phone-only contacts for the field team",
      "contact_count": 0,
      "created_at": "2026-10-13T09:15:00.000Z",
      "updated_at": "2026-10-13T09:15:00.000Z"
    },
    "meta": {
      "request_id": "req_list_create",
      "timestamp": "2026-10-13T09:15:00.000Z"
    }
  }
  ```
</ResponseExample>

`name` is required (1–255 characters); `description` is optional (up to 1000).

### 2. Patch two members onto it

Add each member with `identifier`, a free-form phone, email, or contact id that
the API resolves to the matching contact. A phone in national format and a bare
email both work; an unresolvable identifier answers 404 with
`CONTACT_NOT_FOUND_FOR_IDENTIFIER` so you can tell "no such contact" apart from a
bad request.

<RequestExample>
  ```bash theme={null}
  curl -X POST 'https://api.orbit.devotel.io/api/v1/contacts/lists/lst_01K2FOPS…/members' \
    -H 'X-API-Key: dv_live_sk_your_key_here' \
    -H 'Content-Type: application/json' \
    -d '{ "identifier": "+451594424717" }'

  curl -X POST 'https://api.orbit.devotel.io/api/v1/contacts/lists/lst_01K2FOPS…/members' \
    -H 'X-API-Key: dv_live_sk_your_key_here' \
    -H 'Content-Type: application/json' \
    -d '{ "identifier": "+14155550134" }'
  ```
</RequestExample>

<ResponseExample>
  ```json 201 theme={null}
  {
    "data": {
      "list_id": "lst_01K2FOPS…",
      "contact_id": "cnt_9J2WYA…",
      "already_member": false
    },
    "meta": {
      "request_id": "req_member_add",
      "timestamp": "2026-10-13T09:16:00.000Z"
    }
  }
  ```
</ResponseExample>

A freshly written membership answers 201. Re-adding a contact that is already on
the list is idempotent: it answers 200 with `already_member: true` and writes
nothing, so a retry loop never needs special-casing. When you already hold
canonical contact ids, `POST /lists/{id}/members/bulk` accepts up to 1,000
`contact_ids` in one call and reports `added` plus any
`failed_contact_ids`.

### 3. Read the members back

`GET /api/v1/contacts/lists/{id}/members` pages the membership cursor-style; the
same `?q=` filter the dashboard's member box uses narrows it server-side, either
as free text (a name fragment, phone, or email) or as the Stripe-style DSL
(`email:*@example.com`).

<RequestExample>
  ```bash theme={null}
  curl -X GET 'https://api.orbit.devotel.io/api/v1/contacts/lists/lst_01K2FOPS…/members?limit=2' \
    -H 'X-API-Key: dv_live_sk_your_key_here'
  ```
</RequestExample>

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": [
      {
        "id": "cnt_9J2WYA…",
        "list_id": "lst_01K2FOPS…",
        "contact_id": "cnt_9J2WYA…",
        "first_name": "Ada",
        "phone": "+451594424717",
        "added_at": "2026-10-13T09:16:00.000Z"
      },
      {
        "id": "cnt_8K3XZB…",
        "list_id": "lst_01K2FOPS…",
        "contact_id": "cnt_8K3XZB…",
        "first_name": "Grace",
        "phone": "+14155550134",
        "added_at": "2026-10-13T09:16:01.000Z"
      }
    ],
    "meta": {
      "request_id": "req_members_list",
      "timestamp": "2026-10-13T09:17:00.000Z",
      "pagination": {
        "cursor": "eyJsYXN0X2FkZGVkX2F0Ijoi…",
        "has_more": false,
        "total": 2
      }
    }
  }
  ```
</ResponseExample>

Treat `meta.pagination.cursor` as opaque, keep the same filters across pages, and
stop when `has_more` is false. The reverse read,
`GET /api/v1/contacts/{id}/lists`, returns the lists one contact belongs to.

### 4. Export the list as CSV

`POST /api/v1/contacts/export` with a `list_id` filter streams the list back as a
downloadable CSV, every custom-field value included. Pass `?format=xlsx` for a
spreadsheet instead.

<RequestExample>
  ```bash theme={null}
  curl -X POST 'https://api.orbit.devotel.io/api/v1/contacts/export?filename=field-ops' \
    -H 'X-API-Key: dv_live_sk_your_key_here' \
    -H 'Content-Type: application/json' \
    -d '{ "list_id": "lst_01K2FOPS…" }' \
    -o field-ops.csv
  ```
</RequestExample>

One pull carries up to 10,000 rows; for a larger audience run the background job
at `POST /contacts/bulk/export` instead. Each export is audit-logged, so a
compliance review can see who pulled which list and when.

## Response shapes

### Contact list row

| Field | Type | Notes |
| - | - | - |
| `id` | string | `lst_…` |
| `name` | string | 1–255 characters |
| `description` | string \| null | null when never set |
| `contact_count` | number | Cached member count, kept current as members are added and removed |
| `created_at` / `updated_at` | ISO-8601 string | |

### Member row

| Field | Type | Notes |
| - | - | - |
| `id` | string | The member's contact id (`cnt_…`) |
| `list_id` | string | The list the row belongs to |
| `contact_id` | string | Same value as `id`, matching the add/remove payloads |
| `first_name` / `phone` / `email` | string | Joined contact columns the member read returns inline |
| `added_at` | ISO-8601 string | When the membership was written |

Writes answer a smaller body: `{ list_id, contact_id, already_member }` under
`data`, with 201 for a new membership and 200 for the idempotent re-add.

Every response on this page follows the same envelope: `data` plus a `meta` block
carrying `request_id`, `timestamp`, and, on paged reads, `pagination`. Errors use
the `{ error, meta }` envelope with codes such as
`CONTACT_NOT_FOUND_FOR_IDENTIFIER` (404) and `VALIDATION_ERROR` (422).

## See also

* [Contacts endpoints](/api-reference/endpoints/contacts) — the full operation reference for this resource, plus create/read/search on the underlying contact records
* [List vs segment membership models](/concepts/list-vs-segment-membership-models) — when to curate a list, when to define a segment
* [Segments API](/api-reference/segments) — rule-defined audiences that recompute as contacts change
* [Messaging](/api-reference/endpoints/messaging) — send to the audience you built here
* [Contact lists for static targeting](/guides/contact-lists-static-targeting) — the dashboard-side guide to the same workflow


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.