<!-- Inbox — https://docs.warmerly.com/inbox -->

# Inbox

The Inbox API exposes Warmerly's unified inbox, every email, LinkedIn DM, and WhatsApp
message tied to a contact, in one place. Use it to list contacts, pull a full
conversation timeline, and send replies across channels.

Inbox endpoints only need `X-Api-Key`. They do **not** read `X-Project-Id`, unlike
most other resources, the inbox is scoped to your **active workspace** (the same one
selected in the dashboard), which can span multiple projects at once. If you're a
member of more than one workspace, requests use whichever workspace is currently
active on your account.

```bash
curl https://app.warmerly.com/api/v1/inbox/contacts \
  -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```

## List contacts

```
GET /inbox/contacts
```

Returns every inbox contact in your active workspace, the people/companies you've
exchanged messages with across email, LinkedIn, and WhatsApp, newest activity first.

| Query param | Required | Description |
| --- | --- | --- |
| `workspaceId` | No | Filter to contacts belonging to a single project. Despite the name, this is a project ID (the inbox filter bar labels projects as "workspaces"). |
| `accountId` | No | Filter to contacts with at least one message sent/received via this account (email account today). |
| `q` | No | Search contact name/email/phone/company and message content (subject, body, sender). Terms match by prefix. |
| `needsReply` | No | `true`, only conversations classified as a lead whose newest message is inbound. |
| `archived` | No | `true`, only conversations where every message is archived. |
| `unread` | No | `true`, only conversations with at least one unread inbound message. |
| `category` | No | `lead`, `not_relevant`, or `spam`. |
| `channel` | No | `email`, `linkedin`, or `whatsapp`. |
| `limit` | No | Page size, 1-200 (default 50). |
| `offset` | No | Page offset (default 0). |

All filters are applied before pagination, so `total` is the real number of matching
conversations and paging through with `offset` reaches every one of them.

```bash
curl "https://app.warmerly.com/api/v1/inbox/contacts?accountId=8f14e45f-ceea-4c6a-8c8d-6b1a5b5c9c1a" \
  -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```

```json
{
  "contacts": [
    {
      "id": "c3a1f6e2-9c4d-4e2a-8b5f-6a2c1d9e0f7b",
      "name": "Jamie Rivera",
      "company": "Northwind Traders",
      "avatarUrl": null,
      "email": "jamie@northwindtraders.com",
      "phone": "+15551234567",
      "hasWhatsapp": true,
      "linkedinProfileUrl": "https://www.linkedin.com/in/jamierivera",
      "channels": ["email", "linkedin"],
      "lastActivityAt": "2026-07-01T16:42:03.000Z",
      "hasUnread": true,
      "lastPreview": "Thanks for reaching out, can we do a call next week?",
      "category": "lead",
      "categoryReason": "Replied positively and asked to schedule a call"
    }
  ],
  "total": 128,
  "hasMore": true
}
```

| Field | Type | Description |
| --- | --- | --- |
| `id` | string (UUID) | Contact ID, use this for the timeline and reply endpoints. |
| `name` | string | Display name, falling back to email or phone if unset. |
| `company` | string \| null | Company name, when known from an associated campaign lead. |
| `email` | string \| null | Contact's email address. |
| `phone` | string \| null | Contact's phone number, when known. |
| `hasWhatsapp` | boolean | True if the contact has a phone number **or** a raw WhatsApp JID on file. Use this (not `phone`) to decide whether a WhatsApp reply is possible. |
| `linkedinProfileUrl` | string \| null | LinkedIn profile URL, when known. |
| `channels` | array | Channels (`email`, `linkedin`, `whatsapp`) this contact has any message on. |
| `lastActivityAt` | string | ISO 8601 timestamp of the most recent message. |
| `hasUnread` | boolean | True if there's at least one unread inbound message. |
| `lastPreview` | string \| null | Snippet of the most recent message. |
| `category` | `"lead"` \| `"not_relevant"` \| `"spam"` \| null | AI/rule-assigned triage category. |
| `categoryReason` | string \| null | Short explanation for the assigned category. |

## Get a contact's timeline

```
GET /inbox/contacts/{contactId}/timeline
```

Returns the full conversation with a contact across all channels, in chronological
order, plus any autosaved email draft in progress for the latest inbound thread.

```bash
curl https://app.warmerly.com/api/v1/inbox/contacts/c3a1f6e2-9c4d-4e2a-8b5f-6a2c1d9e0f7b/timeline \
  -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```

```json
{
  "items": [
    {
      "id": "9d1e0b7a-5e7b-4f10-b2aa-1c8f0d3e6a4b",
      "channel": "email",
      "direction": "inbound",
      "status": "sent",
      "content": "Hi, thanks for the intro, happy to learn more.",
      "bodyHtml": "<p>Hi, thanks for the intro, happy to learn more.</p>",
      "subject": "Re: Quick question about your workflow",
      "sentAt": "2026-06-30T11:05:00.000Z",
      "fromAddr": "jamie@northwindtraders.com",
      "fromName": "Jamie Rivera",
      "toAddrs": ["you@yourcompany.com"],
      "externalId": null,
      "error": null
    },
    {
      "id": "7f3a2cdb-9e1a-4f0c-8b6d-2e5f7a1c3b9d",
      "channel": "email",
      "direction": "outbound",
      "status": "sent",
      "content": "Great, does Tuesday at 3pm work for a quick call?",
      "bodyHtml": null,
      "subject": "Re: Quick question about your workflow",
      "sentAt": "2026-07-01T16:42:03.000Z",
      "fromAddr": "you@yourcompany.com",
      "fromName": "Your Name",
      "toAddrs": ["jamie@northwindtraders.com"],
      "externalId": null,
      "error": null
    }
  ],
  "draft": null,
  "contact": {
    "id": "c3a1f6e2-9c4d-4e2a-8b5f-6a2c1d9e0f7b",
    "category": "lead",
    "categoryReason": "Replied positively and asked to schedule a call"
  }
}
```

| Field | Type | Description |
| --- | --- | --- |
| `items[].channel` | `"email"` \| `"linkedin"` \| `"whatsapp"` | Channel the message was sent/received on. |
| `items[].direction` | `"inbound"` \| `"outbound"` | Who sent it. |
| `items[].status` | `"queued"` \| `"sent"` \| `"failed"` | Delivery status (WhatsApp sends start `queued` until the worker delivers them). |
| `items[].content` | string | Plain-text body (falls back to snippet if the full body wasn't stored). |
| `draft` | object \| null | Autosaved email draft for the latest inbound email thread, if one exists (`subject`, `bodyText`). |

If `contactId` doesn't exist, or belongs to a contact outside your active workspace,
this returns `404`:

```json
{ "error": { "code": "not_found", "message": "not_found", "details": null } }
```

## Reply to a contact

```
POST /inbox/contacts/{contactId}/reply
```

Sends a reply to a contact on a specific channel and appends it to the timeline. Each
channel has its own requirements:

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `channel` | `"email"` \| `"linkedin"` \| `"whatsapp"` | Yes | Channel to send on. |
| `body` | string | Yes | Message text. |
| `subject` | string | Email only | Required when `channel` is `"email"`. |
| `sourceAccountId` | string | Yes | ID of the connected account to send from (email account, LinkedIn account, or WhatsApp account, matching `channel`). |

```bash
curl -X POST https://app.warmerly.com/api/v1/inbox/contacts/c3a1f6e2-9c4d-4e2a-8b5f-6a2c1d9e0f7b/reply \
  -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
        "channel": "email",
        "subject": "Re: Quick question about your workflow",
        "body": "Great, does Tuesday at 3pm work for a quick call?",
        "sourceAccountId": "8f14e45f-ceea-4c6a-8c8d-6b1a5b5c9c1a"
      }'
```

```json
{
  "message": {
    "id": "7f3a2cdb-9e1a-4f0c-8b6d-2e5f7a1c3b9d",
    "channel": "email",
    "direction": "outbound",
    "status": "sent",
    "contactId": "c3a1f6e2-9c4d-4e2a-8b5f-6a2c1d9e0f7b",
    "accountId": "8f14e45f-ceea-4c6a-8c8d-6b1a5b5c9c1a",
    "subject": "Re: Quick question about your workflow",
    "bodyText": "Great, does Tuesday at 3pm work for a quick call?",
    "toAddrs": ["jamie@northwindtraders.com"],
    "fromAddr": "you@yourcompany.com",
    "receivedAt": "2026-07-01T16:42:03.000Z"
  }
}
```

Notes on behavior by channel:

- **Email** sends synchronously over SMTP; the contact must have an `email` on file
  and `subject` is required. A send failure returns `500` with the SMTP error message,
  but the failed message is still recorded in the timeline with `status: "failed"`.
- **LinkedIn** sends synchronously via Unipile; the contact must have a
  `linkedinProfileId` on file.
- **WhatsApp** is sent asynchronously: the message is inserted with
  `status: "queued"` and delivered by a background worker shortly after. The contact
  must have a phone number or WhatsApp JID on file.

Missing channel-specific data returns `400`, e.g.:

```json
{ "error": { "code": "bad_request", "message": "contact_has_no_email", "details": null } }
```

## List threads

```
GET /inbox/threads
```

Returns email threads (grouped by `threadKey` + `accountId`) with aggregate counts,
for building a mailbox-style view. This is account/folder-oriented, as opposed to
`/inbox/contacts`, which is contact-oriented and spans channels.

| Query param | Required | Description |
| --- | --- | --- |
| `accountId` | No | Filter to a single email account. |
| `folder` | No | IMAP folder to filter by. Defaults to `INBOX`. Ignored unless `view` is `inbox`. |
| `view` | No | One of `inbox` (default), `archived`, `starred`, `sent`. |
| `q` | No | Full-text search across subject, sender, and body (Postgres FTS with an ILIKE fallback). |
| `limit` | No | Max threads to return, `1` to `100`. Defaults to `50`. |

```bash
curl "https://app.warmerly.com/api/v1/inbox/threads?accountId=8f14e45f-ceea-4c6a-8c8d-6b1a5b5c9c1a&view=inbox&limit=20" \
  -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```

```json
{
  "threads": [
    {
      "threadKey": "<CADx...@mail.gmail.com>",
      "accountId": "8f14e45f-ceea-4c6a-8c8d-6b1a5b5c9c1a",
      "lastReceivedAt": "2026-07-01T16:42:03.000Z",
      "messageCount": 4,
      "unreadCount": 1,
      "starred": false,
      "lastFromAddr": "jamie@northwindtraders.com",
      "lastFromName": "Jamie Rivera",
      "lastSubject": "Re: Quick question about your workflow",
      "lastSnippet": "Great, does Tuesday at 3pm work for a quick call?"
    }
  ]
}
```

## Get a thread

```
GET /inbox/threads/{key}
```

Returns every raw message in a single email thread, chronological order. `{key}` is
the `threadKey` from `/inbox/threads` (URL-encode it, thread keys are typically
`Message-Id`-style strings and contain special characters).

| Query param | Required | Description |
| --- | --- | --- |
| `accountId` | No | Narrow to messages on this account (recommended, since thread keys aren't guaranteed unique across accounts). |

```bash
curl "https://app.warmerly.com/api/v1/inbox/threads/%3CCADx...%40mail.gmail.com%3E?accountId=8f14e45f-ceea-4c6a-8c8d-6b1a5b5c9c1a" \
  -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```

```json
{
  "messages": [
    {
      "id": "9d1e0b7a-5e7b-4f10-b2aa-1c8f0d3e6a4b",
      "accountId": "8f14e45f-ceea-4c6a-8c8d-6b1a5b5c9c1a",
      "folder": "INBOX",
      "threadKey": "<CADx...@mail.gmail.com>",
      "fromAddr": "jamie@northwindtraders.com",
      "fromName": "Jamie Rivera",
      "toAddrs": ["you@yourcompany.com"],
      "subject": "Re: Quick question about your workflow",
      "bodyText": "Hi, thanks for the intro, happy to learn more.",
      "isRead": true,
      "isStarred": false,
      "isArchived": false,
      "channel": "email",
      "direction": "inbound",
      "status": "sent",
      "receivedAt": "2026-06-30T11:05:00.000Z"
    }
  ]
}
```

This endpoint returns raw `inbox_messages` rows (unfiltered by workspace/project
ownership) rather than the trimmed shape used by the timeline endpoint, scope your
query with `accountId` to keep results to accounts you control.

## Filter options

```
GET /inbox/filter-options
```

Returns the projects and email accounts available to filter the inbox by, for
populating a workspace/account picker in your own UI.

```bash
curl https://app.warmerly.com/api/v1/inbox/filter-options \
  -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```

```json
{
  "workspaces": [
    { "id": "b2f4a6c1-3e5d-4f7a-9b1c-0d2e4f6a8c1b", "name": "Marketing outreach" }
  ],
  "accounts": [
    {
      "id": "8f14e45f-ceea-4c6a-8c8d-6b1a5b5c9c1a",
      "email": "you@yourcompany.com",
      "provider": "gmail",
      "projectId": "b2f4a6c1-3e5d-4f7a-9b1c-0d2e4f6a8c1b"
    }
  ]
}
```

`workspaces` here is a list of **projects** within your active workspace (the
`workspaceId` query param used elsewhere in this API is a project ID, see the note
under [List contacts](#list-contacts)).

## Other endpoints

These cover more specialized inbox workflows and are not detailed here:

| Endpoint | Description |
| --- | --- |
| `POST /inbox/compose` | Start a new (non-reply) conversation over email, LinkedIn, or WhatsApp. |
| `POST /inbox/sync` | Trigger an IMAP sync for one account (`?accountId=`) or all connected accounts. |
| `POST /inbox/contacts/{contactId}/draft-reply` | Generate an AI draft reply for a contact's latest inbound email and autosave it. |
| `GET/POST /inbox/drafts`, `PATCH/DELETE /inbox/drafts/{id}` | Manually saved (non-autosaved) email drafts. `GET` requires `?accountId=`, a draft belongs to one mailbox, so there is no cross-mailbox listing. |
| `POST /inbox/mark-all-read` | Mark unread inbound messages as read. Pass `contactIds` (up to 500) to mark just those conversations, or omit it to mark the whole workspace inbox. Returns `{ "marked": n }`. |
| `GET /inbox/warmup-jobs` | Warmup job activity feed for the inbox view. |
| `PATCH/DELETE /inbox/messages/{id}` | Update flags on (read/starred/archived), or delete, a single message. |
| `GET /inbox/messages/{id}/attachments/{part}` | Download a specific email attachment part. |
