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:
{
"subscriptionId": "692597070fe21cfa5e6a010f",
"eventId": "692597070fe21cfa5e6a010a",
"eventType": "booking.created",
"identifiers": {
"bookingId": "507f1f77bcf86cd799439011",
"patientId": "507f191e810c19729de860ea",
"doctorId": "507f1f77bcf86cd799439012"
}
}
eventTypeis the same string you subscribed to. Route on it.identifiersholds string IDs of the related records. Optional ones are left out rather than set tonull.metadatacarries event-specific fields and is omitted when unused.eventIdis 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:
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.
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:
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 also leaves the timestamp check off unless you set SEMBLE_.
Make Semble webhooks production-ready. Hookdeck Event Gateway verifies the
X-Webhook-Signatureheader 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.deliveryFailedandwebhook.subscription.updatedon 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:
eventTypeplus the record IDs inidentifiers. - 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 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
updatedAtfilter instead.
How Hookdeck Can Help: Once Hookdeck has accepted a delivery, your own retry 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
identifiersandmetadatait 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 and our guide to 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 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 for free and handle Semble webhooks reliably in minutes.