# Audit Job Messages

One read over every conversation for a job: proposal messages, job messages, and channels, with who still needs a reply from your team.

Source: https://www.opentrain.ai/docs/developers/api-reference/messages/audit/

Endpoint: GET /api/public/v1/messages/audit

Reads every conversation for one job in a single call: proposal messages, job messages after a hire, and job channels. Use it to answer "who is still waiting on us for this job?" and to review the full message history of a job.

The response has two parts:

- **`conversations`** always covers the whole job. Each summary says whether the conversation still needs a reply from your team (`needsEmployerReply`). The message filters below never hide a conversation that is waiting on you. One exception: a direct conversation with a banned account is left out, and a banned account's messages are not returned or counted anywhere in the response.
- **`messages`** is a cursor-paged stream of complete message bodies, newest first, with the sender's name, role, and whether they belong to your organization.

This endpoint is read-only: it never creates or changes a conversation. For the full replies of one thread, use [`GET /messages/thread`](https://www.opentrain.ai/docs/developers/api-reference/messages/thread/). Attachments appear as opaque metadata only; to download one, pass its `attachments[].attachmentId` to [`GET /messages/{messageId}/attachments/{attachmentId}`](https://www.opentrain.ai/docs/developers/api-reference/messages/get-attachment/), and read [privacy and sensitive files](https://www.opentrain.ai/docs/developers/api-reference/messages/get-attachment/#privacy-and-sensitive-files) first.

**Requirements:** `messages:read` scope and employer access to the job.

## How `needsEmployerReply` works

`needsEmployerReply` is decided for your **organization**, not for one person:

- A reply from any teammate in your organization answers the conversation.
- A conversation where only the freelancer has written stays open.
- A freelancer follow-up after a teammate's reply opens it again.
- An older thread that nobody answered keeps its conversation open, even when your team posted newer top-level messages.
- Only person-written messages count. System events, deleted messages and moderated messages never open or answer a conversation.

It describes who wrote last. It does not say whether the underlying question is resolved.

## Request

#### jobId (string, required)

The job to audit.

#### contextType (string)

`proposal_dm` (messages with a candidate before a hire), `job_dm` (messages with a hired freelancer), or `channel`. Limits both `conversations` and `messages`.

#### since (string)

ISO timestamp. Only messages sent at or after this time.

#### until (string)

ISO timestamp. Only messages sent at or before this time.

#### search (string)

Case-insensitive text to find in message content (1–500 characters).

#### senderUserId (string)

Only messages from this user.

#### hasAttachments (boolean)

`true` returns only messages with attachments; `false` only messages without.

#### includeThreads (boolean, default: true)

`false` returns top-level messages only, without thread replies.

#### limit (number, default: 50)

Page size, 1–100. For a large job, use a smaller page (10–25) and follow `nextCursor`.

#### cursor (string)

`nextCursor` from the previous page.

`since`, `until`, `search`, `senderUserId`, `hasAttachments`, and `includeThreads` filter the `messages` stream only. `contextType` filters both parts.

## Response

#### asOf (string)

ISO time of the read.

#### jobId (string)

The audited job.

#### employerOrganizationUserIds (string\[])

User IDs that count as your organization for `needsEmployerReply`.

#### conversations (object\[])

#### id (string)

Conversation ID.

#### contextType (string)

`proposal_dm`, `job_dm`, or `channel`.

#### jobId (string | null)

Linked job.

#### proposalId (string | null)

Linked proposal, for proposal conversations.

#### channelName (string | null)

Channel name, for channels.

#### counterpart (object | null)

`{userId, displayName}` of the freelancer or candidate in a direct conversation; `null` for channels.

#### contract (object | null)

`{id, status}` for job messages after a hire, where `status` is `active` or `ended`; `null` otherwise.

#### needsEmployerReply (boolean)

`true` when the conversation is waiting on your organization. See [How `needsEmployerReply` works](#how-needsemployerreply-works).

#### latestActivityAt (string | null)

ISO time of the latest activity.

#### messages (object\[])

Newest first. Each entry is a message record:

#### id (string)

Message ID.

#### conversationId (string)

The conversation.

#### contextType (string)

`proposal_dm`, `job_dm`, or `channel`.

#### threadId (string | null)

Thread this message replies in; `null` for a top-level message.

#### createdAt (string)

ISO sent time.

#### updatedAt (string)

ISO last-change time.

#### editedAt (string | null)

ISO time of the last edit; `null` if never edited.

#### state (string)

`visible`, `deleted`, or `moderated`.

#### content (string | null)

Full message text; `null` for deleted and moderated messages.

#### messageType (string | null)

Type of a system or automated message.

#### system (object | null)

`{blueText, greyText}` for system notices; `null` for ordinary messages.

#### sender (object)

`{userId, displayName, role, employerOrganizationMember, isFromViewer}`. `role` is `freelancer`, `employer_member`, `employer_owner`, `platform_admin`, `system`, or `unknown`.

#### attachments (object\[])

Opaque attachment metadata, the same shape as in [`GET /messages`](https://www.opentrain.ai/docs/developers/api-reference/messages/list/). Never a URL or storage path. Deleted and moderated messages return `[]`.

#### hasAudio (boolean)

`true` when the message has an audio recording.

#### reactionCount (number)

Number of emoji reactions.

#### thread (object | null)

For a top-level message that has replies: `{threadId, replyCount, lastReplyAt, needsEmployerReply}`. Read the replies with [`GET /messages/thread`](https://www.opentrain.ai/docs/developers/api-reference/messages/thread/).

#### nextCursor (string | null)

Pass back as `cursor` for the next page; `null` at the end.

## Errors

| Status | `code` | Meaning |
| --- | --- | --- |
| `400` | `BAD_REQUEST` | Invalid query parameters. A `limit` outside 1–100 says so in the message and `details`. An unknown `cursor` returns `details.reason: "UNKNOWN_CURSOR"` |
| `401` | `UNAUTHORIZED` | Missing or invalid token |
| `403` | `FORBIDDEN` | Missing `messages:read` scope, or no employer access to the job (`details.reason: "job_access_denied"` or `"not_a_participant"`) |
| `404` | `NOT_FOUND` | No such job (`details.reason: "job_missing"`) |
| `503` | `SERVICE_UNAVAILABLE` | A database dependency was briefly unavailable. Retry the same request after `Retry-After` |

## CLI, MCP, and SDK

- **CLI:** `opentrain messages audit --job-id <JOB_ID>` with `--context`, `--since`, `--until`, `--search`, `--sender`, `--has-attachments`, `--no-threads`, `--cursor`, and `--limit`.
- **MCP:** `opentrain_audit_job_messages`, with the same parameter names as the REST query.
- **TypeScript SDK:** `opentrain.auditJobMessages({ jobId, ... })`.

```bash curl
curl -sS "https://app.opentrain.ai/api/public/v1/messages/audit?jobId=<JOB_ID>&limit=25" \
  -H "Authorization: Bearer $OT_API_TOKEN"
```

```bash CLI
opentrain messages audit --job-id <JOB_ID> --limit 25
```

```json MCP: opentrain_audit_job_messages
{
  "jobId": "<JOB_ID>",
  "limit": 25
}
```

```json 200
{
  "asOf": "2026-06-12T09:00:00.000Z",
  "jobId": "<JOB_ID>",
  "employerOrganizationUserIds": ["<EMPLOYER_USER_ID>"],
  "conversations": [
    {
      "id": "<CONVERSATION_ID>",
      "contextType": "job_dm",
      "jobId": "<JOB_ID>",
      "proposalId": null,
      "channelName": null,
      "counterpart": { "userId": "<FREELANCER_ID>", "displayName": "Sam R." },
      "contract": { "id": "<CONTRACT_ID>", "status": "active" },
      "needsEmployerReply": true,
      "latestActivityAt": "2026-06-12T08:30:00.000Z"
    }
  ],
  "messages": [
    {
      "id": "<MESSAGE_ID>",
      "conversationId": "<CONVERSATION_ID>",
      "contextType": "job_dm",
      "threadId": null,
      "createdAt": "2026-06-12T08:30:00.000Z",
      "updatedAt": "2026-06-12T08:30:00.000Z",
      "editedAt": null,
      "state": "visible",
      "content": "I finished the first batch. Can you check it?",
      "messageType": null,
      "system": null,
      "sender": {
        "userId": "<FREELANCER_ID>",
        "displayName": "Sam R.",
        "role": "freelancer",
        "employerOrganizationMember": false,
        "isFromViewer": false
      },
      "attachments": [],
      "hasAudio": false,
      "reactionCount": 0,
      "thread": null
    }
  ],
  "nextCursor": null
}
```
