# MerchDash Public API — v1

This file is the complete contract for MerchDash's public API, written to be handed to an
AI assistant along with "write me a client for this". It is generated from the API's own
contract, so it describes what the server actually does rather than what somebody
remembered to write down. Human-readable versions of the same material live at
`/docs/api` and `/docs/api-reference`.

## Read this first

Six rules that change how the client is built. Everything else is detail.

1. **A write endpoint proposes, and whether the proposal is then applied is the KEY's
   setting, not yours.** `POST /api/v1/changes` and `POST /api/v1/structures` create a
   run and return immediately. On an ordinary key — the default every key is minted with —
   nothing reaches Amazon: a signed-in human approves or turns the proposal down inside
   MerchDash. On a key whose owner has switched it to **auto-apply**, MerchDash writes the
   batch to Amazon itself, straight away. Read `applyMode` in the response and say the
   right thing: with `review`, never tell a user a change has been made; with `auto`,
   never tell them it is waiting for approval. One operation is a documented exception to
   the *first* half of that: `set_auto_clause_bids` READS an auto campaign's targeting
   clauses from Amazon while it validates the request, because that is the only place the
   full list of them exists. It still writes nothing — but it is slower than the other
   operations, and it can answer `503 temporarily_unavailable` when Amazon is
   unreachable, in which case no proposal was created and the same
   `Idempotency-Key` may be retried.
2. **An API key can never apply, discard or revert an EXISTING run**, and there is no
   endpoint that would let it. Do not look for one; do not build a retry loop that expects
   one. Auto-apply is a standing decision its owner made about the key, in MerchDash — not
   something a request can grant itself. `"applyMode": "auto"` on a key without it is
   `403 scope`; `"applyMode": "review"` is always accepted, on any key, and is how a
   batch that wants a second pair of eyes asks for one. Poll
   `GET /api/v1/runs/{runId}` for what happened either way.
3. **Every id is a string, always.** Amazon's ids exceed the safe integer range. Parsing
   one as a number silently corrupts it. Keep them as strings end to end.
4. **Reads are answered from MerchDash's synced archive, never live from Amazon.** A read
   costs none of the account's Amazon rate limit and never waits on Amazon's latency, but
   the numbers are only as fresh as the last sync — `GET /api/v1/sync/status`
   reports how fresh that is. Recent days keep moving as Amazon restates attribution.
5. **Both proposal endpoints require an `Idempotency-Key` header.** Over HTTP a retry is
   the normal failure mode, not an edge case. Derive the key from the job, never from the
   clock — see Idempotency below.
6. **Branch on the `code` field of an error, never on the message text.** Codes are a
   promise; wording is not. The full vocabulary is below.

## Base URL and authentication

```
https://app.merchdash.net/api/v1
Authorization: Bearer md_live_...
```

The key is a bearer token and nothing else. MerchDash will not read a key from a query
string — that would put the credential in access logs, proxies and browser history.

A key is minted by its owner in MerchDash under **Profile → Account → API keys**, is shown
exactly once at creation, and is stored only as a hash. Up to 10
active keys per account. Two scopes exist: `read` (every `GET`) and `read`+`write`
(the reads plus the two proposal endpoints). Scope is checked against the ROUTE, not the
HTTP method, so do not assume a `GET` is always allowed.

Each write-capable key also carries an **auto-apply** setting, off unless its owner turned
it on. Off, every proposal the key sends waits in the review queue. On, every proposal it
sends is written to Amazon by MerchDash as soon as it is accepted, and `applyMode` in the
response says which happened. A key cannot read, change or grant itself that setting —
key management is session-only, and so is this.

API access is part of the Growth and Scale plans and the free trial. On an ineligible plan
every key answers `403` with code `plan`; the keys are disabled, not deleted, and an
upgrade restores them.

Only the `/api/v1/` endpoints listed here accept a key. Every other MerchDash
route — the dashboard's own endpoints, admin, key management itself — answers `404` to a
key whether or not it exists.

None. v1 is server-to-server: no Access-Control-* headers are sent and preflights are not answered.

## Response envelopes and pagination

- Collections: `{ "data": [ ... ], "nextCursor": string|null }`
- Single resources: `{ "data": { ... } }`
- Errors: `{ "error": "human message", "code": "machine_code" }`, sometimes with a
  bounded `details` array on a validation failure.

Page with `limit` (default 100, maximum 500) and `cursor`.
Pass the previous response's `nextCursor` back as `cursor` and stop when it is `null`.
A cursor is opaque and signed: it encodes the endpoint and the filters it was issued for,
so reusing one with different filters is refused with `invalid_cursor` rather than
answering the wrong page. Paging is **not** a snapshot — rows synced between two pages can
appear — so do not assume a stable set across pages.

## Metric windows, metrics and currency

Metric reads take `start` and `end` as `YYYY-MM-DD`, inclusive, in the marketplace's
own report days. The default window is the last 30 days ending
2 days ago, because Amazon is still restating attribution for the
days after that. The maximum window is 365 days.

Only these six raw metrics are published, summed over the window:

| Metric | Type | Meaning |
| --- | --- | --- |
| `metrics.impressions` | `integer` | Impressions in the window. |
| `metrics.clicks` | `integer` | Clicks in the window. |
| `metrics.cost` | `number` | Ad spend in the window, in the marketplace currency. |
| `metrics.orders` | `integer` | Attributed orders (14-day) in the window. |
| `metrics.units` | `integer` | Attributed units (14-day) in the window. |
| `metrics.sales` | `number` | Attributed sales (14-day) in the window, in the marketplace currency. |

ACOS, ROAS, CTR, CVR and CPC are deliberately absent: each is one division, and publishing
them would freeze five rounding decisions into a contract that cannot change. Compute them
client-side. `cost` and `sales` are in the marketplace's own currency and are never
converted, so do not sum them across marketplaces without converting first.

## Rate limits

| Bucket | Reads | Proposals | Of those, auto-applied |
| --- | --- | --- | --- |
| Per key | 120 / minute | 30 / minute | 5 / minute |
| Per account (all of its keys together) | 300 / minute | 60 / minute | 10 / minute |

An auto-applied proposal is charged to the auto-apply bucket **in addition to** the
proposal bucket, and that limit is deliberately far lower: a proposal that queues for
review is bounded by a person's attention, while one that skips review is bounded by
nothing else at all.

The per-key and per-account buckets in each pair are checked atomically: if either one is
empty, neither is charged. An auto-apply request first spends the ordinary proposal pair
and then its additional auto-apply pair; if that stricter pair is exhausted, the attempt
still counts against the ordinary proposal limit. Limits are enforced across every
MerchDash server at once, so the published number is the real number, and minting more
keys does not buy more throughput. Exhausting a bucket is `429` with code
`rate_limited` and a `Retry-After` header — honour it.

## Idempotency

Both proposal endpoints require `Idempotency-Key`: 1–128 visible ASCII characters,
unique per logical request.

| Sent | Result |
| --- | --- |
| Same key, same body | The first call's exact response. Nothing is created twice. |
| Same key, different body | `409 idempotency_conflict`. |
| Same key after 24 hours | Treated as a fresh request — keys are remembered for 24 hours. |

Derive the key from the job: `waste-sweep-{profileId}-{date}` is retryable, a fresh
`uuid()` per attempt is not. A retry replays its first answer even if the human has since
turned the proposal down — the run stays readable at that id with status `discarded`, so
a retry never resurrects declined work.

Creation is idempotent at a second level: `POST /api/v1/structures` RESERVES
each campaign and ad group under a stable identity when it is proposed, so a retry, a
second proposal, or one of the account's own automation rules asking for the same campaign
joins that reservation instead of building a duplicate.

And at a third: a container named by `name` that the marketplace ALREADY has is added to
rather than created again — campaigns, ad groups and portfolios alike, matched
case-insensitively and whitespace-collapsed the way Amazon collides names. So the safe
client is the simple one: describe the structure you want in full every time and send it.
What was already there comes back in `alreadyPresent` instead of being proposed, an
existing container is never modified (the `budget`/`defaultBid`/`state` you sent for one
is ignored, while the matched container and id are reported), and `defaultBid` is only required for an ad group that has to
be created. Two campaigns sharing a name is the one case with no answer, and it is refused.

## Errors

Every error carries a `code`. Branch on it.

| Code | HTTP | Retry class | Meaning |
| --- | --- | --- | --- |
| `invalid_key` | 401 | `stop` | The bearer token is not a usable MerchDash API key. |
| `key_revoked` | 401 | `stop` | The key was revoked by its owner. |
| `key_expired` | 401 | `stop` | The key is past its expiry date. |
| `plan` | 403 | `stop` | The account's plan does not include API access. The key is not revoked — an upgrade re-enables it. |
| `scope` | 403 | `fix` | The credential does not carry the capability this request requires — a read-only key on a write route, a browser session on an endpoint that needs a key, or `"applyMode": "auto"` on a key whose owner has not turned auto-apply on for it. |
| `not_found` | 404 | `fix` | No such route, or no such resource in this account. An id belonging to somebody else answers exactly this. |
| `validation` | 400 | `fix` | The request could not be read or accepted. May carry a bounded `details` array naming each problem. |
| `invalid_cursor` | 400 | `fix` | The cursor is not one of ours, or was issued for a different endpoint or different filters. |
| `idempotency_conflict` | 409 | `fix` | This Idempotency-Key was already used for a different request body. |
| `creation_conflict` | 409 | `later` | A campaign or ad group this request wants to create is already reserved by another proposal that nobody has answered yet. Apply or turn that one down first — the reservation is what stops the same container being built twice. |
| `rate_limited` | 429 | `wait` | A rate limit was exhausted. Retry-After says how long to wait. |
| `review_queue_full` | 429 | `later` | This marketplace already holds 25 un-answered proposals. Retry once a human has worked through some. |
| `sync_incomplete` | 409 | `later` | The marketplace's first data sync has not finished, so proposals cannot be validated against its snapshot yet. |
| `restarting` | 503 | `wait` | MerchDash is mid-deploy. The request was refused before it did anything and is safe to re-send. |
| `temporarily_unavailable` | 503 | `wait` | A dependency the request needs — shared rate enforcement, for one — could not be reached. |

| Codes | What the client should do |
| --- | --- |
| `rate_limited`, `restarting`, `temporarily_unavailable` | Retry automatically. Honour `Retry-After` when it is present, otherwise back off. |
| `creation_conflict`, `review_queue_full`, `sync_incomplete` | Retry later, but only once something changes — a human clears the queue, a sync finishes, another proposal is answered. Immediate retries will keep failing. |
| `scope`, `not_found`, `validation`, `invalid_cursor`, `idempotency_conflict` | Do not retry unchanged. The request itself has to change. |
| `invalid_key`, `key_revoked`, `key_expired`, `plan` | Stop and alert a human. No retry can fix a credential or a plan. |

`not_found` is deliberately the same answer for a route that does not exist, an id that
does not exist, and an id belonging to another account. Do not try to tell them apart.

## Run lifecycle

A proposal returns a `runId`. Poll `GET /api/v1/runs/{runId}` for its status.
This is a **closed** enum — a switch over it may be exhaustive, and no value will ever be
added without a `/api/v2/`.

| Status | Meaning | Can become |
| --- | --- | --- |
| `preview` | Proposed and waiting for a human to approve it in MerchDash. Nothing has been written to Amazon. | `running`, `discarded`, `partial` |
| `running` | A human approved it and the writes are in flight. | `applied`, `partial`, `failed` |
| `applied` | Every change was written to Amazon. | `reverted` |
| `partial` | Some changes were written and others were not — because they failed, or because they are still awaiting approval. | `reverted`, `running` |
| `failed` | The run ended without writing anything it meant to write. | terminal |
| `reverted` | At least one applied change has since been rolled back. | terminal |
| `discarded` | A human turned the proposal down. Nothing was written to Amazon, and the run stays readable at this id. | terminal |

A fresh proposal is `preview` at the instant it is created, whichever mode it is in — so
branch on the response's `applyMode`, never on this. With `applyMode: "review"` it stays
`preview` until a human acts, which may be hours or never: poll on a human timescale
(minutes), not a machine one. With `applyMode: "auto"` it leaves `preview` on its own
within seconds and reaches `applied`, `partial` or `failed` as the writes land; a run
that is still `preview` a minute later is one whose apply could not be started, and it is
now an ordinary review card waiting for its owner.

## Hard limits

| Limit | Value |
| --- | --- |
| Rows per page (default / max) | 100 / 500 |
| Metric window (default / max) | 30 days ending 2 days ago / 365 days |
| Changes per `POST /api/v1/changes` | 500 |
| Campaigns + ad groups per `POST /api/v1/structures` | 2000 |
| ASINs + keywords + targets per `POST /api/v1/structures` | 20000 |
| Request body, `POST /api/v1/changes` and `/structures` | 24 MB (every other endpoint: 1 MB) |
| Un-answered proposals held per marketplace | 25 |
| Detailed `alreadyPresent` entries returned | 50 |
| Active API keys per account | 10 |

Exceeding the outstanding-proposal cap is `429 review_queue_full`: the queue exists for a
human to work through, and a client that fills it faster than anyone can answer has broken
the feature rather than used it.

## Worked examples

Read one marketplace's campaigns for a month:

```bash
curl -H "Authorization: Bearer $MERCHDASH_KEY" \
  "https://app.merchdash.net/api/v1/campaigns?profileId=1234567890&start=2026-08-01&end=2026-08-31&limit=100"
```

Propose a bid change and a negative keyword:

```bash
curl -X POST "https://app.merchdash.net/api/v1/changes" \
  -H "Authorization: Bearer $MERCHDASH_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: waste-sweep-1234567890-2026-09-05" \
  -d '{
    "profileId": "1234567890",
    "label": "Nightly waste sweep",
    "changes": [
      { "operation": "set_bid", "entityType": "keyword", "entityId": "98765",
        "bid": 0.42, "reason": "ACOS 78% over 30 days" },
      { "operation": "add_negative_keyword", "campaignId": "555",
        "keywordText": "free", "matchType": "NEGATIVE_EXACT" }
    ]
  }'
```

Propose a new campaign with one ad group, one ASIN and one keyword:

```bash
curl -X POST "https://app.merchdash.net/api/v1/structures" \
  -H "Authorization: Bearer $MERCHDASH_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: q4-seeding-1234567890-batch-1" \
  -d '{
    "profileId": "1234567890",
    "label": "Q4 seeding",
    "campaigns": [{
      "name": "SP - Manual - Widget",
      "budget": 20.00,
      "targeting": "manual",
      "adGroups": [{
        "name": "Core terms",
        "defaultBid": 0.75,
        "asins": ["B0ABCDEFGH"],
        "keywords": [{ "text": "widget", "matchType": "EXACT", "bid": 0.90 }]
      }]
    }]
  }'
```

Ask for human review from a key that would otherwise apply by itself — always allowed,
and the safe thing to send when a batch is bigger or stranger than usual:

```json
{ "profileId": "1234567890", "label": "Nightly waste sweep", "applyMode": "review", "changes": [] }
```

Then poll for the outcome — a human's decision on a review, or the writes landing on an
auto-apply:

```bash
curl -H "Authorization: Bearer $MERCHDASH_KEY" "https://app.merchdash.net/api/v1/runs/RUN_ID"
```

## Out of scope — do not attempt these

- Applying, discarding or reverting a run that already exists. Human-only, by design —
  auto-apply decides what happens to a proposal as it is made, and nothing more.
- Turning auto-apply on. It is the key owner's decision, made in MerchDash. A request can
  only ask for less than the key allows, never more.
- Any MerchDash route outside `/api/v1/`. They are private to the dashboard,
  answer `404` to a key, and are free to change without notice.
- Creating, listing or revoking API keys with a key, including its own.
- Disconnecting Amazon, deleting the account, or invoking the AI copilot.
- Creating Sponsored Brands or Sponsored Display structures: an SB ad group needs a
  creative and a landing page and an SD one a tactic, none of which this API can supply,
  so such a node is refused rather than half-built.
- Reading negative keywords or negative targets. They can be created through
  `POST /api/v1/changes` and seen in the review queue, but are not readable
  entities in v1.
- Webhooks. v1 is polling only.

## Stability

The `/api/v1/` prefix is the promise: these shapes do not change without a
`/api/v2/`. Additive changes that leave every documented field's name, type and meaning
alone are compatible — a new optional parameter, a new response field, a new value in an
enum marked open — so ignore fields you do not recognise rather than failing on them. Run
statuses are a closed enum and will not gain values.

## Endpoints

| Endpoint | Scope | Summary |
| --- | --- | --- |
| `GET /api/v1/profiles` | read | Every marketplace this account owns and has synced. |
| `GET /api/v1/campaigns` | read | Campaigns with their metrics for a window. |
| `GET /api/v1/ad-groups` | read | Ad groups with their metrics for a window. |
| `GET /api/v1/keywords` | read | Keywords with their metrics for a window. |
| `GET /api/v1/targets` | read | Targeting clauses with their metrics for a window. |
| `GET /api/v1/asins` | read | Advertising performance aggregated per ASIN. |
| `GET /api/v1/search-terms` | read | Shopper search terms with their metrics for a window. |
| `GET /api/v1/metrics/daily` | read | Account totals for each day in a window. |
| `GET /api/v1/sync/status` | read | How current the synced archive is for a marketplace. |
| `GET /api/v1/runs` | read | Automation runs for a marketplace, newest activity first. |
| `GET /api/v1/runs/:runId` | read | One run — the polling target for the runId a proposal returned. |
| `GET /api/v1/runs/:runId/changes` | read | The individual changes a run proposed, and what became of each. |
| `POST /api/v1/changes` | write | Propose a batch of bid, state and negative changes — for a human to approve, or to apply at once. |
| `POST /api/v1/structures` | write | Propose new campaigns, ad groups, product ads, keywords and targeting clauses for a human to approve. |

### GET /api/v1/profiles

Every marketplace this account owns and has synced.

Scope required: `read`

Not "the marketplaces currently ticked in the switcher" — the switcher is a UI preference. This is every advertising profile the account owns and has synced at least once, which is exactly the set of profileIds the other endpoints accept.

Response: `{ "data": [ ... ], "nextCursor": string|null }`

Order: profileId ascending

| Field | Type |  | Meaning |
| --- | --- | --- | --- |
| `profileId` | `string` |  | The Amazon advertising profile id. |
| `accountName` | `string` | nullable | Advertiser account name from the last Amazon discovery. Null if MerchDash has not discovered it. |
| `countryCode` | `string` | nullable | ISO country of the marketplace, when known. |
| `marketplaceId` | `string` | nullable | Amazon marketplace id, when known. |
| `region` | `string` | nullable | Amazon Ads API region (NA / EU / FE) this profile is read through. |
| `lastSyncAt` | `timestamp` | nullable | When a sync last completed for this marketplace. Null until the first one finishes. |

### GET /api/v1/campaigns

Campaigns with their metrics for a window.

Scope required: `read`

One row per campaign in the marketplace's snapshot, including campaigns that delivered nothing in the window — their metrics are zero rather than absent. Archived campaigns are included; filter on `state`.

Parameters:

| Parameter | In | Type |  | Meaning |
| --- | --- | --- | --- | --- |
| `profileId` | query | `string` | **required** | The Amazon advertising profile (marketplace) to read, as returned by GET /api/v1/profiles. When this marketplace is one half of a linked pair of Amazon accounts, the read covers both, exactly as the MerchDash dashboard does; every row carries the profileId it actually came from. |
| `start` | query | `date` | optional | First day of the metric window, inclusive, as YYYY-MM-DD (UTC, the marketplace's own report days). Defaults to 29 days before `end`. |
| `end` | query | `date` | optional | Last day of the metric window, inclusive, as YYYY-MM-DD. Defaults to 2 days ago, because Amazon is still restating attribution for the days after that. |
| `limit` | query | `integer` | optional | Rows per page, 1–500. Defaults to 100. |
| `cursor` | query | `string` | optional | An opaque cursor from a previous response's `nextCursor`. It encodes the endpoint and the filters it was issued for: reusing it with different filters is `invalid_cursor` rather than a silently wrong page. Paging is not a snapshot — rows synced between two pages may appear. |

Response: `{ "data": [ ... ], "nextCursor": string|null }`

Order: campaignId ascending

| Field | Type |  | Meaning |
| --- | --- | --- | --- |
| `profileId` | `string` |  | The marketplace this row belongs to. |
| `adProduct` | `enum` (`SPONSORED_PRODUCTS` \| `SPONSORED_BRANDS` \| `SPONSORED_DISPLAY`) |  | Amazon ad product. Open enum: Amazon may add products. |
| `state` | `string` | nullable | ENABLED / PAUSED / ARCHIVED as of the last sync. Null if never observed. |
| `campaignId` | `string` |  | Amazon campaign id. Always a string, never a number — ids exceed the safe integer range. |
| `name` | `string` | nullable |  |
| `targetingType` | `string` | nullable | MANUAL or AUTO for Sponsored Products. |
| `budget` | `number` | nullable | Daily budget in the marketplace currency. |
| `budgetType` | `string` | nullable |  |
| `biddingStrategy` | `string` | nullable |  |
| `portfolioId` | `string` | nullable |  |
| `portfolioName` | `string` | nullable |  |
| `startDate` | `string` | nullable |  |
| `endDate` | `string` | nullable |  |
| `metrics` | `object` |  | The six report metrics summed over the window. |
| `metrics.impressions` | `integer` |  | Impressions in the window. |
| `metrics.clicks` | `integer` |  | Clicks in the window. |
| `metrics.cost` | `number` |  | Ad spend in the window, in the marketplace currency. |
| `metrics.orders` | `integer` |  | Attributed orders (14-day) in the window. |
| `metrics.units` | `integer` |  | Attributed units (14-day) in the window. |
| `metrics.sales` | `number` |  | Attributed sales (14-day) in the window, in the marketplace currency. |

### GET /api/v1/ad-groups

Ad groups with their metrics for a window.

Scope required: `read`

One row per ad group in the snapshot. `campaignId` narrows the read to one campaign.

Parameters:

| Parameter | In | Type |  | Meaning |
| --- | --- | --- | --- | --- |
| `profileId` | query | `string` | **required** | The Amazon advertising profile (marketplace) to read, as returned by GET /api/v1/profiles. When this marketplace is one half of a linked pair of Amazon accounts, the read covers both, exactly as the MerchDash dashboard does; every row carries the profileId it actually came from. |
| `campaignId` | query | `string` | optional | Only ad groups in this campaign. |
| `start` | query | `date` | optional | First day of the metric window, inclusive, as YYYY-MM-DD (UTC, the marketplace's own report days). Defaults to 29 days before `end`. |
| `end` | query | `date` | optional | Last day of the metric window, inclusive, as YYYY-MM-DD. Defaults to 2 days ago, because Amazon is still restating attribution for the days after that. |
| `limit` | query | `integer` | optional | Rows per page, 1–500. Defaults to 100. |
| `cursor` | query | `string` | optional | An opaque cursor from a previous response's `nextCursor`. It encodes the endpoint and the filters it was issued for: reusing it with different filters is `invalid_cursor` rather than a silently wrong page. Paging is not a snapshot — rows synced between two pages may appear. |

Response: `{ "data": [ ... ], "nextCursor": string|null }`

Order: adGroupId ascending

| Field | Type |  | Meaning |
| --- | --- | --- | --- |
| `profileId` | `string` |  | The marketplace this row belongs to. |
| `adProduct` | `enum` (`SPONSORED_PRODUCTS` \| `SPONSORED_BRANDS` \| `SPONSORED_DISPLAY`) |  | Amazon ad product. Open enum: Amazon may add products. |
| `state` | `string` | nullable | ENABLED / PAUSED / ARCHIVED as of the last sync. Null if never observed. |
| `adGroupId` | `string` |  |  |
| `campaignId` | `string` | nullable |  |
| `name` | `string` | nullable |  |
| `defaultBid` | `number` | nullable | The ad group's default bid — what a keyword or target with no bid of its own bids. |
| `metrics` | `object` |  | The six report metrics summed over the window. |
| `metrics.impressions` | `integer` |  | Impressions in the window. |
| `metrics.clicks` | `integer` |  | Clicks in the window. |
| `metrics.cost` | `number` |  | Ad spend in the window, in the marketplace currency. |
| `metrics.orders` | `integer` |  | Attributed orders (14-day) in the window. |
| `metrics.units` | `integer` |  | Attributed units (14-day) in the window. |
| `metrics.sales` | `number` |  | Attributed sales (14-day) in the window, in the marketplace currency. |

### GET /api/v1/keywords

Keywords with their metrics for a window.

Scope required: `read`

Positive keywords only. Negative keywords are not entities a caller can read in v1 — they are created through POST /api/v1/changes and shown in the review queue.

Parameters:

| Parameter | In | Type |  | Meaning |
| --- | --- | --- | --- | --- |
| `profileId` | query | `string` | **required** | The Amazon advertising profile (marketplace) to read, as returned by GET /api/v1/profiles. When this marketplace is one half of a linked pair of Amazon accounts, the read covers both, exactly as the MerchDash dashboard does; every row carries the profileId it actually came from. |
| `campaignId` | query | `string` | optional | Only keywords in this campaign. |
| `adGroupId` | query | `string` | optional | Only keywords in this ad group. |
| `start` | query | `date` | optional | First day of the metric window, inclusive, as YYYY-MM-DD (UTC, the marketplace's own report days). Defaults to 29 days before `end`. |
| `end` | query | `date` | optional | Last day of the metric window, inclusive, as YYYY-MM-DD. Defaults to 2 days ago, because Amazon is still restating attribution for the days after that. |
| `limit` | query | `integer` | optional | Rows per page, 1–500. Defaults to 100. |
| `cursor` | query | `string` | optional | An opaque cursor from a previous response's `nextCursor`. It encodes the endpoint and the filters it was issued for: reusing it with different filters is `invalid_cursor` rather than a silently wrong page. Paging is not a snapshot — rows synced between two pages may appear. |

Response: `{ "data": [ ... ], "nextCursor": string|null }`

Order: keywordId ascending

| Field | Type |  | Meaning |
| --- | --- | --- | --- |
| `profileId` | `string` |  | The marketplace this row belongs to. |
| `adProduct` | `enum` (`SPONSORED_PRODUCTS` \| `SPONSORED_BRANDS` \| `SPONSORED_DISPLAY`) |  | Amazon ad product. Open enum: Amazon may add products. |
| `state` | `string` | nullable | ENABLED / PAUSED / ARCHIVED as of the last sync. Null if never observed. |
| `keywordId` | `string` |  |  |
| `campaignId` | `string` | nullable |  |
| `adGroupId` | `string` | nullable |  |
| `keywordText` | `string` | nullable |  |
| `matchType` | `string` | nullable | EXACT / PHRASE / BROAD. |
| `bid` | `number` | nullable | The keyword's own bid. Null means it has none and bids its ad group's defaultBid. |
| `metrics` | `object` |  | The six report metrics summed over the window. |
| `metrics.impressions` | `integer` |  | Impressions in the window. |
| `metrics.clicks` | `integer` |  | Clicks in the window. |
| `metrics.cost` | `number` |  | Ad spend in the window, in the marketplace currency. |
| `metrics.orders` | `integer` |  | Attributed orders (14-day) in the window. |
| `metrics.units` | `integer` |  | Attributed units (14-day) in the window. |
| `metrics.sales` | `number` |  | Attributed sales (14-day) in the window, in the marketplace currency. |

### GET /api/v1/targets

Targeting clauses with their metrics for a window.

Scope required: `read`

Positive targeting clauses — product targets, categories, and an auto campaign's close-match / loose-match / substitutes / complements clauses. Negative targets are not readable in v1.

Parameters:

| Parameter | In | Type |  | Meaning |
| --- | --- | --- | --- | --- |
| `profileId` | query | `string` | **required** | The Amazon advertising profile (marketplace) to read, as returned by GET /api/v1/profiles. When this marketplace is one half of a linked pair of Amazon accounts, the read covers both, exactly as the MerchDash dashboard does; every row carries the profileId it actually came from. |
| `campaignId` | query | `string` | optional | Only targets in this campaign. |
| `adGroupId` | query | `string` | optional | Only targets in this ad group. |
| `start` | query | `date` | optional | First day of the metric window, inclusive, as YYYY-MM-DD (UTC, the marketplace's own report days). Defaults to 29 days before `end`. |
| `end` | query | `date` | optional | Last day of the metric window, inclusive, as YYYY-MM-DD. Defaults to 2 days ago, because Amazon is still restating attribution for the days after that. |
| `limit` | query | `integer` | optional | Rows per page, 1–500. Defaults to 100. |
| `cursor` | query | `string` | optional | An opaque cursor from a previous response's `nextCursor`. It encodes the endpoint and the filters it was issued for: reusing it with different filters is `invalid_cursor` rather than a silently wrong page. Paging is not a snapshot — rows synced between two pages may appear. |

Response: `{ "data": [ ... ], "nextCursor": string|null }`

Order: targetId ascending

| Field | Type |  | Meaning |
| --- | --- | --- | --- |
| `profileId` | `string` |  | The marketplace this row belongs to. |
| `adProduct` | `enum` (`SPONSORED_PRODUCTS` \| `SPONSORED_BRANDS` \| `SPONSORED_DISPLAY`) |  | Amazon ad product. Open enum: Amazon may add products. |
| `state` | `string` | nullable | ENABLED / PAUSED / ARCHIVED as of the last sync. Null if never observed. |
| `targetId` | `string` |  |  |
| `campaignId` | `string` | nullable |  |
| `adGroupId` | `string` | nullable |  |
| `expression` | `string` | nullable | The targeting clause as Amazon stores it. |
| `expressionType` | `string` | nullable |  |
| `bid` | `number` | nullable | Null means the clause bids its ad group's defaultBid. |
| `metrics` | `object` |  | The six report metrics summed over the window. |
| `metrics.impressions` | `integer` |  | Impressions in the window. |
| `metrics.clicks` | `integer` |  | Clicks in the window. |
| `metrics.cost` | `number` |  | Ad spend in the window, in the marketplace currency. |
| `metrics.orders` | `integer` |  | Attributed orders (14-day) in the window. |
| `metrics.units` | `integer` |  | Attributed units (14-day) in the window. |
| `metrics.sales` | `number` |  | Attributed sales (14-day) in the window, in the marketplace currency. |

### GET /api/v1/asins

Advertising performance aggregated per ASIN.

Scope required: `read`

An ASIN-LEVEL AGGREGATION, not a list of product ads. One row per advertised ASIN, summing every ad of it across every campaign and ad group in the marketplace. `ads` is how many distinct product ads contributed. To act on one ad, read it from the ad group it lives in.

Parameters:

| Parameter | In | Type |  | Meaning |
| --- | --- | --- | --- | --- |
| `profileId` | query | `string` | **required** | The Amazon advertising profile (marketplace) to read, as returned by GET /api/v1/profiles. When this marketplace is one half of a linked pair of Amazon accounts, the read covers both, exactly as the MerchDash dashboard does; every row carries the profileId it actually came from. |
| `start` | query | `date` | optional | First day of the metric window, inclusive, as YYYY-MM-DD (UTC, the marketplace's own report days). Defaults to 29 days before `end`. |
| `end` | query | `date` | optional | Last day of the metric window, inclusive, as YYYY-MM-DD. Defaults to 2 days ago, because Amazon is still restating attribution for the days after that. |
| `limit` | query | `integer` | optional | Rows per page, 1–500. Defaults to 100. |
| `cursor` | query | `string` | optional | An opaque cursor from a previous response's `nextCursor`. It encodes the endpoint and the filters it was issued for: reusing it with different filters is `invalid_cursor` rather than a silently wrong page. Paging is not a snapshot — rows synced between two pages may appear. |

Response: `{ "data": [ ... ], "nextCursor": string|null }`

Order: asin ascending

| Field | Type |  | Meaning |
| --- | --- | --- | --- |
| `profileId` | `string` |  | The marketplace you asked for. An ASIN row can span both halves of a linked pair of Amazon accounts — the ASIN is the identity here, not the profile. |
| `asin` | `string` |  |  |
| `ads` | `integer` |  | Distinct product ads of this ASIN that delivered in the window. |
| `campaigns` | `integer` |  | Distinct campaigns this ASIN delivered in during the window. |
| `metrics` | `object` |  | The six report metrics summed over the window. |
| `metrics.impressions` | `integer` |  | Impressions in the window. |
| `metrics.clicks` | `integer` |  | Clicks in the window. |
| `metrics.cost` | `number` |  | Ad spend in the window, in the marketplace currency. |
| `metrics.orders` | `integer` |  | Attributed orders (14-day) in the window. |
| `metrics.units` | `integer` |  | Attributed units (14-day) in the window. |
| `metrics.sales` | `number` |  | Attributed sales (14-day) in the window, in the marketplace currency. |

### GET /api/v1/search-terms

Shopper search terms with their metrics for a window.

Scope required: `read`

One row per (campaign, ad group, keyword, search term) as Amazon reported it. For a product-targeting campaign the "search term" may be an ASIN rather than a shopper query — that is Amazon's own shape.

Parameters:

| Parameter | In | Type |  | Meaning |
| --- | --- | --- | --- | --- |
| `profileId` | query | `string` | **required** | The Amazon advertising profile (marketplace) to read, as returned by GET /api/v1/profiles. When this marketplace is one half of a linked pair of Amazon accounts, the read covers both, exactly as the MerchDash dashboard does; every row carries the profileId it actually came from. |
| `start` | query | `date` | optional | First day of the metric window, inclusive, as YYYY-MM-DD (UTC, the marketplace's own report days). Defaults to 29 days before `end`. |
| `end` | query | `date` | optional | Last day of the metric window, inclusive, as YYYY-MM-DD. Defaults to 2 days ago, because Amazon is still restating attribution for the days after that. |
| `limit` | query | `integer` | optional | Rows per page, 1–500. Defaults to 100. |
| `cursor` | query | `string` | optional | An opaque cursor from a previous response's `nextCursor`. It encodes the endpoint and the filters it was issued for: reusing it with different filters is `invalid_cursor` rather than a silently wrong page. Paging is not a snapshot — rows synced between two pages may appear. |

Response: `{ "data": [ ... ], "nextCursor": string|null }`

Order: campaignId, adGroupId, keywordId, searchTerm — all ascending

| Field | Type |  | Meaning |
| --- | --- | --- | --- |
| `profileId` | `string` |  |  |
| `adProduct` | `string` |  |  |
| `campaignId` | `string` |  |  |
| `adGroupId` | `string` |  |  |
| `keywordId` | `string` |  | The keyword or target the term matched through. |
| `searchTerm` | `string` |  |  |
| `keyword` | `string` | nullable | The keyword text or targeting clause it matched. |
| `matchType` | `string` | nullable |  |
| `metrics` | `object` |  | The six report metrics summed over the window. |
| `metrics.impressions` | `integer` |  | Impressions in the window. |
| `metrics.clicks` | `integer` |  | Clicks in the window. |
| `metrics.cost` | `number` |  | Ad spend in the window, in the marketplace currency. |
| `metrics.orders` | `integer` |  | Attributed orders (14-day) in the window. |
| `metrics.units` | `integer` |  | Attributed units (14-day) in the window. |
| `metrics.sales` | `number` |  | Attributed sales (14-day) in the window, in the marketplace currency. |

### GET /api/v1/metrics/daily

Account totals for each day in a window.

Scope required: `read`

One row per report day, summing every campaign in the marketplace — the same daily totals the MerchDash dashboard charts. This is the endpoint to ask "what did we spend on this day"; the entity reads above answer "what did this campaign do over this window", and summing a page of them is neither cheaper nor more correct. A day the archive holds nothing for is absent rather than zero — before the first sync covered it, no spend and no data look identical, and only GET /api/v1/sync/status can tell them apart. When the marketplace is one half of a linked pair the row is the sum of both, exactly as the dashboard shows it, so a row carries no profileId of its own. There is no paging: the whole window comes back in one response, which the 365-day window cap bounds at 365 rows.

Parameters:

| Parameter | In | Type |  | Meaning |
| --- | --- | --- | --- | --- |
| `profileId` | query | `string` | **required** | The Amazon advertising profile (marketplace) to read, as returned by GET /api/v1/profiles. When this marketplace is one half of a linked pair of Amazon accounts, the read covers both, exactly as the MerchDash dashboard does; every row carries the profileId it actually came from. |
| `campaignId` | query | `string` | optional | Sum only this campaign. It narrows what each day totals; it never splits a day into several rows. |
| `adProduct` | query | `enum` (`SPONSORED_PRODUCTS` \| `SPONSORED_BRANDS` \| `SPONSORED_DISPLAY`) | optional | Sum only this Amazon ad product. |
| `start` | query | `date` | optional | First day of the metric window, inclusive, as YYYY-MM-DD (UTC, the marketplace's own report days). Defaults to 29 days before `end`. |
| `end` | query | `date` | optional | Last day of the metric window, inclusive, as YYYY-MM-DD. Defaults to 2 days ago, because Amazon is still restating attribution for the days after that. |

Response: `{ "data": [ ... ], "nextCursor": string|null }`

Order: date ascending

| Field | Type |  | Meaning |
| --- | --- | --- | --- |
| `date` | `string` |  | The report day, YYYY-MM-DD, in the marketplace's own report calendar. |
| `metrics` | `object` |  | The six report metrics summed over that one day. |
| `metrics.impressions` | `integer` |  | Impressions in the window. |
| `metrics.clicks` | `integer` |  | Clicks in the window. |
| `metrics.cost` | `number` |  | Ad spend in the window, in the marketplace currency. |
| `metrics.orders` | `integer` |  | Attributed orders (14-day) in the window. |
| `metrics.units` | `integer` |  | Attributed units (14-day) in the window. |
| `metrics.sales` | `number` |  | Attributed sales (14-day) in the window, in the marketplace currency. |

### GET /api/v1/sync/status

How current the synced archive is for a marketplace.

Scope required: `read`

The freshness of the data every read above answers from, and whether the first backfill has finished — proposals are refused with `sync_incomplete` until it has.

Parameters:

| Parameter | In | Type |  | Meaning |
| --- | --- | --- | --- | --- |
| `profileId` | query | `string` | **required** | The Amazon advertising profile (marketplace) to read, as returned by GET /api/v1/profiles. When this marketplace is one half of a linked pair of Amazon accounts, the read covers both, exactly as the MerchDash dashboard does; every row carries the profileId it actually came from. |

Response: `{ "data": { ... } }`

| Field | Type |  | Meaning |
| --- | --- | --- | --- |
| `profileId` | `string` |  |  |
| `lastSyncAt` | `timestamp` | nullable | When a sync last completed. |
| `initialSyncComplete` | `boolean` |  | False while the first backfill is still running. Proposals are refused until it is true. |
| `syncing` | `boolean` |  | True while a sync is running or queued right now. |
| `firstSyncedDate` | `string` | nullable | Oldest report day held, YYYY-MM-DD. |
| `lastSyncedDate` | `string` | nullable | Newest report day held, YYYY-MM-DD. |
| `syncedDays` | `integer` |  | How many report days are held. |
| `campaigns` | `integer` |  | Campaigns in the snapshot. |

### GET /api/v1/runs

Automation runs for a marketplace, newest activity first.

Scope required: `read`

Every run MerchDash recorded for the marketplace — proposals made through this API and runs made by the account's own automation rules alike. `trigger` says which.

Parameters:

| Parameter | In | Type |  | Meaning |
| --- | --- | --- | --- | --- |
| `profileId` | query | `string` | **required** | The Amazon advertising profile (marketplace) to read, as returned by GET /api/v1/profiles. When this marketplace is one half of a linked pair of Amazon accounts, the read covers both, exactly as the MerchDash dashboard does; every row carries the profileId it actually came from. |
| `status` | query | `enum` (`preview` \| `running` \| `applied` \| `partial` \| `failed` \| `reverted` \| `discarded`) | optional | Only runs in this public status. Omit for all. Internal bookkeeping states are never accepted or returned. |
| `limit` | query | `integer` | optional | Rows per page, 1–500. Defaults to 100. |
| `cursor` | query | `string` | optional | An opaque cursor from a previous response's `nextCursor`. It encodes the endpoint and the filters it was issued for: reusing it with different filters is `invalid_cursor` rather than a silently wrong page. Paging is not a snapshot — rows synced between two pages may appear. |

Response: `{ "data": [ ... ], "nextCursor": string|null }`

Order: most recent activity first, then runId — a review enters the list when it is approved, not when it was proposed

| Field | Type |  | Meaning |
| --- | --- | --- | --- |
| `runId` | `string` |  |  |
| `profileId` | `string` |  |  |
| `status` | `enum` (`preview` \| `running` \| `applied` \| `partial` \| `failed` \| `reverted` \| `discarded`) |  | Closed enum. |
| `trigger` | `string` |  | api, manual, schedule, … — what started the run. |
| `mode` | `string` | nullable | `review` waits for a human; `auto` applies by itself. |
| `label` | `string` | nullable | The batch label for an API proposal, or the rule name for a rule run. |
| `ruleId` | `string` | nullable | Null for an API proposal — it has no rule. |
| `apiKeyId` | `string` | nullable | Which key proposed it. Survives that key being revoked. |
| `startedAt` | `timestamp` | nullable |  |
| `finishedAt` | `timestamp` | nullable |  |
| `counts` | `object` |  | How the run's own changes stand. Revert records are not counted. |
| `counts.proposed` | `integer` |  | Still awaiting approval. |
| `counts.applied` | `integer` |  |  |
| `counts.failed` | `integer` |  |  |
| `counts.reverted` | `integer` |  |  |
| `counts.discarded` | `integer` |  |  |

### GET /api/v1/runs/:runId

One run — the polling target for the runId a proposal returned.

Scope required: `read`

The same object the list returns. An unknown id and a run belonging to another account answer identically with 404 `not_found`: telling them apart would describe somebody else's account.

Parameters:

| Parameter | In | Type |  | Meaning |
| --- | --- | --- | --- | --- |
| `runId` | path | `string` | **required** | The run id, as returned by POST /api/v1/changes. |

Response: `{ "data": { ... } }`

Fields: same as runs.list

### GET /api/v1/runs/:runId/changes

The individual changes a run proposed, and what became of each.

Scope required: `read`

One row per change the run itself proposed. The revert records a later revert writes are excluded: they belong to the revert, not to this run, and `status: reverted` on the original already says it happened.

Parameters:

| Parameter | In | Type |  | Meaning |
| --- | --- | --- | --- | --- |
| `runId` | path | `string` | **required** |  |
| `limit` | query | `integer` | optional | Rows per page, 1–500. Defaults to 100. |
| `cursor` | query | `string` | optional | An opaque cursor from a previous response's `nextCursor`. It encodes the endpoint and the filters it was issued for: reusing it with different filters is `invalid_cursor` rather than a silently wrong page. Paging is not a snapshot — rows synced between two pages may appear. |

Response: `{ "data": [ ... ], "nextCursor": string|null }`

Order: changeId ascending

| Field | Type |  | Meaning |
| --- | --- | --- | --- |
| `changeId` | `string` |  |  |
| `runId` | `string` |  |  |
| `status` | `enum` (`proposed` \| `applied` \| `failed` \| `reverted` \| `discarded`) |  | Closed enum. |
| `entityType` | `string` | nullable | campaign / adGroup / keyword / target / productAd / negativeKeyword / negativeTarget. |
| `entityId` | `string` | nullable | Null for a change that creates something — there is no id until it is applied. |
| `entityName` | `string` | nullable |  |
| `campaignId` | `string` | nullable |  |
| `adGroupId` | `string` | nullable |  |
| `field` | `string` | nullable | What the change alters: bid, state, negative, … |
| `beforeVal` | `string` | nullable | The value MerchDash read before proposing. Null for a create. |
| `afterVal` | `string` | nullable | The value the change writes. |
| `reason` | `string` | nullable | The caller's own note, shown to the human reviewing it. |
| `error` | `string` | nullable | Why this change failed, when it did. |
| `appliedAt` | `timestamp` | nullable |  |
| `createdAt` | `timestamp` | nullable |  |

### POST /api/v1/changes

Propose a batch of bid, state and negative changes — for a human to approve, or to apply at once.

Scope required: `write`

Writes a run and returns immediately; the writing to Amazon, when there is any, happens in the background. On a review key — the default, and what every key is until its owner says otherwise — nothing reaches Amazon here at all: a signed-in human applies or turns down the proposal in MerchDash's review queue. On an auto-apply key, or with `"applyMode": "auto"`, MerchDash applies the batch itself as soon as it is accepted. Either way an API key can watch the outcome through GET /api/v1/runs/:runId and can never approve, discard or revert a run itself.

Headers:

| Header |  | Meaning |
| --- | --- | --- |
| `Content-Type` | **required** | Must be application/json. |
| `Idempotency-Key` | **required** | 1–128 visible ASCII characters, unique per logical request. A retry with the same key and the same body returns the first call's response and creates nothing; the same key with a different body is 409 `idempotency_conflict`. Keys are remembered for 24 hours. |

Request body:

| Field | Type |  | Meaning |
| --- | --- | --- | --- |
| `profileId` | `string` | **required** | Every change in the batch must belong to this marketplace. A mixed batch is refused whole. |
| `label` | `string` | **required** | 1–80 characters. What the review card is titled — the human deciding sees this and nothing else about who sent it. |
| `changes` | `array` | **required** | 1–500 change objects, discriminated on `operation`. `set_auto_clause_bids` expands into several changes each, and a request whose expansion exceeds 500 is refused whole rather than truncated. |
| `applyMode` | `enum` (`review` \| `auto`) | optional | What happens to the proposal. `review` queues it for a human, which is what a key does by default. `auto` writes it to Amazon as soon as it is accepted, with nobody in the loop, and is only allowed on a key whose owner has turned auto-apply on for it — on any other key it is 403 `scope`. Omit it to use whatever the key is set to. A key that CAN auto-apply may still send `review` for a batch that wants a second pair of eyes; the reverse is never possible. |

`operation: "set_bid"` — Set a keyword's or target's bid.

| Field | Type |  | Meaning |
| --- | --- | --- | --- |
| `entityType` | `enum` (`keyword` \| `target`) | **required** |  |
| `entityId` | `string` | **required** | Must exist in this marketplace's snapshot. |
| `bid` | `number` | **required** | In the marketplace currency. $0.02–$1,000 ($0.25 minimum on Sponsored Brands). A bid above the account's own maximum-bid setting is written at that maximum and flagged as adjusted, exactly as an automation rule's would be. |
| `reason` | `string` | optional | Up to 200 characters, shown to the reviewer. Display only. |

`operation: "set_state"` — Enable or pause an entity.

| Field | Type |  | Meaning |
| --- | --- | --- | --- |
| `entityType` | `enum` (`campaign` \| `adGroup` \| `keyword` \| `target` \| `productAd`) | **required** |  |
| `entityId` | `string` | **required** |  |
| `state` | `enum` (`ENABLED` \| `PAUSED`) | **required** | ARCHIVED is not offered: archiving is irreversible on Amazon and cannot be undone by a revert. |
| `reason` | `string` | optional |  |

`operation: "set_auto_clause_bids"` — Set the bids of an auto campaign's four predefined targeting clauses — the same action as Actions → Adjust Auto Targeting Bid in the dashboard. This is the only operation that reads from Amazon while it compiles: an auto campaign's clauses appear in MerchDash's own snapshot only once they have delivered, so the clauses are enumerated live and the request expands into one target-bid change per clause it actually alters. Expect it to be slower than the other operations, and to answer 503 `temporarily_unavailable` when Amazon cannot be reached — no proposal is created in that case.

| Field | Type |  | Meaning |
| --- | --- | --- | --- |
| `campaignId` | `string` | **required** | An auto-targeting Sponsored Products campaign. Any other campaign is refused: nothing else has these clauses. |
| `bids` | `object` | **required** | One bid per clause, keyed `close-match`, `loose-match`, `substitutes`, `complements`. A clause you omit — or set to `null` — is left alone. Same range and the same maximum-bid clamp as `set_bid`. |
| `adGroupIds` | `array` | optional | Narrows the fill to these ad groups of the campaign, at most 125 of them. Omit it to fill every ad group in the campaign. An empty array is refused rather than read as "all". |
| `emptyOnly` | `boolean` | optional | Default `true`: only clauses with no bid of their own — the ones inheriting the ad group default — are filled. `false` overwrites existing clause bids too. |
| `reason` | `string` | optional | Shown to the reviewer on every change this expands into. Omit it and each change explains itself (what the clause was bidding, or which default it was inheriting). |

`operation: "add_negative_keyword"` — Add a negative keyword at campaign or ad-group scope.

| Field | Type |  | Meaning |
| --- | --- | --- | --- |
| `campaignId` | `string` | **required** |  |
| `adGroupId` | `string` | optional | Omit for a campaign-wide negative. Present means ad-group scope. |
| `keywordText` | `string` | **required** | 1–80 characters. |
| `matchType` | `enum` (`NEGATIVE_EXACT` \| `NEGATIVE_PHRASE`) | **required** |  |
| `reason` | `string` | optional |  |

`operation: "add_negative_target"` — Add a negative product target (an ASIN) to an ad group.

| Field | Type |  | Meaning |
| --- | --- | --- | --- |
| `campaignId` | `string` | **required** |  |
| `adGroupId` | `string` | **required** | Required — Amazon has no campaign-scoped negative product target. |
| `asin` | `string` | **required** | A 10-character ASIN. |
| `reason` | `string` | optional |  |

Unknown fields are refused, not ignored. Any field not named above is refused rather than ignored — including the internal fields of a stored change (apiCall, beforeVal, afterVal, status). The server derives all of those from its own snapshot, so a caller can never dictate what MerchDash will send to Amazon.

Response: `{ "data": { ... } }`

| Field | Type |  | Meaning |
| --- | --- | --- | --- |
| `runId` | `string` |  | Poll GET /api/v1/runs/:runId for what a human decides. |
| `count` | `integer` |  | How many changes the run holds. |
| `applyMode` | `enum` (`review` \| `auto`) |  | What this run will actually do — what the key allows, narrowed by whatever the request asked for. `review` means a human decides; `auto` means MerchDash is already writing it. |
| `status` | `enum` (`preview` \| `running` \| `applied` \| `partial` \| `failed` \| `reverted` \| `discarded`) |  | The run's status at the instant it was created, which is `preview` either way — an auto-applied run leaves it within seconds, on its own. Branch on `applyMode`, not on this: with `applyMode: "auto"` never tell a user their changes are waiting for approval. Poll GET /api/v1/runs/:runId for what happened. |

### POST /api/v1/structures

Propose new campaigns, ad groups, product ads, keywords and targeting clauses for a human to approve.

Scope required: `write`

Takes a nested graph and turns it into durable creation intents plus one run. On a review key — the default — nothing reaches Amazon here: a signed-in human applies the proposal in MerchDash, and only then is the structure built, parents before children. On an auto-apply key, or with `"applyMode": "auto"`, MerchDash starts building it immediately, in that same dependency order. Because a create cannot be made idempotent by value — the id does not exist yet — each container is RESERVED under a stable identity at proposal time. A retry, a second proposal, or a nightly automation rule asking for the same campaign in the same marketplace lands on that same reservation instead of building a second one.

Headers:

| Header |  | Meaning |
| --- | --- | --- |
| `Content-Type` | **required** | Must be application/json. |
| `Idempotency-Key` | **required** | 1–128 visible ASCII characters, unique per logical request. A retry with the same key and the same body returns the first call's response and creates nothing; the same key with a different body is 409 `idempotency_conflict`. Keys are remembered for 24 hours. |

Refusals — this endpoint will not do these, so do not build on them:

- Sponsored Products only. An SB ad group needs a creative and a landing page and an SD one a tactic, neither of which this API can supply, so a node naming a Sponsored Brands or Sponsored Display campaign is refused.
- Auto-targeting campaigns take no keywords and no positive targeting clauses. Amazon creates their close match, loose match, substitutes and complements clauses with the ad group and lets nothing be added to them; only negatives are accepted there, through POST /api/v1/changes.
- Created entities start ENABLED unless a node says `"state": "PAUSED"`, and campaigns are created with no start date, so a proposal begins spending when the human applies it.
- A campaign or ad group that already exists — named by id, or matched by the name you gave — is added to, never modified: its budget, bid, targeting and state are not changed here. `alreadyPresent` reports the matched container and its id; it does not echo settings that were ignored.
- At most 2000 campaigns and ad groups, and 20000 ASINs, keywords and targeting clauses, per request.

Request body:

| Field | Type |  | Meaning |
| --- | --- | --- | --- |
| `profileId` | `string` | **required** | Every node in the graph belongs to this marketplace. |
| `label` | `string` | **required** | 1–80 characters. What the review card is titled. |
| `campaigns` | `array` | optional | Campaigns to create, or existing campaigns to add ad groups to. Each entry names EITHER `name` OR `campaignId`, never both. A `name` the marketplace already has is ADDED TO, not created again — the same get-or-create an automation rule performs — so sending the same request twice builds one campaign. |
| `adGroups` | `array` | optional | The single-shot form: ad groups that ALREADY exist, named by `adGroupId`, to add ASINs, keywords or targeting clauses to. This is how a script adds product ads — there is no stage-then-commit step, unlike the dashboard's paste box, and unlike that path nothing is written until a human approves it. |
| `applyMode` | `enum` (`review` \| `auto`) | optional | What happens to the proposal. `review` queues it for a human, which is what a key does by default. `auto` builds the structure on Amazon as soon as the request is accepted, and is only allowed on a key whose owner has turned auto-apply on for it — on any other key it is 403 `scope`. Omit it to use whatever the key is set to. Worth a thought here in particular: a campaign or an ad group Amazon has created cannot be un-created by a revert. |

Node `campaign (new)`:

| Field | Type |  | Meaning |
| --- | --- | --- | --- |
| `name` | `string` | **required** | 1–128 characters. Compared case-insensitively and whitespace-collapsed, exactly as Amazon collides names. A campaign of this name that already exists is used as-is; its targeting type must match the one asked for, since Amazon cannot change that after creation. |
| `budget` | `number` | **required** | Daily budget in the marketplace currency. Below Amazon's floor of 1 it is raised to it; above the account's creation ceiling it is lowered to that and reported in `adjustments`. |
| `targeting` | `enum` (`manual` \| `auto`) | **required** | Amazon cannot change a campaign's targeting type after it is created. |
| `state` | `enum` (`ENABLED` \| `PAUSED`) | optional | Defaults to ENABLED. |
| `biddingStrategy` | `string` | optional | Amazon's value or the Campaign Manager label — "DYNAMIC BIDS - UP AND DOWN", "DYNAMIC BIDS - DOWN ONLY" (the default), or "FIXED BIDS". |
| `portfolio` | `string` | optional | A portfolio name. Created if the account does not have one, and the campaign waits for it. |
| `adGroups` | `array` | optional | Ad groups to get-or-create inside it by `name`. An existing `adGroupId` may also be used when the campaign name resolves to an existing campaign; if the campaign itself has to be created, its children must use `name`. |

Node `campaign (existing)`:

| Field | Type |  | Meaning |
| --- | --- | --- | --- |
| `campaignId` | `string` | **required** | Must exist in this marketplace and be Sponsored Products. |
| `adGroups` | `array` | **required** | At least one ad group to create in it, or to add leaves to. |

Node `adGroup (new)`:

| Field | Type |  | Meaning |
| --- | --- | --- | --- |
| `name` | `string` | **required** | 1–128 characters. Unique within its campaign — "Core terms" in two campaigns is two ad groups. One the campaign already holds is added to rather than created again, and then `defaultBid` is not needed. |
| `defaultBid` | `number` | optional | The bid a keyword or clause with no bid of its own uses. $0.02–$1,000, and the account's own maximum-bid setting applies. Required only when the ad group has to be created — an ad group of this name that the campaign already holds keeps its own default bid. |
| `state` | `enum` (`ENABLED` \| `PAUSED`) | optional | Defaults to ENABLED. |
| `asins` | `array` | optional | ASINs to advertise, as 10-character strings. |
| `keywords` | `array` | optional |  |
| `targets` | `array` | optional |  |

Node `adGroup (existing)`:

| Field | Type |  | Meaning |
| --- | --- | --- | --- |
| `adGroupId` | `string` | **required** | Must exist in this marketplace. At least one of asins/keywords/targets is required — an existing ad group's own settings are not changed here. |
| `asins` | `array` | optional |  |
| `keywords` | `array` | optional |  |
| `targets` | `array` | optional |  |

Node `keyword`:

| Field | Type |  | Meaning |
| --- | --- | --- | --- |
| `text` | `string` | **required** | 1–80 characters. An ASIN is refused: bought as keyword text it is a literal string no shopper types, so it costs a write and never serves. Send it under `targets`. |
| `matchType` | `enum` (`EXACT` \| `PHRASE` \| `BROAD`) | **required** |  |
| `bid` | `number` | optional | Omit to follow the ad group's default bid. |
| `state` | `enum` (`ENABLED` \| `PAUSED`) | optional |  |
| `reason` | `string` | optional | Up to 200 characters, shown to the reviewer. Display only. |

Node `target`:

| Field | Type |  | Meaning |
| --- | --- | --- | --- |
| `asin` | `string` | optional | A product target. Exactly one of asin or category. |
| `category` | `string` | optional | An Amazon category id. Exactly one of asin or category. |
| `bid` | `number` | optional |  |
| `state` | `enum` (`ENABLED` \| `PAUSED`) | optional |  |
| `reason` | `string` | optional |  |

Unknown fields are refused, not ignored. Any field not named above is refused rather than ignored, including every internal field of a stored change and of a creation intent (apiCall, desiredKey, specHash, intentId). The server derives all of those, so a caller can never dictate what MerchDash will send to Amazon or what identity it will reserve under.

Response: `{ "data": { ... } }`

| Field | Type |  | Meaning |
| --- | --- | --- | --- |
| `runId` | `string` |  | Poll GET /api/v1/runs/:runId for what a human decides. |
| `count` | `integer` |  | How many change rows the run holds. Not the number of entities: ASINs and targeting clauses are batched one row per ad group. |
| `intents` | `integer` |  | How many containers were reserved — portfolios, campaigns and ad groups. |
| `applyMode` | `enum` (`review` \| `auto`) |  | What this run will actually do — what the key allows, narrowed by whatever the request asked for. `review` means a human decides; `auto` means MerchDash is already building it. |
| `status` | `enum` (`preview` \| `running` \| `applied` \| `partial` \| `failed` \| `reverted` \| `discarded`) |  | The run's status at the instant it was created, which is `preview` either way — an auto-applied run leaves it within seconds, on its own. Branch on `applyMode`, not on this. Poll GET /api/v1/runs/:runId for what happened. |
| `alreadyPresent` | `array` | nullable | Up to the first 50 things the marketplace snapshot already held, so they were added to rather than proposed again. Absent when there were none. Each entry is `{ kind, value }` plus the id it resolved to — `adGroupId` for `asin`, `keyword`, `target` and `adGroup`, `campaignId` for `campaign`, or `portfolioId` for `portfolio`. |
| `alreadyPresentCount` | `integer` | nullable | Total number of already-present containers and leaves, including entries omitted from the bounded `alreadyPresent` sample. Absent when there were none. |
| `alreadyPresentTruncated` | `boolean` | nullable | True when `alreadyPresentCount` is larger than the returned `alreadyPresent` sample. Absent when there were no already-present items. |
| `adjustments` | `array` | nullable | Values an account or Amazon ceiling moved, with the number that will actually be used. Absent when there were none. Each entry is `{ path, field, note }`. |

