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-Id header — 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 the X-Webhook-Timestamp header, 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 as v2= 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_window of ±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:

  1. Add the new secret to your accepted set (verify against old and new).

  2. PATCH the new secret to Provet as verification_salt (see Registering an Available Wholesaler) — a salt-only update should use PATCH, not PUT.

  3. Provet starts signing new deliveries with the new secret right away; you already accept it.

  4. 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 Token does not match this wholesaler.

Write (update / delete) on an available_wholesaler registered by a different OAuth application

403 Token does not match this wholesaler.

mark_delivered on an order placed with another application’s wholesaler, or with no integration wholesaler at all

403 Token does not match this wholesaler.

mark_delivered on an order that is not in Ordered status

400

Creating an available_wholesaler with a legacy API key instead of an OAuth2 token

401

Wholesaler id doesn’t exist, is archived, or isn’t an integration wholesaler

404 No Wholesaler matches the given query.

Filename is not .csv or contains .., /, \

400

S3 presign fails

503

Webhook callback fails — bad/invalid webhook_callback_url

Logged and dropped immediately, non-retryable

Webhook callback fails — transient network/request error

Retried up to 5 times with exponential backoff