Bulk operations

Bulk operations move your whole inventory at once — importing a file of lines, exporting your lines to a file, applying up to 200 targeted changes in a single call, and previewing then applying one change to every line that matches a filter. Imports, exports and bulk edits run as background jobs: you start one, then follow it by polling GET /v1/inventory/bulk/jobs/{jobId} or by webhook.

Importing a file

POST /v1/inventory/bulk/import imports inventory lines from a file in one call. Send the request as multipart/form-data with the file in the file field and its content type — text/csv, application/json, or application/gzip. Files up to 256 MB are accepted, and the file may be gzip-compressed. mode and format go in as plain form fields alongside it; format may be omitted when the content type is text/csv or application/json, and is required for gzip-compressed files. The call returns a job right away with status pending.

curl -X POST https://public-api.cardnexus.com/v1/inventory/bulk/import \
  -H "Authorization: Bearer cnk_live_4f2d8c91e5b34a7f9e6c2d1a8b5f7e3c" \
  -H "Idempotency-Key: 9b4e7c20-5d1a-4f8e-b3c6-a7d9e1f2c4b8" \
  -F "file=@inventory.csv;type=text/csv" \
  -F "mode=upsert"

Uploading a large file

A file sent with multipart/form-data goes through the API. For a large file, upload it straight to storage instead:

  1. POST /v1/inventory/bulk/import/uploads with the contentType (text/csv, application/json or application/gzip) and the exact sizeBytes, up to 268,435,456 (256 MiB). The response carries an uploadId, a temporary uploadUrl, and the headers to send.
  2. PUT the file to uploadUrl with exactly those headers. The address writes this one file only and stops working at expiresAt, 15 minutes after the call.
  3. POST /v1/inventory/bulk/import/uploads/{uploadId}/start with an optional format. It is required for a gzip file and for the three stock-file layouts (eu_marketplace_stock, us_marketplace_seller, third_party_tool_export). The call returns a job right away.
curl -X POST https://public-api.cardnexus.com/v1/inventory/bulk/import/uploads \
  -H "Authorization: Bearer cnk_live_4f2d8c91e5b34a7f9e6c2d1a8b5f7e3c" \
  -H "Content-Type: application/json" \
  -d '{"contentType":"text/csv","sizeBytes":4821733}'

The import adds or updates the lines in the file and leaves every other line as it is. Rows that name a tag or a location you do not have fail with TAG_NOT_FOUND or LOCATION_NOT_FOUND in the error report: an import never creates labels. Starting the same upload again returns the same job. A file holds up to 5,000,000 rows, also once decompressed.

The file

A CSV file starts with a header row. Columns are matched by name — any order works, columns a row doesn't need can be left out entirely, and unknown columns are ignored. Every row needs finish, language, quantity, a product reference — a productId, or a cardmarketId / tcgplayerId (see Keying a row by a marketplace id) — and either condition or the graded* columns. The full column set:

productId,cardmarketId,tcgplayerId,finish,condition,gradedGrade,gradedCertification,gradedGradingService,language,quantity,customId,comment,notes,location,tags,listingPrice,listingCurrency
productId,finish,condition,gradedGrade,gradedCertification,gradedGradingService,language,quantity,customId,comment,listingPrice,listingCurrency
50212,Standard,NM,,,,en,5,YRG|5555-5DS1-2EX-006-f-2-2008-08-01,Slight edge wear on the back corner,42.50,EUR
50213,Foil,,9.5,,PSA,jp,1,,,,

A minimal file is just as valid:

productId,finish,condition,language,quantity
50212,Standard,NM,en,5

A JSON file is a top-level array of row objects. The three graded* columns become a single graded object, and fields a row doesn't set are left out:

[
  {
    "productId": 50212,
    "finish": "Standard",
    "condition": "NM",
    "language": "en",
    "quantity": 5,
    "customId": "YRG|5555-5DS1-2EX-006-f-2-2008-08-01",
    "comment": "Slight edge wear on the back corner",
    "listingPrice": 42.50,
    "listingCurrency": "EUR"
  },
  {
    "productId": 50213,
    "finish": "Foil",
    "graded": { "grade": "9.5", "gradingService": "PSA" },
    "language": "jp",
    "quantity": 1
  }
]

Each row describes one inventory line:

Field Description
productId The catalogue product's numeric id, from GET /v1/products. In a JSON file it may be a number or a numeric string. Required unless you supply a cardmarketId or tcgplayerId.
cardmarketId A Cardmarket product id (the idProduct in a Cardmarket stock file). CardNexus resolves it to the matching product. Used only when productId is absent.
tcgplayerId A TCGplayer product id. CardNexus resolves it to the matching product. Used only when productId is absent.
finish The card's finish, e.g. Standard, Foil, Reverse Holo. Required.
condition NM, LP, MP, HP, or DMG — the same values as everywhere else in the API. Required for raw cards; leave empty for graded cards.
gradedGrade, gradedCertification, gradedGradingService Grading details for graded cards. A graded card needs gradedGrade and gradedGradingService; gradedCertification may be left empty when you don't have the certification number. Leave all three empty for raw cards.
language The card's language as a two-letter code, e.g. en. Required.
quantity The line's total quantity, a whole number of 0 or more. Required.
customId Your own stable identifier for the line, unique across your live lines.
comment Free-text note for the line, shown to buyers on your Marketplace listing.
notes Private note for the line, visible only to you.
location The name of a location to put the line in. Must already exist, or the row fails with LOCATION_NOT_FOUND. See Tags & locations.
tags The names of tags to attach — a comma-separated list in CSV (Trade binder, Graded pile), an array in JSON. Each must already exist, or the row fails with TAG_NOT_FOUND.
listingPrice, listingCurrency Publish the line to the Marketplace at this per-card price, in decimal units (19.99). Either both or neither.

A file can hold up to 5,000,000 rows and 256 MB of data (after decompression).

Files produced by POST /v1/inventory/bulk/export use this exact layout, so an export can be edited and imported back unchanged.

Keying a row by a marketplace id

Instead of a CardNexus productId, a row can carry a cardmarketId (a Cardmarket product id — the idProduct in a Cardmarket stock file) or a tcgplayerId (a TCGplayer product id). CardNexus resolves it to the matching product, so you can import straight from a marketplace export without mapping ids first. The row's own finish, condition, language, and quantity still apply.

cardmarketId,finish,condition,language,quantity
728934,Standard,NM,en,5

A marketplace id is finish-specific on the catalogue side, but the import takes the line's finish from the row's finish column — so a single cardmarketId works for whichever finish you're stocking. When a row carries both a productId and a marketplace id, the productId wins. A marketplace id that matches no product fails that row with PRODUCT_NOT_FOUND in the error report; the rest of the file still imports.

To map a batch of marketplace ids to product ids yourself instead, use POST /v1/products/resolve.

How rows apply

quantity is an absolute count, not an increment.

  • Rows with a customId that matches one of your live lines update that line to the row's state — quantity, attributes, and listing. Fields the row leaves empty (comment, notes, location, tags, the listing pair) keep the line's current value. Rows whose customId matches nothing create a new line. location and tags are referenced by name and must already exist — importing never creates a label.
  • Rows without a customId are additive: the quantity merges into an existing line with the same product, finish, condition, language, and grading, or creates one.

The default mode is upsert, which applies the file's rows on top of your current inventory and leaves everything else alone. With mode: "replace", every live line the file does not mention is set to a quantity of zero after all rows are applied — the file becomes your full inventory.

Tracking the job and the error report

Poll GET /v1/inventory/bulk/jobs/{jobId} with the job's id. status moves from pending through processing to completed or failed, progress runs from 0 to 100, and counts tallies rows while the job runs.

A row that fails — unknown product, invalid value — does not stop the import: the job completes with partial counts, and the failed rows are collected into a JSON error report linked from errorReportUrl.

{
  "job": {
    "id": "6848a3f0c8d4e9f7a6b5c4e1",
    "kind": "import",
    "status": "completed",
    "format": "csv",
    "mode": "upsert",
    "counts": { "total": 1842, "succeeded": 1836, "failed": 6 },
    "progress": 100,
    "errorMessage": null,
    "downloadUrl": null,
    "errorReportUrl": "https://cardnexus-files.s3.eu-west-1.amazonaws.com/inventory-bulk-jobs/665f3a2b1c8d4e9f7a6b5c4d/6848a3f0c8d4e9f7a6b5c4e1/error-report.json?X-Amz-Expires=900&X-Amz-Signature=4e9a...",
    "expiresAt": "2026-06-17T09:22:10.000Z",
    "createdAt": "2026-06-10T09:20:45.000Z"
  }
}

The error report lists every failed row with its position in your file (starting at 0), a code, and a message:

{
  "jobId": "6848a3f0c8d4e9f7a6b5c4e1",
  "errors": [
    { "row": 17, "code": "PRODUCT_NOT_FOUND", "message": "No product found for id 51877" },
    { "row": 41, "code": "VALIDATION", "message": "quantity: Invalid input: expected number, received string" }
  ]
}

errorReportUrl (and downloadUrl on exports) are temporary links: each stops working 15 minutes after the response that carried it — fetch the job again for fresh ones. The files themselves are kept until the job's expiresAt, 7 days after completion.

When the job finishes — completed or failed — an inventory.import.completed webhook fires; its payload is documented on the Webhooks page.

Importing a competitor's file

If your stock already lives on Cardmarket, TCGplayer, or TCG Power Tools, upload that platform's own export file as-is — no reformatting:

Endpoint Accepts Matched on
POST /v1/inventory/bulk/import/cardmarket A Cardmarket "Export Own Stock" file idProduct
POST /v1/inventory/bulk/import/tcgplayer A TCGplayer seller CSV game + set + collector number
POST /v1/inventory/bulk/import/tcgpowertools A TCG Power Tools CSV export cardmarketId

Each is sent the same way as a normal import — multipart/form-data, the file in the file field, mode alongside it — and returns the same kind of background job, which you poll at GET /v1/inventory/bulk/jobs/{jobId} and which fires the same inventory.import.completed webhook. The file's structure is checked when you upload it: a file that can't be read as that platform's export comes back right away with UNPROCESSABLE_FILE. Per-row problems land in the job's error report and the rest of the file still imports: a row whose id matches no product fails with PRODUCT_NOT_FOUND, and a TCGplayer row that matches more than one catalogue product fails with PRODUCT_AMBIGUOUS. In these reports row is the row's position among the file's readable rows, and the message names the id or card it couldn't place.

Use mode=replace only when the file is your full stock — it sets every line the file doesn't mention to zero. A partial export (for example a TCGplayer repricing file) should use mode=upsert, or it will zero the inventory the file leaves out.

curl -X POST https://public-api.cardnexus.com/v1/inventory/bulk/import/cardmarket \
  -H "Authorization: Bearer cnk_live_4f2d8c91e5b34a7f9e6c2d1a8b5f7e3c" \
  -H "Idempotency-Key: 9b4e7c20-5d1a-4f8e-b3c6-a7d9e1f2c4b8" \
  -F "file=@cardmarket-stock.csv;type=text/csv" \
  -F "mode=upsert"

How fields map

Field Cardmarket TCGplayer TCG Power Tools
Product idProduct game + set + Number cardmarketId
Finish isFoil / isReverseHolo the Foil condition suffix isFoil
Currency EUR USD EUR
Your line key idArticle → customId TCGplayer Id (a SKU) → customId —

Condition tokens map to the five CardNexus conditions. TCGplayer's five map one-to-one. Cardmarket and TCG Power Tools use Cardmarket's seven: MT and NM → NM, EX and LP → LP, GD → MP, PL → HP, PO → DMG.

These endpoints require the inventory:write scope. To map a competitor's ids to CardNexus products yourself instead — for example to build your own import file — use POST /v1/products/resolve.

Exporting your inventory

POST /v1/inventory/bulk/export writes your inventory lines to a gzip-compressed json or csv file. Pass filters to export a subset — the same filters POST /v1/inventory/search accepts: name (with the same Accept-Language header support), printNumber, nameSlug, tags and location (with and/or semantics — see Tags & locations), customId / customIdPrefix / customIdContains, commentContains, condition, language, finish, graded, forSale, quantity and listingPrice ranges, productIds, expansionId, and gameFilters. Omit filters to export everything.

curl -X POST https://public-api.cardnexus.com/v1/inventory/bulk/export \
  -H "Authorization: Bearer cnk_live_4f2d8c91e5b34a7f9e6c2d1a8b5f7e3c" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 2c8f5a1d-7e94-4b3a-9d6c-f1e8a2b4c7d0" \
  -d '{ "format": "csv", "filters": { "location": { "op": "or", "values": ["Store A"] }, "forSale": true } }'

Filtered exports match lines the same way POST /v1/inventory/search does, so the selection can lag your latest changes by a few seconds. Exports without filters always reflect your latest changes.

Exported rows carry each line's comment, notes, location, and tags — location as its name and tags as a comma-separated list in CSV (an array in JSON) — so your labels survive the round-trip.

In a json file a field a line doesn't set is left out of that row rather than written as null, which is what keeps the file importable as-is. A line with no tags has no tags key, and if no line in the export carries a comment then no row has a comment key. A csv file always writes every column, empty cell included, so use csv if you need a fixed set of columns.

The call returns a job right away. Poll GET /v1/inventory/bulk/jobs/{jobId} until status is completed; downloadUrl then carries a link to the file, valid for 15 minutes per fetch, and the file itself is kept for 7 days (the job's expiresAt). The inventory.export.completed webhook fires when the job finishes.

Starting an export counts against the inventory-export rate limit: 1 request every 10 minutes.

Extra columns in exported files

Exported rows carry extra columns around productId, so a file tells you which line each row is, what it is — and how it maps to Cardmarket and TCGplayer — without looking anything up:

Column Description
inventoryId The line's own identifier, the same id GET /v1/inventory and GET /v1/listings return. Pass it to PATCH /v1/inventory/{inventoryId} or name it in POST /v1/inventory/bulk/update to change that line.
updatedAt When the line was last modified, ISO 8601 UTC.
name The product's name, e.g. Charizard ex.
expansion The name of the expansion the product belongs to, e.g. Surging Sparks. Empty when the product has no expansion.
url A link to the product's page on cardnexus.com.
game The game the product belongs to, e.g. pokemon.
printNumber The collector / print number within the expansion, e.g. 223. Empty when the product has none.
rarity The product's rarity, e.g. Double Rare. Empty when the product has none.
cardmarketId The Cardmarket product id for this line's finish, e.g. 728934. Empty when Cardmarket has no id for that finish.
tcgplayerId The TCGplayer product id for this line's finish, e.g. 558991. Empty when TCGplayer has no id for that finish.

These columns are informational: inventoryId, updatedAt, name, expansion, url, game, printNumber, and rarity are ignored on import. cardmarketId and tcgplayerId carry the marketplace product id for the line's own finish — a Foil line exports the Foil id, a Standard line the Standard id — so each row maps straight to the matching listing on those marketplaces, and they also let a row import by marketplace id when it has no productId (see Keying a row by a marketplace id). Exported rows always carry a productId, which takes precedence, so an export still imports back unchanged.

inventoryId is what lets you reconcile a file against your live stock: productId identifies the card, but you can hold several lines of the same card in different conditions, languages, or locations, and inventoryId tells those lines apart. In a CSV file it leads the header and updatedAt closes it:

inventoryId,productId,name,expansion,url,game,printNumber,rarity,cardmarketId,tcgplayerId,finish,condition,gradedGrade,gradedCertification,gradedGradingService,language,quantity,customId,comment,notes,location,tags,listingPrice,listingCurrency,updatedAt
665f3a2b1c8d4e9f7a6b5c4d,50212,Charizard ex,Surging Sparks,https://cardnexus.com/en/explore/pokemon/surging-sparks/card/charizard-ex-50212,pokemon,223,Double Rare,728934,558991,Standard,NM,,,,en,5,YRG|5555-5DS1-2EX-006-f-2-2008-08-01,Slight edge wear on the back corner,Bought at the March show,Shelf A,"Trade binder, Graded pile",42.50,EUR,2026-06-01T17:40:02.000Z

In a JSON file they appear as keys on each row object:

{
  "productId": 50212,
  "name": "Charizard ex",
  "expansion": "Surging Sparks",
  "url": "https://cardnexus.com/en/explore/pokemon/surging-sparks/card/charizard-ex-50212",
  "game": "pokemon",
  "printNumber": "223",
  "rarity": "Double Rare",
  "cardmarketId": 728934,
  "tcgplayerId": 558991,
  "finish": "Standard",
  "condition": "NM",
  "language": "en",
  "quantity": 5
}

Updating lines in bulk

POST /v1/inventory/bulk/update changes up to 200 inventory lines in one call, without a file round-trip. Each item names one line — by inventoryId or by customId, exactly one of the two — and carries the changes to apply: quantity ({"set": n} or {"adjust": n}), condition, language, finish, comment, notes, location, tags, or listing. Send only the fields you want to change. The same change rules as PATCH /v1/inventory/{inventoryId} apply, including count splits, merging into an identical line, the location name (or null to clear), and the tags {"set"|"add"|"remove"} change object — see Tags & locations. location and tags names must already exist, or the item fails with LOCATION_NOT_FOUND or TAG_NOT_FOUND.

curl -X POST https://public-api.cardnexus.com/v1/inventory/bulk/update \
  -H "Authorization: Bearer cnk_live_4f2d8c91e5b34a7f9e6c2d1a8b5f7e3c" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 5e2a9c47-1f8b-4d63-a0e9-c7b3d5f8a1e4" \
  -d '{
    "items": [
      { "inventoryId": "665f3a2b1c8d4e9f7a6b5c4d", "quantity": { "adjust": -2 } },
      { "customId": "YRG|5555-5DS1-2EX-006-f-2-2008-08-01", "condition": "LP", "comment": "Light edge wear on the corners" },
      { "inventoryId": "665f3a2b1c8d4e9f7a6b5c52", "listing": { "price": { "amount": 42.5, "currency": "EUR" } } }
    ]
  }'

The response carries one result per item, in the order you sent them:

{
  "results": [
    { "index": 0, "status": "ok", "inventoryId": "665f3a2b1c8d4e9f7a6b5c4d" },
    {
      "index": 1,
      "status": "error",
      "code": "NOT_FOUND",
      "data": { "customId": "YRG|5555-5DS1-2EX-006-f-2-2008-08-01" }
    },
    { "index": 2, "status": "ok", "inventoryId": "665f3a2b1c8d4e9f7a6b5c52" }
  ]
}

listing sets the per-card price: { "price": { "amount": 42.5, "currency": "EUR" } } puts a line up for sale, or changes the price of a line that is already for sale, and "listing": null takes the line off sale. The price must be in your seller currency.

Items are checked one by one. Items that fail — unknown line (NOT_FOUND), quantity underflow (INSUFFICIENT_QUANTITY), invalid count (INVALID_COUNT), fields that cannot be applied together or a second item naming the same line (INVALID_ITEM), an unknown location (LOCATION_NOT_FOUND) or tag (TAG_NOT_FOUND) name, a listing price outside your seller currency (CURRENCY_MISMATCH), a listing on a line that is not for sale while your seller account is suspended or banned (SELLER_ACCOUNT_NOT_ACTIVE) — come back with status: "error" and do not block the rest. All items that pass are applied together: either every one of them takes effect, or — when they collide with each other or with another of your lines — none do and the call fails with CONFLICT.

Deleting lines in bulk

POST /v1/inventory/bulk/delete removes up to 200 lines in one call, whatever their quantity, and cancels the listing of any that are for sale. Name each line by inventoryId or by your own customId. Like a bulk update, it answers with one result per item.

Editing many lines with a preview

To change one field on every line that matches a filter, preview the change, then apply it.

POST /v1/inventory/bulk/edit/preview changes nothing. Send a selection and one action:

  • selection.filters takes the filters of POST /v1/inventory/search, market filters included; {} selects every line. Alternatively selection.inventoryIds names 1 to 1,000 lines. selection.excludeInventoryIds leaves lines out of a filtered selection.
  • action.type is set_quantity, delete, remove_from_sale, set_condition, set_language, set_location, set_comment, set_notes, add_tags or remove_tags, with the one extra field that type takes.

The response gives the exact number of lines matched, how many the change would leave alone and why (already holding the value, graded, sealed, or not printed in the language), the current values against the new one, up to 10 sample lines, the market value of the matched lines, and a planId valid for 24 hours.

POST /v1/inventory/bulk/edit/apply starts the plan and returns a job with kind: "edit". Poll the job until status is completed. The lines changed are the lines the selection matched when the job started; a line sold, removed or changed in the meantime is skipped and counted in counts.skipped. If more lines match when the job starts than the preview counted, nothing changes and the call returns 409 PLAN_STALE: preview again. Applying the same planId again returns the same job. An edit cannot be undone, except by running another edit.

While an import, a bulk edit or a price update runs, other inventory writes return 409 INVENTORY_BUSY; see Error handling.

  • Repricing — change prices by filter, with a draft and an undo
  • Webhooks — inventory.import.completed and inventory.export.completed payloads
  • Managing lines — per-line create, update, and delete
  • Inventory overview — the line model and customId