> ## Documentation Index
> Fetch the complete documentation index at: https://docs.hi-doctor.ai/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> Connect the Hi Doctor MCP connector at https://mcp.hi-doctor.ai/mcp. Add this URL as a remote HTTP MCP server in your client, then sign in and authorise access through OAuth. Setup: https://docs.hi-doctor.ai/mcp/connect.
>
> The REST API base is https://api.hi-doctor.ai. Hi Doctor Docs at https://docs.hi-doctor.ai/mcp searches documentation; connect to the product endpoint above for patient tools.
>
> Hi Doctor operates across the whole European Union: residents of all 27 EU member states can use the service, and every consultation is reviewed by an EU-licensed doctor. Every consultation is reviewed in full by an EU-licensed doctor, who decides on medical grounds only. No algorithm, assistant or API call approves a consultation, issues a prescription or changes a dose. The service is available in eight languages: English, Spanish, German, Dutch, Italian, French, Portuguese and Chinese.
>
> ## Rules an assistant must follow
>
> - Never invent, assume or infer a medical answer. "I don't know" is a real answer: ask the patient to pick the closest option rather than guessing for them.
> - Never give medical advice and never suggest a medication or a dose. An EU-licensed doctor decides those.
> - If a questionnaire returns `ineligible`, say so plainly. Do not retry with altered answers.
> - Before anything that costs money or cancels a plan, state exactly what will happen and get an explicit yes.
> - Cancellation takes effect at the **end of the paid period**, not immediately. Say so.
> - An early order is irreversible: it creates the renewal consultation immediately and uses up the period's one early order. Explain that and get an explicit yes.
> - Prescription and invoice links are medical documents. A `signed_redirect` link opens without signing in: give it only to the patient, never repeat it anywhere shared.
> - Send option `value`s, not the labels shown to the patient. Labels are translated, values are stable.
> - Never ask for card details. Payment happens on Stripe's hosted checkout via a link.
> - On severe or sudden symptoms (chest pain, difficulty breathing, severe allergic reaction, suicidal thoughts), stop and direct the patient to their local emergency number.

# Partner API webhooks: events, signing and retries

> Hi Doctor Partner API webhooks: event types, the full-snapshot envelope, Standard Webhooks signatures in Node and Python, retries and replay.

Partner API webhooks tell your system when a consultation or prescription changes, so you do not have to poll. Every event is signed with the Standard Webhooks scheme, carries the complete consultation snapshot and has a version number for ordering. Verify the signature, deduplicate on `webhook-id`, keep the highest version, and answer with a `2xx` status quickly.

Last reviewed: 2026-11-04

Events follow the statuses described in [Consultation lifecycle](https://docs.hi-doctor.ai/partners/consultation-lifecycle.md) and the prescription data described in [Prescriptions](https://docs.hi-doctor.ai/partners/prescriptions.md).

## Register an endpoint

Manage endpoints in the [Business portal](https://docs.hi-doctor.ai/partners/business-portal.md) or with the API. Scopes: `webhooks:manage`, plus `consultations:read` and `prescriptions:read`, because events carry full consultation and prescription data; every webhook endpoint and delivery route needs all three. Endpoints belong to one mode: test events go only to test endpoints, live events only to live endpoints.

| Method and path | Does |
| - | - |
| `GET /v1/partner/webhook-endpoints` | List your endpoints for the key's mode. |
| `POST /v1/partner/webhook-endpoints` | Create an endpoint: `url`, optional `description`, `events` (`["*"]` or a list of the event types below, except `endpoint.test`). Returns the signing secret **once**. |
| `PATCH /v1/partner/webhook-endpoints/{id}` | Change `url`, `description`, `events`, or enable or disable it. |
| `DELETE /v1/partner/webhook-endpoints/{id}` | Delete the endpoint. |
| `POST /v1/partner/webhook-endpoints/{id}/rotate-secret` | Issue a new signing secret. See [Secret rotation](#secret-rotation). |
| `POST /v1/partner/webhook-endpoints/{id}/test` | Send an `endpoint.test` event with a fixed sample body. |
| `GET /v1/partner/webhook-endpoints/{id}/deliveries` | Delivery attempts with status codes, newest first, with [cursor pagination](https://docs.hi-doctor.ai/partners/pagination.md). |
| `POST /v1/partner/webhook-deliveries/{id}/replay` | Deliver an event again. See [Replay](#replay). |

Endpoint URLs must use `https` on port 443, with a public host name. Redirects are not followed.

## Event types

| Type | Sent when |
| - | - |
| `consultation.submitted` | The consultation was created. |
| `consultation.in_review` | A doctor opened it, or you answered an information request. |
| `consultation.info_requested` | The doctor asked for structured information. |
| `consultation.approved` | The doctor approved treatment. |
| `consultation.rejected` | The doctor declined treatment. |
| `consultation.cancelled` | You cancelled it. |
| `prescription.issued` | A prescription became available. |
| `prescription.cancelled` | A prescription was cancelled. |
| `prescription.replaced` | A prescription was replaced by a new one. |
| `consultation.updated` | The consultation resource changed without a status change or a prescription event. |
| `endpoint.test` | You sent a test event with `POST /v1/partner/webhook-endpoints/{id}/test`. It goes only to that endpoint and cannot be subscribed to: listing it in `events` fails with `422 invalid_webhook_events`. |

Accept event types you do not know and ignore them: new types can be added at any time.

## Event envelope

```json theme={null}
{
  "id": "5a0f2c1e-7d3b-4a9e-8f61-2b4c6d8e0a12",
  "object": "event",
  "type": "prescription.issued",
  "mode": "live",
  "created_at": "2026-11-03T11:24:51Z",
  "version": 4,
  "data": {
    "id": "6b1c9a52-2f0d-4e8e-9d1e-7a4c0b2e5f10",
    "object": "consultation",
    "status": "approved",
    "version": 4,
    "prescriptions": [
      { "id": "3e9d2b7a-5c41-4f08-8a6e-1b2c3d4e5f60", "consultation_id": "6b1c9a52-2f0d-4e8e-9d1e-7a4c0b2e5f10", "status": "issued", "active": true }
    ],
    "prescription_id": "3e9d2b7a-5c41-4f08-8a6e-1b2c3d4e5f60"
  }
}
```

The `data` object above is shortened. In real events `data` is the **complete consultation resource**, exactly as `GET /v1/partner/consultations/{id}` returns it at that version, including every prescription. Prescription events add `prescription_id` naming the prescription that changed.

## Ordering: keep the highest version

Events can arrive late, more than once, or out of order. Prescription events in particular come from a periodic reconciliation and can arrive after a later consultation event. Do not apply events as partial updates. Instead:

1. Read `data.id` (the consultation) and `version`.
2. If you already stored a snapshot for that consultation with an equal or higher `version`, ignore the event.
3. Otherwise replace your stored consultation **wholesale** with `data`.

Because every event carries the whole snapshot, the highest version is always the full current state, whatever order the events arrived in.

## Headers

Deliveries follow the [Standard Webhooks](https://www.standardwebhooks.com/) specification.

| Header | Value |
| - | - |
| `webhook-id` | The event `id`. The same on every delivery attempt and every replay of the event: deduplicate on it. |
| `webhook-timestamp` | When this attempt was signed, in Unix seconds. |
| `webhook-signature` | One or more space-separated `v1,<base64 signature>` entries. During a secret rotation it carries one entry per secret, newest first. |
| `webhook-event-type` | The event `type`, for example `consultation.approved`. |
| `webhook-delivery-attempt` | Attempt number, starting at 1. |
| `Content-Type` | `application/json` |
| `User-Agent` | `HiDoctor-Webhooks/1` |

Header names are case-insensitive: read them case-insensitively, as your framework normally does.

## Verify the signature

Your endpoint's signing secret looks like `whsec_` followed by standard base64 (`A-Z`, `a-z`, `0-9`, `+`, `/`, `=` padding). The HMAC key is the **decoded bytes** of the part after `whsec_`, not the secret string. The signature is the base64-encoded HMAC-SHA256 of `{webhook-id}.{webhook-timestamp}.{body}`, where `body` is the **raw request body bytes**: verify before parsing JSON, because re-serialising changes the bytes.

1. Read `webhook-id`, `webhook-timestamp` and `webhook-signature`. Reject the request if `webhook-id` or `webhook-signature` is missing or empty, or if `webhook-timestamp`, trimmed, is not only the digits `0-9`.
2. Reject the request if the timestamp is more than **300 seconds** away from your clock, in either direction, both counted in whole seconds (drop the fraction of your clock). Exactly 300 seconds is accepted.
3. Decode your secret: strip `whsec_` and base64-decode the rest strictly. Hi Doctor secrets decode to 32 bytes; a secret that is not valid standard base64 of 24 to 64 bytes never verifies.
4. Compute the HMAC-SHA256 of `{webhook-id}.{timestamp}.` followed by the raw body, writing the timestamp as an integer without leading zeros, and base64-encode it with padding.
5. Split `webhook-signature` on single spaces. In each entry, the version is the text before the first `,` and the signature is everything after it. Ignore entries whose version is not exactly `v1` or whose signature is empty.
6. Accept if your value equals **any** `v1` signature, using a constant-time comparison.

Any Standard Webhooks library verifies Hi Doctor deliveries: the official `standardwebhooks` packages exist for Node.js, Python and other languages, and some webhook platforms ship compatible verifiers. Pass the secret with its `whsec_` prefix and the raw body. The samples below implement the same steps with only the standard library.

```javascript Node.js theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";

const SECRET = process.env.HD_WEBHOOK_SECRET; // whsec_...
const TOLERANCE_SECONDS = 300;
const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;

function signingKey(secret) {
  if (typeof secret !== "string" || !secret.startsWith("whsec_")) return null;
  const encoded = secret.slice("whsec_".length);
  // Strict standard base64: Buffer.from() alone would also accept url-safe or broken input.
  if (!BASE64.test(encoded)) return null;
  const key = Buffer.from(encoded, "base64");
  return key.length >= 24 && key.length <= 64 ? key : null;
}

export function verifyWebhook(rawBody, headers, secret = SECRET, now = Date.now() / 1000) {
  const lowered = {};
  for (const [name, value] of Object.entries(headers ?? {})) lowered[name.toLowerCase()] = value;
  const id = lowered["webhook-id"] ?? "";
  const timestamp = String(lowered["webhook-timestamp"] ?? "").trim();
  const signatures = [];
  for (const entry of String(lowered["webhook-signature"] ?? "").split(" ")) {
    const comma = entry.indexOf(",");
    if (comma !== -1 && entry.slice(0, comma) === "v1" && comma < entry.length - 1) {
      signatures.push(entry.slice(comma + 1));
    }
  }
  if (!id || !/^[0-9]+$/.test(timestamp) || signatures.length === 0) return false;
  // Whole seconds on both sides, like Hi Doctor: drop the fraction of the clock.
  const seconds = Number(timestamp);
  if (Math.abs(Math.trunc(now) - seconds) > TOLERANCE_SECONDS) return false;
  const key = signingKey(secret);
  if (key === null) return false;
  const expected = Buffer.from(
    createHmac("sha256", key).update(`${id}.${seconds}.`).update(rawBody).digest("base64"),
  );
  return signatures.some((signature) => {
    const candidate = Buffer.from(signature);
    return candidate.length === expected.length && timingSafeEqual(candidate, expected);
  });
}

const app = express();
app.post("/hi-doctor/webhooks", express.raw({ type: "application/json" }), (req, res) => {
  if (!verifyWebhook(req.body, req.headers)) {
    return res.status(400).send("invalid signature");
  }
  const event = JSON.parse(req.body.toString("utf8"));
  // Store the event keyed by req.get("webhook-id"), then answer quickly.
  res.status(200).send("ok");
});
app.listen(3000);
```

```python Python theme={null}
import base64
import binascii
import hashlib
import hmac
import json
import os
import time

from flask import Flask, abort, request

SECRET = os.environ["HD_WEBHOOK_SECRET"]  # whsec_...
TOLERANCE_SECONDS = 300


def signing_key(secret: str) -> bytes | None:
    if not isinstance(secret, str) or not secret.startswith("whsec_"):
        return None
    try:
        # validate=True: strict standard base64, no url-safe characters or whitespace.
        key = base64.b64decode(secret[len("whsec_"):], validate=True)
    except (binascii.Error, ValueError):
        return None
    return key if 24 <= len(key) <= 64 else None


def verify_webhook(raw_body: bytes, headers, secret: str = SECRET, now: float | None = None) -> bool:
    lowered = {str(name).lower(): value for name, value in (headers or {}).items()}
    msg_id = lowered.get("webhook-id") or ""
    timestamp = (lowered.get("webhook-timestamp") or "").strip()
    signatures = []
    for entry in (lowered.get("webhook-signature") or "").split(" "):
        version, _, signature = entry.partition(",")
        if version == "v1" and signature:
            signatures.append(signature)
    if not msg_id or not (timestamp.isascii() and timestamp.isdigit()) or not signatures:
        return False
    seconds = int(timestamp)
    # Whole seconds on both sides, like Hi Doctor; an explicit now=0 is honoured.
    current = int(time.time() if now is None else now)
    if abs(current - seconds) > TOLERANCE_SECONDS:
        return False
    key = signing_key(secret)
    if key is None:
        return False
    digest = hmac.new(key, f"{msg_id}.{seconds}.".encode() + raw_body, hashlib.sha256).digest()
    expected = base64.b64encode(digest)
    return any(hmac.compare_digest(expected, signature.encode()) for signature in signatures)


app = Flask(__name__)


@app.post("/hi-doctor/webhooks")
def receive():
    raw_body = request.get_data()
    if not verify_webhook(raw_body, request.headers):
        abort(400)
    event = json.loads(raw_body)
    # Store the event keyed by request.headers["webhook-id"], then answer quickly.
    return "ok", 200
```

Both samples run in this documentation's test suite against the signature vectors exported by the Hi Doctor backend, including the rotation, non-ASCII body and Standard Webhooks specification examples, and against malformed headers and secrets.

## Process events safely

The consumer below applies the three rules together: verify, dedupe on `webhook-id`, keep the highest version. Replace the in-memory maps with your database.

```javascript Node.js theme={null}
const seenEvents = new Set();
const consultations = new Map();

export function applyEvent(eventId, event) {
  if (seenEvents.has(eventId)) return "duplicate";
  seenEvents.add(eventId);
  if (event.type === "endpoint.test") return "test";
  const snapshot = event.data;
  const stored = consultations.get(snapshot.id);
  if (stored && stored.version >= event.version) return "stale";
  consultations.set(snapshot.id, { ...snapshot, version: event.version });
  return "applied";
}
```

```python Python theme={null}
seen_events: set[str] = set()
consultations: dict[str, dict] = {}


def apply_event(event_id: str, event: dict) -> str:
    if event_id in seen_events:
        return "duplicate"
    seen_events.add(event_id)
    if event["type"] == "endpoint.test":
        return "test"
    snapshot = event["data"]
    stored = consultations.get(snapshot["id"])
    if stored and stored["version"] >= event["version"]:
        return "stale"
    consultations[snapshot["id"]] = {**snapshot, "version": event["version"]}
    return "applied"
```

## How are failed deliveries retried?

* Answer with any `2xx` status as soon as you have stored the event. Process it afterwards. Anything else, a timeout or a connection error counts as a failure. Hi Doctor waits up to 10 seconds for a response and reads at most 64 KB of it.
* A failed delivery is retried on this schedule after the previous attempt: **1 minute, 5 minutes, 30 minutes, 2 hours, 8 hours, 24 hours, 24 hours**. That is the first attempt plus 7 retries, 8 attempts in total, after which the delivery is marked exhausted.
* The same event can be delivered more than once, for example when a delivery succeeded but the acknowledgement was lost. Deduplicate on `webhook-id`: it is the event `id`, identical on every attempt and replay. Each attempt is signed again with a fresh `webhook-timestamp`, so the signature changes between attempts.
* Hi Doctor disables an endpoint automatically when 50 deliveries in a row have failed, or when it has kept failing for more than 3 days. It then emails your partner contact and shows a banner in the Business portal. Pending deliveries are kept; re-enable the endpoint once it is fixed and they resume.

## Replay

`POST /v1/partner/webhook-deliveries/{id}/replay` resets the existing delivery for immediate delivery, with the same delivery id, event id and body: its status returns to `pending`, `attempt_count` to 0 and the retry schedule starts again. It does not create a second delivery. A pending delivery that a worker is sending at that moment is left as it is. Replaying is itself safe to repeat. Use it after fixing an endpoint, or replay from the deliveries list in the Business portal.

## Secret rotation

`POST /v1/partner/webhook-endpoints/{id}/rotate-secret` returns a new `whsec_` secret once. For the next **24 hours** every delivery is signed with both secrets, newest first, so `webhook-signature` carries two space-separated entries:

```text theme={null}
webhook-signature: v1,<signature with the new secret> v1,<signature with the old secret>
```

Deploy the new secret within that window. A verifier holding either secret finds a matching entry, so nothing fails while you deploy. After 24 hours only the new secret signs.

## Test events

`POST /v1/partner/webhook-endpoints/{id}/test` sends an `endpoint.test` event with a fixed sample body, signed like any other event. Use it to check your URL, TLS and signature code. To test real consultation events, use [test mode](https://docs.hi-doctor.ai/partners/test-mode.md).

## Retention

Events and delivery records are deleted after about 30 days by a periodic cleanup. A delivery is kept longer while it was created or replayed within the last 30 days or is being sent, and an event is kept while it still has a delivery; a few older events can be kept longer. Do not rely on any record outliving 30 days: store what you need on your side.

Endpoint errors, such as an invalid URL or event list, are listed in [Partner API errors](https://docs.hi-doctor.ai/partners/errors.md#webhooks).

## Frequently asked questions

### Do I need webhooks, or can I poll?

You can read consultations by polling the consultation list, but webhooks tell you about every change without polling. Each event carries the full consultation, so one event is enough to update your records.

### Why do events arrive out of order or twice?

Deliveries can be late, retried or replayed, and prescription events come from a periodic reconciliation. Deduplicate on the webhook-id header and keep the snapshot with the highest version number.

### How many times is a failed delivery retried?

Up to 8 attempts in total: the first attempt plus 7 retries, spread from 1 minute to 24 hours apart. After that the delivery is marked exhausted, and you can replay it.

### When is an endpoint disabled automatically?

When 50 deliveries in a row have failed, or when the endpoint has kept failing for more than 3 days. Hi Doctor emails your partner contact, and pending deliveries resume once you re-enable it.

### Can I use a Standard Webhooks library to verify signatures?

Yes. Any Standard Webhooks library verifies Hi Doctor deliveries. Pass the secret with its whsec\_ prefix and the raw request body.

### What happens to signatures when I rotate the secret?

For 24 hours every delivery is signed with both the new and the old secret, so a verifier holding either one keeps working while you deploy.

### How long are events kept?

About 30 days. Store what you need on your side.

## Next steps

> [card] [Test mode](https://docs.hi-doctor.ai/partners/test-mode.md)
>
> Trigger real consultation events against your test endpoint.

> [card] [Business portal](https://docs.hi-doctor.ai/partners/business-portal.md)
>
> Manage endpoints, deliveries and replays.

> [card] [Go-live checklist](https://docs.hi-doctor.ai/partners/go-live-checklist.md)
>
> What must be in place before live mode.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.