# Inbox placement test endpoints

> Start placement tests and batches, read where each copy landed, manage the workspace's seed inboxes, and schedule a campaign's placement monitor.

Source: https://docs.ayinbox.com/api/reference/placement/

An inbox placement test sends a template or a campaign step from one of the workspace's mailboxes to a panel of seed inboxes and reports where each copy landed: the primary inbox, a Gmail tab, spam, or nowhere. The [placement tests guide](/guides/placement-tests/) explains how copies are rendered, sent and found, and how to read a result.

Every route is scoped to the selected organization (API keys are always bound to one), and every id in a request is checked against it: a sending mailbox, campaign, step or contact from another workspace is a `404`. Errors follow the shared `{error, message, code, request_id}` envelope; the codes these routes add are listed under [placement test refusals](/api/error-codes/#placement-test-refusals).

Changes are pushed live as a `PLACEMENT_TEST_UPDATED` event on the organization's [realtime](/api/realtime/) channel when a test starts, on every new verdict, and when it finishes or is cancelled. A batch sends the same event with `batch_id` instead of `test_id` when it starts, skips or defers a mailbox, and finishes.

Get the overview [#get-the-overview]

`GET /placement/overview`

Returns the seed panels this workspace can test on, with each panel's seed count and provider mix, and the workspace's monthly allowance.

Auth: **Scope** `READ_ANALYTICS` · **Org permission** `view_analytics`

Response [#response]

```json
{
  "data": {
    "panels": [
      {
        "panel": "instance",
        "available": true,
        "seeds": 38,
        "families": [
          { "family": "gmail", "label": "Gmail", "seeds": 10 },
          { "family": "google_workspace", "label": "Google Workspace", "seeds": 8 },
          { "family": "microsoft365", "label": "Microsoft 365", "seeds": 8 },
          { "family": "outlook", "label": "Outlook.com", "seeds": 6 },
          { "family": "yahoo", "label": "Yahoo Mail", "seeds": 6 }
        ],
        "metered": true
      },
      {
        "panel": "workspace",
        "available": false,
        "reason": "Mark one of this workspace's mailboxes as a seed inbox to test on it.",
        "seeds": 0,
        "families": [],
        "metered": false
      }
    ],
    "usage": {
      "used": 4,
      "limit": 40,
      "credits_per_test": 25,
      "credit_balance": 1840,
      "period_start": "2026-09-01T00:00:00Z",
      "period_end": "2026-10-01T00:00:00Z"
    },
    "workspace_seeds": 0,
    "seeds_per_test": 20,
    "spacing_seconds": 60
  }
}
```

| Field                    | Description                                                                                                                                                                                                                                                                             |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `panels[].panel`         | `instance` (the operator's panel), `workspace` (the workspace's own seed inboxes) or `cloud` (AY Inbox Cloud's panel). `cloud` is listed on a self-hosted instance, and elsewhere only when it is available                                                                             |
| `panels[].available`     | Whether a test can run on the panel now. `reason` says why not, in one sentence                                                                                                                                                                                                         |
| `panels[].families`      | Seeds per provider family. `family` is a stable id (`gmail`, `google_workspace`, `microsoft365`, `outlook`, `yahoo`, `aol`, `icloud`, `zoho`, `fastmail`, `gmx`, `proton`, `yandex`, `other` and more); `label` is its display name                                                     |
| `panels[].metered`       | Whether a test on the panel counts against a monthly allowance                                                                                                                                                                                                                          |
| `usage`                  | Free tests used this calendar month (UTC) against `limit`, the month's free tests. A test paid in credits, or one that finished without delivering a copy, is not counted. `limit` is `null` when the instance does not meter tests, which is always the case on a self-hosted instance |
| `usage.credits_per_test` | What a test on the `instance` panel costs in credits once the free tests are used up. `0` when tests cannot be paid for, and then a workspace past its free tests waits for the next month                                                                                              |
| `usage.credit_balance`   | The workspace's spendable credits, `null` when credits are off                                                                                                                                                                                                                          |
| `seeds_per_test`         | The most seeds one test sends to                                                                                                                                                                                                                                                        |
| `spacing_seconds`        | The average gap between two copies from one sender                                                                                                                                                                                                                                      |

Start a test [#start-a-test]

`POST /placement/tests`

Starts a test, or two linked tests for a tracking comparison. Every check (entitlement, allowance, tests in flight, the sender's state and daily limit, available seeds) runs before anything is scheduled, so a refused request sends nothing and charges nothing. A paid test is charged when it starts and refunded by itself if no copy is delivered; an `insufficient_credits` (`402`) or `usage_cap_exceeded` (`429`) refusal charges nothing. Supports `Idempotency-Key`, and a retry with the same key is never charged twice.

Auth: **Scope** `SEND_CAMPAIGNS` · **Org permission** `send_campaigns`

Request body [#request-body]

| Field               | Type      | Required | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| ------------------- | --------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sender_account_id` | uuid      | yes      | The mailbox that sends every copy. It must be connected and active, cannot be a seed inbox, and must be one an API key with a mailbox allowlist may use                                                                                                                                                                                                                                                                                                                                                                     |
| `campaign_id`       | uuid      | no       | Test with this campaign's settings: its opt-out line, tracking and attachments                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `sequence_id`       | uuid      | no       | A step of `campaign_id`. Its subject and body are used when the request carries neither. Needs `campaign_id`                                                                                                                                                                                                                                                                                                                                                                                                                |
| `contact_id`        | uuid      | no       | The contact merge fields and AI blocks are rendered for. Defaults to the campaign's first lead, except on the `cloud` panel, where it defaults to a placeholder so no lead's details leave the instance unless you choose one; AI blocks are left out when no contact is used                                                                                                                                                                                                                                               |
| `subject`           | string    | no       | The subject. Required when there is no step to take it from                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `body_html`         | string    | no       | HTML body. A test needs this or `body_plain`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `body_plain`        | string    | no       | Plain-text body                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `tracking`          | string    | no       | `campaign` (default: the campaign's own open and click tracking, none for an ad-hoc test), `on`, `off`, or `compare` (one untracked and one tracked test to the same seeds, each seed getting the same rendered copy from both)                                                                                                                                                                                                                                                                                             |
| `panel`             | string    | no       | `instance` (default), `workspace` or `cloud`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `families`          | string\[] | no       | Only seeds at these provider families, as `panels[].families[].family` lists them. Works on every panel; on `instance` and `cloud` it is the only way to narrow a test, since their addresses are masked. A panel with no seed at those providers is refused with `placement_no_seeds`                                                                                                                                                                                                                                      |
| `pace`              | string    | no       | `spaced` (default, about 60 seconds between copies) or `quick` (about 8 seconds, so a test sends in minutes)                                                                                                                                                                                                                                                                                                                                                                                                                |
| `max_credits`       | integer   | no       | The most credits you agree to pay for the test when the month's free tests on the `instance` panel are used up. A request whose price is higher, or that omits it, is refused with `placement_quota_exceeded`, whose `message` names the price, and nothing is charged. Ignored while free tests are left                                                                                                                                                                                                                   |
| `seed_ids`          | uuid\[]   | no       | Only with `panel` `workspace`: send to these seed inboxes of the workspace and no others. Each must be a seed inbox of the workspace that is connected and running (`placement_invalid_seeds` otherwise, naming any that are not), and each gets a copy: `seeds_per_test` does not cap a chosen list. When the sender's daily limit cannot pay for all of them the request is refused with `placement_daily_budget` rather than dropping some. One on the sender's domain is skipped as usual. Omit it to let the test pick |

```json
{
  "sender_account_id": "a0a1a2a3-0000-0000-0000-000000000003",
  "campaign_id": "b1f2c3d4-0000-0000-0000-000000000001",
  "sequence_id": "c7d8e9f0-0000-0000-0000-000000000002",
  "tracking": "compare"
}
```

Response [#response-1]

`201 Created` with the new tests: one, or two sharing a `compare_group_id` for a comparison. Their copies start going out within seconds.

```json
{
  "data": [
    {
      "id": "d1e2f3a4-0000-0000-0000-000000000010",
      "sender_account_id": "a0a1a2a3-0000-0000-0000-000000000003",
      "sender_email": "sales@yourdomain.com",
      "created_by": "9c2a0000-0000-0000-0000-000000000001",
      "campaign_id": "b1f2c3d4-0000-0000-0000-000000000001",
      "sequence_id": "c7d8e9f0-0000-0000-0000-000000000002",
      "contact_id": "e5f6a7b8-0000-0000-0000-000000000004",
      "monitor_id": null,
      "subject": "Quick question about outbound at Acme",
      "open_tracking": false,
      "link_tracking": false,
      "compare_group_id": "f0f1f2f3-0000-0000-0000-000000000020",
      "origin": "manual",
      "panel": "instance",
      "status": "running",
      "pace": "spaced",
      "credits_charged": 0,
      "credits_refunded": 0,
      "credits_settled_at": null,
      "created_at": "2026-09-25T14:00:00Z",
      "finished_at": null,
      "summary": {
        "total": 38, "pending": 38, "inbox": 0, "promotions": 0, "other": 0, "spam": 0,
        "missing": 0, "failed": 0, "cancelled": 0, "delivered": 0,
        "inbox_rate": null, "tabs_rate": null, "spam_rate": null, "missing_rate": null
      },
      "families": []
    }
  ]
}
```

The second element (omitted above) is the tracked half, with `open_tracking` and `link_tracking` set. A comparison counts as two tests against the allowance and the limit on tests in flight, and sends twice as many copies.

List tests [#list-tests]

`GET /placement/tests`

Returns the workspace's tests, newest first, each with its counts. The copy's bodies are left out of the list.

Auth: **Scope** `READ_ANALYTICS` · **Org permission** `view_analytics`

| Parameter     | In    | Type    | Description                                                                                   |
| ------------- | ----- | ------- | --------------------------------------------------------------------------------------------- |
| `limit`       | query | integer | `1` to `100`, default `25`. Anything else is a `400`                                          |
| `cursor`      | query | string  | Opaque cursor from a previous `pagination.next_cursor`. One this API did not issue is a `400` |
| `campaign_id` | query | uuid    | Only tests of this campaign                                                                   |

Tests a [batch](#start-a-batch) started are left out; read them through their batch.

Response [#response-2]

```json
{
  "data": [
    {
      "id": "d1e2f3a4-0000-0000-0000-000000000010",
      "sender_email": "sales@yourdomain.com",
      "status": "completed",
      "panel": "instance",
      "origin": "manual",
      "finished_at": "2026-09-25T16:20:00Z",
      "summary": {
        "total": 38, "pending": 0, "inbox": 27, "promotions": 4, "other": 1, "spam": 4,
        "missing": 2, "failed": 0, "cancelled": 0, "delivered": 38,
        "inbox_rate": 0.7105, "tabs_rate": 0.1316, "spam_rate": 0.1053, "missing_rate": 0.0526
      },
      "families": [
        {
          "family": "gmail",
          "label": "Gmail",
          "counts": {
            "total": 10, "pending": 0, "inbox": 5, "promotions": 4, "other": 1, "spam": 0,
            "missing": 0, "failed": 0, "cancelled": 0, "delivered": 10,
            "inbox_rate": 0.5, "tabs_rate": 0.5, "spam_rate": 0, "missing_rate": 0
          }
        }
      ]
    }
  ],
  "pagination": { "total": 12, "has_more": false, "next_cursor": null }
}
```

Each test carries the fields shown under [start a test](#start-a-test); the example trims them.

| Field                | Description                                                                                                                                                                                |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `status`             | `running`, `completed`, `cancelled` or `failed`. A test fails when no copy was delivered at all, with the reason in `error`                                                                |
| `origin`             | `manual` (started by a person or a key), `monitor` (a campaign's placement monitor), `batch` (a [placement batch](#start-a-batch), named by `batch_id`), `admin` (an operator) or `remote` |
| `batch_id`           | The batch that started the test, `null` otherwise                                                                                                                                          |
| `pace`               | `spaced` or `quick`                                                                                                                                                                        |
| `credits_charged`    | Credits the test cost past the month's free tests, `0` for a free test                                                                                                                     |
| `credits_refunded`   | Credits a paid test that delivered no copy got back. Monthly credits whose allowance has reset since the charge are not returned, so this can be less than `credits_charged`               |
| `credits_settled_at` | When a paid test's charge was settled after it finished: refunded if it delivered nothing, kept otherwise. `null` for a free test and until then                                           |
| `summary`            | Counts over every copy. `delivered` is `inbox + promotions + other + spam + missing`: every copy that left the sender and got a verdict                                                    |
| `summary.*_rate`     | Fractions from `0` to `1` of `delivered`, `null` until something is delivered. `tabs_rate` covers `promotions` and `other`                                                                 |
| `families`           | The same counts per provider family                                                                                                                                                        |

Get a test [#get-a-test]

`GET /placement/tests/:id`

Returns one test with its copy, every seed's result, the content check of the copy and, for a tracking comparison, the other half.

Auth: **Scope** `READ_ANALYTICS` · **Org permission** `view_analytics`

Response [#response-3]

```json
{
  "data": {
    "id": "d1e2f3a4-0000-0000-0000-000000000010",
    "subject": "Quick question about outbound at Acme",
    "body_html": "<p>Hi Dana,</p><p>...</p>",
    "body_plain": "Hi Dana,\n\n...",
    "status": "completed",
    "panel": "instance",
    "summary": { "total": 38, "inbox": 27, "delivered": 38, "inbox_rate": 0.7105 },
    "families": [],
    "results": [
      {
        "seed": "a***@gmail.com",
        "family": "gmail",
        "family_label": "Gmail",
        "folder": "promotions",
        "scheduled_at": "2026-09-25T14:00:05Z",
        "sent_at": "2026-09-25T14:00:06Z",
        "detected_at": "2026-09-25T14:01:30Z"
      }
    ],
    "content": {
      "score": 84,
      "issues": []
    }
  }
}
```

The test's own fields are the ones listed under [list tests](#list-tests), plus `body_html` and `body_plain`; the example trims them.

| Field              | Description                                                                                                                                                                                                               |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `results[].seed`   | The seed's address, masked on the `instance` and `cloud` panels and shown in full on `workspace`                                                                                                                          |
| `results[].folder` | `pending`, `inbox`, `promotions`, `other` (another Gmail tab: Updates, Social or Forums), `spam`, `missing` (not seen within 120 minutes of `sent_at`), `failed` (never left the sender; `error` says why) or `cancelled` |
| `content`          | The advisory content score of the test's copy, the same shape as [score template content](/api/reference/deliverability-ops/#score-template-content)                                                                      |
| `compare`          | Present only on a tracking comparison: the other half, with its counts and without its results                                                                                                                            |

Cancel a test [#cancel-a-test]

`POST /placement/tests/:id/cancel`

Stops the copies that have not been sent yet. Copies already sent keep being classified. Supports `Idempotency-Key`.

Auth: **Scope** `SEND_CAMPAIGNS` · **Org permission** `send_campaigns`

Response [#response-4]

`200 OK` with the test, in the list shape. A test that has already finished answers `409` with `placement_not_running`.

Start a batch [#start-a-batch]

`POST /placement/batches`

Runs the same test from many sending mailboxes. The request is validated as a whole (the copy, panel, tracking, allowance and credits agreed), the senders are resolved and written down, and the batch is answered `queued` right away. Nothing is sent by the request itself: the backend starts the senders a few at a time, each with its own test, checking each one's daily limit, connection and worker when its turn comes. See [testing a fleet with batches](/guides/placement-tests/#testing-a-fleet-with-batches). Supports `Idempotency-Key`.

Auth: **Scope** `SEND_CAMPAIGNS` · **Org permission** `send_campaigns`

Request body [#request-body-1]

The copy fields are the ones [start a test](#start-a-test) takes: `campaign_id`, `sequence_id`, `contact_id`, `subject`, `body_html`, `body_plain`, `tracking`, `panel`, `families` and `seed_ids`. `pace` is always `spaced` for a batch; `quick` is a `400`. The copy is resolved once, when the batch is created, so every sender tests the same email. Instead of `sender_account_id` a batch takes exactly one of:

| Field                           | Type      | Description                                                                                                                                                                                                                                           |
| ------------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sender_account_ids`            | uuid\[]   | These mailboxes. Duplicates are dropped; one that is not in the workspace, or is a seed inbox, is a `404`, and one an API key with a mailbox allowlist may not use is a `403`. A disconnected one is kept and deferred or skipped when its turn comes |
| `sender_scope`                  | object    | Mailboxes resolved on the server, so a fleet of thousands needs no id list                                                                                                                                                                            |
| `sender_scope.type`             | string    | `campaign` (every mailbox `campaign_id`'s scheduler sends from) or `workspace` (every mailbox of the workspace)                                                                                                                                       |
| `sender_scope.campaign_id`      | uuid      | Required with `type` `campaign`                                                                                                                                                                                                                       |
| `sender_scope.providers`        | string\[] | Only mailboxes hosted by these families, such as `google_workspace`, `gmail`, `microsoft365`, `outlook` or `other`                                                                                                                                    |
| `sender_scope.domains`          | string\[] | Only mailboxes sending from these domains                                                                                                                                                                                                             |
| `sender_scope.tag_ids`          | uuid\[]   | Only mailboxes carrying one of these tags                                                                                                                                                                                                             |
| `sender_scope.include_inactive` | boolean   | Keep disconnected mailboxes. Default `false`                                                                                                                                                                                                          |
| `sender_scope.untested_days`    | integer   | Only mailboxes with no finished placement test in this many days, `1` to `365`                                                                                                                                                                        |

| Field             | Type    | Required | Description                                                                                                                                                                                                                                                                                  |
| ----------------- | ------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sample`          | object  | no       | Which of the resolved mailboxes to keep. Default `{"mode": "all"}`                                                                                                                                                                                                                           |
| `sample.mode`     | string  | no       | `all`, `random` (`count` mailboxes), `percent` (`percent` of them, rounded up), `per_domain` or `per_provider` (up to `count` from each)                                                                                                                                                     |
| `sample.stratify` | string  | no       | With `random` or `percent` only: `provider` or `domain` splits the sample in proportion to each group's share                                                                                                                                                                                |
| `on_unavailable`  | string  | no       | `defer` (default: retry a mailbox that cannot send when its turn comes, for up to seven days) or `skip`                                                                                                                                                                                      |
| `max_credits`     | integer | no       | The most credits you agree to pay across the whole batch for tests past the month's free ones. Needed only when the batch needs more tests than are free: the request is refused with `placement_quota_exceeded`, naming the most it can cost, when it is lower. The batch never spends more |

```json
{
  "sender_scope": { "type": "campaign", "campaign_id": "b1f2c3d4-0000-0000-0000-000000000001", "providers": ["google_workspace"] },
  "sample": { "mode": "percent", "percent": 10, "stratify": "domain" },
  "campaign_id": "b1f2c3d4-0000-0000-0000-000000000001",
  "sequence_id": "c7d8e9f0-0000-0000-0000-000000000002",
  "tracking": "compare"
}
```

Response [#response-5]

`201 Created` with the batch.

```json
{
  "data": {
    "id": "a9b8c7d6-0000-0000-0000-000000000030",
    "created_by": "9c2a0000-0000-0000-0000-000000000001",
    "campaign_id": "b1f2c3d4-0000-0000-0000-000000000001",
    "sequence_id": "c7d8e9f0-0000-0000-0000-000000000002",
    "contact_id": "e5f6a7b8-0000-0000-0000-000000000004",
    "subject": "Quick question about outbound at Acme",
    "tracking": "compare",
    "panel": "instance",
    "pace": "spaced",
    "families": [],
    "seed_ids": [],
    "on_unavailable": "defer",
    "selection": {
      "sender_scope": { "type": "campaign", "campaign_id": "b1f2c3d4-0000-0000-0000-000000000001", "providers": ["google_workspace"] },
      "sample": { "mode": "percent", "percent": 10, "stratify": "domain" },
      "matched": 1126
    },
    "sender_count": 113,
    "max_credits": 0,
    "credits_spent": 0,
    "status": "queued",
    "retry_until": "2026-10-02T14:00:00Z",
    "created_at": "2026-09-25T14:00:00Z",
    "started_at": null,
    "finished_at": null,
    "progress": { "total": 113, "queued": 113, "deferred": 0, "running": 0, "completed": 0, "skipped": 0, "failed": 0, "cancelled": 0 },
    "summary": { "total": 0, "pending": 0, "inbox": 0, "promotions": 0, "other": 0, "spam": 0, "missing": 0, "failed": 0, "cancelled": 0, "delivered": 0, "inbox_rate": null, "tabs_rate": null, "spam_rate": null, "missing_rate": null }
  }
}
```

| Field         | Description                                                                                                                                                                                                      |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `status`      | `queued` until the first mailbox starts, then `running`; at the end `completed`, `completed_with_warnings` (some mailboxes were skipped or failed), `failed` (none finished a test, with `error`) or `cancelled` |
| `selection`   | How the mailboxes were chosen, and how many the scope `matched` before sampling. The mailboxes themselves are the snapshot under [list a batch's senders](#list-a-batchs-senders)                                |
| `progress`    | The batch's mailboxes by status. `running` is a mailbox whose test has not finished yet                                                                                                                          |
| `summary`     | Where the batch's copies landed so far, over the copy the campaign really sends: the tracked half of a tracking comparison                                                                                       |
| `retry_until` | Deferred mailboxes are retried until then, then skipped                                                                                                                                                          |

Preview a batch [#preview-a-batch]

`POST /placement/batches/preview`

Takes the body of [start a batch](#start-a-batch) and returns what it would come to, without writing anything or sending anything. The copy fields may be left out to count the mailboxes alone; once any is given, the copy is checked as on start. A random sample is drawn again when the batch starts, so the counts are the same but the mailboxes can differ.

Auth: **Scope** `SEND_CAMPAIGNS` · **Org permission** `send_campaigns`

Response [#response-6]

```json
{
  "data": {
    "matched": 1347, "selected": 135, "inactive": 2, "domains": 41,
    "providers": [
      { "key": "google_workspace", "label": "Google Workspace", "senders": 80 },
      { "key": "other", "label": "Other provider", "senders": 40 },
      { "key": "microsoft365", "label": "Microsoft 365", "senders": 15 }
    ],
    "variants": 2, "tests": 270, "seeds_per_test": 20, "max_sends": 5400,
    "metered": true, "free_tests": 40, "paid_tests": 230, "credits": 5750,
    "usage": { "used": 0, "limit": 40, "credits_per_test": 25, "credit_balance": 9000, "period_start": "2026-09-01T00:00:00Z", "period_end": "2026-10-01T00:00:00Z" },
    "senders_max": 10000, "concurrency": 20
  }
}
```

| Field                                 | Description                                                                                                        |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `variants`                            | `2` for a tracking comparison, which doubles `tests` and `max_sends`                                               |
| `max_sends`                           | The most copies the batch sends: `tests` times `seeds_per_test`. Each mailbox's daily limit can only make it fewer |
| `free_tests`, `paid_tests`, `credits` | How `tests` splits against the month's free tests, and the most the paid ones cost. All `0` on an unmetered panel  |
| `concurrency`                         | How many of the workspace's batch mailboxes send at once                                                           |

List batches [#list-batches]

`GET /placement/batches`

Returns the workspace's batches, newest first, in the shape above. Takes `limit` (`1` to `100`, default `25`) and an opaque `cursor`, and returns `data` plus `pagination`.

Auth: **Scope** `READ_ANALYTICS` · **Org permission** `view_analytics`

Get a batch [#get-a-batch]

`GET /placement/batches/:id`

Returns one batch with its placement grouped across the fleet.

Auth: **Scope** `READ_ANALYTICS` · **Org permission** `view_analytics`

Response [#response-7]

The batch's fields as above, plus:

| Field        | Description                                                                                                                                  |
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `untracked`  | On a tracking comparison only: the untracked half's counts. `summary` is the tracked half                                                    |
| `domains`    | Per sending domain, worst inbox rate first: `key` (the domain), `senders` in the batch, `tested` (senders that finished a test) and `counts` |
| `providers`  | The same per sending provider, `key` a host family and `label` its name                                                                      |
| `recipients` | Counts per recipient provider family, like a test's `families`                                                                               |
| `matrix`     | Per sending domain, in the order of `domains`, its counts per recipient provider in the order of `recipients`                                |
| `content`    | The content score of the batch's copy                                                                                                        |

The groups read the same copy as `summary`. They are not broken down by worker: a worker signs in to the mailbox provider, which delivers from its own servers, so a recipient's filter never sees it.

List a batch's senders [#list-a-batchs-senders]

`GET /placement/batches/:id/senders`

Returns the batch's mailboxes with where each one's copies landed.

Auth: **Scope** `READ_ANALYTICS` · **Org permission** `view_analytics`

| Parameter | In    | Type    | Description                                                                                                     |
| --------- | ----- | ------- | --------------------------------------------------------------------------------------------------------------- |
| `limit`   | query | integer | `1` to `100`, default `25`                                                                                      |
| `cursor`  | query | string  | Opaque cursor from a previous `pagination.next_cursor`                                                          |
| `sort`    | query | string  | `worst` (default: lowest inbox rate first, untested last), `best`, `email` or `status`                          |
| `status`  | query | string  | Only mailboxes in this status: `queued`, `deferred`, `running`, `completed`, `skipped`, `failed` or `cancelled` |
| `q`       | query | string  | Only addresses containing this text                                                                             |

Response [#response-8]

```json
{
  "data": [
    {
      "id": "c0c1c2c3-0000-0000-0000-000000000040",
      "batch_id": "a9b8c7d6-0000-0000-0000-000000000030",
      "email_account_id": "a0a1a2a3-0000-0000-0000-000000000003",
      "sender_email": "john@domain2.com",
      "sender_domain": "domain2.com",
      "sender_family": "google_workspace",
      "sender_family_label": "Google Workspace",
      "status": "completed",
      "attempts": 1,
      "next_attempt_at": "2026-09-25T14:00:00Z",
      "started_at": "2026-09-25T14:02:00Z",
      "finished_at": "2026-09-25T16:40:00Z",
      "summary": { "total": 20, "pending": 0, "inbox": 8, "promotions": 0, "other": 0, "spam": 12, "missing": 0, "failed": 0, "cancelled": 0, "delivered": 20, "inbox_rate": 0.4, "tabs_rate": 0, "spam_rate": 0.6, "missing_rate": 0 },
      "test_ids": ["d1e2f3a4-0000-0000-0000-000000000011", "d1e2f3a4-0000-0000-0000-000000000012"]
    }
  ],
  "pagination": { "total": 113, "has_more": true, "next_cursor": "MjU" }
}
```

| Field              | Description                                                                                                                                      |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `status`           | `queued`, `deferred` (waiting to retry at `next_attempt_at`), `running`, `completed`, `skipped`, `failed` or `cancelled`                         |
| `reason`, `detail` | Why a mailbox was deferred, skipped or failed: a code from [placement test refusals](/api/error-codes/#placement-test-refusals) and its sentence |
| `test_ids`         | The mailbox's tests, readable with [get a test](#get-a-test): the untracked half first on a comparison                                           |

Cancel a batch [#cancel-a-batch]

`POST /placement/batches/:id/cancel`

Stops the batch: no mailbox starts again, copies not sent yet are cancelled, and copies already sent keep being classified.

Auth: **Scope** `SEND_CAMPAIGNS` · **Org permission** `send_campaigns`

Response [#response-9]

`200 OK` with the batch. A batch that has already finished answers `409` with `placement_batch_not_running`.

Get fleet coverage [#get-fleet-coverage]

`GET /placement/coverage`

How many of the workspace's connected sending mailboxes finished a placement test recently.

Auth: **Scope** `READ_ANALYTICS` · **Org permission** `view_analytics`

Response [#response-10]

```json
{
  "data": { "mailboxes": 1347, "tested_7d": 312, "tested_30d": 947, "never_tested": 126 }
}
```

List seed inboxes [#list-seed-inboxes]

`GET /placement/seeds`

Returns every mailbox of the workspace with whether it is one of the workspace's seed inboxes. A key restricted to certain mailboxes sees only those.

Auth: **Scope** `READ_EMAILS` · **Org permission** `view_campaigns`

Response [#response-11]

```json
{
  "data": [
    {
      "email_account_id": "a0a1a2a3-0000-0000-0000-000000000009",
      "email": "dana.seed@gmail.com",
      "family": "gmail",
      "label": "Gmail",
      "status": "active",
      "seed": true
    },
    {
      "email_account_id": "a0a1a2a3-0000-0000-0000-000000000003",
      "email": "sales@yourdomain.com",
      "family": "google_workspace",
      "label": "Google Workspace",
      "status": "active",
      "seed": false,
      "blocker": "A placement test is sending from this mailbox"
    }
  ]
}
```

`blocker` is present when the mailbox cannot become a seed inbox right now, and says why.

Mark a seed inbox [#mark-a-seed-inbox]

`PUT /placement/seeds/:email_account_id`

Makes a workspace mailbox one of its seed inboxes, or an ordinary mailbox again. A seed never warms up (its warmup is turned off here, and starting it again is refused with `mailbox_is_seed`), never sends campaign mail, and cannot send a placement test. The body states the value to hold, so a retry lands on the same state.

Auth: **Scope** `WRITE_EMAILS` · **Org permission** `manage_emails`

Request body [#request-body-2]

| Field  | Type    | Required | Description                                                                     |
| ------ | ------- | -------- | ------------------------------------------------------------------------------- |
| `seed` | boolean | yes      | `true` to make the mailbox a seed inbox, `false` to make it an ordinary mailbox |

```json
{ "seed": true }
```

Response [#response-12]

`200 OK` with the mailbox, in the shape of [list seed inboxes](#list-seed-inboxes). A mailbox on the instance panel, or one a test is still sending from, answers `409` with `placement_seed_unavailable`; a workspace that already has `50` seed inboxes answers `placement_seed_limit`.

Get a campaign's placement monitor [#get-a-campaigns-placement-monitor]

`GET /campaigns/:id/placement-monitor`

Returns the campaign's scheduled placement test, or `null` when it has none.

Auth: **Scope** `READ_CAMPAIGNS` · **Org permission** `view_campaigns`

Response [#response-13]

```json
{
  "data": {
    "id": "a9b8c7d6-0000-0000-0000-000000000030",
    "campaign_id": "b1f2c3d4-0000-0000-0000-000000000001",
    "created_by": "9c2a0000-0000-0000-0000-000000000001",
    "enabled": true,
    "interval_days": 7,
    "panel": "instance",
    "alert_below": 70,
    "pause_on_alert": false,
    "next_run_at": "2026-10-02T14:05:00Z",
    "last_run_at": "2026-09-25T14:05:00Z",
    "last_test_id": "d1e2f3a4-0000-0000-0000-000000000010",
    "last_alert_at": null,
    "created_at": "2026-09-18T14:00:00Z",
    "updated_at": "2026-09-25T14:05:00Z"
  }
}
```

`last_error` is present when the last run could not start a test, and says why (for example, the campaign was not running); the monitor then tries again a day later.

Set a campaign's placement monitor [#set-a-campaigns-placement-monitor]

`PUT /campaigns/:id/placement-monitor`

Creates or updates the campaign's monitor. Every field is optional: an absent field keeps its current value, or its default on a new monitor. The body states values rather than changes, so a retry lands on the same state. A new or re-enabled monitor runs its first test within about five minutes; changing `interval_days` moves the next run to that interval after the last one.

Each run tests the campaign's first email step with a subject and a body, rendered for its first lead with the campaign's own tracking, from the next of the campaign's sending mailboxes in turn. It runs only while the campaign is `active`, and counts against the allowance and the mailbox's daily limit like any other test.

Auth: **Scope** `SEND_CAMPAIGNS` · **Org permission** `send_campaigns`

Request body [#request-body-3]

| Field            | Type    | Default    | Description                                                                                                                                                        |
| ---------------- | ------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `enabled`        | boolean | `true`     | Whether the monitor runs                                                                                                                                           |
| `interval_days`  | integer | `7`        | Days between runs, `1` to `30`                                                                                                                                     |
| `panel`          | string  | `instance` | `instance`, `workspace` or `cloud`                                                                                                                                 |
| `alert_below`    | integer | `70`       | Inbox percentage, `0` to `100`. A run whose primary-inbox share lands below it notifies every member who can view campaigns with a `placement_alert` notification  |
| `pause_on_alert` | boolean | `false`    | Also pause the campaign when a run alerts. The campaign moves to `paused_guardrail` with the reason in its activity log and stays paused until it is started again |

```json
{ "interval_days": 3, "alert_below": 80, "pause_on_alert": true }
```

Response [#response-14]

`200 OK` with the monitor, in the shape above.

Remove a campaign's placement monitor [#remove-a-campaigns-placement-monitor]

`DELETE /campaigns/:id/placement-monitor`

Removes the campaign's monitor. Tests it already ran stay in the list.

Auth: **Scope** `SEND_CAMPAIGNS` · **Org permission** `send_campaigns`

Response [#response-15]

`204 No Content`. A campaign with no monitor answers `404`.
