Webhook HMAC Signature Verification
When HMAC authentication is enabled, each webhook event we send will include a set of signature headers that allow you to verify authenticity using the HMAC secret key generated in your account's Export Settings section.
Webhooks are delivered as POST requests with Content-Type: application/json to the HTTPS endpoint you configured in Settings → Developer → Export settings. The body is a JSON object of the form:
{
"results": [
{
"id": "<event id>",
"workspaceId": "<workspace id>",
"eventType": "process_run_start",
"timestamp": 1731057600,
"spektrId": "<customer id>",
"data": { "...": "event-specific payload" }
}
]
}A single request may contain multiple events; treat results as a batch and process each entry independently.
1. Generate your HMAC secret
- Go to Settings → Developer → Export settings.
- Toggle on the HMAC authentication and click Generate HMAC Secret.
- Copy the secret key and store it securely.
- The secret is shown only once for security reasons.
- You'll always be able to view the Key ID associated with it for future reference or rotation.
You will use this secret key to recompute and verify webhook signatures. Store the Key ID alongside the secret — you'll need it during verification to support key rotation.
2. Headers included in webhook requests
When HMAC authentication is enabled, each webhook request includes the following headers:
| Header | Description |
|---|---|
x-signature-alg | The hashing algorithm used. Currently always sha256. |
x-signature-timestamp | Unix timestamp (in seconds) when the signature was generated. |
x-signature-key-id | Identifier of the secret key used to sign the payload. |
x-signature | The HMAC signature, hex-encoded (lowercase, 64 characters for sha256). |
Note: HTTP header names are case-insensitive. Access them however your framework normalizes case — most lowercase them by default.
3. How to verify the signature
Follow these steps to validate that the webhook event is authentic and has not been modified.
Step 1. Capture the required values
From the incoming request, extract the following values:
const algo = req.headers['x-signature-alg'] as string;
const ts = req.headers['x-signature-timestamp'] as string;
const keyId = req.headers['x-signature-key-id'] as string;
const sig = req.headers['x-signature'] as string;
const rawBody = req.rawBody; // see note below — must be the raw bytesalgo— the algorithm used (currently alwayssha256).ts— Unix timestamp in seconds.keyId— identifies which of your stored secrets was used to sign.sig— the hex-encoded signature to verify.rawBody— the exact raw request body bytes as received, before any JSON parsing or reformatting.
Capturing the raw body is the most common source of verification failures. Most Node frameworks parse and discard the raw bytes by default. In Express, attach a
verifycallback to keep them:import express from 'express'; app.use( '/spektr-webhook', express.json({ verify: (req, _res, buf) => { (req as Request & { rawBody: Buffer }).rawBody = buf; }, }) );Equivalents: Fastify's
addContentTypeParserwithparseAs: 'buffer', Koa'skoa-bodyparserwithrawBody: true, or skipping the JSON middleware entirely on this route and reading the request stream manually.If
rawBodyends up being a string rather than aBuffer, thetoString('base64url')call in Step 2 silently becomes a no-op (it returns the original UTF-8 text) and the resulting signature will not match.
Step 2. Recreate the canonical string
Use the same construction logic that the platform uses:
const bodyB64 = rawBody.toString('base64url');
const stringToBeSigned = `alg=${algo}&ts=${ts}&b64=${bodyB64}`;Note: the body is encoded with base64url, not standard base64.
base64url (RFC 4648 §5) is URL-safe base64: it uses
-and_instead of+and/, and omits=padding. Standard base64 will produce a different string for any payload whose underlying bytes contain+or/, and signatures will not match.Equivalents in other languages:
- Node:
Buffer.toString('base64url')- Python:
base64.urlsafe_b64encode(body).rstrip(b'=').decode()- Go:
base64.RawURLEncoding.EncodeToString(body)- Java:
Base64.getUrlEncoder().withoutPadding().encodeToString(body)- .NET:
Base64UrlEncoder.Encode(body)(Microsoft.IdentityModel.Tokens)
Step 3. Compute the HMAC on your side
import crypto from 'crypto';
// 3a. Validate the algorithm against an allow-list.
// Never trust 'x-signature-alg' blindly — an unguarded receiver can be
// downgraded to a weaker algorithm in a future change or by a malicious
// caller.
const ALLOWED_ALGS = new Set(['sha256']);
if (!ALLOWED_ALGS.has(algo)) {
throw new Error(`Unsupported signature algorithm: ${algo}`);
}
// 3b. Resolve the secret using the key id (supports key rotation).
const secret = findSecretByKeyId(keyId);
if (!secret) {
throw new Error(`Unknown HMAC key id: ${keyId}`);
}
// 3c. Compute the HMAC using the algorithm from the header.
const computed = crypto
.createHmac(algo, secret)
.update(stringToBeSigned)
.digest('hex');findSecretByKeyIdis your own lookup. During rotation you will temporarily store more than one secret; usekeyIdto pick the right one..digest('hex')produces a lowercase 64-character hexadecimal string forsha256. Both sides must use the same output encoding.
Key rotation tip: when rotating, keep the previous secret active in your keystore until you've confirmed all in-flight webhooks have been signed with the new key id. If an unknown
keyIdarrives outside of a rotation window you initiated, reject the request and alert — it may indicate a forged call.
Step 4. Compare signatures
Use a timing-safe comparison to protect against timing attacks, where an attacker infers the signature character-by-character from how long the comparison takes to fail.
const a = Buffer.from(sig, 'hex');
const b = Buffer.from(computed, 'hex');
const isAuthentic =
a.length === b.length && crypto.timingSafeEqual(a, b);
if (!isAuthentic) {
return res.status(401).end();
}Note:
crypto.timingSafeEqualthrows if its two inputs have different byte lengths, so always checka.length === b.lengthfirst.
If the signatures match → the event is authentic and unmodified. If they don't match → respond with a non-2xx status (typically 401). Spektr treats anything outside the 2xx range as a delivery failure and will retry on the next scheduler tick.
Step 5. Apply a freshness check (recommended)
Reject stale or replayed events by checking the timestamp against a tolerance window:
const MAX_ALLOWED_SKEW = 300; // 5 minutes, matching Stripe/GitHub conventions
const now = Math.floor(Date.now() / 1000);
const skew = Math.abs(now - Number(ts));
if (skew > MAX_ALLOWED_SKEW) {
return res.status(401).end();
}Why this is safe against timestamp tampering: the timestamp is part of the signed canonical string (
alg=…&ts=…&b64=…), so an attacker cannot movex-signature-timestampforward without invalidatingx-signature.Replay within the window is still possible. A 5-minute tolerance allows an attacker who captured a valid request to replay it within those 5 minutes. For full replay protection, also record the event
id(from each entry inresults[]) and reject any id you have already processed. This also makes your endpoint idempotent, which matters because Spektr retries failed deliveries and the same event may legitimately arrive more than once.
4. Delivery, retries, and idempotency
- Method & content type:
POSTwithContent-Type: application/json. - Expected response: any 2xx status acknowledges receipt. Any non-2xx status, timeout, or connection error is treated as a failure.
- Retries: failed batches stay marked as undelivered and are retried on the next scheduler tick (currently every 5 seconds). There is no exponential backoff today.
Result
If the HMAC signature, timestamp, and (optional) replay-id checks all pass, the webhook event is verified as authentic, unaltered, and recent. Respond with 2xx to acknowledge it; otherwise respond with a non-2xx status so Spektr knows to retry.
Updated 12 days ago