Bulk operations

Bulk operations move your whole inventory at once — importing a file of lines, exporting your lines to a file, and applying up to 200 targeted changes in a single call. Imports and exports 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"

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 idArticlecustomId 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 NMNM, EX and LPLP, GDMP, PLHP, PODMG.

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, 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 tagslocation as its name and tags as a comma-separated list in CSV (an array in JSON) — so your labels survive the round-trip.

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 next to productId, so a file tells you what each line is — and how it maps to Cardmarket and TCGplayer — without looking up the product:

Column Description
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: 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. In a CSV file they sit right after productId:

productId,name,expansion,url,game,printNumber,rarity,cardmarketId,tcgplayerId,finish,condition,gradedGrade,gradedCertification,gradedGradingService,language,quantity,customId,comment,notes,location,tags,listingPrice,listingCurrency
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

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, or tags. 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" }
    ]
  }'

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" }
    }
  ]
}

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 — 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.