The thing to know first

A 400 from v1 can arrive in four different JSON shapes. They come from four different layers — model binding, field validation, business rules and the controller — and nothing in the status line distinguishes them. An integration that branches on the status code alone will mis-read three of the four. Branch on the body, using the test in Telling them apart.

Status codes

The four bodies

1 · Business rule — message, integer errorCode, projectType

This is the one carrying a stable, structured code you can branch on, and it is what most refusals on POST /api/v1/marketplace-listings look like.
The code reads as project, category, error — 50 InStockRx, 01 Pharmacy, 11 the error itself. The ones you are most likely to meet on create:
On 500114 the message text is configured per state rather than fixed, so treat the string as display-only and branch on the code.
Every error in this shape is a 400 today, but that is a property of the current code rather than a promise: the status travels on the error itself and merely defaults to 400. Nothing sets anything else at present. Read the body, not the status.The same shape is also used for an unexpected server error, as a 500 with "errorCode": 0 and "projectType": "General". A zero code means something broke, not that a rule refused you.

2 · Field validation — isValid and errors.$values

Business-rule validators that run inside the command report this way. It is a .NET validation result serialised with reference handling turned on, which is where $id and $values come from.
$id is serialiser bookkeeping, not data — ignore it. The errors are at errors.$values, not at errors. A client that reads errors as an array finds an object and sees nothing at all.
errorCode here is a string, and that is what tells this shape apart from the one above at a glance. Some rules carry a stable code such as medicationScanRequired or ndcSellLimitReached; others were never given one and fall back to an internal validator name, which is not something to branch on. Key off errorCode where you recognise it and show errorMessage where you don’t — every errorMessage is written to be read by an end user. Listing rules lists which rules carry which code.

3 · Request shape — errors keyed by field

Produced before your request reaches the endpoint, when a field is missing or the wrong type. This is the framework’s own response, and the only body with title and status inside it.
Here errors is an object keyed by field name with an array of messages per field — not the $values array of shape 2. Same property name, different structure.

4 · Everything else — error, statusCode, message

Authentication, permission and listing-state problems.
This shape is not only for statuses above 400. It is also returned as a 400 — a missing pharmacyNpi on a request that needs one comes back this way rather than as shape 3. So a 400 on its own tells you nothing about which body to expect.

And one with no body at all — 429

A rate-limit rejection is produced before the request reaches the API’s error handling, so a 429 has an empty body. Everything it tells you is in the status line and the Retry-After header.

Telling them apart

Run these in order on the response body. Each test is on a field the other shapes don’t have.
Don’t assume message exists, and don’t assume there is a body to parse at all. Shapes 1 and 4 have a message; shapes 2 and 3 do not. Test for the distinguishing field first, and treat anything unrecognised as “show whatever text you can find, and stop”.

Common cases

If the body carries a numeric errorCode, your request was well-formed and a rule refused it. Nothing about the payload will fix it — the pharmacy is on probation, restricted, or the NDC is not one they may list. See Listing rules.
Request validation runs before authentication. A request that is both malformed and unauthenticated reports the malformed body, not 401. Fix the body; if you then get 401, it’s the key.
Marketplace listings, draft listings and cash offers can only be modified while Pending. Once purchased, accepted or cancelled, PUT and DELETE return 409. Re-read the object and check its status before offering an edit.
The NPI isn’t in your group. Call GET /api/v1/pharmacies/search to see the current membership — a pharmacy added by NPI stays pending until it registers with InStockRx.
The v1 ceiling is 300 requests per minute counted per calling IP, not per API key, and the window is fixed rather than rolling. A batch job sharing your egress address draws on the same allowance. See rate limits.
quantity is conditional on fullPackage: exactly 1 when fullPackage is true, between 0.01 and the drug’s package size when it’s false.

Showing errors to your users

message and errorMessage are written to be read by a pharmacist, and we intend to keep them that way. Passing them straight through is a reasonable default. The exception is the rules v1 cannot satisfy at all — certification files and 2D scan requirements. There is no way to complete those through this API today, so a raw error leaves your user stuck with no next step. Route those to a message pointing at the InStockRx web interface instead; Listing rules marks which ones they are.

Retries

v1 has no idempotency mechanism. Retrying a failed POST may create a second listing or offer.
Retry GET requests freely. For POST, PUT and DELETE, confirm the outcome with a search before retrying — a network timeout doesn’t tell you whether the write landed.
A 429 is the one safe rejection to retry as-is: it means the request was refused before anything was written. Wait for Retry-After, then send it again. Idempotency keys arrive with v2.