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

# List Campaign Dispatches

> Read one outbound campaign's call detail with cursor pagination and incremental pulls

Read every dispatch result for a single outbound campaign — the same data the dashboard's CSV export produces, returned as JSON. Intended for scheduled jobs that pull the day's results after dialing finishes.

This endpoint is read-only. It performs no writes.

## Request

```bash theme={null}
GET https://api.pathors.com/v1/campaigns/{campaign_id}/dispatches
```

### Headers

<ParamField header="Authorization" type="string" required>
  Bearer token authentication with your Developer Key (`dk_...`).
</ParamField>

### Query parameters

<ParamField query="status" type="string">
  Filter by dispatch status, comma-separated (e.g. `completed,failed`). Omit for no filtering. An unrecognized value returns `400` rather than silently matching nothing.
</ParamField>

<ParamField query="updated_after" type="string">
  ISO 8601 timestamp with offset. Returns only records updated strictly after this instant — use it for incremental pulls.
</ParamField>

<ParamField query="cursor" type="string">
  Pagination cursor. Echo back the previous page's `next_cursor` verbatim; do not parse or construct its contents.
</ParamField>

<ParamField query="limit" type="integer">
  Maximum records per page. Defaults to `1000`, capped at `5000`.
</ParamField>

### Example

```bash theme={null}
curl -H "Authorization: Bearer dk_your_key" \
  "https://api.pathors.com/v1/campaigns/cmp_your_campaign_id/dispatches?limit=100&status=completed,failed"
```

## Response

```json theme={null}
{
  "campaign_id": "cmp_your_campaign_id",
  "data": [
    {
      "dispatch_id": "dsp_example001",
      "session_id": "ses_example001",
      "phone_number": "+886900000000",
      "dispatch_status": "completed",
      "dispatch_failed_reason": null,
      "call_status": "userHangup",
      "duration_seconds": 96,
      "message_count": 12,
      "evaluation": "Pass",
      "created_at": "2026-08-19T02:20:00.000Z",
      "started_at": "2026-08-19T02:23:00.000Z",
      "ended_at": "2026-08-19T02:24:36.000Z",
      "updated_at": "2026-08-19T02:25:12.000Z",
      "variables": {
        "customer_intent": "interested",
        "preferred_time": "afternoon"
      }
    }
  ],
  "next_cursor": "eyJ1IjoiMjAyNi0wOC0xOVQwMjoyNToxMi4wMDBaIiwiaSI6ImRzcF9leGFtcGxlMDAxIn0",
  "has_more": true
}
```

### Fields

| Field                    | Type            | Description                                                                    |
| ------------------------ | --------------- | ------------------------------------------------------------------------------ |
| `dispatch_id`            | string          | Identifier for this dispatch; also one of the pagination sort keys             |
| `session_id`             | string \| null  | The call session. `null` when the contact has not been dialed                  |
| `phone_number`           | string          | Destination number in E.164 format                                             |
| `dispatch_status`        | string          | Dispatch state — see the enum below                                            |
| `dispatch_failed_reason` | string \| null  | Why dispatch failed; only ever set when `failed`                               |
| `call_status`            | string \| null  | Call outcome (e.g. `userHangup`, `userNoAnswer`). `null` when never dialed     |
| `duration_seconds`       | integer \| null | Call duration in seconds                                                       |
| `message_count`          | integer \| null | Conversation turns. `null` when never dialed, `0` when dialed with no exchange |
| `evaluation`             | string \| null  | Evaluation result — see below                                                  |
| `created_at`             | string          | When this dispatch row was created (ISO 8601, UTC)                             |
| `started_at`             | string \| null  | Call start time                                                                |
| `ended_at`               | string \| null  | Call end time                                                                  |
| `updated_at`             | string          | Last update, the value to feed back as `updated_after`                         |
| `variables`              | object          | Dynamic fields — see below                                                     |

### `dispatch_status` values

| Value       | Meaning                                                                         |
| ----------- | ------------------------------------------------------------------------------- |
| `pending`   | On the call list, not yet queued                                                |
| `queued`    | Queued, waiting to dial                                                         |
| `running`   | Call in progress                                                                |
| `completed` | Dispatch finished (this does not imply the callee answered — see `call_status`) |
| `failed`    | Dispatch failed; see `dispatch_failed_reason`                                   |

### `evaluation`

The content depends on the evaluation criteria configured for the project:

* **Pass/fail**: `"Pass"`, `"Fail"`, or `"Unknown"` when undecidable
* **Numeric**: the score as a string, e.g. `"4.5"`
* **Enum**: the project's own enum value as a string

`null` when the call has not been evaluated or the project defines no criteria.

### `variables`

Dynamic fields whose keys come from each project's variable settings — whatever the call extracted is what appears here. Adding a variable to a project is reflected automatically, with no API change.

<Warning>
  **If a call did not extract a given variable, that key is ABSENT from the record** rather than present with an empty value. The CSV export pads every row to match its header; JSON does not.

  Write your client defensively — `record.variables.customer_intent ?? ""` — instead of assuming the key exists.
</Warning>

When a record has no extraction result at all, `variables` falls back to the seed fields the contact was imported with. That covers two cases, not one: contacts never dialed, and contacts that were dialed but produced no variables (no answer, immediate hangup).

<Note>
  This fallback is a deliberate difference from the CSV export, which builds its header from extracted variables plus the seed fields of never-dialed contacts only — so a dialed-but-no-extraction contact shows blank cells there while this API returns its seed fields.

  The two cases are distinguishable only at the extremes: `session_id: null` means never dialed, so those `variables` are always seed fields. For a record with a `session_id`, the API does not flag whether the values came from the call or from the import — treat them as "the best values we have for this contact".
</Note>

## Pagination

Page with the cursor until `has_more` is `false`:

```bash theme={null}
# First page
curl -H "Authorization: Bearer dk_your_key" \
  "https://api.pathors.com/v1/campaigns/cmp_your_campaign_id/dispatches?limit=1000"

# Subsequent pages: pass the previous next_cursor
curl -H "Authorization: Bearer dk_your_key" \
  "https://api.pathors.com/v1/campaigns/cmp_your_campaign_id/dispatches?limit=1000&cursor=eyJ1IjoiMjAyNi0w..."
```

<Note>
  A page may return fewer records than `limit`: when `variables` payloads are large, the response is cut short to bound its size. This is reported the same way, with `has_more: true` and a `next_cursor` to continue from — so **decide whether to keep paging from `has_more`, never from whether the page is full**.
</Note>

<Note>
  While a campaign is still dialing, a record can come back on more than one page. Pages are ordered by `updated_at`, which the dialer rewrites as a call progresses, so a record you already read can be pushed past your cursor and returned again later. Nothing is ever skipped — only repeated. **Deduplicate by `dispatch_id`** when paging through a running campaign; a pull that starts after dialing finishes is unaffected.
</Note>

The cursor is an opaque string; pass it back verbatim. Its internal format is not part of the public contract and may change without breaking compatibility.

## Daily incremental pull

The recommended scheduled usage: run once after dialing finishes, passing the last successful pull's timestamp as `updated_after` to fetch only what changed.

```bash theme={null}
curl -H "Authorization: Bearer dk_your_key" \
  "https://api.pathors.com/v1/campaigns/cmp_your_campaign_id/dispatches?updated_after=2026-08-19T00:00:00%2B08:00&limit=1000"
```

<Note>
  `+` must be encoded as `%2B` in a query string; otherwise it is parsed as a space and the offset is misread.
</Note>

Prefer carrying forward the largest `updated_at` you saw on the previous run over using local midnight as the boundary — post-call evaluation writes can land after the day rolls over.

Subtract a small safety margin from that watermark — a few seconds is plenty — and deduplicate by `dispatch_id`. `updated_after` is a strict `>` comparison, so a record written in the very millisecond your previous run read up to would otherwise never be picked up by any later run.

## Errors

| Status | When                                                                                                         |
| ------ | ------------------------------------------------------------------------------------------------------------ |
| 400    | Unrecognized `status` value, `updated_after` not valid ISO 8601, malformed `cursor`, or `limit` out of range |
| 401    | Missing or invalid Developer Key                                                                             |
| 403    | The Developer Key has no access to the campaign's project                                                    |
| 404    | Campaign not found                                                                                           |
| 500    | Internal server error                                                                                        |

## Notes

* **Scope**: a Developer Key can only read campaigns under projects it has access to. Cross-project access returns `403`.
* **Differences from the CSV export**: same source and same fields, but the API returns raw values rather than display formatting — `dispatch_status` is `completed`, not `Completed`; `duration_seconds` is `96`, not `1m 36s`. Timestamps are always ISO 8601 in UTC.
* **Network allowlisting**: allowlist the hostname `api.pathors.com`. The domain sits behind Cloudflare, so the resolved addresses are anycast and rotate — **do not pin specific IPs**. If your policy requires IP-based rules, use [Cloudflare's published IP ranges](https://www.cloudflare.com/ips/).
