Introduction
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.
Once Provet has enabled it 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, 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. Retrieving a single order
(GET /<provet_id>/api/0.1/order/<id>/) is likewise limited to
orders placed with your own wholesalers.
Why webhooks are central to this integration
Nothing in this flow is pollable as a primary mechanism. You learn that a
clinic connected you (wholesaler.created) 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 — there is no endpoint that lists “orders
placed since X” as a substitute for the order webhook, for example.
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 and order.placed are ever delivered this
way — do not expect to see or subscribe to them through the classic
webhooks system.
Both 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.
Invalid callback URL are not retried.
Network error, timeout, or non-2xx response: retried with exponential backoff, up to 5 retries.
Attempt |
Delay before this attempt |
|---|---|
1 (initial) |
— |
2 |
30s |
3 |
60s |
4 |
120s |
5 |
240s |
6 (final) |
480s |
Total retry window is ~15.5 minutes from the initial attempt to the last retry. If all retries are exhausted, delivery fails permanently.
Deduplicating deliveries
Delivery is at-least-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.
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:
X-Webhook-Signature: v1=<hex>
X-Webhook-Timestamp: <unix seconds>
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.
import hashlib, hmac, time
def verify(raw_body: bytes, headers, salt: str, skew_window: int = 300) -> bool:
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()
return hmac.compare_digest(expected, signature[len("v1="):])
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).A
skew_windowof ±5 minutes (300s) tolerates clock drift and delivery latency while still rejecting replays.
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 starts signing new deliveries with the new secret right away; you already accept it.
Keep the old secret for a while to cover deliveries that were already scheduled (queued for a retry) before the rotation — those still carry the old signature until they either succeed or exhaust their retries. Drop the old secret once you’re confident none are still in flight.
If a secret leaks, rotate it through the update path above as soon as possible.
Error reference
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 |
|
403 |
|
400 |
Creating an |
401 |
Wholesaler id doesn’t exist, is archived, or isn’t an integration wholesaler |
404 |
Filename is not |
400 |
S3 presign fails |
503 |
Webhook callback fails — bad/invalid |
Logged and dropped immediately, non-retryable |
Webhook callback fails — transient network/request error |
Retried up to 5 times with exponential backoff |