Developers

Public API

Read everything MerchDash has synced, propose changes, queued for your review, or applied on the spot, and manage and run your automation rules, 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.

Review by default. A new key proposes; it does not write. Everything it proposes lands in the same review queue your automation rules use, and a signed-in human presses Apply. That is the default because it is the safe one: a runaway script costs you a full review queue, never four hundred ad groups at 3 a.m. If you want a key to write without you, you can turn auto-apply on for it, deliberately a decision you make in MerchDash, never one a request can make for itself.

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.

Building this with an AI? Download the whole contract as one Markdown file and hand it to your assistant along with “write me a client for this”. It is generated from the same source as these pages, front-loads the rules that change how a client is built, and is also linked from the API keys card in your account. You can fetch it from a script too: curl -sL <this site>/docs/api.md. Every copy names its version on the second line, and GET /docs/api.md/version returns the current one as JSON ({"version": "…"}, no key needed), if they differ, your copy is out of date and worth downloading again.

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 → API / MCP 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:

ScopeWhat it can do
readEvery GET under /api/v1/, and the POST live reads that only ask Amazon (bid, keyword and category recommendations, negative-brand search, eligibility). Cannot propose anything.
read + writeThe reads, plus POST /api/v1/changes and POST /api/v1/structures, and uploading videos and logos. Cannot apply or discard a run that already exists, though a key set to auto-apply applies its own proposals as it makes them.
read + write + automationAll of the above, plus managing your automation rules: create, edit, run and delete rules and categories, and apply or discard any pending review. In the card this is Manage rules & approve, and you can switch it on or off for an existing key.

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.

Up to 10 active keys per accountRevoking is instantRevoked keys stay listed until you delete them

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.

Limit a key to some marketplaces. If you have more than one marketplace, untick the others in the All marketplaces dropdown when you create the key. A key limited to some sees only those: GET /api/v1/profiles lists just them, and any request for another marketplace, or for a run or rule that belongs to one, answers 404 not_found exactly as if it did not exist. It is meant for an agency giving each client’s tool its own key. You can change it later from the same dropdown in the key’s row. The new limit applies from the key’s next request. Ticking every marketplace removes the limit, so the key also reaches marketplaces you connect later.

Auto-apply or review

Every write-capable key is one of two things, and you decide which in the same API keys card:

SettingWhat a proposal does
Review (default)Lands in your review queue as a card. Nothing reaches Amazon until you press Apply.
Apply without reviewMerchDash 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:

{ "data": { "runId": "run_8f2c…", "count": 2, "applyMode": "auto", "status": "preview" } }

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.

Auto-apply is rate limited far harder. A proposal that waits for you is bounded by your attention; one that skips you is bounded by nothing else, so auto-applied proposals draw on their own small bucket on top of the ordinary one, see Rate limits. Two more things worth knowing before you turn it on: bids, states and negatives can be reverted from History afterwards, and a campaign or ad group Amazon has already created cannot.

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.

Authorization: Bearer md_live_…

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.

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

Every collection answers with the same envelope, so one pagination helper covers all of them:

{ "data": [ … ], "nextCursor": "eyJ2Ijox…" }

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: 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; POST /api/v1/sync/start asks for a fresh one (at most every 15 minutes per marketplace, and it spends one request of your write budget). The exceptions are the live Amazon reads, recommendations, the negative-brand search and eligibility, which ask Amazon at request time and also spend one request of your write budget each.

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.

curl -H "Authorization: Bearer $MERCHDASH_KEY" \ "https://app.merchdash.net/api/v1/metrics/daily?profileId=1234567890&start=2026-08-01&end=2026-08-31"
{ "data": [ { "date": "2026-08-01", "metrics": { "impressions": 18402, "clicks": 211, "cost": 96.41, "orders": 37, "units": 41, "sales": 512.8 } } ], "nextCursor": null }

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, an auto campaign’s clause bids, a campaign’s budget, bidding strategy, name and portfolio, and a portfolio’s budget cap, name and state. It writes a run and returns immediately, queued for your review, or applied straight away if the key is set to auto-apply.

curl -X POST "https://app.merchdash.net/api/v1/changes" \ -H "Authorization: Bearer $MERCHDASH_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: harvest-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" } ] }'

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. A run that ended partial or failed can resend its failed changes with POST /api/v1/runs/{runId}/retry-failed (the automation scope).

Before you change a bid, read GET /api/v1/changes?profileId=…&entityId=…: every change MerchDash wrote to that keyword, target or campaign, by your rules, by the API, by a bulk edit, and any revert, newest first, for the last 30 days by default. Without entityId it is the whole marketplace’s history.

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.

The queue is finite. One marketplace holds at most 75 un-answered API proposals. Past that, proposals are refused with 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.

{ "operation": "set_auto_clause_bids", "campaignId": "555", "bids": { "close-match": 0.45, "substitutes": 0.30 }, "emptyOnly": true }

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.

Campaign budget, strategy, name and portfolio

Four operations change a campaign’s own settings, the same changes an automation rule makes with setBudget(), setBiddingStrategy(), rename() and moveToPortfolio():

{ "operation": "set_campaign_budget", "campaignId": "555", "budget": 40 } { "operation": "set_bidding_strategy", "campaignId": "555", "strategy": "DYNAMIC BIDS - UP AND DOWN" } { "operation": "rename_campaign", "campaignId": "555", "name": "SP - Widget - Scale" } { "operation": "set_campaign_portfolio", "campaignId": "555", "portfolioId": "7001" }

Audience bid adjustments

A Sponsored Products campaign carries up to ten bid adjustments, each for one audience. GET /api/v1/campaigns returns them as audienceBids ([{ audienceId, name, percent }]), GET /api/v1/audiences lists the audiences a marketplace offers, and set_audience_bid changes one, the same change a rule’s setAudienceBid() makes:

{ "operation": "set_audience_bid", "campaignId": "555", "audience": "High interest", "percent": 50 }

audience is an audienceId or part of a name; percent is 0–900 and 0 removes the adjustment. Only that audience changes, the campaign’s other audiences, placements and bidding strategy are kept, so several audiences on one campaign can go in one batch. The audience list is read from MerchDash’s stored copy and refreshed by syncs, so a marketplace that has not synced yet lists none.

  • Budget is the daily budget, for Sponsored Products, Brands and Display. Below 1 it is written at 1 and marked as adjusted. A Sponsored Brands campaign with a lifetime budget is refused, because Amazon cannot change a budget’s type.
  • Bidding strategy is Sponsored Products only, and keeps the placement adjustments. It shares a slot with set_placement_bid: one of the two per campaign per batch, and one waiting for approval at a time.
  • Name must not already belong to another live campaign of the same Amazon account, or be one another waiting proposal is about to give a campaign, because rules and /structures find campaigns by name.
  • Portfolio must be a live portfolio of the campaign’s own account. "portfolioId": null takes the campaign out of its portfolio; leaving the field out is refused rather than read as “out”.

Each of these settings can have one change waiting for approval per campaign. A second one, from another batch or from a rule’s review, is 409 conflict until the first is applied or turned down, because these values are written as they were proposed and a second change would undo the first on revert. A change that would leave the setting as it is gets refused rather than queued. All four are revertible from History, except a move of a campaign that was in no portfolio before.

Portfolios and their budget caps

GET /api/v1/portfolios lists every portfolio in a marketplace, including ones that hold no campaigns, with its budget cap: budgetPolicy (NO_CAP, MONTHLY_RECURRING or DATE_RANGE), budgetAmount, budgetCurrency, budgetStartDate, budgetEndDate, Amazon’s own inBudget flag, and campaignCount. It has no metric window: a portfolio has no report of its own, so sum its campaigns from GET /api/v1/campaigns, which carry portfolioId.

{ "operation": "set_portfolio_budget", "portfolioId": "7001", "amount": 500, "policy": "DATE_RANGE", "startDate": "2026-11-01", "endDate": "2026-12-31" }

A cap is one object on Amazon, so it is proposed, shown and reverted whole. Every field but portfolioId and amount is optional, and what you leave out is decided for you in one predictable way: policy keeps what the portfolio has (a portfolio with no cap gets MONTHLY_RECURRING), and the dates are kept while the policy stays the same and dropped when it changes. A DATE_RANGE cap therefore needs a startDate unless the portfolio already has one. "endDate": null removes the end date, null and leaving the field out mean different things here.

Three more operations act on a portfolio: remove_portfolio_budget_cap takes the cap off, rename_portfolio renames it (a name another non-archived portfolio of the same Amazon account already has is refused, because portfolios are found by name; each half of a linked pair is checked on its own), and set_state with "entityType": "portfolio" pauses or enables it. A proposal that names an archived portfolio is refused: Amazon cannot change one.

archive_portfolio archives one, and it is permanent: Amazon never lets an archived portfolio be enabled, changed, or given a campaign again, and History has no revert for it. It is a separate operation rather than a set_state value so it cannot be sent by accident. While an archive waits for approval, nothing else is accepted for that portfolio, and it cannot be proposed while another change to the portfolio, or a move of a campaign into it, is still waiting.

Sponsored Brands videos

GET /api/v1/videos lists the videos in the marketplace’s Creative Asset Library: assetId, name, status, ready (true once Amazon has finished processing the video) and durationSeconds. The name is what a rule’s addVideoAd(video: "…") looks a video up by. Archived videos are left out.

The list is copied from Amazon during a sync, at most once a day and only for a marketplace that runs Sponsored Brands ads. A video you upload in MerchDash is listed straight away.

Uploading a video, logo or image

A key with write can add a video or a logo to the account’s Creative Asset Library in three steps. The file goes straight to Amazon’s storage and never through MerchDash:

  1. POST /api/v1/videos/uploads with profileId, filename (.mp4 or .mov), size in bytes (at most 500 MB) and the name rules will find it by. The answer holds an uploadId, an uploadUrl and the contentType to send.
  2. PUT the file’s bytes to uploadUrl with that Content-Type and no Authorization header, within 55 minutes.
  3. POST /api/v1/videos/uploads/{uploadId}/complete, optionally with the asin the video shows. Amazon processes it for about a minute; GET /api/v1/videos?refresh=true asks Amazon again and shows it ready once it is (each refresh spends one request of your write budget, so wait a minute rather than looping). specFailures lists what Amazon’s video checks flagged.
curl -s -X POST https://app.merchdash.net/api/v1/videos/uploads -H "Authorization: Bearer $MD_KEY" \ -H 'Content-Type: application/json' -d '{"profileId":"<profileId>","filename":"cat-mom.mp4","size":8812345,"name":"Cat Mom 2026"}' curl -s -X PUT "<uploadUrl>" -H 'Content-Type: video/mp4' --upload-file cat-mom.mp4 curl -s -X POST https://app.merchdash.net/api/v1/videos/uploads/<uploadId>/complete -H "Authorization: Bearer $MD_KEY" \ -H 'Content-Type: application/json' -d '{"asin":"B0ABC12345"}'

A rule then builds a Sponsored Brands video ad from it: ad_group.addVideoAd(video: "Cat Mom 2026", asin: "B0ABC12345"). Logos and custom images work the same way through POST /api/v1/images/uploads (.png or .jpg, at most 5 MB, kind logo or image) and /api/v1/images/uploads/{uploadId}/complete, and a rule uses a logo with addSbAd(from: …, logo: "<name>"). Completing the same upload twice answers the first result again. Amazon has no way to delete a library asset.

GET /api/v1/images lists the logos and images MerchDash holds for an account; add refresh=true to read the library from Amazon first. GET /api/v1/stores lists the account’s Amazon Stores and their pages, the landing pages addSbAd(landingPage: "…") takes. Both ask Amazon (the images only with refresh=true) and spend one request of your write budget, like the live reads. Most Merch accounts have no logo and no store: those need Brand Registry.

Sponsored Brands ads and purchases

GET /api/v1/sb-ads lists the marketplace’s Sponsored Brands ads, each one a creative: adFormat (VIDEO, PRODUCT_COLLECTION, BRAND_VIDEO, STORE_SPOTLIGHT), headline, brandName, the asins it shows, its landing page and Amazon’s ad review (moderationStatus), with metrics for the window from Amazon’s per-ad report. Narrow it with campaignId, adGroupId or adFormat; archived ads need includeArchived=true.

GET /api/v1/sb-purchased-products says what shoppers bought after a Sponsored Brands ad, per purchased ASIN: orders, units, sales and their new-to-brand share. attributionType separates the products the ad showed (Promoted) from the brand’s other products (Brand Halo). There are no clicks or cost here, Amazon reports those per ad, not per purchase.

GET /api/v1/placements covers Sponsored Brands campaigns too, with adProduct on every row. Both reports, and the placements, are kept for the last 60 days.

POST /api/v1/recommendations/sb-bids asks Amazon for suggested bids in a Sponsored Brands campaign: send campaignId with keywords ([{ keywordText, matchType }]) and/or targets ([{ type: "ASIN_SAME_AS", value: "B0…" }] or ASIN_CATEGORY_SAME_AS with a category id), and optionally adFormat (PRODUCT_COLLECTION or VIDEO). Each one comes back in order with suggested, low and high, or an error when Amazon had no suggestion.

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.

{ "profileId": "1234567890", "label": "Q4 seeding - 12 products", "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 }] }] }] }

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. When nothing in it is new, there is nothing to propose: the request is refused with 400 validation (“Everything in this request already exists”), its details listing the first matches. You can tell it from any other validation without reading the message: its details entries are alreadyPresent entries ({ kind, value, …Id }), never { message, path } problems, treat that answer as “nothing to do”, not as a fault. 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. So it is never built twice: a retry with the same key replays the first answer, and one of your own automation rules asking for the same campaign skips it as already pending. A new portfolio a structure creates is reserved the same way. A second proposal naming a container another run already holds gets 409 creation_conflict naming that run: retry once that run has been applied or turned down, or, if Amazon is still creating it, a little later, then, if that run created it, your request adds to it; if it was turned down or its create failed, your request proposes it afresh.

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.

Automation rules

A key with the automation scope can do everything the Automation page does with a rule, through the same machinery: the same syntax check, the same run queue, the same Apply and Discard.

ToCall
List, readGET /api/v1/rules?profileId=…, GET /api/v1/rules/{ruleId}
CreatePOST /api/v1/rules with profileId, name, code, and optionally schedule, mode, enabled, categoryId
EditPATCH /api/v1/rules/{ruleId} with only the fields that change
DeleteDELETE /api/v1/rules/{ruleId}, its history stays
Check a scriptPOST /api/v1/rules/validate, answers valid, and the error and line when it is not
Dry-run a scriptPOST /api/v1/rules/preview, then poll GET /api/v1/rules/preview/{jobId}, what it would change, a sample of the changes, and its log. Nothing is saved or written
Run nowPOST /api/v1/rules/{ruleId}/run, then poll GET /api/v1/rules/{ruleId}/run for the runId
See why a run did what it didGET /api/v1/runs/{runId}/log, the rule’s log() lines and MerchDash’s notes on what it skipped
Approve or turn downPOST /api/v1/runs/{runId}/apply (optionally only some changeIds), POST /api/v1/runs/{runId}/discard
CategoriesGET/POST /api/v1/categories, PATCH/DELETE /api/v1/categories/{categoryId}. A category can sit inside another one: send parentId (or null for the top level) when creating or editing it, up to 4 levels deep. Switching a category off pauses the rules in every category nested inside it too
Writing rules with an AI? Download the automation language as one Markdown file, the complete reference, generated from the same definition the validator uses, plus every cookbook recipe. Hand it to the assistant together with the API guide, and have it check each script with /rules/validate and try it with /rules/preview before saving.

A script is checked before it is saved, the same way the editor’s Save button checks it; one that would not run is refused with validation and the line it failed on. A new rule starts in review mode, with a manual schedule, switched off, it does nothing until you run it or give it a schedule and switch it on.

Rule and category names are unique per marketplace. For retry safety, API-key callers should send an Idempotency-Key when creating either one or starting a dry-run: the same key and contents replay the first response, while different contents return 409 idempotency_conflict. A plain name conflict names the existing resource but does not prove which caller created it.

Apply is a real write. Applying a review through the API does exactly what pressing Apply does: the changes go to Amazon, and they appear in History, revertible there like any other. A rule in auto mode writes every time it runs, on its schedule too. Run it with {"apply": false} first to see what it would do. Reverting stays in MerchDash.

Retries and idempotency

Both proposal endpoints, and starting a rule run (POST /api/v1/rules/{ruleId}/run), 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 sendYou get
The same key and the same bodyThe first call’s exact response. Nothing is created a second time.
The same key, a different body409 idempotency_conflict.
The same key after 24 hoursA 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 (or, if part of it had been applied first, the status of what that part wrote: applied, partial or reverted), so a retry never resurrects work somebody already declined.

Rate limits

Two buckets are checked on every request, and both must allow it:

BucketReadsWritesDry-run startsAmazon writes
Per key120 / minute30 / minute5 / minute5 / minute
Per account, across all its keys300 / minute60 / minute10 / minute10 / 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:

{ "error": "This key is read-only", "code": "scope" }

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:

CodesWhat your client should do
rate_limited, restarting, temporarily_unavailable, internalRetry automatically. Honour Retry-After when it is present, otherwise back off.
run_in_progress, creation_conflict, review_queue_full, sync_incompleteRetry 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, payload_too_large, conflict, idempotency_conflictDo not retry unchanged. The request itself has to change.
invalid_key, key_revoked, key_expired, plan, account_unavailableStop 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.

Webhooks

Instead of polling, register an endpoint under Profile → API / MCP → Webhooks and MerchDash will call it when something happens. Webhooks are managed there only, a key cannot create or change one.

Eventdata
review.queuedA run is waiting for approval: runId, profileId, ruleId, ruleName, trigger, proposed. Fired for reviews from rules and from the API; not for a run that applies itself.
run.appliedA run finished writing to Amazon: runId, profileId, ruleId, ruleName, trigger, status (applied, partial or failed), proposed, applied, failed. Read the run itself for the detail.
sync.finishedA marketplace’s sync finished: profileId, status (done or partial), failures, finishedAt.

Each delivery is a POST of { "id", "type", "createdAt", "data" } with the headers MerchDash-Event, MerchDash-Delivery and MerchDash-Signature. Any 2xx answer is success. Anything else, or no answer within 10 seconds, is retried after 30 seconds, 2 minutes, 10 minutes, 1 hour and 6 hours, then given up on. Delivery is at-least-once: deduplicate on MerchDash-Delivery, which stays the same across retries. An endpoint that fails 20 deliveries in a row is switched off (you can switch it back on), and one that answers 410 Gone is switched off at once.

Check the signature. The header is t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256, keyed with the endpoint’s signing secret, of <t>.<raw request body>. Compute it over the raw bytes, compare in constant time, and refuse a t more than five minutes from now.

const crypto = require('node:crypto'); function verify(secret, rawBody, header) { const p = Object.fromEntries(header.split(',').map(x => x.split('='))); if (Math.abs(Date.now() / 1000 - Number(p.t)) > 300) return false; const mac = crypto.createHmac('sha256', secret).update(p.t + '.' + rawBody).digest('hex'); return mac.length === p.v1.length && crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(p.v1)); }

Endpoints must be https:// URLs on port 443 with a public hostname. MerchDash checks the address it actually connects to on every delivery and will not call a private, loopback or link-local address; redirects are not followed. The signing secret is shown once, when you add the endpoint. Webhooks are an API feature, so they pause with a downgrade to Starter and resume on upgrade.

What the API deliberately cannot do

  • Revert a run. Undoing changes that reached Amazon is done from History in MerchDash. Applying or discarding a pending review needs a key you have allowed to manage rules.
  • Turn auto-apply on, or give itself rule management. A key cannot grant itself the right to skip your review, both switches live 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.
  • Archive campaigns, ad groups, keywords, targets, ads or portfolios. Archiving is permanent on Amazon, so a revert could never undo it. set_state takes ENABLED or PAUSED only. Negatives are the exception: remove_negative_keyword and remove_negative_target archive one. An automation rule can archive a portfolio, which is one reason only a key you have allowed to manage rules can create or edit one.
  • 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.

Browser-side (CORS) access is not in v1 either: call the API from a server.

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.