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 asEmail, with no linkedAvailableWholesaler): 404 (No Wholesaler matches the given query.).An active integration wholesaler whose linked
AvailableWholesalerbelongs 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 (
104857600bytes, also returned asmax_size_bytes). An empty file is rejected.Content type: exactly the
content_typeyou asked for —text/csvby default. A presigned URL pins one value, so a URL issued fortext/csvwill not accept anapplication/csvupload, 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 |
|---|---|---|---|
|
string |
No |
Always |
|
string |
No |
UUID identifying this event; stable across delivery attempts. See deduplication rules. |
|
number |
No |
ID of your registered |
|
string |
No |
Name of that |
|
number |
No |
Per-clinic |
|
number |
No |
The |
|
string |
No |
The |
|
string |
No |
The filename you supplied in Step 1. |
|
string |
No |
|
|
number |
No |
Data rows read from the file, excluding the header. |
|
number |
No |
Rows that became importable products. |
|
number |
No |
Rows dropped, each with an entry in |
|
boolean |
No |
|
|
array / null |
Yes |
Every rejected row, or |
|
array / null |
Yes |
Present only when |
|
string / null |
Yes |
Present only when |
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.
|
Meaning |
|---|---|
|
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. |
|
The file has no header row. Nothing could be read. |
|
A required column is absent from the header. |
|
The header is valid but the file contains no data rows. |
|
A required value was empty on this row. |
|
A value could not be read. |
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.
|
Meaning |
|---|---|
|
Every row was usable. |
|
At least one row was usable and at least one was not. |
|
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 |
|---|---|---|
|
string |
Product category: |
|
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. |
|
string |
Product display name. Must not be blank. Stored on the matched item; used for search and sorting in the import UI. |
|
decimal |
Purchase price excluding VAT ( |
Optional columns
Column |
Type |
Meaning |
|---|---|---|
|
string |
Unit of measure / package description, e.g. |
|
string |
Product barcode (EAN / GTIN). Stored on the item; used as a lowest-priority matching key when barcode matching is enabled. |
|
decimal |
VAT rate as a percentage (e.g. |
|
decimal |
Recommended retail price excluding VAT. Pre-populates the item’s selling price, and is the fallback source for |
|
string |
ISO 4217 currency of the prices, e.g. |
|
decimal |
Total dosable units across all packages. If omitted or not greater than 0, computed as |
|
decimal |
Dosable units in a single package (e.g. |
|
decimal |
Number of packages in one wholesale unit (e.g. |
|
string |
Active pharmaceutical ingredient, for medicines. |
|
integer |
Route-of-administration code for medicines ( |
|
decimal |
Strength / concentration of the active substance, for medicines. |
|
boolean |
Whether the product is a controlled substance. Accepts |
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.