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-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:

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 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.

  • 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_id is 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), on bytes rather than str.

  • A skew_window of ±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:

  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 signs every attempt sent from that point with the new secret, including retries of events scheduled earlier; you already accept it.

  4. Drop the old secret once any attempt still in flight when you sent the PATCH has 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 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.

Retrieving an available_wholesaler registered by a different OAuth application

404

Creating an available_wholesaler without verification_salt

400

webhook_callback_url is not HTTPS, or points to a private or loopback address

400

mark_delivered or set_wholesaler_reference_number 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

set_wholesaler_reference_number on an order still in Ordering in process

400

set_wholesaler_reference_number with a missing, empty or over-63-character value

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 empty, does not end in .csv, or contains .., /, \

400

Upload URL could not be generated (transient S3 error)

503, retryable — no Retry-After is sent, so use your own backoff

Rate limit exceeded — these endpoints are throttled like the rest of the REST API, see Rate limit

429, with Retry-After

Webhook callback fails — transient network/request error

Retried up to 5 times with exponential backoff