# Guide to Semble Webhooks: Features and Best Practices

Semble webhooks tell your systems when something changes in a clinic's Semble account: a booking is made, a patient arrives, an invoice is paid, a lab result comes back. Semble is practice management and electronic health record software for private clinics, and if you build on its GraphQL API, webhooks save you from polling it for changes.

This guide covers how Semble webhook subscriptions are registered and signed, the event groups and payload, what happens when deliveries fail, and the practices that keep a Semble receiver reliable.

## What are Semble webhooks?

A Semble webhook is a subscription you create through the GraphQL API with the `createWebhook` mutation. It names an HTTPS URL and the event types to send there, and it is active as soon as it is created. When a subscribed event happens, Semble sends a `POST` with a JSON body to the URL.

The body identifies the event and the records involved, but doesn't include them. You fetch the booking, patient or invoice through the API using the IDs in the payload.

Every request is signed. The `X-Webhook-Signature` header carries a Unix timestamp and a lowercase hex HMAC-SHA256 of the timestamp and the raw body, in the same `t=...,v1=...` form that Stripe uses.

## Semble webhook features

| Feature | Details |
| --- | --- |
| Availability | Webhooks must be enabled for the practice by Semble support |
| Configuration | GraphQL API only: `createWebhook`, `updateWebhook`, `deleteWebhook` |
| Subscription | An `eventTypes` list per subscription. Events don't overlap, so each one is subscribed explicitly |
| Custom headers | Optional `headers` added to every delivery, such as `Authorization` |
| Payload | `subscriptionId`, `eventId`, `eventType`, `identifiers`, optional `metadata` |
| Signature header | `X-Webhook-Signature: t=<unix seconds>,v1=<hex>` |
| Timestamp header | `X-Webhook-Timestamp`, the same value as `t` |
| Scheme | HMAC-SHA256, lowercase hex |
| Signed content | `<t>.<raw body>` |
| Secret | The `secret` returned by `createWebhook`, used as a string |
| Retries | "A number of times" before a message is marked failed. No schedule is published |
| Logs | 30 days of events in Semble's Webhook Logs page, with requeue |
| Pausing | Repeated failure sets `deliveryPaused: true` and emails the practice's Technical Contact |

## Common events

Semble documents about 100 event types across 17 groups. Event names are dotted and keep camelCase segments, such as `booking.patientJourney.arrived`.

| Group | Example events |
| --- | --- |
| Bookings | `booking.created`, `booking.updated`, `booking.deleted`, `booking.patientJourney.arrived`, `booking.patientJourney.noShow` |
| Patients | `patient.created`, `patient.updated`, `patient.archived`, `patient.merged`, `patient.label.added` |
| Invoices | `invoice.created`, `invoice.paid`, `invoice.partiallyPaid`, `invoice.refunded`, `invoice.integrations.xero.synced` |
| Availability | `resourceAvailability.created`, `availability.blocker.created`, `availability.recurring.updated` |
| Clinical | `clinicalReport.status.updated`, `lab.results.added`, `letter.shared`, `prescription.created` |
| Other records | `contact.*`, `episode.*`, `location.*`, `pathway.*`, `patientDocument.*`, `product.*`, `paymentOnAccount.*`, `questionnaire.response.received` |
| Webhooks | `webhook.event.deliveryFailed`, `webhook.subscription.updated` |

Events don't overlap. Subscribing to `booking.updated` does not deliver `booking.patientJourney.arrived`, so list every event you want.

Here is Semble's `booking.created` example:

```json
{
  "subscriptionId": "692597070fe21cfa5e6a010f",
  "eventId": "692597070fe21cfa5e6a010a",
  "eventType": "booking.created",
  "identifiers": {
    "bookingId": "507f1f77bcf86cd799439011",
    "patientId": "507f191e810c19729de860ea",
    "doctorId": "507f1f77bcf86cd799439012"
  }
}

```

* `eventType` is the same string you subscribed to. Route on it.
* `identifiers` holds string IDs of the related records. Optional ones are left out rather than set to `null`.
* `metadata` carries event-specific fields and is omitted when unused.
* `eventId` is unique per delivery attempt, so a retry may carry a different one.

## Setting up Semble webhooks

Before you start, ask Semble support to enable webhooks for the practice, and make sure your API token has the `seeWebhooks`, `createWebhooks`, `editWebhooks` and `deleteWebhooks` permissions. An older token may need replacing. Add a Technical Contact email in the practice settings, because that is where Semble sends notices about paused subscriptions.

Then register the subscription:

```graphql
mutation CreateWebhook {
  createWebhook(input: {
    label: "Main integration"
    url: "https://example.com/webhooks/semble"
    eventTypes: ["booking.created", "booking.updated", "invoice.paid"]
  }) {
    data { id secret }
    error
  }
}

```

Store the returned `secret` straight away, for example as `SEMBLE_WEBHOOK_SECRET`. It is the signing key.

You can create several subscriptions for the same URL as long as no event type repeats for that URL. `updateWebhook` replaces `eventTypes` rather than adding to them, so send the full list each time. It can also set `deliveryPaused` back to `false` after Semble pauses a subscription.

If you use Hookdeck, create a source with the Semble source type, paste the secret into it, and use the Hookdeck source URL as the subscription's `url`.

## Securing Semble webhooks

Read `X-Webhook-Signature` and split it into `t` and `v1`. Compute the HMAC-SHA256 of `t`, a dot, and the raw request body, with the secret as the key. Encode it as lowercase hex and compare it with `v1` in constant time. Use the `t` from the signature header; `X-Webhook-Timestamp` carries the same value.

Semble's guide calls a timestamp tolerance optional, giving five minutes as an example. The check below is off by default. If you turn it on, remember that a delivery replayed later, for example from Hookdeck, keeps its original timestamp.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifySembleSignature(rawBody, signatureHeader, secret, toleranceSeconds = 0) {
  if (!signatureHeader || !secret) return false;
  let timestamp;
  let signature;
  for (const part of signatureHeader.split(',')) {
    const [key, ...rest] = part.trim().split('=');
    if (key === 't') timestamp = rest.join('=');
    if (key === 'v1') signature = rest.join('=');
  }
  if (!timestamp || !/^\d+$/.test(timestamp) || !signature) return false;

  // Optional replay check, off by default.
  if (toleranceSeconds > 0 && Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
    return false;
  }

  const expected = Buffer.from(
    crypto.createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex'),
    'utf8'
  );
  const received = Buffer.from(signature.toLowerCase(), 'utf8');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

const app = express();

app.post('/webhooks/semble', express.raw({ type: '*/*' }), (req, res) => {
  if (!verifySembleSignature(req.body, req.get('x-webhook-signature'), process.env.SEMBLE_WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Route on event.eventType, then fetch the records in event.identifiers from the API.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac
import time

def verify_semble_signature(raw_body: bytes, signature_header, secret: str, tolerance_seconds: int = 0) -> bool:
    if not signature_header or not secret:
        return False
    timestamp, signature = None, None
    for part in signature_header.split(","):
        key, _, value = part.strip().partition("=")
        if key == "t":
            timestamp = value
        elif key == "v1":
            signature = value
    if not timestamp or not timestamp.isdigit() or not signature:
        return False
    # Optional replay check, off by default.
    if tolerance_seconds > 0 and abs(time.time() - int(timestamp)) > tolerance_seconds:
        return False

    signed = timestamp.encode("utf-8") + b"." + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature.lower().encode("utf-8"), expected.encode("utf-8"))

# In FastAPI: raw_body = await request.body()
# verify_semble_signature(raw_body, request.headers.get("x-webhook-signature"),
#                         os.environ["SEMBLE_WEBHOOK_SECRET"])

```

Semble's Ruby and Python samples allow a space after the comma in the header, which is why the parsers above trim each part. The [Semble webhook skill](https://github.com/hookdeck/webhook-skills/tree/main/skills/semble-webhooks) also leaves the timestamp check off unless you set `SEMBLE_TIMESTAMP_TOLERANCE_SECONDS`.

> **Make Semble webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `X-Webhook-Signature` header on every Semble delivery, stores each booking, patient and invoice event, and retries delivery to your service on a schedule you control.

## Semble webhook limitations and pain points

### A struggling endpoint gets paused

**The Problem:** Events stop arriving after your endpoint has had problems, and nothing in your logs says why.

**Why It Happens:** If delivery to a URL repeatedly fails, Semble sets the subscription to `deliveryPaused: true`. No new deliveries are sent until you unpause it, and the notice goes to the practice's Technical Contact email, which may not be you.

**Workarounds:**

* Make sure the Technical Contact address reaches the team that runs the integration.
* Unpause with `updateWebhook` (`deliveryPaused: false`) once the endpoint is fixed, then replay the paused events from the Webhook Logs page.
* Subscribe to `webhook.event.deliveryFailed` and `webhook.subscription.updated` on a separate, simple endpoint to hear about problems directly.

**How Hookdeck Can Help:** Hookdeck accepts Semble's deliveries even while your service is down, so the subscription isn't paused. Events wait in Hookdeck and are delivered when your service recovers.

### `eventId` doesn't identify an event across retries

**The Problem:** Deduplicating on `eventId` lets retries through.

**Why It Happens:** Semble documents `eventId` as unique for each delivery attempt, not for each event.

**Workarounds:**

* Make processing idempotent on what the event means: `eventType` plus the record IDs in `identifiers`.
* Fetch the current record from the API and upsert it, so processing it twice gives the same result.

**How Hookdeck Can Help:** Hookdeck's [deduplication](/docs/deduplication) can compare the body fields you choose, such as `eventType` and `identifiers`, and drop repeats within a time window.

### An unpublished retry policy

**The Problem:** You can't tell how long Semble keeps trying a failed delivery.

**Why It Happens:** Semble says only that a message is retried "a number of times" before it is marked failed. It publishes no schedule or timeout.

**Workarounds:**

* Respond quickly and keep the endpoint simple, so failures stay rare.
* Use the Webhook Logs page, which keeps 30 days, to requeue failed messages. For large gaps, query the API with an `updatedAt` filter instead.

**How Hookdeck Can Help:** Once Hookdeck has accepted a delivery, your own [retry](/docs/retries) policy applies, and you can replay any event from Hookdeck's log.

### Payloads carry IDs, not records

**The Problem:** Each webhook needs one or more API calls before you can act on it.

**Why It Happens:** The payload holds `identifiers` and some `metadata`. The booking, patient or invoice itself isn't included.

**Workarounds:**

* Fetch records in a background job rather than inside the request.
* Check the event's group page in Semble's docs for which `identifiers` and `metadata` it carries, so you fetch only what you need.

**How Hookdeck Can Help:** Hookdeck can deliver at a rate you set, which keeps the follow-up API calls steady during busy clinic hours.

## Best practices

### Verify against the raw body

Semble's guide says to read the raw body before any JSON parsing. Use `express.raw({ type: '*/*' })` in Express, `await request.text()` in Next.js and `await request.body()` in FastAPI.

### Subscribe to every event you need

Events don't overlap, so a subscription to `booking.updated` won't tell you a patient arrived. List the patient journey events, label changes and other sub-events you care about explicitly.

### Keep the subscription list in code

`updateWebhook` replaces `eventTypes`. Keep the list in your codebase and send the whole list on every update, so you never drop an event by accident.

### Acknowledge fast and process asynchronously

Verify, store the event, return `200`, and run the API calls afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks) and our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

## Conclusion

Semble signs each webhook with `X-Webhook-Signature`, a `t=...,v1=...` header whose `v1` is the hex HMAC-SHA256 of the timestamp and the raw body. Verify it against the raw bytes, subscribe to every event explicitly because none overlap, make processing idempotent on the record IDs rather than `eventId`, and watch for paused subscriptions.

[Hookdeck Event Gateway](https://hookdeck.com) verifies every Semble signature and holds each event until your service has processed it, so a bad afternoon on your side doesn't pause the practice's subscription.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Semble webhooks reliably in minutes.