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

# Webhooks

> Subscribe to real-time events from Solum: how deliveries are signed, how to verify them, retry behavior, and the full event catalog.

# Webhooks

Solum sends webhook events to URLs you register when patients and tasks in your workspace change — created, updated, stage changed, verification completed, comment added. Each delivery is signed with HMAC-SHA256 so you can confirm it came from Solum and that the body has not been modified in transit.

<Note>
  Webhook payloads are intentionally ID-only and do not contain PHI, following the same convention as Epic and athenahealth. The event tells you what changed and which record changed; fetch the current state from the corresponding `GET /v1/...` endpoint with your API key.
</Note>

***

## How webhooks work

1. Create a subscription with the URL of your endpoint and the event types you want to receive. Solum returns a signing secret once.
2. When a matching event occurs, Solum signs the JSON payload with your secret and sends it to your URL with `X-Solum-*` headers.
3. Your endpoint verifies the signature, processes the event, and responds `2xx` within 30 seconds.
4. Any non-2xx response or timeout is retried up to two times: first after 60 seconds, then after 5 minutes.

***

## Create a subscription

Create webhook subscriptions through the API or the dashboard at **Settings → Webhooks**. The example below uses the API.

`POST https://api.getsolum.com/v1/webhooks`

Authentication: `X-API-Key: <your_api_key>`.

```bash theme={null}
curl -X POST https://api.getsolum.com/v1/webhooks \
  -H "X-API-Key: $SOLUM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.example.com/webhooks/solum",
    "event_types": ["patient_created", "patient_verification_completed"],
    "description": "Production endpoint"
  }'
```

```json theme={null}
{
  "id": "550e8400-e29b-41d4-a716-446655440016",
  "url": "https://your-app.example.com/webhooks/solum",
  "event_types": ["patient_created", "patient_verification_completed"],
  "is_active": true,
  "secret_key": "whsec_d22f3d511dc64683b40fed32c8425734",
  "description": "Production endpoint",
  "created_at": "2024-01-15T10:30:00Z",
  "updated_at": "2024-01-15T10:30:00Z"
}
```

<Warning>
  The signing secret is returned only on creation. Store it in a secrets manager — it cannot be retrieved later. If the secret is lost, delete the subscription and create a new one.
</Warning>

### Subscription and delivery endpoints

The webhook routes are not included in the generated API Reference, so the
complete management surface is listed here:

| Method   | Path                                                               | Purpose                                                     |
| -------- | ------------------------------------------------------------------ | ----------------------------------------------------------- |
| `POST`   | `/v1/webhooks`                                                     | Create a subscription and return its signing secret.        |
| `GET`    | `/v1/webhooks`                                                     | List subscriptions.                                         |
| `GET`    | `/v1/webhooks/{id}`                                                | Get one subscription. The signing secret is not returned.   |
| `PATCH`  | `/v1/webhooks/{id}`                                                | Update its URL, event types, description, or active status. |
| `DELETE` | `/v1/webhooks/{id}`                                                | Delete a subscription.                                      |
| `POST`   | `/v1/webhooks/{subscription_id}/tests`                             | Send a synthetic event to the subscription URL.             |
| `GET`    | `/v1/webhooks/{subscription_id}/deliveries`                        | List recent deliveries.                                     |
| `POST`   | `/v1/webhooks/{subscription_id}/deliveries/{delivery_id}/attempts` | Redeliver a recorded event.                                 |

***

## Delivery format

Every delivery is an HTTP `POST` to your endpoint URL with the following headers:

| Header               | Example                                                                   | Description                                                                                  |
| -------------------- | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `Content-Type`       | `application/json`                                                        | Body is UTF-8 encoded JSON.                                                                  |
| `User-Agent`         | `Solum-Webhooks/1.0`                                                      | Identifies Solum traffic.                                                                    |
| `X-Solum-Event-Type` | `patient_verification_completed`                                          | Event type, identical to `event_type` in the body.                                           |
| `X-Solum-Event-ID`   | `2805d797-9a0a-4ae0-b969-fba1a3824a50`                                    | Unique event identifier. Retries reuse the same ID; use it to deduplicate.                   |
| `X-Solum-Signature`  | `sha256=8474e2aa67164a7fdb0f68017582eb141a705b7058c29613d5b8d8ae7fb29c7d` | HMAC-SHA256 of the raw body, hex-encoded. See [Verifying signatures](#verifying-signatures). |

The body is compact JSON with no whitespace. Sign the raw bytes you received, not a re-serialized form (see [Verifying signatures](#verifying-signatures)).

Your endpoint must return a `2xx` status within 30 seconds. Any other response, or a timeout, is treated as a failure and retried.

***

## Event types

| Event type                       | When it fires                                                                                                 |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `patient_created`                | A new patient is created (any source — API, dashboard, batch import).                                         |
| `patient_updated`                | An existing patient's fields change. Body lists the changed field names (not values).                         |
| `patient_stage_changed`          | A patient moves to a different workflow stage.                                                                |
| `patient_insurance_verification` | A patient enters the Insurance Verification stage (created in it or moved into it).                           |
| `patient_verification_completed` | All verifications for a patient are completed (via the Complete Verification button or a stage action).       |
| `task_created`                   | A new task is created.                                                                                        |
| `task_assigned`                  | A task's assignee list changes after creation.                                                                |
| `task_completed`                 | A task transitions to `status: completed`.                                                                    |
| `task_updated`                   | Catch-all for other task field changes (priority, deadline, title, description, tags, non-completion status). |
| `task_comment_created`           | A comment is added to a task.                                                                                 |

### Example payloads

All payloads share three top-level fields: `event_type`, `event_id`, `timestamp`. The rest depends on the event.

<CodeGroup>
  ```json patient_created theme={null}
  {
    "event_type": "patient_created",
    "event_id": "2805d797-9a0a-4ae0-b969-fba1a3824a50",
    "timestamp": "2024-01-15T10:30:00.123456",
    "patient": { "id": "9c1a3b8f-..." },
    "created_by": "u_5a2c..."
  }
  ```

  ```json patient_stage_changed theme={null}
  {
    "event_type": "patient_stage_changed",
    "event_id": "...",
    "timestamp": "2024-01-15T10:30:00.123456",
    "patient": { "id": "9c1a3b8f-..." },
    "previous_stage": {
      "id": "stage-1",
      "title": "Initial Contact",
      "order": 1
    },
    "new_stage": {
      "id": "stage-2",
      "title": "Insurance Verification",
      "order": 2
    },
    "changed_by": "u_5a2c..."
  }
  ```

  ```json patient_updated theme={null}
  {
    "event_type": "patient_updated",
    "event_id": "...",
    "timestamp": "2024-01-15T10:30:00.123456",
    "patient": { "id": "9c1a3b8f-..." },
    "changed_fields": ["first_name", "phone_number"],
    "changed_by": "u_5a2c..."
  }
  ```

  ```json patient_verification_completed theme={null}
  {
    "event_type": "patient_verification_completed",
    "event_id": "...",
    "timestamp": "2024-01-15T10:30:00.123456",
    "patient": { "id": "9c1a3b8f-..." },
    "previous_stage": { "id": "...", "title": "Insurance Verification", "order": 2 },
    "new_stage": { "id": "...", "title": "Ready for Scheduling", "order": 3 },
    "changed_by": "u_5a2c..."
  }
  ```

  ```json task_created theme={null}
  {
    "event_type": "task_created",
    "event_id": "...",
    "timestamp": "2024-01-15T10:30:00.123456",
    "task": { "id": "t_88aa..." },
    "assigned_user_ids": ["u_5a2c...", "u_9d10..."],
    "created_by": "u_5a2c..."
  }
  ```

  ```json task_completed theme={null}
  {
    "event_type": "task_completed",
    "event_id": "...",
    "timestamp": "2024-01-15T10:30:00.123456",
    "task": { "id": "t_88aa..." },
    "completed_at": "2024-01-15T10:29:55.001234",
    "completed_by": "u_5a2c...",
    "created_by": "u_1234..."
  }
  ```
</CodeGroup>

Payloads never contain PHI such as names, phone numbers, email addresses, or dates of birth. Fetch the full record with `GET /v1/patients/{id}` or `GET /v1/tasks/{id}` using the ID in the payload.

***

## Verifying signatures

Solum signs every delivery with HMAC-SHA256 using your subscription's signing secret. Verify the signature on every incoming request to confirm it came from Solum and that the body has not been modified in transit.

The `X-Solum-Signature` header contains a single scheme — `sha256` — followed by the hex-encoded digest:

```
X-Solum-Signature: sha256=8474e2aa67164a7fdb0f68017582eb141a705b7058c29613d5b8d8ae7fb29c7d
```

Solum does not yet ship official client libraries, so verification is implemented in your own code. The steps below describe the algorithm; complete code examples for Python and TypeScript follow.

### Verify signatures manually

#### Step 1: Read the raw request body

The HMAC input is the **raw bytes** of the HTTP request body, byte-for-byte. If your framework parses the body into a dict or object and you re-serialize it, key order, whitespace, and unicode escaping can differ from the original — and the signature will not match.

Read the body before any parsing. In FastAPI: `await request.body()`. In Flask: `request.get_data()`. In Django: `request.body`. In Express: mount `express.raw({ type: "application/json" })` on the webhook route, ahead of any `express.json()` middleware.

#### Step 2: Prepare the signing key

Your subscription secret has the form `whsec_<hex>`. The `whsec_` prefix is a label identifying the secret's purpose — it is not part of the cryptographic key. Strip it before computing the HMAC.

The remaining characters are used directly as UTF-8 bytes. There is no hex-decoding or base64-decoding step.

#### Step 3: Compute the expected signature

Compute an HMAC using SHA-256 with the cleaned secret as the key and the raw body as the message. Hex-encode the digest.

#### Step 4: Compare against the header

Take everything after `sha256=` in `X-Solum-Signature` and compare it to your computed hex using a constant-time comparison (`hmac.compare_digest` in Python, `crypto.timingSafeEqual` in Node).

### Code example

<CodeGroup>
  ```python Python theme={null}
  import hashlib
  import hmac

  from fastapi import FastAPI, Request, HTTPException

  app = FastAPI()
  SOLUM_SIGNING_SECRET = "whsec_d22f3d511dc64683b40fed32c8425734"

  def verify_solum_signature(raw_body: bytes, header_value: str, secret: str) -> bool:
      if not header_value or not header_value.startswith("sha256="):
          return False
      received = header_value.split("=", 1)[1]
      clean_secret = secret.removeprefix("whsec_")
      expected = hmac.new(
          clean_secret.encode("utf-8"),
          raw_body,
          hashlib.sha256,
      ).hexdigest()
      return hmac.compare_digest(received, expected)

  @app.post("/webhooks/solum")
  async def solum_webhook(request: Request):
      raw = await request.body()
      sig = request.headers.get("x-solum-signature", "")

      if not verify_solum_signature(raw, sig, SOLUM_SIGNING_SECRET):
          raise HTTPException(status_code=401, detail="Invalid signature")

      event = await request.json()
      # handle event["event_type"]
      return {"received": True}
  ```

  ```typescript TypeScript theme={null}
  import express, { Request, Response } from "express";
  import crypto from "node:crypto";

  const app = express();
  const SOLUM_SIGNING_SECRET = "whsec_d22f3d511dc64683b40fed32c8425734";

  function verifySolumSignature(rawBody: Buffer, header: string, secret: string): boolean {
    if (!header || !header.startsWith("sha256=")) return false;
    const received = header.slice("sha256=".length);
    const cleanSecret = secret.startsWith("whsec_") ? secret.slice("whsec_".length) : secret;
    const expected = crypto.createHmac("sha256", cleanSecret).update(rawBody).digest("hex");
    const a = Buffer.from(received, "hex");
    const b = Buffer.from(expected, "hex");
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  }

  app.post(
    "/webhooks/solum",
    express.raw({ type: "application/json" }),
    (req: Request, res: Response) => {
      const sig = req.header("x-solum-signature") ?? "";

      if (!verifySolumSignature(req.body as Buffer, sig, SOLUM_SIGNING_SECRET)) {
        return res.status(401).send("invalid signature");
      }

      const event = JSON.parse((req.body as Buffer).toString("utf8"));
      // handle event.event_type
      res.status(200).send("ok");
    },
  );
  ```
</CodeGroup>

<Warning>
  In Express, mount `express.json()` after your webhook route or scope it to non-webhook paths. If it runs first it will consume the raw body and your HMAC will never match.
</Warning>

### Reference test vector

Use this fixed vector to confirm your implementation produces the expected hex before connecting to live deliveries.

| Input                        | Value                                                                     |
| ---------------------------- | ------------------------------------------------------------------------- |
| Signing secret               | `whsec_abc123`                                                            |
| Request body                 | `{"test":true}`                                                           |
| Expected `X-Solum-Signature` | `sha256=d6973f7d4441d3e3af74c4839f744987c2684a1b097ddca0cb4f936a64d66fad` |

Reproduce the expected hex in any of the following environments — each snippet is fully self-contained and prints `d6973f7d4441d3e3af74c4839f744987c2684a1b097ddca0cb4f936a64d66fad`.

<CodeGroup>
  ```bash Bash theme={null}
  printf '%s' '{"test":true}' | openssl dgst -sha256 -hmac "abc123" -hex
  ```

  ```python Python theme={null}
  import hashlib, hmac

  key = "whsec_abc123".removeprefix("whsec_")
  body = b'{"test":true}'
  print(hmac.new(key.encode(), body, hashlib.sha256).hexdigest())
  ```

  ```javascript Node.js theme={null}
  const crypto = require("node:crypto");

  const key = "whsec_abc123".slice("whsec_".length);
  const body = '{"test":true}';
  console.log(crypto.createHmac("sha256", key).update(body).digest("hex"));
  ```
</CodeGroup>

### Verify a specific delivery

When verification fails on a live request, save the exact bytes Solum POSTed to a file — for example by capturing them in your access log — and re-run the HMAC outside your application:

```bash theme={null}
SECRET="d22f3d511dc64683b40fed32c8425734"   # whsec_ prefix removed
openssl dgst -sha256 -hmac "$SECRET" -hex < received_body.bin
```

The hex output must match the value after `sha256=` in the `X-Solum-Signature` header. If it doesn't, the cause is almost always one of:

* The `whsec_` prefix was kept as part of the HMAC key.
* The body was modified after receipt — a logger appended a newline, a reverse proxy re-encoded the JSON, or your handler parsed and re-serialized it before signing.
* The secret was rotated and the request was signed with the previous value.

***

## Test events

Each subscription exposes a test endpoint that delivers a synthetic event to your URL using the subscription's real signing secret. Use it to validate end-to-end signature verification before enabling production traffic.

```bash theme={null}
curl -X POST https://api.getsolum.com/v1/webhooks/{subscription_id}/tests \
  -H "X-API-Key: $SOLUM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "event_type": "patient_verification_completed" }'
```

Test delivery supports every patient and task event in the [event catalog](#event-types). Samples use non-PHI placeholder identifiers such as `test-patient-id`, `test-task-id`, `test-comment-id`, and `test-user-id`, with an `event_id` prefixed with `test_`. The requested event type always matches both `X-Solum-Event-Type` and `event_type` in the body. The same action is available in the dashboard under **Settings → Webhooks → Test**.

***

## Retries

| Parameter           | Value                                                       |
| ------------------- | ----------------------------------------------------------- |
| Timeout per attempt | 30 seconds                                                  |
| Maximum attempts    | 3 (initial delivery plus two retries)                       |
| Backoff             | 60s before the first retry, then 5m before the second retry |
| Retry trigger       | Any non-2xx response, timeout, or connection error          |

Every patient and task event uses the same retry behavior and reuses the same `event_id`. After three failed attempts the delivery is marked `failed`. To redeliver any delivery — including successful ones — call:

```bash theme={null}
curl -X POST https://api.getsolum.com/v1/webhooks/{subscription_id}/deliveries/{delivery_id}/attempts \
  -H "X-API-Key: $SOLUM_API_KEY"
```

List recent delivery attempts with `GET /v1/webhooks/{subscription_id}/deliveries`.

***

## Idempotency

Webhook delivery is at-least-once. A network delay can cause Solum to retry a request your handler already processed, because the original `2xx` response did not arrive within the timeout.

Persist the `X-Solum-Event-ID` of every event you process and short-circuit duplicates on subsequent attempts. The header and the `event_id` field in the body always hold the same value.

***

## Troubleshooting

| Symptom                                    | Cause                                                                                                        | Resolution                                                                                                                                               |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| All signatures fail                        | The `whsec_` prefix is part of the HMAC key.                                                                 | Strip the prefix before computing the HMAC.                                                                                                              |
| All signatures fail                        | The secret is hex- or base64-decoded before HMAC.                                                            | Use the characters after `whsec_` directly as UTF-8 bytes.                                                                                               |
| Most signatures fail                       | The body is parsed and re-serialized before HMAC.                                                            | Sign the raw request body. Use `await request.body()` in FastAPI, `request.get_data()` in Flask, `express.raw({ type: "application/json" })` in Express. |
| Signatures fail only on non-ASCII payloads | Receiver is decoding the body with a non-UTF-8 encoding.                                                     | Read the body as UTF-8 bytes.                                                                                                                            |
| Test deliveries succeed; production fails  | A proxy in front of the production endpoint mutates the body (re-encoding, compression, character rewrites). | Ensure the handler sees byte-identical input to what Solum sent.                                                                                         |
| Duplicate events                           | A retry succeeded after the original request exceeded the 30-second timeout.                                 | Deduplicate on `X-Solum-Event-ID`.                                                                                                                       |
| Solum logs `401` for your endpoint         | The endpoint URL returns a redirect or auth challenge before the request reaches your handler.               | Expose the webhook URL without an auth gate and verify the HMAC inside the handler.                                                                      |

For unresolved issues, contact [support@getsolum.com](mailto:support@getsolum.com) with the delivery ID from `GET /v1/webhooks/{subscription_id}/deliveries` and the hex your implementation computes.
