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-Signature header?
  • How do I handle booking.created, booking.updated, contact.created, or invoice.paid events?
  • 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_TIMESTAMP_TOLERANCE_SECONDS 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.

EventTriggered whenidentifiers
booking.createdAn appointment was createdbookingId; optional patientId, doctorId
booking.updatedAn appointment was updatedbookingId; optional patientId, doctorId
booking.deletedAn appointment was deletedbookingId; optional patientId, doctorId
contact.createdA practice contact was createdcontactId
invoice.paidAn invoice was fully paidinvoiceId, paymentId; optional patientId
resourceAvailability.updatedAppointment-system availability rules changedresourceAvailabilityId

See references/overview.md for more events and the payload.

Important Headers

HeaderDescription
X-Webhook-Signaturet=<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


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 6, 2026

View on GitHub →