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

# Migrating from v1

> Move an integration from /api/v1 to /api/v2

v2 is one API for every platform Inboxapp supports. v1 is frozen and stops responding on 3 December, 2026. Every v1 response carries a `Sunset` header with that date.

<Warning>
  After 3 December, 2026, `/api/v1` returns `410 Gone`, and webhook
  subscriptions still on the legacy event format start receiving v2 events.
</Warning>

## What stays the same

* **Tokens.** Your API token works on both versions.
* **Rate limits.** 300 requests a minute, 10,000 an hour and 100,000 a day per team, shared between v1 and v2.
* **Plans.** Lists and campaigns need an outreach-enabled plan. Messaging someone who isn't a contact yet needs the Advanced API add-on.
* **Webhook delivery.** The same signature header, and the same 7-day replay window.

## Start here

Five v1 endpoints have no v2 endpoint of the same shape. Most integrations use at least one.

### Quick send

`POST /threads/messages` becomes `POST /messages` with a `profile` target.

<CodeGroup>
  ```bash v1 theme={null}
  curl -X POST "https://inboxapp.com/api/v1/threads/messages" \
    -H "Authorization: Bearer $INBOX_API_TOKEN" \
    -H "Content-Type: application/json" \
    -d '{
      "accountLinkId": "df6jbw4h36qm5d9iu2sgn7kx",
      "externalPlatformId": "1876543210987654321",
      "content": "Thanks for the follow, happy to answer any questions."
    }'
  ```

  ```bash v2 theme={null}
  curl -X POST "https://inboxapp.com/api/v2/messages" \
    -H "Authorization: Bearer $INBOX_API_TOKEN" \
    -H "Content-Type: application/json" \
    -H "Idempotency-Key: 7f3c1d52-9a41-4a6e-8a55-0c1d2f6b9e10" \
    -d '{
      "target": {
        "type": "profile",
        "accountLinkId": "df6jbw4h36qm5d9iu2sgn7kx",
        "platform": "twitter",
        "platformId": "1876543210987654321"
      },
      "content": "Thanks for the follow, happy to answer any questions."
    }'
  ```
</CodeGroup>

The response is `{ message, thread }`. Store `thread.id`: a later send can use `{ "type": "thread", "threadId": "…" }`.

`platform` is required and must be the account link's platform. If you have an Inboxapp contact ID instead of a platform ID, use `{ "type": "contact", "accountLinkId": "…", "contactId": "…" }`.

### Lookup by username

`GET /threads/lookup-by-username` becomes two calls: find the contact, then its threads.

```bash theme={null}
curl "https://inboxapp.com/api/v2/contacts?platform=twitter&username=acmecorp" \
  -H "Authorization: Bearer $INBOX_API_TOKEN"

curl "https://inboxapp.com/api/v2/threads?contactId=nq2r8v5ycx1t7m4hj6kd3wzb&folder=all&expand=profile" \
  -H "Authorization: Bearer $INBOX_API_TOKEN"
```

Pass `username` without its `@`.

### Reactions

The doubled path segment is gone, and the thread ID leaves the path.

| v1 | v2 |
| - | - |
| `POST /threads/{threadId}/messages/{messageId}/reactions/reactions` | `POST /messages/{messageId}/reactions` |
| `DELETE /threads/{threadId}/messages/{messageId}/reactions/reactions` with a body | `DELETE /messages/{messageId}/reactions?emoji=👍` |

Both return the message's `reactions` instead of `{ success, messageId }`.

### Create thread

`POST /threads` is removed. A thread appears when its first message is sent, and `POST /messages` returns it.

### The `/campaigns/v2` alias

Removed. See [Lists and campaigns](#lists-and-campaigns).

## What changes everywhere

| Area | v1 | v2 |
| - | - | - |
| Base URL | `https://inboxapp.com/api/v1` | `https://inboxapp.com/api/v2` |
| Platforms | X only, `platform` defaults to `twitter` | Every platform your team can use. `platform` never defaults, see [Platforms and capabilities](/v2/platforms) |
| People | `prospect` | `profile`, the platform account, and `contact`, your team's record of the person |
| Related resources | Threads embed the prospect | IDs, with `?expand=` to embed |
| Lookups | `/lookup` endpoints | Filters on the collection |
| Collections | Bare arrays, `{ threads }`, `{ messages }` | Always `{ data, nextCursor }` |
| Pagination | `cursorId` and `cursorTimestamp`, or `cursor[id]` | One opaque `cursor`. `limit` from 1 to 100, default 50 |
| Query encoding | Brackets: `filters[tags][selectedIds][0]=…` | Flat. Repeat the key for several values: `tag=a&tag=b` |
| Errors | Varies by route | `{ code, message, details, requestId }` everywhere. Branch on `code` |
| Timestamps | Mixed | ISO 8601 in UTC |
| Retries | A retried send can send twice | `Idempotency-Key` on `POST /messages` |
| Missing values | Omitted or `null` | Every documented field is present, `null` when it doesn't apply |

### Pagination

```typescript paginate.ts theme={null}
async function listAll<T>(path: string): Promise<T[]> {
  const items: T[] = [];
  let cursor: string | null = null;

  do {
    const url = new URL(`https://inboxapp.com/api/v2${path}`);
    if (cursor) url.searchParams.set("cursor", cursor);

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

    if (!response.ok) {
      const error = await response.json();
      throw new Error(`${error.code}: ${error.message} (${error.requestId})`);
    }

    const page = await response.json();
    items.push(...page.data);
    cursor = page.nextCursor;
  } while (cursor);

  return items;
}

const tags = await listAll("/tags");
```

A cursor is only valid with the filters it was issued for.

### Errors

```json theme={null}
{
  "code": "capabilityNotSupported",
  "message": "This account can't perform this operation.",
  "details": { "capability": "message:edit" },
  "requestId": "req_tz4a98xxat96iws9zmbrgj3a"
}
```

Each endpoint in the reference lists its codes. New codes can be added within v2, so handle unknown ones by status.

## Endpoint map

### Team, members, account links

| v1 | v2 | Changes |
| - | - | - |
| `GET /team` | same | `image` becomes `imageUrl` |
| `GET /members` | same | `{ data, nextCursor }` |
| `GET /members/{memberId}` | same | |
| `GET /account-links` | same | Paginated. `image` becomes `avatarUrl`. Adds `capabilities`, `pauseReasons` and `displayName` |
| — | `GET /account-links/{accountLinkId}` | New |
| — | `GET /platforms` | New |

### Threads

| v1 | v2 | Changes |
| - | - | - |
| `GET /threads` | same | See [thread filters](#thread-filters) |
| `POST /threads` | removed | Send with `POST /messages` |
| `GET /threads/lookup` | `GET /threads?accountLinkId=…&contactId=…` | Empty `data` instead of `null` |
| `GET /threads/lookup-by-username` | `GET /contacts`, then `GET /threads?contactId=…` | See [lookup by username](#lookup-by-username) |
| `GET /threads/{threadId}` | same | |
| `PATCH /threads/{threadId}` | same | `done` becomes `archived`. Adds `unread` and `typingIndicators`. `assigneeId` sets this thread's assignee only |
| `PATCH /threads/{threadId}/settings` | `PATCH /threads/{threadId}` | `typingIndicatorsEnabled` becomes `typingIndicators`: `enabled`, `disabled` or `teamDefault` |
| `DELETE /threads/{threadId}` | same | Returns `{ scope }`: `remoteAndLocal` or `local` |
| `POST /threads/{threadId}/typing` | same | Returns `202` with `{ status, reason }`. An account that can't send typing indicators fails with `422 capabilityNotSupported` |

#### Thread filters

| v1 | v2 |
| - | - |
| `accountLinkIds[]` | `accountLinkId`, repeated |
| `inbox=default` | `folder=inbox`, the default |
| `inbox=no-reply` | `folder=noReply` |
| `inbox=requests`, `inbox=archived` | `folder=requests`, `folder=archived` |
| — | `folder=all` |
| `filters[tags][selectedIds][]` | `tag`, repeated, by ID or name |
| `filters[statuses][selectedIds][]` | `status`, repeated, by ID or name |
| `filters[assignees][selectedIds][]` | `assigneeId`, repeated |
| `filters[assignees][noAssignee]` | `unassigned=true` |
| `filters[campaigns][selectedIds][]` | `campaignId`, repeated |
| `filters[sortOrder]` | `order` |
| — | `contactId`, `platform`, `q`, `expand` |

#### Thread fields

| v1 | v2 |
| - | - |
| `done` | `archived` |
| `platformId`, `""` when unknown | `platformId`, `null` when unknown |
| `isSyncing` | `syncing` |
| `lastMessageTimestamp`, `computedSortTimestamp` | `activity.at` |
| `lastMessage.content` | `activity.preview` |
| `lastMessage.authorId` | `lastMessage.direction`: `inbound` or `outbound` |
| `prospect`, embedded | `profileId` and `contactId`. Embed with `?expand=profile&expand=contact` |
| `assigneeId`, shared by all the person's threads | `assigneeId`: the thread's own, or else its contact's |
| `variant`, `status` | Removed. Read the account link's `capabilities` and the thread's `restrictions` |
| — | `acceptanceState`, `isRequest`, `restrictions`, `unread` |

### Messages

| v1 | v2 | Changes |
| - | - | - |
| `GET /threads/{threadId}/messages` | same | `{ data, nextCursor }`. `isSyncing` moves to the thread as `syncing` |
| `POST /threads/{threadId}/messages` | `POST /messages` | `target: { "type": "thread", "threadId": "…" }`. Returns `{ message, thread }` |
| `POST /threads/messages` | `POST /messages` | See [quick send](#quick-send) |
| `PATCH /threads/{threadId}/messages/{messageId}` | `PATCH /messages/{messageId}` | Returns the message |
| `DELETE /threads/{threadId}/messages/{messageId}` | `DELETE /messages/{messageId}?scope=…` | `scope` is required: `self` or `all`. It replaces `deleteForAll` |
| `GET /threads/{threadId}/messages/{messageId}/history` | `GET /messages/{messageId}/history` | Adds `times`: whether edit times come from the platform or from when Inboxapp noticed |
| `…/reactions/reactions` | `/messages/{messageId}/reactions` | See [reactions](#reactions) |
| — | `GET /messages/{messageId}` | New |
| — | `GET /messages/{messageId}/attachments/{index}` | New. Returns a fresh URL for a media attachment |

#### Message fields

| v1 | v2 |
| - | - |
| `authorId` | `direction` and `accountLinkId` |
| `userId`, `origin`, `campaignId` | `sentBy`: a `member`, an `agent`, a `campaign` or the `api`. `null` for inbound messages |
| `attachment.media[]`, `attachment.card` | `attachments[]`, each tagged by `kind`. A media entry has a `status` and an expiring `url` |
| `entities.tweetShares` | `entities.postShares` |
| `reactions[].senderPlatformId` | `reactions[].author`: an account link, a profile, or unknown |
| `isEncrypted` | `platformData.encrypted` |
| `platformDeletedAt` | `deletedAt` |
| `failed` | Removed |

### Prospects become contacts and profiles

v1's prospect splits in two:

* A **profile** is the person's account on one platform: `username`, `handle`, `displayName`, `avatarUrl`, `bio`, follower counts, and `platformData`.
* A **contact** is your team's record of the person: `statusId`, `tagIds`, `notes`, `valuation`, `assigneeId`, and their `profiles`.

A contact has its own ID. v1 prospect IDs are not contact IDs, so look contacts up by platform identity once and store the new IDs.

| v1 | v2 | Changes |
| - | - | - |
| `GET /prospects/lookup?identifier=…&by=platformId` | `GET /contacts?platform=twitter&platformId=…` | Repeat `platformId` to look up several at once |
| `GET /prospects/lookup?identifier=…&by=username` | `GET /contacts?platform=twitter&username=…` | |
| `GET /prospects/{prospectId}` | `GET /contacts/{contactId}` | Takes a contact ID |
| `PATCH /prospects/{prospectId}/context` | `PATCH /contacts/{contactId}` | `statusId` becomes `status`, by ID or name |
| `addTags`, `removeTags` | `PUT` and `DELETE /contacts/{contactId}/tags/{tagRef}` | One tag per call, by ID or name. Both are idempotent |
| `done` on the context | `PATCH /threads/{threadId}` with `archived` | Per thread |

Reads never create a contact: a person your team has never talked to or imported has none. Message them with a `profile` target.

#### Profile fields

| v1 | v2 |
| - | - |
| `image`, `imageNormalized` | `avatarUrl` |
| `handle`, a lowercased `username` | `handle`, as the platform displays it: `@acmecorp` |
| `verified`: `none`, `verified`, `business`, `government` | `verified`: a boolean. The badge kind is in `platformData` |
| `profileType` | `isOrganization` |
| `context.threads` | `GET /threads?contactId=…` |
| `lastActiveAt`, `documentId`, `source`, `isFresh`, `isStale`, `confidence` | Removed |

### Tags, statuses, colors

| v1 | v2 | Changes |
| - | - | - |
| `GET /tags`, `GET /statuses` | same | `{ data, nextCursor }` |
| `POST`, `GET`, `PATCH`, `DELETE` on a tag or status | same | `DELETE` returns `204`. Paths take an ID or a name |
| `GET /tags/colors`, `GET /statuses/colors` | `GET /colors` | One endpoint |

### Lists and campaigns

In v2, lists, leads, import jobs and campaigns are available to outreach-enabled plans only. On such a plan, their endpoints are listed in the API explorer in your team's settings, and they keep their v1 request and response shapes. They can change or be removed within v2, and their errors use the v2 body.

## Webhooks and events

A subscription receives one version. New subscriptions receive v2. To switch an existing one, open it in **Settings → Webhooks** and select **Move to v2**. You can't move back to v1.

v2 events are thin. They name the object that changed and carry only the values of the change. Read `object.url` for the current state.

<CodeGroup>
  ```json v1 theme={null}
  {
    "id": "ck9v2m5nj0xp4wq7ybftrae8",
    "seq": 48213,
    "teamId": "hzcai5t59nn9vsck3rbuepyg",
    "type": "message.received",
    "timestamp": "2026-09-14T10:32:00.000Z",
    "version": "1.0",
    "data": {
      "message": {
        "id": "p8rvk2m5j0xn4wq7ybftcael",
        "content": "Sounds good, send it over."
      },
      "thread": {
        "id": "l44e15irdq4db30i77cgphhx",
        "prospect": { "username": "acmecorp" }
      }
    }
  }
  ```

  ```json v2 theme={null}
  {
    "id": "ck9v2m5nj0xp4wq7ybftrae8",
    "seq": 48213,
    "type": "message.received",
    "apiVersion": "2",
    "createdAt": "2026-09-14T10:32:00.000Z",
    "object": {
      "type": "message",
      "id": "p8rvk2m5j0xn4wq7ybftcael",
      "url": "/api/v2/messages/p8rvk2m5j0xn4wq7ybftcael"
    },
    "data": { "threadId": "l44e15irdq4db30i77cgphhx" }
  }
  ```
</CodeGroup>

| v1 | v2 |
| - | - |
| `prospect.statusChanged`, `prospect.tagsChanged`, `prospect.notesChanged`, `prospect.valuationChanged`, `prospect.assigneeChanged` | `contact.*`, one event per contact |
| `prospect.created`, `prospect.enriched` | Removed |
| `message.created` | Removed. Use `message.sent` and `message.received` |
| `thread.assigned`, `thread.unassigned` | Kept, and no longer deprecated. `data.assigneeId` is the thread's own assignee |
| `teamId`, `timestamp`, `version: "1.0"` | `teamId` is removed. `timestamp` becomes `createdAt`, `version` becomes `apiVersion: "2"` |
| `GET /events` returns `{ events }` | `GET /events` returns `{ data, lastSeq, hasMore }` and takes a `type` filter |

Sequence numbers are shared by both versions and by webhooks. To backfill after downtime, call `GET /events?afterSeq=…` with the `seq` of the last event you processed.

Signatures are unchanged: see [Verifying webhook signatures](/v2/webhooks/verifying-signatures).

## Send safely

`POST /messages` takes an `Idempotency-Key` header. A retry with the same key and body returns the original result with `200` instead of sending twice.

```typescript send.ts theme={null}
import { randomUUID } from "node:crypto";

async function send(threadId: string, content: string) {
  const key = randomUUID();

  for (let attempt = 0; attempt < 3; attempt++) {
    const response = await fetch("https://inboxapp.com/api/v2/messages", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.INBOX_API_TOKEN}`,
        "Content-Type": "application/json",
        "Idempotency-Key": key,
      },
      body: JSON.stringify({ target: { type: "thread", threadId }, content }),
    });

    if (response.ok) return (await response.json()).message;

    const error = await response.json();

    if (error.code === "sendUnconfirmed") {
      // The platform may have delivered it. Don't retry with a new key.
      return { id: error.details.messageId, unconfirmed: true };
    }

    if (response.status < 500 && response.status !== 429) {
      throw new Error(`${error.code}: ${error.message} (${error.requestId})`);
    }

    await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** attempt));
  }

  throw new Error("Send failed after 3 attempts");
}
```

## Checklist

* [ ] Change the base URL to `/api/v2`
* [ ] Read collections from `data` and page with `nextCursor`
* [ ] Replace bracket query parameters with repeated keys
* [ ] Branch on error `code`
* [ ] Replace prospect IDs with contact IDs, and `prospect` fields with `profile` and `contact`
* [ ] Send through `POST /messages` with an `Idempotency-Key`
* [ ] Move lists and campaigns to their v2 endpoints, on an outreach-enabled plan
* [ ] Switch webhook subscriptions to v2 and read `object.url` for state


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