Agent skill
Semble Webhooks Skill
Receive and verify Semble webhooks. Use when setting up Semble practice management webhook handlers, debugging X-Webhook-Signature verification, or handling events such as booking.created, booking.updated, contact.created, invoice.paid, and resourceAvailability.updated.
Install this skill
npx skills add hookdeck/webhook-skills --skill semble-webhooks
When to Use This Skill
- How do I receive Semble webhooks?
- How do I verify the
X-Webhook-Signatureheader? - How do I handle
booking.created,booking.updated,contact.created, orinvoice.paidevents? - Why is my Semble webhook signature verification failing?
- Why do Hookdeck retries fail Semble's timestamp check?
Essential Code (USE THIS)
Every Semble delivery carries X-Webhook-Signature: t=<unix seconds>,v1=<hex>. v1 is the lowercase hex HMAC-SHA256 of "<t>.<raw body>", keyed with the subscription's secret as a UTF-8 string (the createWebhook GraphQL mutation returns it). Semble's docs show a single v1 and don't describe sending several during rotation; the code below accepts the header if any v1 matches, which is harmless. X-Webhook-Timestamp repeats t, but sign with the t from X-Webhook-Signature.
Verification (Node.js)
const crypto = require('crypto');
// rawBody: Buffer of the exact request bytes (express.raw({ type: '*/*' }), not express.json)
function verifySemble(rawBody, header, secret) {
if (!secret || !header) return false;
let t; const v1 = [];
for (const part of header.split(',')) { // trim: a space may follow the comma
const kv = part.trim(), i = kv.indexOf('=');
if (i <= 0) continue;
const k = kv.slice(0, i).trim(), v = kv.slice(i + 1).trim();
if (k === 't') t = v; else if (k === 'v1') v1.push(v);
}
if (!t || !/^\d+$/.test(t) || v1.length === 0) return false;
// Optional replay window, off by default: retries and replays keep the original timestamp
const expected = crypto.createHmac('sha256', secret)
.update(`${t}.`).update(rawBody).digest('hex');
return v1.some((sig) => {
if (!/^[0-9a-f]{64}$/i.test(sig)) return false; // reject malformed values first
const a = Buffer.from(sig.toLowerCase()), b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
});
}
Verification (Python)
import hashlib, hmac
def verify_semble(raw_body: bytes, header: str, secret: str) -> bool:
if not secret or not header:
return False
t, v1 = None, []
for part in header.split(","):
k, sep, v = part.strip().partition("=")
if sep and k.strip() == "t":
t = v.strip()
elif sep and k.strip() == "v1":
v1.append(v.strip())
if not t or not (t.isascii() and t.isdigit()) or not v1:
return False
expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
# compare_digest on bytes: a non-ASCII header value fails the match instead of raising
return any(hmac.compare_digest(sig.lower().encode(), expected.encode()) for sig in v1)
For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.
Timestamp window: off by default
Semble's guide suggests rejecting timestamps older than 5 minutes. Hookdeck retries and replays resend the original timestamp, so a 5-minute window rejects late retries. The examples therefore skip the freshness check unless you set SEMBLE_ to a positive number of seconds (default 0 = off). For idempotency, note that Semble documents eventId as unique per delivery attempt, so a Semble retry may carry a new one (a Hookdeck retry or replay resends the same body); make the handler idempotent on eventType plus the record IDs in identifiers. references/verification.md shows exactly where the window can be dropped or widened.
Dispatch on eventType
The body is a JSON object: subscriptionId, eventId, eventType, identifiers (string IDs of related records), and optional metadata. eventType is a lowercase-first dotted camelCase string that matches what you subscribed to. Events don't overlap: subscribing to booking.updated does not deliver booking.patientJourney.arrived.
| Event | Triggered when | identifiers |
|---|---|---|
booking.created | An appointment was created | bookingId; optional patientId, doctorId |
booking.updated | An appointment was updated | bookingId; optional patientId, doctorId |
booking.deleted | An appointment was deleted | bookingId; optional patientId, doctorId |
contact.created | A practice contact was created | contactId |
invoice.paid | An invoice was fully paid | invoiceId, paymentId; optional patientId |
resourceAvailability.updated | Appointment-system availability rules changed | resourceAvailabilityId |
See references/overview.md for more events and the payload.
Important Headers
| Header | Description |
|---|---|
X-Webhook-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256>; sign with this t |
X-Webhook-Timestamp | <unix seconds>, the same value as t |
Environment Variables
SEMBLE_WEBHOOK_SECRET=your_webhook_secret # `secret` returned by createWebhook
SEMBLE_TIMESTAMP_TOLERANCE_SECONDS=0 # optional; a positive value opts in to the replay window
Local Development
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 semble --path /webhooks/semble
Reference Materials
- references/overview.md - Semble webhook concepts, events, payload
- references/setup.md - Creating a subscription with the GraphQL API and saving the secret
- references/verification.md - Signature details, the timestamp window, and debugging