Scope, Webhooks and Errors
Note
Part of the Wholesaler Integrations guide.
A dedicated OAuth scope
Wholesaler endpoints are authenticated the same way as the rest of the REST
API — see Authentication via OAuth 2.0 — but accept a dedicated
wholesaler OAuth 2.0 scope instead of the general restapi scope.
Note
Wholesaler integration applications are not granted access to the
restapi scope. wholesaler is the only scope your application
will be issued.
Getting the scope enabled is not a self-service step. It is agreed with Provet as part of becoming an integration partner, alongside the commercial side of the arrangement, rather than requested through the API or switched on in a settings screen. If you have not started that conversation yet, begin with the Become an integration partner form; see Adding an Application in Provet for how application registration works more generally.
Once it is enabled for your application, request
wholesaler when obtaining your client_credentials access token. It
grants everything the wholesaler flow needs — registering and managing your
AvailableWholesaler entries (the registrations clinics pick you from,
defined in Registering an Available Wholesaler), uploading catalogs, and
the order actions described in this guide — without any additional
Provet permission. This is the point of the scope.
Note
wholesaler is the supported way to authenticate these endpoints.
Ownership is enforced on every write — a wholesaler-scoped token can
only act on the wholesalers tied to your own application; naming a
registration or a wholesaler that belongs to another integration is
rejected with 403 (Token does not match this wholesaler.).
Note
Read access is scoped to you automatically. A token carrying the
wholesaler scope sees only its own data: listing
available_wholesaler/ returns just your registrations, and retrieving
one you do not own responds 404.
Why webhooks are central to this integration
You learn that a clinic connected you (wholesaler.created), that a
catalog you uploaded was validated (catalog.processed), and that a
clinic placed an order with you (order.placed) exclusively through
webhooks delivered to your webhook_callback_url. Your integration
must run a public HTTPS endpoint that receives, verifies, and processes
these events before you can do anything useful with the API.
Important
These wholesaler webhooks are a separate mechanism from the
general-purpose Webhooks feature configurable in
Settings > General > Integrations > Webhooks. They are not
configured by clinic staff, are not listed alongside other webhook
triggers in List of Webhook Triggers, and use their own signing scheme
(below) rather than the classic webhook delivery format. Only
wholesaler.created, catalog.processed and order.placed are
ever delivered this way — do not expect to see or subscribe to them
through the classic webhooks system.
All event types share the same delivery, signing, and retry mechanics, described once here and referenced from the rest of this guide.
Webhook delivery and retries
Each POST to your webhook_callback_url uses a 10s timeout and does
not follow redirects — your callback URL must respond 2xx directly,
not via a redirect chain.
The registered webhook_callback_url is read and validated before each
attempt, so updating it also applies to retries already scheduled.
A delivery that fails — network error, timeout, or any non-2xx response — is retried with exponential backoff, up to 5 times.
Attempt |
Delay before this attempt |
|---|---|
1 (initial) |
n/a |
2 |
30s |
3 |
60s |
4 |
120s |
5 |
240s |
6 (final) |
480s |
The delays add up to ~15.5 minutes between the first attempt and the last one starting, so allow a little longer than that in wall-clock terms — each attempt can itself take up to the 10s timeout. If all retries are exhausted, delivery fails permanently.
What every event body contains
Every body carries an event discriminator naming the event type, and an
event_id identifying that particular event. The remaining fields depend
on the type: see Registering an Available Wholesaler for
wholesaler.created, Making Products Available for
catalog.processed and Handling Orders for
order.placed. Those three are the only types delivered today; further
types may be added, so ignore an event you do not recognise.
Deduplicating deliveries
An event may reach you more than once: a delivery that fails is retried, and a retry that succeeds after your endpoint already processed the first attempt means you receive the same event twice. It may also never reach you: once the retries above are exhausted the event is dropped, and it is not delivered again.
Every event carries an event_id — a UUID identifying the event, not
the delivery attempt:
Generated once, when the event is scheduled; every retry of that event carries the same
event_id.Two genuinely separate events always have different ids, even when the rest of the payload is identical.
Present on every event type, current and future.
Mirrored in an
X-Webhook-Event-Idheader — the payload field is canonical; the header is a convenience for deduping before parsing the body.
Keep the ids you have processed (a few days is plenty, given the ~15.5
minute retry window) and discard a delivery whose event_id you have
already handled. See Handling Orders for a nuance specific
to order.placed and repeated deliveries for the same order.
Separate events with otherwise-identical business data have different
event_id values and therefore different bodies and signatures — no
special handling is needed to tell them apart. A retry of the same
event keeps the same event_id and body; verify and deduplicate it
normally by event_id as described above.
Webhook signature verification
Every delivery to your webhook_callback_url is signed with HMAC-SHA256
using a shared secret you provide — verification_salt — so you can
verify it genuinely came from Provet. Two headers carry the
signature:
Content-Type: application/json
X-Webhook-Signature: v1=<hex>
X-Webhook-Timestamp: <unix seconds>
X-Webhook-Event-Id: <uuid>
Algorithm: HMAC-SHA256.
Signed string:
"{timestamp}.{raw_body}"— the value of theX-Webhook-Timestampheader, a literal., then the raw request body (the exact bytes received, before any re-serialization).Encoding: lowercase hex.
Scheme version: the
v1=prefix versions the whole scheme (algorithm + signed-string format + encoding) as one unit. Future changes ship asv2=with a full new definition — allowlist the versions you accept and reject unknown ones; the scheme is never negotiated by the sender.Scope: each attempt is timestamped and signed as it is sent, with the secret registered at that moment. Retries of an event carry fresh header values;
event_idis what stays constant across them.
import hashlib, hmac, time
def verify(raw_body: bytes, headers, salt: str, skew_window: int = 300) -> bool:
# Header names are case-insensitive, and ASGI, HTTP/2 and some gateways
# hand you them lowercased, so normalise rather than matching casing.
headers = {name.lower(): value for name, value in headers.items()}
signature = headers.get("x-webhook-signature", "")
timestamp = headers.get("x-webhook-timestamp", "")
if not signature.startswith("v1="):
return False # reject unknown scheme versions
try:
timestamp_int = int(timestamp)
except (TypeError, ValueError):
return False # reject non-numeric timestamps
if abs(int(time.time()) - timestamp_int) > skew_window:
return False # reject stale / replayed deliveries
expected = hmac.new(
salt.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
).hexdigest()
# Compare bytes, not str: compare_digest raises TypeError on a non-ASCII
# str, so a crafted header would 500 instead of being rejected.
return hmac.compare_digest(expected.encode(), signature[len("v1="):].encode())
Compute the HMAC over the raw body bytes exactly as received — do not parse and re-serialize the JSON first, or the bytes (and the signature) can change.
Always compare in constant time (
hmac.compare_digest), onbytesrather thanstr.A
skew_windowof ±5 minutes (300s) tolerates clock drift and delivery latency while still rejecting replays. It covers every attempt, including the last retry, since each one is stamped as it is sent.
Warning
Delivery bodies can contain clinic credentials in plain text.
wholesaler.created carries the username and password the clinic
entered for their account with you, decrypted (see
Registering an Available Wholesaler).
Handle the raw body accordingly: do not log it, do not store it in a request trace or an error report, and do not keep the payload after you have extracted what you need. This is easy to get wrong while debugging a signature mismatch, which is exactly when logging the raw body is tempting.
You provide verification_salt when you register your
AvailableWholesaler (see Registering an Available Wholesaler) — it is
required on create, write-only (never returned by any response), and
updates take effect atomically with no grace period, so coordinate
rotation from your side:
Add the new secret to your accepted set (verify against old and new).
PATCHthe new secret to Provet asverification_salt(see Registering an Available Wholesaler) — a salt-only update should usePATCH, notPUT.Provet signs every attempt sent from that point with the new secret, including retries of events scheduled earlier; you already accept it.
Drop the old secret once any attempt still in flight when you sent the
PATCHhas finished — a matter of seconds.
If a secret leaks, rotate it through the update path above as soon as possible.
Error reference
These are the responses specific to this integration — the ones you cannot infer from ordinary REST conventions. Authentication, field validation, throttling and server errors otherwise behave as they do everywhere else in the REST API, so this is not an exhaustive list of everything an endpoint can answer.
Situation |
Response |
|---|---|
OAuth token invalid/expired |
401 |
Wholesaler exists as an active integration wholesaler but belongs to a different OAuth application |
403 |
Write (update / delete) on an |
403 |
Retrieving an |
404 |
Creating an |
400 |
|
400 |
|
403 |
|
400 |
|
400 |
|
400 |
Creating an |
401 |
Wholesaler id doesn’t exist, is archived, or isn’t an integration wholesaler |
404 |
Filename is empty, does not end in |
400 |
Upload URL could not be generated (transient S3 error) |
503, retryable — no |
Rate limit exceeded — these endpoints are throttled like the rest of the REST API, see Rate limit |
429, with |
Webhook callback fails — transient network/request error |
Retried up to 5 times with exponential backoff |