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 Contactemail 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 subscriptionsecret- 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 ast)
Example:
X-Webhook-Signature: t=1700000500,v1=4b9f...
X-Webhook-Timestamp: 1700000500
Signing algorithm
Semble computes the signature as:
- Build the string to sign:
<timestamp>.<raw_request_body> - Compute HMAC-SHA256 using your webhook secret
- Encode the result as lowercase hex
Pseudocode:
signature = HMAC_SHA256_HEX(secret, `${timestamp}.${rawBody}`)
Validation steps
- Read the raw request body exactly as received (before JSON parsing/re-serialization).
- Read
X-Webhook-Signatureand extract:t(timestamp)v1(signature)
- Recompute the expected signature with your stored webhook secret.
- Compare expected vs received using a constant-time comparison.
- Optionally enforce a timestamp tolerance window (for example, reject if older than 5 minutes) to reduce replay risk.
- Reject with
401/403if any check fails.
Example implementations
- Ruby
- Python
- PHP
- Java
- Node.js
- Go
- .NET
# 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
from __future__ import annotations
import hmac
import re
import time
from typing import Any, TypedDict
class VerifyResult(TypedDict):
valid: bool
message: str
_HEADER_RE = re.compile(r"^t=(\d+),\s*v1=([a-f0-9]{64})$", re.IGNORECASE)
def _compute_signature(secret: str, timestamp: str, raw_body: str) -> str:
msg = f"{timestamp}.{raw_body}".encode("utf-8")
return hmac.new(secret.encode("utf-8"), msg, "sha256").hexdigest()
def _validate_input(secret: Any, raw_body: Any, signature_header: Any) -> bool:
return (
isinstance(secret, str)
and len(secret) > 0
and isinstance(raw_body, str)
and isinstance(signature_header, str)
and len(signature_header) > 0
)
def _parse_signature_header(
signature_header: str,
) -> tuple[str | None, str | None]:
m = _HEADER_RE.match(signature_header.strip())
if not m:
return None, None
return m.group(1), m.group(2).lower()
def _replay_window_active(tolerance_seconds: Any) -> bool:
return (
isinstance(tolerance_seconds, (int, float))
and tolerance_seconds == tolerance_seconds
and tolerance_seconds > 0
)
def verify_semble_webhook(options: dict[str, Any] | None = None) -> VerifyResult:
options = options or {}
secret = options.get("secret")
raw_body = options.get("rawBody")
signature_header = options.get("signatureHeader")
tolerance_seconds = options.get("toleranceSeconds")
if not _validate_input(secret, raw_body, signature_header):
return {"valid": False, "message": "Invalid input"}
assert isinstance(signature_header, str)
timestamp, received_signature = _parse_signature_header(signature_header)
if not timestamp or not received_signature:
return {"valid": False, "message": "Invalid signature header"}
message = "Replay window disabled"
if _replay_window_active(tolerance_seconds):
now = int(time.time())
if abs(now - int(timestamp)) > float(tolerance_seconds):
return {"valid": False, "message": "Replay window violation"}
message = "Replay window enabled"
assert isinstance(secret, str) and isinstance(raw_body, str)
expected_hex = _compute_signature(secret, timestamp, raw_body)
if len(expected_hex) != len(received_signature):
return {"valid": False, "message": "Signature length mismatch"}
try:
expected_bytes = bytes.fromhex(expected_hex)
received_bytes = bytes.fromhex(received_signature)
except ValueError:
return {"valid": False, "message": "Timing safe check failed"}
if not hmac.compare_digest(expected_bytes, received_bytes):
return {"valid": False, "message": "Timing safe check failed"}
return {"valid": True, "message": message}
<?php
declare(strict_types=1);
/**
* @param array{secret?: string, rawBody?: string, signatureHeader?: string, toleranceSeconds?: float|int}|null $options
* @return array{valid: bool, message: string}
*/
function verifySembleWebhook(?array $options = null): array
{
$options = $options ?? [];
$secret = $options['secret'] ?? null;
$rawBody = $options['rawBody'] ?? null;
$signatureHeader = $options['signatureHeader'] ?? null;
$toleranceSeconds = $options['toleranceSeconds'] ?? null;
if (!validateInput($secret, $rawBody, $signatureHeader)) {
return ['valid' => false, 'message' => 'Invalid input'];
}
/** @var string $signatureHeader */
$parsed = parseSignatureHeader($signatureHeader);
if ($parsed['timestamp'] === null || $parsed['receivedSignature'] === null) {
return ['valid' => false, 'message' => 'Invalid signature header'];
}
$timestamp = $parsed['timestamp'];
$receivedSignature = $parsed['receivedSignature'];
$message = 'Replay window disabled';
if (isFinitePositiveNumber($toleranceSeconds)) {
$now = time();
if (abs($now - (int) $timestamp) > (float) $toleranceSeconds) {
return ['valid' => false, 'message' => 'Replay window violation'];
}
$message = 'Replay window enabled';
}
/** @var string $secret */
/** @var string $rawBody */
$expectedHex = computeSignature($secret, $timestamp, $rawBody);
if (strlen($expectedHex) !== strlen($receivedSignature)) {
return ['valid' => false, 'message' => 'Signature length mismatch'];
}
$expectedBin = hex2bin($expectedHex);
$receivedBin = hex2bin($receivedSignature);
if ($expectedBin === false || $receivedBin === false || !hash_equals($expectedBin, $receivedBin)) {
return ['valid' => false, 'message' => 'Timing safe check failed'];
}
return ['valid' => true, 'message' => $message];
}
function computeSignature(string $secret, string $timestamp, string $rawBody): string
{
return hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
}
function validateInput(mixed $secret, mixed $rawBody, mixed $signatureHeader): bool
{
return is_string($secret) && $secret !== ''
&& is_string($rawBody)
&& is_string($signatureHeader) && $signatureHeader !== '';
}
/**
* @return array{timestamp: string|null, receivedSignature: string|null}
*/
function parseSignatureHeader(string $signatureHeader): array
{
$trimmed = trim($signatureHeader);
if (preg_match('/^t=(\d+),\s*v1=([a-f0-9]{64})$/i', $trimmed, $m) !== 1) {
return ['timestamp' => null, 'receivedSignature' => null];
}
return ['timestamp' => $m[1], 'receivedSignature' => strtolower($m[2])];
}
function isFinitePositiveNumber(mixed $toleranceSeconds): bool
{
if (!is_int($toleranceSeconds) && !is_float($toleranceSeconds)) {
return false;
}
return is_finite($toleranceSeconds) && $toleranceSeconds > 0;
}
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public final class VerifySembleWebhook {
private static final Pattern HEADER_RE =
Pattern.compile("^t=(\\d+),\\s*v1=([a-f0-9]{64})$", Pattern.CASE_INSENSITIVE);
private VerifySembleWebhook() {}
public static VerifySembleWebhookResult verifySembleWebhook(VerifySembleWebhookInput input) {
ParsedSignature parsed = parseSignature(input.signatureHeader());
if (parsed.isInvalid()) {
return new VerifySembleWebhookResult(false, "Invalid signature header");
}
String message = "Replay window disabled";
if (isReplayWindowEnabled(input.toleranceSeconds())) {
long now = System.currentTimeMillis() / 1000L;
long ts = Long.parseLong(parsed.timestamp);
double tol = input.toleranceSeconds();
if (Math.abs(now - ts) > tol) {
return new VerifySembleWebhookResult(false, "Replay window violation");
}
message = "Replay window enabled";
}
String expectedHex = computeSignature(input.secret(), parsed.timestamp, input.rawBody());
if (expectedHex.length() != parsed.receivedSignature.length()) {
return new VerifySembleWebhookResult(false, "Signature length mismatch");
}
byte[] expectedBin = hexToBytes(expectedHex);
byte[] receivedBin = hexToBytes(parsed.receivedSignature);
boolean timingSafe = expectedBin != null
&& receivedBin != null
&& expectedBin.length == receivedBin.length
&& MessageDigest.isEqual(expectedBin, receivedBin);
if (!timingSafe) {
return new VerifySembleWebhookResult(false, "Timing safe check failed");
}
return new VerifySembleWebhookResult(true, message);
}
private static ParsedSignature parseSignature(String signatureHeader) {
Matcher m = HEADER_RE.matcher(signatureHeader.trim());
if (!m.matches()) {
return new ParsedSignature(null, null);
}
return new ParsedSignature(m.group(1), m.group(2).toLowerCase());
}
private static boolean isReplayWindowEnabled(Double toleranceSeconds) {
if (toleranceSeconds == null) {
return false;
}
double v = toleranceSeconds;
return Double.isFinite(v) && v > 0;
}
private static String computeSignature(String secret, String timestamp, String rawBody) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String payload = timestamp + "." + rawBody;
byte[] raw = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
return toHex(raw);
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
throw new IllegalStateException(e);
}
}
private static String toHex(byte[] bytes) {
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
private static byte[] hexToBytes(String hex) {
int n = hex.length();
if (n % 2 != 0) {
return null;
}
byte[] out = new byte[n / 2];
for (int i = 0; i < n; i += 2) {
int hi = Character.digit(hex.charAt(i), 16);
int lo = Character.digit(hex.charAt(i + 1), 16);
if (hi < 0 || lo < 0) {
return null;
}
out[i / 2] = (byte) ((hi << 4) + lo);
}
return out;
}
private static final class ParsedSignature {
final String timestamp;
final String receivedSignature;
ParsedSignature(String timestamp, String receivedSignature) {
this.timestamp = timestamp;
this.receivedSignature = receivedSignature;
}
boolean isInvalid() {
return timestamp == null
|| receivedSignature == null
|| timestamp.isEmpty()
|| receivedSignature.isEmpty();
}
}
public record VerifySembleWebhookInput(
String secret,
String rawBody,
String signatureHeader,
Double toleranceSeconds) {}
public record VerifySembleWebhookResult(boolean valid, String message) {}
}
/* eslint-disable -- example code */ //
import { createHmac, timingSafeEqual } from 'crypto';
export function verifySembleWebhook(options) {
const { secret, rawBody, signatureHeader, toleranceSeconds } = options ?? {};
const inputsOk = validateInputHelper(secret, rawBody, signatureHeader);
if (!inputsOk) {
return {
valid: false,
message: 'Invalid input'
};
}
const { timestamp, receivedSignature } = parseSignatureHelper(signatureHeader);
if (!timestamp || !receivedSignature) {
return {
valid: false,
message: 'Invalid signature header'
};
}
let message = 'Replay window disabled';
if (Number.isFinite(toleranceSeconds) && toleranceSeconds > 0) {
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(timestamp)) > toleranceSeconds) {
return {
valid: false,
message: 'Replay window violation'
};
}
message = 'Replay window enabled';
}
const expectedSignature = computeSignatureHelper(secret, timestamp, rawBody);
const signatureLengthCheck = expectedSignature.length === receivedSignature.length;
if (!signatureLengthCheck) {
return {
valid: false,
message: 'Signature length mismatch'
};
}
const timingSafeCheck = timingSafeEqual(Buffer.from(expectedSignature, 'hex'), Buffer.from(receivedSignature, 'hex'));
if (!timingSafeCheck) {
return {
valid: false,
message: 'Timing safe check failed'
};
}
return {
valid: true,
message
};
}
function computeSignatureHelper(secret, timestamp, rawBody) {
return createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
}
function validateInputHelper(secret, rawBody, signatureHeader) {
const secretOk = typeof secret === 'string' && secret.length > 0;
const rawBodyOk = typeof rawBody === 'string';
const headerStringOk = typeof signatureHeader === 'string' && signatureHeader.length > 0;
return secretOk && rawBodyOk && headerStringOk;
}
function parseSignatureHelper(signatureHeader) {
const match = /^t=(\d+),\s*v1=([a-f0-9]{64})$/i.exec(signatureHeader.trim());
if (!match) return {
timestamp: null,
receivedSignature: null
};
const [, timestamp, v1Hex] = match;
return {
timestamp,
receivedSignature: v1Hex.toLowerCase()
};
}
package verifysemble
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"math"
"regexp"
"strings"
"time"
)
type VerifyInput struct {
Secret string
RawBody string
SignatureHeader string
ToleranceSeconds *float64
}
type VerifyResult struct {
Valid bool
Message string
}
var headerRe = regexp.MustCompile(`(?i)^t=(\d+),\s*v1=([a-f0-9]{64})$`)
func VerifySembleWebhook(input VerifyInput) VerifyResult {
tol, replayEnabled := replayTolerance(input.ToleranceSeconds)
ts, v1Hex, ok := parseSignatureHeader(input.SignatureHeader)
if !ok || ts == "" || v1Hex == "" {
return VerifyResult{Valid: false, Message: "Invalid signature header"}
}
message := "Replay window disabled"
if replayEnabled {
now := time.Now().Unix()
parsedTs := parseInt64(ts)
if math.Abs(float64(now-parsedTs)) > tol {
return VerifyResult{Valid: false, Message: "Replay window violation"}
}
message = "Replay window enabled"
}
expectedHex := computeSignature(input.Secret, ts, input.RawBody)
if len(expectedHex) != len(v1Hex) {
return VerifyResult{Valid: false, Message: "Signature length mismatch"}
}
expectedBin, err1 := hex.DecodeString(expectedHex)
receivedBin, err2 := hex.DecodeString(v1Hex)
if err1 != nil || err2 != nil || len(expectedBin) != len(receivedBin) {
return VerifyResult{Valid: false, Message: "Timing safe check failed"}
}
if subtle.ConstantTimeCompare(expectedBin, receivedBin) != 1 {
return VerifyResult{Valid: false, Message: "Timing safe check failed"}
}
return VerifyResult{Valid: true, Message: message}
}
func computeSignature(secret, timestamp, rawBody string) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp + "." + rawBody))
return hex.EncodeToString(mac.Sum(nil))
}
func replayTolerance(v *float64) (float64, bool) {
if v == nil {
return 0, false
}
return *v, !math.IsNaN(*v) && !math.IsInf(*v, 0) && *v > 0
}
func parseSignatureHeader(signatureHeader string) (timestamp, v1HexLower string, ok bool) {
m := headerRe.FindStringSubmatch(strings.TrimSpace(signatureHeader))
if m == nil {
return "", "", false
}
return m[1], strings.ToLower(m[2]), true
}
func parseInt64(s string) int64 {
var n int64
for i := 0; i < len(s); i++ {
c := s[i]
if c < '0' || c > '9' {
return 0
}
n = n*10 + int64(c-'0')
}
return n
}
using System;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
public static class VerifySembleWebhook
{
private static readonly Regex HeaderRe = new Regex(
@"^t=(\d+),\s*v1=([a-f0-9]{64})$",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
public static VerifySembleWebhookResult Verify(VerifySembleWebhookInput input)
{
ParsedSignatureHeader parsed = ParseSignatureHeader(input.SignatureHeader);
if (parsed.Timestamp == null || parsed.ReceivedSignature == null)
{
return new VerifySembleWebhookResult(false, "Invalid signature header");
}
string message = "Replay window disabled";
if (IsReplayWindowEnabled(input.ToleranceSeconds))
{
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
long ts = long.Parse(parsed.Timestamp, CultureInfo.InvariantCulture);
double tol = input.ToleranceSeconds.Value;
if (Math.Abs(now - ts) > tol)
{
return new VerifySembleWebhookResult(false, "Replay window violation");
}
message = "Replay window enabled";
}
string expectedHex = ComputeSignature(input.Secret, parsed.Timestamp, input.RawBody);
if (expectedHex.Length != parsed.ReceivedSignature.Length)
{
return new VerifySembleWebhookResult(false, "Signature length mismatch");
}
try
{
byte[] expectedBin = HexToBytes(expectedHex);
byte[] receivedBin = HexToBytes(parsed.ReceivedSignature);
if (!FixedTimeEquals(expectedBin, receivedBin))
{
return new VerifySembleWebhookResult(false, "Timing safe check failed");
}
}
catch (FormatException)
{
return new VerifySembleWebhookResult(false, "Timing safe check failed");
}
return new VerifySembleWebhookResult(true, message);
}
public static string ComputeSignature(string secret, string timestamp, string rawBody)
{
byte[] key = Encoding.UTF8.GetBytes(secret);
byte[] msg = Encoding.UTF8.GetBytes(timestamp + "." + rawBody);
using (HMACSHA256 hmac = new HMACSHA256(key))
{
byte[] hash = hmac.ComputeHash(msg);
return BytesToHex(hash);
}
}
private static ParsedSignatureHeader ParseSignatureHeader(string signatureHeader)
{
Match m = HeaderRe.Match(signatureHeader.Trim());
if (!m.Success)
{
return new ParsedSignatureHeader(null, null);
}
return new ParsedSignatureHeader(
m.Groups[1].Value,
m.Groups[2].Value.ToLowerInvariant());
}
private static bool IsReplayWindowEnabled(double? toleranceSeconds)
{
if (!toleranceSeconds.HasValue) return false;
double v = toleranceSeconds.Value;
return !double.IsNaN(v) && !double.IsInfinity(v) && v > 0;
}
private static bool FixedTimeEquals(byte[] left, byte[] right)
{
if (left == null || right == null || left.Length != right.Length) return false;
int diff = 0;
for (int i = 0; i < left.Length; i++)
{
diff |= left[i] ^ right[i];
}
return diff == 0;
}
private static byte[] HexToBytes(string hex)
{
if (hex == null || (hex.Length % 2) != 0)
{
throw new FormatException("Invalid hex");
}
byte[] result = new byte[hex.Length / 2];
for (int i = 0; i < result.Length; i++)
{
int hi = ParseHexNibble(hex[i * 2]);
int lo = ParseHexNibble(hex[(i * 2) + 1]);
if (hi < 0 || lo < 0) throw new FormatException("Invalid hex");
result[i] = (byte)((hi << 4) | lo);
}
return result;
}
private static int ParseHexNibble(char c)
{
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
private static string BytesToHex(byte[] bytes)
{
StringBuilder sb = new StringBuilder(bytes.Length * 2);
for (int i = 0; i < bytes.Length; i++)
{
sb.Append(bytes[i].ToString("x2", CultureInfo.InvariantCulture));
}
return sb.ToString();
}
private sealed class ParsedSignatureHeader
{
public string Timestamp;
public string ReceivedSignature;
public ParsedSignatureHeader(string timestamp, string receivedSignature)
{
Timestamp = timestamp;
ReceivedSignature = receivedSignature;
}
}
}
public sealed class VerifySembleWebhookInput
{
public string Secret;
public string RawBody;
public string SignatureHeader;
public double? ToleranceSeconds;
public VerifySembleWebhookInput(
string secret,
string rawBody,
string signatureHeader,
double? toleranceSeconds = null)
{
Secret = secret;
RawBody = rawBody;
SignatureHeader = signatureHeader;
ToleranceSeconds = toleranceSeconds;
}
}
public sealed class VerifySembleWebhookResult
{
public bool Valid;
public string Message;
public VerifySembleWebhookResult(bool valid, string message)
{
Valid = valid;
Message = message;
}
}
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.