Skip to main content

How to: Webhooks

This guide explains how to register webhook subscriptions and how to verify that incoming webhook payloads were sent by Semble.

Prerequisites

  • Your API token must have permissions for seeWebhooks, createWebhooks, editWebhooks, and/or deleteWebhooks. You may need to update or generate a new API token.
  • Webhooks must be enabled for your practice (contact the support team for details).
  • An HTTPS endpoint that can receive POST requests.
  • Email notifications for webhooks (e.g. paused subscriptions) will be sent to your practice's Technical Contact email address, which can be added in the settings page in Semble.

1: Register a webhook subscription

Use the createWebhook mutation to register a destination URL and the events you want to receive. The subscription is active as soon as it is created.

Required:

  • label - Short name to help you identify this subscription.
  • url - Your HTTPS endpoint (e.g. https://your-app.com/webhooks/semble)
  • eventTypes - Array of webhook event enum values (see Event types)

Optional:

  • headers - Custom headers to include on each delivery (e.g. Authorization, X-Request-Id)

Response: The mutation returns CreateWebhookPayload, particularly important are:

  • id - The unique ID for this webhook subscription
  • secret - Store the secret securely; you will need it to verify incoming webhooks.

Example:

mutation CreateWebhook {
createWebhook(
input: {
label: "Main integration"
url: "https://your-app.com/webhooks/semble"
eventTypes: [PATIENT_CREATED, PATIENT_DELETED]
headers: [{ key: "X-Custom-Header", value: "your-value" }]
}
) {
data {
id
secret
}
error
}
}

Note: It is possible to create multiple subscriptions for the same url, as long as they do not include duplicate eventTypes for that url. Attempting to create a duplicate url and eventType combination will return an error. This also applies to updateWebhook.

Event types

Use the WebhookEventType enum values in eventTypes when creating or updating a subscription. Any webhook created or updated can be subscribed to any number of event types.

Note: All webhook events are non-overlapping, so subscribing to BOOKING_UPDATED does not mean that you will be alerted when BOOKING_PATIENT_JOURNEY_ARRIVED happens. You need to specifically subscribe to every event you want to receive.

2: Errors and logs

Every webhook event from the last 30 days can be found in the webhook logs page in Semble.

If an individual webhook message cannot be sent for any reason, it will be retried a number of times before it is marked failed. The logs page has details of the last send attempt to help debug failed messages. When neccessary a webhook message can be requeued for sending.

Alternatively, you may want to use the relevant query with an appropriate updatedAt filter to avoid requeuing many events.

If delivery to a registered URL is repeatedly not possible, the webhook subscription will automatically be set to deliveryPaused: true. If this happens, you will be notified via the Technical Contact email address in your practice settings.

In this state, no new webhook deliveries will be sent to this subscription. Instead, the events will be registered as paused and may be replayed via the Webhook Logs page in Semble. You can use updateWebhook to set deliveryPaused: false when necessary.

3: Webhook subscription lifecycle

You can update the details of a webhook subscription using updateWebhook. The main difference between update and create is that when updating you can also set the deliveryPaused state.

Note: When eventTypes is set in UpdateWebhook, this replaces the existing eventTypes; it does not add to the existing value.

Example:

mutation UpdateWebhook {
updateWebhook(
id: "692597070fe21cfa5e6a010f"
input: {
label: "Main integration (v2)"
eventTypes: [PATIENT_CREATED, PATIENT_UPDATED, PATIENT_DELETED]
}
) {
data {
id
}
error
}
}

The subscription can also be deleted using the deleteWebhook mutation.

Example:

mutation DeleteWebhook {
deleteWebhook(id: "692597070fe21cfa5e6a010f") {
data {
id
}
error
}
}

If the webhook is not found for your practice (e.g. it does not exist, or is already deleted), the mutation returns data: null and an error message.

4: Verifying webhook signatures

Each outbound webhook request is signed so you can verify both authenticity and payload integrity.

Headers sent by Semble

  • X-Webhook-Signature: t=<unix_seconds>,v1=<hex_hmac_sha256>
  • X-Webhook-Timestamp: <unix_seconds> (same value as t)

Example:

X-Webhook-Signature: t=1700000500,v1=4b9f...
X-Webhook-Timestamp: 1700000500

Signing algorithm

Semble computes the signature as:

  1. Build the string to sign: <timestamp>.<raw_request_body>
  2. Compute HMAC-SHA256 using your webhook secret
  3. Encode the result as lowercase hex

Pseudocode:

signature = HMAC_SHA256_HEX(secret, `${timestamp}.${rawBody}`)

Validation steps

  1. Read the raw request body exactly as received (before JSON parsing/re-serialization).
  2. Read X-Webhook-Signature and extract:
    • t (timestamp)
    • v1 (signature)
  3. Recompute the expected signature with your stored webhook secret.
  4. Compare expected vs received using a constant-time comparison.
  5. Optionally enforce a timestamp tolerance window (for example, reject if older than 5 minutes) to reduce replay risk.
  6. Reject with 401/403 if any check fails.

Example implementations

# frozen_string_literal: true

require "openssl"

module VerifySembleWebhook
module_function

HEADER_RE = /\At=(\d+),\s*v1=([a-f0-9]{64})\z/i.freeze

def verify_semble_webhook(options = nil)
options = {} if options.nil?
secret = options[:secret]
raw_body = options[:rawBody]
signature_header = options[:signatureHeader]
tolerance_seconds = options[:toleranceSeconds]

return { valid: false, message: "Invalid input" } unless validate_input(secret, raw_body, signature_header)

timestamp, received_signature = parse_signature_header(signature_header)
if timestamp.nil? || received_signature.nil? || timestamp.empty? || received_signature.empty?
return { valid: false, message: "Invalid signature header" }
end

message = "Replay window disabled"
if replay_window_active?(tolerance_seconds)
now = Time.now.to_i
if (now - timestamp.to_i).abs > tolerance_seconds.to_f
return { valid: false, message: "Replay window violation" }
end
message = "Replay window enabled"
end

expected_hex = compute_signature(secret, timestamp, raw_body)
unless expected_hex.length == received_signature.length
return { valid: false, message: "Signature length mismatch" }
end

expected_bin = [expected_hex].pack("H*")
received_bin = [received_signature].pack("H*")
unless expected_bin.bytesize == received_signature.length / 2 &&
received_bin.bytesize == received_signature.length / 2 &&
OpenSSL.fixed_length_secure_compare(expected_bin, received_bin)
return { valid: false, message: "Timing safe check failed" }
end

{ valid: true, message: message }
end

def compute_signature(secret, timestamp, raw_body)
OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}")
end

def validate_input(secret, raw_body, signature_header)
secret.is_a?(String) && !secret.empty? &&
raw_body.is_a?(String) &&
signature_header.is_a?(String) && !signature_header.empty?
end

def parse_signature_header(signature_header)
m = HEADER_RE.match(signature_header.strip)
return [nil, nil] unless m

[m[1], m[2].downcase]
end

def replay_window_active?(tolerance_seconds)
tolerance_seconds.is_a?(Numeric) && tolerance_seconds.finite? && tolerance_seconds.positive?
end
end

Security notes

  • Keep webhook secrets private. Do not log or expose them client-side.
  • Always verify the signature before processing payload contents.
  • Use the raw body bytes exactly as received for verification.
  • Rotate secrets if compromise is suspected.

Next steps

Use the interactive Create Webhook mutation builder to pick event types, optional headers, and copy a ready-to-run createWebhook mutation.