When you create a webhook subscription, Snapdragon Guardian generates a signing secret and returns it exactly once. Every HTTP request delivered to your endpoint includes a cryptographic signature computed from that secret. You can use this signature to verify that a request genuinely came from Snapdragon Guardian and that its body has not been tampered with in transit.

How it works

For each webhook delivery, Snapdragon Guardian:

  1. Records the current UTC time as an ISO 8601 timestamp using round-trip ("O") format (e.g. 2026-08-27T14:30:00.0000000+00:00). Note the 7-digit fractional seconds and numeric UTC offset — the value never uses a Z suffix.
  2. Constructs the signed data by concatenating <timestamp>.<raw-JSON-body-bytes>.
  3. Computes HMAC-SHA256(<signing-secret>, <signed-data>) and hex-encodes the result in lowercase.
  4. Sends the following headers with the request:
HeaderExample valuePurpose
X-QRMS-Timestamp2026-08-27T14:30:00.0000000+00:00UTC time the delivery was created
X-QRMS-Signaturesha256=a3f1c9...HMAC-SHA256 signature
X-QRMS-Delivery-Idd3b4e5f6-...Unique ID for this delivery
X-QRMS-Event-Id42ID of the triggering event

Suggested validation steps

The following steps are recommended to ensure the authenticity of the received event and protect against stale or replayed requests.

1 — Reject replayed requests (timestamp check)

Parse X-QRMS-Timestamp and verify it is within 5 minutes of the current UTC time. Reject requests outside this window to prevent replay attacks.

2 — Recompute the expected signature

Construct the signed data and compute the HMAC:

signed_data = <X-QRMS-Timestamp value> + "." + <raw request body bytes>
expected    = "sha256=" + HMAC-SHA256(<signing-secret>, signed_data).hexdigest()

Important: Use the raw, unmodified body bytes — do not parse or re-serialise the JSON first, as that can change the byte-level representation and cause a mismatch.

3 — Compare using a timing-safe comparison

Compare expected to the value of X-QRMS-Signature using a constant-time string comparison to prevent timing side-channel attacks.

4 — Deduplicate on X-QRMS-Delivery-Id

Snapdragon Guardian delivers at-least-once — retries due to worker restarts or retryable responses (5xx, 408, 429, or timeouts) can cause the same event to arrive more than once. You may persist processed delivery IDs to skip repeats or apply any other logic as appropriate for your use case.

Code examples

C#

using System.Security.Cryptography;
using System.Text;

static bool VerifySignature(
    string timestamp, string signatureHeader, byte[] bodyBytes, string secret)
{
    if (string.IsNullOrWhiteSpace(timestamp) ||
        !signatureHeader.StartsWith("sha256="))
        return false;

    if (!DateTimeOffset.TryParse(timestamp, out var parsed) ||
        Math.Abs((DateTimeOffset.UtcNow - parsed).TotalSeconds) > 300)
        return false;

    var prefixBytes = Encoding.UTF8.GetBytes(timestamp + ".");
    var signedData = new byte[prefixBytes.Length + bodyBytes.Length];
    Buffer.BlockCopy(prefixBytes, 0, signedData, 0, prefixBytes.Length);
    Buffer.BlockCopy(bodyBytes, 0, signedData, prefixBytes.Length, bodyBytes.Length);

    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
    var expected = "sha256=" +
        Convert.ToHexString(hmac.ComputeHash(signedData)).ToLowerInvariant();

    return CryptographicOperations.FixedTimeEquals(
        Encoding.UTF8.GetBytes(expected),
        Encoding.UTF8.GetBytes(signatureHeader));
}

Java

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.OffsetDateTime;
import java.time.format.DateTimeParseException;
import java.time.temporal.ChronoUnit;
import java.util.HexFormat;

public static boolean verifySignature(
        String timestamp, String signatureHeader, byte[] bodyBytes, String secret)
        throws Exception {

    if (timestamp == null || signatureHeader == null ||
            !signatureHeader.startsWith("sha256="))
        return false;

    try {
        long ageSeconds = Math.abs(ChronoUnit.SECONDS.between(
                OffsetDateTime.parse(timestamp), OffsetDateTime.now()));
        if (ageSeconds > 300)
            return false;
    } catch (DateTimeParseException e) {
        return false;
    }

    byte[] prefixBytes = (timestamp + ".").getBytes(StandardCharsets.UTF_8);
    byte[] signedData = new byte[prefixBytes.length + bodyBytes.length];
    System.arraycopy(prefixBytes, 0, signedData, 0, prefixBytes.length);
    System.arraycopy(bodyBytes, 0, signedData, prefixBytes.length, bodyBytes.length);

    Mac hmac = Mac.getInstance("HmacSHA256");
    hmac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
    String expected = "sha256=" + HexFormat.of().formatHex(hmac.doFinal(signedData));

    return MessageDigest.isEqual(
            expected.getBytes(StandardCharsets.UTF_8),
            signatureHeader.getBytes(StandardCharsets.UTF_8));
}

Python

import hashlib
import hmac
from datetime import datetime, timezone

def verify_signature(headers: dict, body: bytes, secret: str) -> bool:
    timestamp = headers.get("x-qrms-timestamp")
    signature_header = headers.get("x-qrms-signature", "")

    if not timestamp or not signature_header.startswith("sha256="):
        return False

    try:
        age = abs((datetime.now(timezone.utc) -
                   datetime.fromisoformat(timestamp)).total_seconds())
    except ValueError:
        return False

    if age > 300:
        return False

    signed_data = (timestamp + ".").encode("utf-8") + body
    expected = "sha256=" + hmac.new(
        secret.encode("utf-8"), signed_data, hashlib.sha256).hexdigest()

    return hmac.compare_digest(expected, signature_header)

Managing your signing secret

ActionEndpointNotes
Create subscriptionPOST /qrms/webhooksSecret returned once in the response
Rotate secretPOST /qrms/webhooks/{id}/rotate-secretNew secret returned once; old secret invalidated immediately
View subscriptionGET /qrms/webhooks/{id}Secret is never returned

If you lose your signing secret, rotate it immediately using POST /qrms/webhooks/{id}/rotate-secret. There is no way to retrieve the existing value. Update your receiver before the next event fires — deliveries signed with the old secret will fail verification.

Signing secret format

Secrets follow the format whsec_<base64>, where the value after the prefix is a Base64-encoded 256-bit (32-byte) cryptographically random key.

Example: whsec_A1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8s9T0u1V2w3X4y5Z6=

Store this value securely (for example, in a secrets manager or environment variable). Do not commit it to source control.