HomeGuidesAPI Reference
Guides

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

  1. Go to Settings → Developer → Export settings.
  2. Toggle on the HMAC authentication and click Generate HMAC Secret.
  3. 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:

HeaderDescription
x-signature-algThe hashing algorithm used. Currently always sha256.
x-signature-timestampUnix timestamp (in seconds) when the signature was generated.
x-signature-key-idIdentifier of the secret key used to sign the payload.
x-signatureThe 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 bytes
  • algo — the algorithm used (currently always sha256).
  • 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 verify callback 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 addContentTypeParser with parseAs: 'buffer', Koa's koa-bodyparser with rawBody: true, or skipping the JSON middleware entirely on this route and reading the request stream manually.

If rawBody ends up being a string rather than a Buffer, the toString('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');
  • findSecretByKeyId is your own lookup. During rotation you will temporarily store more than one secret; use keyId to pick the right one.
  • .digest('hex') produces a lowercase 64-character hexadecimal string for sha256. 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 keyId arrives 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.timingSafeEqual throws if its two inputs have different byte lengths, so always check a.length === b.length first.

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 move x-signature-timestamp forward without invalidating x-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 in results[]) 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: POST with Content-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.