Developers
Public API
Read everything MerchDash has synced and propose changes — queued for your review, or applied on the spot — from a script, a workflow, or your own tooling.
MerchDash’s public API lets a script, an n8n or Make workflow, or your own tooling read everything MerchDash has synced from Amazon and propose changes to it. It lives under /api/v1/, speaks JSON over HTTPS, and is authenticated with an API key you mint yourself.
The full machine-level detail — every parameter, every response field, every error code — is in the endpoint reference. This page is how to get going.
curl -sL <this site>/docs/api.md.Who can use it
API access is included in Growth and Scale, and in the free trial. It is not part of Starter. See Plans & billing.
Access is checked on every request, not at mint time. If an account is downgraded to Starter its keys stop authenticating — they answer 403 with the code plan — but they are not deleted. Upgrading again brings the same keys back, so nobody has to re-key their scripts to recover.
Create a key
In MerchDash, open Profile → Account and find the API keys card. Give the key a name that says where it runs — “n8n production”, “nightly harvest script” — and choose its scope:
| Scope | What it can do |
|---|---|
read | Every GET under /api/v1/. Cannot propose anything. |
read + write | The reads, plus POST /api/v1/changes and POST /api/v1/structures. Cannot apply, discard or revert a run that already exists — though a key set to auto-apply applies its own proposals as it makes them. |
The key is shown once, at creation, and stored only as a hash — MerchDash cannot show it to you again and cannot recover it. Copy it into your secret store there and then. If you lose it, revoke it and mint another.
Give each integration its own key. They are rate limited separately, the run history records which key proposed what, and revoking one does not disturb the others.
Auto-apply or review
Every write-capable key is one of two things, and you decide which in the same API keys card:
| Setting | What a proposal does |
|---|---|
| Review (default) | Lands in your review queue as a card. Nothing reaches Amazon until you press Apply. |
| Apply without review | MerchDash writes it to Amazon as soon as it is accepted, and the run appears in History already applied. |
You can switch a key between the two at any time — the integration using it does not need a new credential. The setting is per key, so the same account can run a trusted nightly bid script on auto-apply while a newer workflow still proposes for review.
A request may ask for less than its key allows, never more. Send "applyMode": "review" from an auto-apply key when a particular batch deserves a second pair of eyes; send "applyMode": "auto" from a key that does not have it and you get 403 with the code scope. The response tells you which mode actually took effect:
Branch on applyMode, not on status: a run is preview for the instant it is created whichever mode it is in, and an auto-applied one leaves that within seconds. Never tell someone their changes are waiting for approval when applyMode says auto.
Authenticate
Send the key as a bearer token. There is no other accepted form — a key in a query string would end up in access logs, proxies and browser history, so MerchDash never reads one from there.
Only the curated /api/v1/ endpoints accept a key. Every other route in MerchDash — the dashboard’s own endpoints, the admin routes, key management itself — answers 404 to a key, whether or not it exists. That is a structural barrier rather than a convention: a route has to opt in before a key can reach it.
There is no CORS in v1. The API is server-to-server; a browser cannot call it directly, and a key belongs in a backend or a workflow runner, never in a page.
Read your account
Start with GET /api/v1/profiles, which lists every marketplace you have synced. Its profileId values are what every other endpoint takes.
Every collection answers with the same envelope, so one pagination helper covers all of them:
Page by passing that value back as cursor until it comes back null. Cursors are opaque and signed: they carry the endpoint and the filters they were issued for, so reusing one against a different query is refused with invalid_cursor rather than silently answering the wrong page. Paging is not a snapshot — rows synced between two pages can appear — so a job that must see a consistent set should read it in one window and compare against its own copy.
Which numbers you get
Reads are answered from MerchDash’s synced archive, never live from Amazon: a read costs you none of your Amazon rate limit and never waits on Amazon’s latency. It is therefore exactly as fresh as your last sync, and GET /api/v1/sync/status tells you how fresh that is.
A day’s totals
The reads above are per entity: one row per campaign, keyword or ASIN, each summed over the window you asked for. When the question is about a day rather than an entity — what did we spend yesterday, which day did ACOS spike — use GET /api/v1/metrics/daily instead of paging the campaigns and adding them up. It returns one row per report day, totalled across the whole marketplace, which is the same figure the dashboard charts.
There is no paging here — the 365-day window cap already bounds the answer. campaignId and adProduct narrow what each day sums without splitting it into more rows, and a single day is simply start and end set to the same date. A day the archive holds nothing for is left out rather than returned as zero: before a sync has covered a day, “no spend” and “no data” look identical, and only GET /api/v1/sync/status can tell you which one it is.
Metric windows default to the last 30 days ending two days ago, because Amazon is still restating attribution for the days after that — see Attribution & settled data. Only the six raw report metrics are published (impressions, clicks, cost, orders, units, sales); ACOS, ROAS, CTR, CVR and CPC are one division each and are yours to compute, which keeps five rounding decisions out of a contract that can never change.
Propose a change
POST /api/v1/changes proposes bid changes, pauses and enables, negative keywords or targets, and an auto campaign’s clause bids. It writes a run and returns immediately — queued for your review, or applied straight away if the key is set to auto-apply.
You get back a runId and an applyMode. Poll GET /api/v1/runs/{runId} for the outcome — what the human decided, or how the writes landed — and GET /api/v1/runs/{runId}/changes for what became of each individual change.
The batch is all-or-nothing: if one change fails validation, no run and no changes are written at all. The server reads the current bid, the entity’s name and its hierarchy from its own snapshot and builds the Amazon call itself — a caller supplies intent, never the internal fields of a stored change. Your optional reason is shown to whoever reviews it, which is the difference between an approved proposal and a puzzled one.
review_queue_full until a human works through some. A queue nobody can get to the bottom of is not review. An auto-applied proposal normally leaves the queue immediately, but still needs one free slot when accepted so a failed apply can fall back safely to human review. Its own stricter rate limit separately bounds automatic throughput.Fill an auto campaign’s clause bids
An auto-targeting campaign bids through four predefined clauses — close match, loose match, substitutes, complements — and each ad group has its own four. Naming every one of them as a set_bid would mean knowing their target ids, and most of them have none you can look up: a clause that has never delivered is not in the archive the reads answer from. So there is one operation that does the whole campaign, the same way Actions → Adjust Auto Targeting Bid does in the dashboard.
A clause you leave out of bids is left alone. emptyOnly defaults to true, which fills only the clauses that have no bid of their own — the ones inheriting the ad group default; send false to overwrite existing clause bids too. Add adGroupIds to narrow the fill to part of the campaign.
This is the one operation that reads from Amazon while it compiles, because that is the only place the full clause list exists. Three consequences to build around: it is slower than the other operations; if Amazon cannot be reached you get 503 temporarily_unavailable and no proposal is created, so the request is safe to retry under the same Idempotency-Key; and it expands into one change per clause it alters, so a campaign whose expansion would exceed the 500-change batch is refused whole rather than half-filled — narrow it with adGroupIds. A request where every clause already carries the bid you asked for is refused too, rather than queuing an empty run for somebody to look at.
What comes back is ordinary changes: each one a target bid, capped by your maximum bids for that clause, reviewable and revertible like any other.
Propose new campaigns and ad groups
POST /api/v1/structures takes a nested graph — campaigns holding ad groups holding ASINs, keywords and targeting clauses — and proposes the whole thing as one review. When the human applies it, MerchDash builds it on Amazon parents before children.
Name an existing container by id (campaignId, adGroupId) instead of by name to add to it. Adding ASINs to an ad group that already exists is the single-shot form — and unlike the dashboard’s paste box, which writes to Amazon straight away, this one waits for review.
You do not have to know which containers exist. A name the marketplace already has is added to, not created again — the same get-or-create your automation rules perform — so you can send the same graph every night and only the genuinely new parts are proposed. What was already there comes back in alreadyPresent: containers with the id they resolved to, leaves with the ad group that already held them. The response also carries alreadyPresentCount and alreadyPresentTruncated, because the detailed list is capped at 50 entries. An existing container is never modified, so the budget, defaultBid or state you sent for one is ignored rather than applied; the matched container and id are what the response reports. defaultBid is only required for an ad group that has to be created. The one setting that is enforced instead of ignored is a campaign’s targeting: Amazon cannot change it after creation, so asking for manual when the campaign is auto is refused rather than silently adopted.
A create cannot be made idempotent by value, because the id does not exist yet. So every campaign and ad group is reserved under a stable identity the moment you propose it. A retry, a second proposal, or one of your own automation rules asking for the same campaign in the same marketplace joins that reservation instead of building a second campaign. If another un-answered proposal already holds the container you asked for, you get 409 creation_conflict naming that run: apply it or turn it down, and the reservation resolves.
Two constraints worth knowing before you write the client: this endpoint is Sponsored Products only, and auto-targeting campaigns take no keywords or positive clauses. The reference lists the rest.
Structures are proposals like any other, but be aware of one asymmetry when you review one: bids, states and negatives can be reverted, while a campaign or ad group that Amazon has already created stays created. MerchDash says so on the review card.
Retries and idempotency
Both proposal endpoints require an Idempotency-Key header: 1–128 visible ASCII characters, unique per logical request. Over HTTP a retry is not an edge case, it is the normal failure mode — a timeout tells you nothing about whether the server acted.
| You send | You get |
|---|---|
| The same key and the same body | The first call’s exact response. Nothing is created a second time. |
| The same key, a different body | 409 idempotency_conflict. |
| The same key after 24 hours | A fresh request — keys are remembered for 24 hours. |
Derive the key from the job, not from the clock: waste-sweep-1234567890-2026-09-05 is retryable, uuid() generated fresh on each attempt is not. A retry still replays its first answer even if the human has since turned the proposal down — the run stays readable at that id with the status discarded, so a retry never resurrects work somebody already declined.
Rate limits
Two buckets are checked on every request, and both must allow it:
| Bucket | Reads | Proposals | Of those, auto-applied |
|---|---|---|---|
| Per key | 120 / minute | 30 / minute | 5 / minute |
| Per account, across all its keys | 300 / minute | 60 / minute | 10 / minute |
The account bucket is why minting more keys does not buy more throughput; the per-key bucket is why one busy integration cannot starve another. Each per-key/account pair is charged atomically, so a refusal by either member consumes neither. An auto-apply request first spends the ordinary proposal pair and then its stricter auto-apply pair; if that additional pair refuses it, 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.
A proposal that will be auto-applied draws on the third column in addition to the second. That number is small on purpose: it is the ceiling on how much a script can change on Amazon without anyone looking.
Exhausting one is 429 with the code rate_limited and a Retry-After header saying how long to wait. Honour it — retrying sooner just spends the next window early.
Errors and what to retry
Every v1 error carries a machine-readable code alongside its human message, including the ones raised before your request reaches a handler — an unknown route, a malformed body, a failed key lookup, an exhausted bucket:
Switch on code, never on the message text: the wording can be improved, the code cannot change. Validation failures may add a bounded details array naming each problem. The full list is in the reference; the short version for a client author:
| Codes | What your 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 somebody else’s account. Telling those apart would describe another customer’s account to you.
What the API deliberately cannot do
- Apply, discard or revert a run that already exists. That is a signed-in human in MerchDash, always. Auto-apply settles what happens to a proposal as it is made, and nothing else.
- Turn auto-apply on. A key cannot grant itself the right to skip your review — that switch lives in your account, next to the key.
- Reach anything outside
/api/v1/. The dashboard’s own endpoints are private implementation details and stay free to change; admin routes and key management are barred outright. - Manage keys. A key cannot mint, list or revoke keys — including itself.
- Disconnect Amazon, delete the account, or run the AI Copilot. None of these is in any scope, at any tier.
- Create Sponsored Brands or Sponsored Display structures. An SB ad group needs a creative and a landing page and an SD one a tactic; neither is expressible here, so both are refused rather than half-built.
Webhooks and browser-side (CORS) access are not in v1 either. Poll GET /api/v1/runs/{runId} for now.
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 field on a response, a new value in an enum the reference marks as open — so write a client that ignores fields it does not recognise. Run statuses are a closed enum: nothing is ever added to them.