Changelog

Notable changes to the CardNexus Public API. The most recent release is listed first.

Versions follow semantic versioning. The API is in preview, so versions are 0.x: the minor number changes on any new release — including a breaking change, such as an endpoint or field being removed, renamed, or made stricter — and the patch number on documentation-only fixes. Breaking changes are always called out in the release notes below. The version here matches info.version in the API Reference.

0.13.0 — 2026-08-13

Search a card across several expansions at once. Carry your own references and fulfillment states on your sales, and filter your sales by them. Read your account id from your own account, and re-approving an application now changes what it can do.

Breaking changes

expansionId takes a list. On POST /v1/products/search and POST /v1/inventory/search, expansionId now takes an array of up to 200 expansion ids instead of a single id, and matches any of them. Wrap the id you send today in an array: "expansionId": 42 becomes "expansionId": [42].

The expansion game filter is gone. gameFilters.filters.expansion no longer exists on POST /v1/products/search, POST /v1/inventory/search and POST /v1/inventory/bulk/export. It matched on ids that are not the expansion ids these endpoints return, so no value you could read from the API ever matched. Send expansionId instead. A request that still carries the filter is accepted and the filter is ignored, so results widen to every expansion.

Added

Sales metadata — every sale has a metadata object of your own key/value pairs. CardNexus never reads or changes them, and the buyer never sees them.

  • PATCH /v1/sales/{orderNumber}/metadata writes them. The write merges: keys you send are set, keys you send as null are removed, keys you leave out keep their value — so two of your systems can each own their keys on the same sale. Works in any status, including on completed and cancelled sales.
  • metadata on GET /v1/sales filters your sales on exact key/value pairs — ?metadata[fulfillment_stage]=packed, repeatable for up to 10 keys, AND-combined.
  • POST /v1/sales/{orderNumber}/mark-shipped takes the same metadata object, so a sale can be shipped and stamped in one call. The two either both apply or neither does.
  • metadata is returned on every sales response, and on the seller's copy of the order.status.changed webhook under data.metadatanull on the buyer's copy.

Keys start with a letter and may contain letters, digits, _, . and -, up to 64 characters. Values are strings of up to 500 characters. A sale holds at most 50 keys; a write that would go past that stores nothing and returns 422 METADATA_LIMIT_EXCEEDED. See Track sales in your own system.

Fixed

  • Sending gameFilters with any filter in it returned 500 on POST /v1/products/search. Filtering on a game's own attributes — rarity, colour, finish — now works.
  • When an account goes through the connect flow again, the permissions it approves now replace the ones it granted before. Previously the second approval was ignored and the account kept its original permissions, so an application whose permissions had been widened never received the new ones even after the account re-approved. POST /v1/connect/exchange returns the permissions currently in force. See Building an integration.

0.10.0 — 2026-08-07

The fixed 10-unit-per-listing cart cap is gone — each seller now sets their own per-listing limit, and most set none.

Changed

  • Cartquantity on POST /v1/cart/items and PATCH /v1/cart/items/{listingId} no longer has a fixed maximum of 10. A cart line can hold any quantity the listing has for sale, up to any per-listing limit the seller sets.
  • Cart — the QUANTITY_LIMIT_EXCEEDED rejection code on POST /v1/cart/items now means the seller's per-listing limit was exceeded, and data.max carries that seller's limit instead of the former fixed 10. Sellers without a limit never return it.

0.9.0 — 2026-08-07

Third-party applications can act on accounts on their owners' behalf. Search by name and print number together, in another language, and find the cheapest copy that can actually reach you. Trace an exported row back to the line it came from. See exactly which fields a rejected request failed on.

Breaking changes

Some list parameters now have a maximum length. Sending more entries than the maximum returns 400. Requests within the limits are unaffected.

Endpoint Parameter Maximum
POST /v1/products/search productIds 200
GET /v1/inventory, GET /v1/listings productId 200
GET /v1/inventory, GET /v1/listings tags 50
POST /v1/inventory/search language.values 50
POST /v1/inventory/bulk/export filters.language.values 50
POST /v1/optimizer/runs sellers.include, sellers.exclude 200
POST /v1/optimizer/runs sellers.countries 250

To look up more products than one call takes, split the ids across several calls. The maximum for each parameter is listed on its field in the reference.

Added

Integrations — an approved application can act on an account on its owner's behalf: it authenticates with its own credential and names the account in the CardNexus-Account header. Every endpoint works unchanged, bounded by the permissions that account granted. See Building an integration.

  • POST /v1/connect/exchange — exchange the one-time code from the connect redirect for the account id and its granted permissions.
  • POST /v1/accounts — create an account for a seller who isn't on CardNexus yet, staged until they finish onboarding. Requires the managed-account privilege. Capped per day; past the cap you get QUOTA_EXCEEDED with limit and resetAt.
  • POST /v1/account/onboarding-link — issue a hosted-onboarding link so a managed account's seller can accept the terms, set up payouts, and go live.
  • Webhooks carry an account field, so one endpoint can serve every account you manage.

ProductsPOST /v1/products/search takes a listings object and answers with what you can actually buy. Every result gains an availability block: listingCount, plus cheapest with its price, quantity, condition, language, seller, and a listingId for POST /v1/cart/items. Filter with deliveryCountry, condition, language, finish, and inStock. deliveryCountry applies the checkout rule, so anything returned can be added to a cart. See Listing-aware search.

Search filtersprintNumber and nameSlug on both POST /v1/products/search and POST /v1/inventory/search, and in the filters of POST /v1/inventory/bulk/export. nameSlug returns one card across every printing and expansion.

Accept-Language (en, fr, it, es, de) on product search, inventory search, and bulk export — Blizzaroi with Accept-Language: fr finds Abomasnow. Product and expansion names come back translated where one exists.

Bulk operations — exported rows carry inventoryId and updatedAt. inventoryId is the line's own id, the same one GET /v1/inventory and GET /v1/listings return, so a row traces back to its line and can be passed to PATCH /v1/inventory/{inventoryId}. productId names the card, but several of your lines can share one. Both columns are ignored on import.

Sales — every item of GET /v1/sales carries shippingAddress, so a batch of parcels can be prepared from one call.

ListsbannerUrl on the list endpoints, or null when a list has no banner.

Validation errors — a 400 names each rejected field under data.issues: field is a dotted path into your body (lines.3.quantity), message says what's wrong. Up to 100 per response. See Error handling.

Changed

  • Products / Linesname free-text understands a print number before or after the name (sephiroth 44), with or without padding and prefixes (38 finds 038, 44 finds ST02-44), and an expansion code plus print number (msh-54, msh 54, msh54). On product search an expansion code alone (msh) surfaces that expansion after any name matches.
  • ProductsnameSlug is matched ignoring case. A slug differing only in case used to return nothing.
  • Bulk operationsBulk import & export now states that a json export leaves out any field a line doesn't set, so a line with no tags has no tags key. A csv export always writes every column.

Removed

  • The products:read, pricing:read, and marketplace:read scopes. Catalogue, pricing, and feed endpoints need no scope — any valid key reads them — so these granted nothing. They no longer appear when creating a key; existing keys carrying them work exactly as before.

Fixed

  • All endpoints — every error response carries data, as documented. Some errors previously left it out.

0.8.0 — 2026-07-19

Search your inventory server-side — by product name, tag, location, and more — and export exactly the lines a search matches.

Breaking changes

filters on POST /v1/inventory/bulk/export has a new shape. It is now the same filter set POST /v1/inventory/search accepts, so a search can be turned into an export unchanged.

Exports without filters are unaffected — if you never pass filters, there is nothing to change.

If you do pass filters, update each field:

Field Before Now
game "game": "mtg" "gameFilters": { "game": "mtg" }
productId "productId": [50212] "productIds": [50212]
condition "condition": "NM" "condition": { "op": "or", "values": ["NM"] }
language "language": "en" "language": { "op": "or", "values": ["en"] }
finish "finish": "Foil" "finish": { "op": "or", "values": ["Foil"] }
graded, forSale "forSale": true unchanged
customId, customIdPrefix, customIdContains "customIdPrefix": "LOC-" unchanged shape; matching is now case-insensitive
commentContains "commentContains": "binder" unchanged shape; matches whole words in order, not substrings inside a word

The same export, before and after:

{ "format": "csv", "filters": { "game": "mtg", "condition": "NM", "language": "en", "forSale": true } }
{
  "format": "csv",
  "filters": {
    "gameFilters": { "game": "mtg" },
    "condition": { "op": "or", "values": ["NM"] },
    "language": { "op": "or", "values": ["en"] },
    "forSale": true
  }
}

Two behaviour notes that come with the new shape:

  • Filtered exports match lines the same way the search endpoint does, so the selection can lag your latest changes by a few seconds. Exports without filters always reflect your latest changes, as before.
  • Every other search filter now works on exports too: tags, location, name, quantity, listingPrice, productType, productCategory, and expansionId — export a single location, every untagged line, or every line matching a name.

Added

  • LinesPOST /v1/inventory/search searches your inventory lines. Filter by free-text name (ranked by relevance), tags and location by label name with { "op": "and" | "or", "values": [...] } semantics — a null value matches lines with no tag or no location — customId / customIdPrefix / customIdContains (case-insensitive), commentContains (whole words in order), condition, language, finish, productType, graded, forSale, quantity and listingPrice ranges, productIds, expansionId, and the same gameFilters object POST /v1/products/search accepts. Sort with sortBy and sortDirection. Results are offset-paginated over the first 10,000 matches and use the same line shape as GET /v1/inventory; they can lag your latest changes by a few seconds, where GET /v1/inventory always reflects them. Requires inventory:read, with a dedicated inventory-search rate limit of 600 per hour. See Tags & locations.
  • LinesGET /v1/inventory takes two new filters: location (one location name) and tags (repeatable — matches lines carrying any of the named tags). Names match case-insensitively.
  • Listings — the same location and tags filters on GET /v1/listings.
  • Tags / LocationsPOST /v1/inventory/tags and POST /v1/inventory/locations accept "upsert": true: when a label with that name already exists, the call returns it — updated with any color or icon you sent — instead of failing with CONFLICT. One call then guarantees the label exists before you reference it on inventory lines.

Fixed

  • Lines / ListingsGET /v1/inventory and GET /v1/listings now accept a single productId, e.g. ?productId=10000. Passing one value previously returned 400; passing two or more (?productId=10000&productId=10001) already worked. Both forms now behave the same.
  • Sales / Purchases — the same fix for the status filter on GET /v1/sales and GET /v1/purchases: a single ?status=… is now accepted, matching the repeated form.
  • Products — examples across the reference and guides showed yugioh as the Yu-Gi-Oh! game slug; the actual slug is ygo. Example-only fix; the endpoints are unchanged.

0.7.0 — 2026-07-18

Adding or updating a card in a list with a language, finish, or game the product doesn't exist in now fails with a clear validation error instead of a server error.

Changed

  • List itemsPOST /v1/lists/{listId}/items now returns a 422 naming what's wrong with a card instead of a 500: LANGUAGE_NOT_AVAILABLE when the product doesn't exist in the language you sent (the response lists the languages it does exist in), FINISH_NOT_AVAILABLE when it doesn't exist in the finish you sent (the response lists the finishes it does), PRODUCT_GAME_MISMATCH when the product belongs to a different game than the list, and ITEM_NOT_FOUND when the itemId you sent is not in the list.

0.6.0 — 2026-07-17

Every sold item names the inventory line it came from, cart items say what you're buying — condition, finish, language, grading — and each optimizer package names its seller.

Added

Changed

  • Optimizer (experimental) — each package in a run's result embeds its seller — id, username, country, type, and rating — replacing the bare sellerId, on GET /v1/optimizer/runs/{id}.
  • Optimizer (experimental) — each option in a run's result carries modes, the list of objectives it satisfies (lowest_price, fewest_sellers, balanced), replacing the single kind. When two modes produce the same solution, the run returns one option listing both — a run requesting lowest_price and fewest_sellers that converges on one cart now says so explicitly. The same shape applies to the in-progress options returned while a run is solving and to the options summary on the optimizer.run.completed webhook event.
  • Optimizer (experimental)POST /v1/optimizer/runs/{id}/apply takes the option to apply as mode (was kind). Any mode listed on an option selects that option.

0.5.1 — 2026-07-16

Buy and sell through the API: mark sales shipped or cancel them, browse a product's Marketplace listings, and fill a shopping cart — item by item or straight from an optimizer run.

Added

  • Sales — act on the orders you're selling. POST /v1/sales/{orderNumber}/mark-shipped marks an order shipped and registers its tracking number (the carrier is detected automatically); allowed while the order is awaiting dispatch or has an open cancellation request. POST /v1/sales/{orderNumber}/cancel cancels an order awaiting dispatch, with a reason code and a note to the buyer — the buyer is refunded in full and the cards return to your inventory. Both return the updated sale and require the sales:write scope.
  • ProductsGET /v1/products/{productId}/listings returns a product's Marketplace listings: each listing's price, quantity for sale, condition, language, finish, grading, seller note, and the seller behind it. Sorted cheapest first, cursor-paginated, and filterable by condition, language, finish, and region; pass deliveryCountry to keep only sellers who ship to you, with each seller's shipping charge. Any valid API key works — no scope; requests count against a dedicated product-listings rate limit of 120 per hour, raisable per account through support.
  • Cart — a shopping cart, under the new cart:read and cart:write scopes. GET /v1/cart returns it grouped by seller with per-seller shipping. POST /v1/cart/items adds up to 1000 listings by listingId in one call, each item succeeding or failing on its own; the deliveryCountry you send applies on every call. PATCH /v1/cart/items/{listingId} sets an item's quantity (0 removes it), DELETE /v1/cart/items/{listingId} removes an item, and DELETE /v1/cart clears the cart. Every call returns the updated cart.
  • Optimizer (experimental)POST /v1/optimizer/runs/{id}/apply replaces your cart with one of a finished run's options in one step. To add a run's items to your cart instead, pass their listingId and quantity to POST /v1/cart/items.

Fixed

0.5.0 — 2026-07-13

One price shape everywhere: every price is a cardmarket, tcgplayer, or cardnexus block, under a map named pricesByFinish.

Added

Changed

  • Products — a card's pricesByFinish and a sealed product's prices are now keyed by source — cardmarket (EUR), tcgplayer (USD), cardnexus — with the currency as a field inside each block, instead of by currency (eur / usd). Every pricing surface now uses this one block shape.
  • Pricing — the response map of GET /v1/products/{productId}/prices is named pricesByFinish (was finishes). finishes now always means one thing: the list of finishes a product exists in.
  • Prices feed — the same rename on every line: the price map is pricesByFinish (was finishes). See the prices feed guide.
  • Pricing — the blocks on GET /v1/products/{productId}/prices and on product responses no longer carry a date — they reflect the moment of the request. The prices feed keeps date on its cardmarket / tcgplayer blocks (the calendar day of that marketplace snapshot), and price history keeps its per-day date.
  • Catalog feedattributes now carries exactly the game's gameplay fields, the same keys and values the Products endpoints return. The card-database ids that used to sit there moved into externalIds.

0.4.0 — 2026-07-13

Feed records now use the same field names as the API endpoints, and an experimental cart optimizer arrives.

Added

  • Cart optimizer (experimental) — optimize a basket of cards into the cheapest, fewest-seller, or balanced ways to buy it across the marketplace: POST /v1/optimizer/runs starts a run in the background and GET /v1/optimizer/runs/{id} polls it; the optimizer.run.completed and optimizer.run.failed webhook events fire when a run finishes. These endpoints are experimental, even by this preview API's standards — expect their request and response shapes to change more than the rest of the API.
  • Catalog feed — each product line now carries rarity, imageBackUrl, productCategory, and externalIds (the product's Cardmarket and TCGplayer ids, one per finish). See the catalog feed guide.
  • Prices feed — the marketplace price blocks now carry the full daily snapshot: low, mid, and high tiers, currency, and the snapshot date, alongside marketValue and the trend percentages. See the prices feed guide.
  • Catalog changelog — with includeChanges=true, GET /v1/feeds/{gameId}/changelog entries now identify each changed product and expansion by id as well as slug: productId on product entries, expansionId on expansion entries.

Changed

0.3.0 — 2026-07-08

Match cards to every printing by name slug.

Added

0.2.0 — 2026-07-08

Build and manage your lists — decks, want lists, and for-sale lists.

Added

0.1.0 — 2026-07-07

Organise your inventory with tags and locations.

Added

Changed