Making Products Available

Note

Part of the Wholesaler Integrations guide. This step assumes you have already completed Registering an Available Wholesaler — catalog uploads are checked against a specific clinic’s Wholesaler and its linked AvailableWholesaler.

Once a clinic has connected your wholesaler (and you have received its wholesaler.created webhook), you can push a product catalog for that clinic. Clinic staff later import the catalog into their own stock from the Provet UI.

A CSV file is the only way to provide a catalog. There is no endpoint that accepts individual products one at a time, and no other file format is accepted — every product you want a clinic to be able to import must be a row in the CSV described below, uploaded as a whole file in one go (see CSV column reference).

A minimal example file, using only the required columns:

product_type,product_number,product_name,wholesale_price,vat
Medicine,ACM-001,Amoxicillin 250mg,4.50,24
Supply,ACM-200,Surgical Gloves M,0.80,14

Step 1 — Request a presigned upload URL

# pk = the Wholesaler ID received in the wholesaler.created webhook.
curl -X POST \
  "https://[env.]provetcloud.com/<provet_id>/api/0.1/wholesaler/<wholesaler_pk>/catalog_upload_url/" \
  -H "Authorization: Bearer <oauth_access_token>" \
  -H "Content-Type: application/json" \
  -d '{"filename": "acme-catalog-2026-06.csv"}'

content_type may also be supplied and must be one of text/csv or application/csv. It defaults to text/csv, so most integrations can leave it out; send it only if your uploader labels the file differently.

Response:

{
  "upload_id": 55,
  "catalog_id": "3f1b8c72-5d64-4a09-9f4e-1c2d7a6b30ee",
  "filename": "acme-catalog-2026-06.csv",
  "s3_key": "<opaque S3 object key>",
  "url": "<presigned S3 upload URL>",
  "fields": {
    "key": "<opaque S3 object key>",
    "Content-Type": "text/csv",
    "AWSAccessKeyId": "...",
    "policy": "...",
    "signature": "...",
    "x-amz-security-token": "..."
  },
  "expected_columns": ["product_type", "product_number", "..."],
  "content_type": "text/csv",
  "max_size_bytes": 104857600
}

catalog_id identifies the catalog you are about to upload and comes back to you in the catalog.processed event, so keep it: it is how you tie an outcome to the file you sent. Each new catalog gets a new one — a new file is a new catalog, not a revision of the last. Requesting a URL again before you have uploaded returns the same pending upload, and with it the same catalog_id, so retrying the request is safe and does not start a second catalog.

s3_key, url, and fields are opaque — pass them through as-is to the S3 upload in Step 2 without parsing or constructing them yourself. fields is not a fixed set: submit every key-value pair it returns, not just the ones shown here.

The URL is valid for five minutes. After that the upload answers 403 AccessDenied with Policy expired — the same status as a rejected content type, so read the message to tell them apart. Request a new URL and upload again; doing so reuses the same pending upload rather than creating a second one, so the upload_id you already hold stays valid. Request the URL when you are ready to upload rather than holding one open.

The five-minute window bounds when the upload may start, not how long it may take: S3 checks the expiry when it receives the request, so a transfer that begins inside the window completes however long the body takes to send.

If the upload URL cannot be generated, this call answers 503 and keeps no record of the attempt, so retrying simply starts a fresh upload.

Authorization check. The target must be an active integration wholesaler — not archived, and created from an AvailableWholesaler (Registering an Available Wholesaler). Checks run in order, so the failure you see depends on which condition is not met:

  • Not an active integration wholesaler (does not exist, archived, or a wholesaler registered with a type other than Integration, such as Email, with no linked AvailableWholesaler): 404 (No Wholesaler matches the given query.).

  • An active integration wholesaler whose linked AvailableWholesaler belongs to a different OAuth application: 403 (Token does not match this wholesaler.).

Filename validation. The name must end in .csv, checked case-insensitively — .CSV is equally fine — and must not contain /, \ or ... Surrounding whitespace is trimmed before both checks, and the trimmed name cannot be empty. Nothing else is restricted, so the rest of the name is yours to choose.

Upload limits. The presigned URL carries the limits below in its signed policy, so S3 enforces them itself:

  • Size: at least 1 byte and at most 100 MB (104857600 bytes, also returned as max_size_bytes). An empty file is rejected.

  • Content type: exactly the content_type you asked for — text/csv by default. A presigned URL pins one value, so a URL issued for text/csv will not accept an application/csv upload, and vice versa. Request a new URL to switch.

Violations are rejected by S3 at upload time in Step 2, with an XML error body naming the cause:

  • Too large: 400 EntityTooLarge.

  • Empty: 400 EntityTooSmall.

  • Wrong content type: 403 AccessDenied (Invalid according to Policy: Policy Condition failed).

Nothing is stored and no notification is sent, so the upload_id from Step 1 simply stays pending; fix the file and retry against the same URL until it expires.

The size limit is re-checked against the stored object once it lands, so an object that somehow bypasses the upload policy is still not imported.

Step 2 — Upload the CSV directly to S3

curl --request POST \
  --url "<url from step 1>" \
  --form "key=<key from fields>" \
  --form "Content-Type=<Content-Type from fields>" \
  --form "AWSAccessKeyId=<AWSAccessKeyId from fields>" \
  --form "policy=<policy from fields>" \
  --form "signature=<signature from fields>" \
  --form "x-amz-security-token=<x-amz-security-token from fields, if present>" \
  --form "file=@/path/to/acme-catalog-2026-06.csv"

Include every field returned under fields in Step 1 as its own --form entry, all of them before file, which must come last — including the Content-Type field, which the signed policy requires. Their order among themselves does not matter. Do not set a Content-Type: multipart/form-data header yourself — curl --form generates the multipart body and its matching boundary for you, and a manually-set header without that boundary breaks the upload. The Content-Type form field describes the uploaded object and is unrelated to that request header.

Uploads that break the size or content-type limits are rejected at this step; see Step 1 — Request a presigned upload URL for the exact errors.

Note

Nothing needs to be reported back to Provet once the upload completes — Provet is notified automatically by S3. The file is read and validated within moments of landing, and the outcome is delivered to you as a catalog.processed event. You do not need to wait for a clinic to import anything to find out whether your file was usable.

Step 3 — Read the outcome

catalog.processed fires once Provet has finished validating the uploaded file — see catalog.processed below for the payload. Until it arrives the upload is still being processed; there is no endpoint to poll.

catalog.processed event reference

Provet POSTs this event to available_wholesaler.webhook_callback_url once it has finished validating an uploaded catalog. It fires for every completed upload, successful or not — there is no separate failure event, so a single handler sees every outcome. Read upload.status to tell them apart.

Note

This event is also published in the API reference, whose machine-readable payload schema you can generate a client from.

catalog.processed is sent exactly once per outcome, but S3 can hand us the same uploaded object more than once. If that happens you receive the event again with the same event_id and catalog_id, so the ordinary deduplication rules apply — unlike order.placed, a repeat here never means something new happened.

The name says processed, not uploaded: bytes landing in S3, Provet validating the file, and a clinic importing it into stock are three different moments. This event is the middle one — the third may never happen at all.

{
  "event": "catalog.processed",
  "event_id": "9f2c8e1a-4c1e-4f8a-9d2b-7d3f6b5a1c04",
  "available_wholesaler": {"id": 7, "name": "Acme Veterinary Supplies"},
  "wholesaler": {"id": 123},
  "upload": {
    "id": 55,
    "catalog_id": "3f1b8c72-5d64-4a09-9f4e-1c2d7a6b30ee",
    "filename": "acme-catalog-2026-06.csv",
    "status": "partially_accepted",
    "rows_total": 12043,
    "rows_accepted": 12003,
    "rows_rejected": 40,
    "errors": [
      {
        "row": 87,
        "code": "missing_required_field",
        "field": "product_name",
        "detail": "'product_name' is required and was empty."
      }
    ],
    "errors_truncated": false,
    "error_summary": null,
    "error_report_url": null
  }
}

Field

Type

Optional

Meaning

event

string

No

Always "catalog.processed".

event_id

string

No

UUID identifying this event; stable across delivery attempts. See deduplication rules.

available_wholesaler.id

number

No

ID of your registered AvailableWholesaler.

available_wholesaler.name

string

No

Name of that AvailableWholesaler.

wholesaler.id

number

No

Per-clinic Wholesaler ID the catalog was uploaded against.

upload.id

number

No

The upload_id returned in Step 1.

upload.catalog_id

string

No

The catalog_id returned in Step 1. Use this to match the outcome to the file you uploaded.

upload.filename

string

No

The filename you supplied in Step 1.

upload.status

string

No

accepted, partially_accepted or rejected — see Partial uploads are accepted.

upload.rows_total

number

No

Data rows read from the file, excluding the header.

upload.rows_accepted

number

No

Rows that became importable products.

upload.rows_rejected

number

No

Rows dropped, each with an entry in errors or counted in error_summary.

upload.errors_truncated

boolean

No

true when there were too many errors to send inline. errors is then null and you must use error_summary and error_report_url.

upload.errors

array / null

Yes

Every rejected row, or null when errors_truncated is true. Never a partial list.

upload.error_summary

array / null

Yes

Present only when errors_truncated is true: exact counts per code and field, e.g. {"code": "missing_required_field", "field": "product_name", "count": 5}.

upload.error_report_url

string / null

Yes

Present only when errors_truncated is true: a pre-signed URL for a JSON file holding the complete error list. Valid for up to one hour from delivery, and may be null if we could not sign one — download it as soon as you receive the event rather than storing the URL.

Each entry in errors (and in the downloadable report) has the same shape: row (1-based line number in your file, null for problems with the file as a whole), code, field (the offending column, or null), and a human-readable detail.

code

Meaning

unreadable_file

The file could not be read as UTF-8 CSV at all — wrong encoding, or not a CSV. Save it as UTF-8 CSV and upload again.

missing_header

The file has no header row. Nothing could be read.

missing_column

A required column is absent from the header. field names it.

empty_file

The header is valid but the file contains no data rows.

missing_required_field

A required value was empty on this row. field names the column.

invalid_value

A value could not be read. field names the column.

Note

Errors are all-or-nothing by design. Up to 100 of them are delivered in full; beyond that you receive exact per-code counts and a download link rather than the first 100. A partial list cannot be acted on — you would have no way of knowing whether the errors you cannot see are more of the same or something different.

Partial uploads are accepted

A bad row does not spoil the file. Rows are validated individually, and every row that is usable is imported even if the rest are not — one valid product out of ten thousand rows still yields a catalog of one product. It is up to you to correct the rejected rows and re-upload if you want them; Provet will not chase you for them.

upload.status

Meaning

accepted

Every row was usable.

partially_accepted

At least one row was usable and at least one was not.

rejected

No usable product was produced — either the file was structurally broken (no header, a missing required column, no data rows) or every individual row failed.

Important

A rejected upload changes nothing. The clinic keeps whatever catalog it had before, and your previous upload stays live. This matters most for the case that looks like bad luck rather than a broken file — a locale mistake such as decimal commas in every price passes the header check but fails every row, and would otherwise leave the clinic with an empty product list.

An accepted or partially_accepted upload replaces the previous catalog for that clinic in full. Each upload is the complete catalog, not a delta: products missing from a new file simply stop being offered for import. Nothing is marked discontinued in the clinic’s own stock as a result — items they already imported are untouched.

CSV column reference

Each row describes one product. A header row is mandatory; columns are matched by name and may appear in any order. Files are read as UTF-8 (a byte-order mark is tolerated), values are trimmed of surrounding whitespace, and a blank optional cell is treated as “not set”.

Required columns

The header must contain product_type, product_number, product_name, and at least one of wholesale_price or product_price. A header missing any of those rejects the whole file, since no row in it could be read.

Column

Type

Meaning

product_type

string

Product category: Food, Medicine, Supply, or Unknown (case-insensitive; blank becomes Unknown). Any other value rejects the row. Selects which stock item type the row is matched against; Unknown matches across all types.

product_number

string

Your product code / SKU. Must not be blank. Primary matching key: matched against each item’s primary, then secondary, then tertiary wholesaler code, and written to the item’s wholesaler code on import.

product_name

string

Product display name. Must not be blank. Stored on the matched item; used for search and sorting in the import UI.

wholesale_price

decimal

Purchase price excluding VAT (0 allowed). Becomes the item’s wholesale price and drives price-change detection. Falls back to product_price if blank; a row with neither is rejected.

Optional columns

Column

Type

Meaning

product_uom

string

Unit of measure / package description, e.g. bottle or box of 10. Stored as the item’s package description.

product_barcode

string

Product barcode (EAN / GTIN). Stored on the item; used as a lowest-priority matching key when barcode matching is enabled.

vat

decimal

VAT rate as a percentage (e.g. 24), not a fraction. Matched to the item’s VAT group percentage. Clinic staff choose a VAT group during import, so a blank is accepted.

product_price

decimal

Recommended retail price excluding VAT. Pre-populates the item’s selling price, and is the fallback source for wholesale_price when that is blank.

currency_code

string

ISO 4217 currency of the prices, e.g. EUR. Descriptive metadata; not validated.

dosage_units

decimal

Total dosable units across all packages. If omitted or not greater than 0, computed as unit_size × wholesale_package_size, ultimately defaulting to 1.0.

unit_size

decimal

Dosable units in a single package (e.g. 100 for a 100 ml bottle). Only used as an input to dosage_units; values of 0 or less are ignored.

wholesale_package_size

decimal

Number of packages in one wholesale unit (e.g. 3 for a 3×100 multipack). Only used as an input to dosage_units; values of 0 or less are ignored.

active_substance

string

Active pharmaceutical ingredient, for medicines.

administration_method

integer

Route-of-administration code for medicines (133; e.g. 1 = CRI, 14 = IM, 19 = IV, 23 = PO, 25 = SC, 27 = Topical). An unrecognized value rejects the row.

drug_strength

decimal

Strength / concentration of the active substance, for medicines.

controlled_substance

boolean

Whether the product is a controlled substance. Accepts true, false, 1 or 0 (case-insensitive); any other value rejects the row. Blank means not stated. Applied only when the import creates a new medicine: it is dropped for other item types, and an item that already exists in the clinic’s stock is never changed.

Note

wholesale_price is the clinic’s purchase cost; product_price is the suggested resale price. Matching is always scoped by product_type, then resolved by product_number (primary, then secondary, then tertiary wholesaler code), with barcode as a fallback when barcode matching is enabled.

Clinic-side import

Importing the uploaded catalog into stock is a manual step clinic staff perform later from the Provet UI, under Settings > Import & Export > Import from Lists — it is not something your system calls or needs to wait for. Clinic staff filter by wholesaler and item type, review new items (highlighted) and updates to existing items (compared side-by-side) in the generated list, then assign a sub-group, VAT group, and markup before finalizing the import.

See Import or Update Items from Integrated Wholesalers for the full customer-facing walkthrough of this screen.

Problems with your file are reported to you at upload time via catalog.processed, not here — by the time a clinic opens this screen, only rows that validated are present. Note that clinic staff also filter the list by item type and search term while importing; rows they filter out are their own choice, not errors, and are never reported to you.

See the error reference for the integration-specific errors your own API calls can receive.