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

# Run a campaign end to end

> Set up, start, monitor and control a calling campaign, or push leads into a standing campaign with the /v1 API.

There are two ways to get people called:

| Approach | Use it when | Endpoints |
| - | - | - |
| **Contact-list campaign** | You have a list of people to call now. | `POST /campaigns`, `POST /campaigns/{id}/contacts`, `POST /campaigns/{id}/start` |
| **Standing API campaign** | Leads arrive one at a time from your systems (form fills, sign-ups, alerts) and should be called as they arrive. | `POST /v1/campaigns`, then `POST /v1/campaigns/{campaignId}/leads` for each lead |

## Contact-list campaign

The [Quickstart](/quickstart) shows the minimal flow. This section covers the options and the failure cases.

<Note>The contact-list endpoints below are part of the workspace API and need a scoped API key.</Note>

### 1. Create the campaign

`POST /campaigns` creates a campaign in `DRAFT`. Commonly used fields:

| Field | Meaning |
| - | - |
| `name` | Up to 100 characters. |
| `agentId` | The agent that makes the calls. Required for voice campaigns. |
| `timezone` | IANA time zone for the calling window and dial days. Defaults to `Asia/Kolkata`. |
| `dailyWindowStart`, `dailyWindowEnd` | `HH:MM` local calling window. See [Timestamps](/concepts/timestamps-and-durations#campaign-time-zones-and-calling-windows). |
| `dialDays` | Allowed weekdays, `0` (Sunday) to `6` (Saturday). |
| `scheduledStartAt`, `scheduledEndAt` | Optional absolute start and end times. |
| `retryCount` | Re-dial attempts for unanswered calls (0–10). |
| `cooldownMinutes`, `retryDelaysMinutes` | Wait between attempts, in **minutes**. |
| `campaignTypeId` | A [campaign type](/api-reference/list-campaign-types) whose fields are extracted from every call. |
| `customExtractionKeys` | Extra fields to extract from each call (up to 40). Set at creation only. |
| `channel` | `VOICE` (default) or a WhatsApp / mixed channel. `EMAIL` campaigns cannot be created with an API key. |

The full schema is on the endpoint's reference page.

### 2. Attach phone numbers

`POST /campaigns/{id}/phone-numbers` with `{"phoneNumberIds": [...]}` (1–50 IDs). Use `GET /phone-numbers` to list
your numbers. Numbers already attached are skipped. A number that isn't active in your workspace returns
`400 phone_number_not_in_workspace`.

### 3. Add contacts

`POST /campaigns/{id}/contacts` with up to 10,000 contacts per request:

```json theme={null}
{ "contacts": [ { "phone": "+15555550100", "name": "Jordan Example", "metadata": { "first_name": "Jordan" } } ] }
```

* `phone` must be E.164 (`+` and 8–15 digits). It is not reformatted.
* `metadata` holds the values for the agent's variables.
* Duplicate phone numbers in the request are dropped (the first one wins). Numbers already in the campaign are not
  added again.
* If a phone number already exists elsewhere in your workspace, the existing contact's name and metadata are kept,
  and the values you send are **ignored**.
* Contacts can only be added while the campaign is in `DRAFT` (`400 campaign_not_in_draft` otherwise).
* The response `{ "added": 2, "total": 2 }` counts this request only: `added` is the number of new contacts
  and `total` the number of valid unique phones you sent.

You can also upload a CSV first with `POST /campaigns/uploads` and pass the returned `uploadId` to `POST /campaigns`.

### 4. Start

`POST /campaigns/{id}/start` returns `204` when the campaign is running. It checks, in order:

| Error | Status | Fix |
| - | - | - |
| `campaign_not_found` | 404 | Check the ID. |
| `campaign_not_startable` | 400 | Only `DRAFT`, `PAUSED` and credit-exhausted campaigns can be started. |
| `workspace_not_active`, `activity_frozen` | 402 | Your workspace can't place calls right now. Contact Pineyard. |
| `campaign_schedule_expired` | 400 | `scheduledEndAt` is in the past. Update the schedule. |
| `insufficient_credits` | 402 | Top up your wallet in the dashboard. |
| `campaign_no_pending_contacts` | 400 | Add contacts first. |
| `campaign_no_phone_numbers` | 400 | Attach at least one active phone number. |
| `reserved_exceeds_allocation` | 400 | Lower `reservedConcurrency` in the start body. |
| `outreach_template_not_approved`, `campaign_channel_needs_template` | 400 | WhatsApp channels need an approved template. |

### 5. Monitor

| What | Endpoint | Scope |
| - | - | - |
| Counts by contact status | `GET /campaigns/{id}/stats` | `campaigns.view` |
| Calls, newest first | `GET /calls?campaignId={id}` | `calls.view` |
| One call: transcript, summary, extracted data | `GET /calls/{callId}` | `calls.view` |
| One row per lead with outcome and extracted fields | `GET /campaigns/{id}/leads` | `intel.view` |
| Full lead export (CSV) | `GET /campaigns/{id}/export.csv` | `intel.view` |

`stats` counts contacts by status: `PENDING`, `IN_FLIGHT`, `MESSAGED`, `COMPLETED`, `FAILED` and `SKIPPED`.

Calls are enriched after they end: the transcript, recording and extracted fields can appear a little later than the
call's final status. See [Recordings & transcripts](/guides/recordings-and-transcripts).

### 6. Pause, resume, stop, restart

All four are `POST /campaigns/{id}/<action>` with scope `campaigns.edit` and return `204`.

| Action | Effect | Errors |
| - | - | - |
| `pause` | Status becomes `PAUSED`; no new calls are started. | `400 campaign_not_running` |
| `resume` | Runs the same checks as `start` and sets the campaign running again. | as for `start` |
| `stop` | Ends the campaign: status becomes `COMPLETED` and contacts that were never called become `SKIPPED`. | `400 campaign_already_terminal` |
| `restart` | Re-queues the contacts skipped by `stop` and starts again. Contacts already called keep their result. | `400 campaign_not_restartable` |

## Push leads with the public API

A **standing API campaign** stays running and calls each lead you push, then produces one final result per lead.

<Steps>
  <Step title="Create and start an API campaign">
    `POST /v1/campaigns` (source defaults to `API`), attach numbers with `POST /v1/campaigns/{id}/phone-numbers`,
    then `POST /v1/campaigns/{id}/start`. An API campaign can start with no contacts.
  </Step>

  <Step title="Push each lead">
    ```bash theme={null}
    curl -X POST https://api.pineyard.ai/v1/campaigns/00000000-0000-4000-8000-0000000000c1/leads \
      -H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
      -H "Content-Type: application/json" \
      -d '{
        "idempotencyKey": "crm-lead-48213",
        "lead": { "phone": "+15555550100", "name": "Jordan Example", "fields": { "first_name": "Jordan" } }
      }'
    ```

    ```json Response (202) theme={null}
    {
      "leadId": "00000000-0000-4000-8000-0000000000a1",
      "status": "queued",
      "deduplicated": false,
      "requestId": "00000000-0000-4000-8000-0000000000f1",
      "resultUrl": "https://api.pineyard.ai/v1/leads/00000000-0000-4000-8000-0000000000a1/result"
    }
    ```
  </Step>

  <Step title="Collect the result">
    Receive it by [webhook](/guides/receive-results), or poll `resultUrl` (`GET /v1/leads/{leadId}/result`).
  </Step>
</Steps>

### Lead rules

* **`idempotencyKey` is required** (in the body, up to 200 characters). It identifies the lead within the campaign:
  pushing the same key again returns `200` with `"deduplicated": true` and the original `leadId`, and nobody is
  called twice. Use a stable ID from your system, such as your CRM's lead ID. The same phone with a different key
  is a new lead.
* `phone` is normalised. Include the country code; numbers without one are read as Indian numbers.
* `fields` are string values for the agent's variables. Keys the agent doesn't use are returned in
  `warnings.undeclaredVariables` and dropped.
* A number on your Do Not Call list is accepted but not called; its result (`skipped`) is produced immediately.

| Status | Body | Meaning |
| - | - | - |
| 202 | `deduplicated: false` | New lead queued. |
| 200 | `deduplicated: true` | This `idempotencyKey` was already pushed. |
| 409 | `{"code": "campaign_not_accepting", "reason": "..."}` | The campaign isn't a running API campaign (`reason` is `not_api` or its status, e.g. `PAUSED`). This also applies to a repeated key. |
| 404 | `campaign_not_found` | Unknown campaign. |
| 422 | `invalid_body`, `invalid_phone`, `idempotency_key_required` | Fix the request. |
| 429 | `journey_campaign_live_cap` | Too many leads waiting in this campaign. Retry the same push later. |
| 429 | `rate_limited` | See [Rate limits](/concepts/rate-limits). |

Every response includes a `requestId`, also sent as the `X-Request-Id` header. Quote it when contacting support.

### Push up to 100 leads at once

`POST /v1/campaigns/{campaignId}/leads/batch` (scope `campaigns.edit`) takes `{"leads": [ ... ]}` with 1–100
items. Each item is shaped exactly like the single-lead body, including its own **`idempotencyKey`** in the body.
The batch does not use the `Idempotency-Key` header.

```bash theme={null}
curl -X POST https://api.pineyard.ai/v1/campaigns/00000000-0000-4000-8000-0000000000c1/leads/batch \
  -H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "leads": [
      { "idempotencyKey": "crm-lead-48214", "lead": { "phone": "+15555550101", "fields": { "first_name": "Sam" } } },
      { "idempotencyKey": "crm-lead-48213", "lead": { "phone": "+15555550100", "fields": { "first_name": "Jordan" } } },
      { "idempotencyKey": "crm-lead-48215", "lead": { "phone": "12345" } }
    ]
  }'
```

It returns `200` with one result per item, in the order you sent them:

```json theme={null}
{
  "requestId": "00000000-0000-4000-8000-0000000000f2",
  "summary": { "queued": 1, "duplicate": 1, "rejected": 1 },
  "results": [
    { "index": 0, "idempotencyKey": "crm-lead-48214", "phone": "+15555550101", "status": "queued", "leadId": "00000000-0000-4000-8000-0000000000a2", "resultUrl": "https://api.pineyard.ai/v1/leads/00000000-0000-4000-8000-0000000000a2/result", "requestId": "00000000-0000-4000-8000-0000000000f3" },
    { "index": 1, "idempotencyKey": "crm-lead-48213", "phone": "+15555550100", "status": "duplicate", "leadId": "00000000-0000-4000-8000-0000000000a1", "resultUrl": "https://api.pineyard.ai/v1/leads/00000000-0000-4000-8000-0000000000a1/result", "requestId": "00000000-0000-4000-8000-0000000000f4" },
    { "index": 2, "idempotencyKey": "crm-lead-48215", "phone": "12345", "status": "rejected", "error": "invalid_phone", "requestId": "00000000-0000-4000-8000-0000000000f5" }
  ]
}
```

* Each item is `queued`, `duplicate` or `rejected`, with the same rules as a single push. A rejected item
  carries the error code in `error`; the other items are not affected.
* Every result echoes the item's `idempotencyKey` and `phone` exactly as you sent them (when they were strings),
  for every outcome, including rejected and malformed items. Match results to your records with these rather than
  relying on order alone.
* Items are processed in order, so a key that appears twice in the same batch is queued once and reported as
  `duplicate` the second time.
* Items marked `"retryable": true` (a `429` live cap or an error on our side) were not accepted. Send them again
  later with the same `idempotencyKey`.
* Problems with the campaign itself (`403`, `404`, `409`) or a malformed envelope (`422 invalid_body`) reject the
  whole batch.
* A batch counts as one request towards your [rate limit](/concepts/rate-limits).


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