Skip to content

Guide

Verify a CalSpark webhook signature (HMAC SHA-256)

Every CalSpark webhook is signed. Check the signature before you trust a request. This page is a reference for the header, the signed string, working Node and Python code, and what the delivery system does when you answer slowly or not at all. Webhooks are off until an admin turns on API and Webhooks in Company, Features.

By CalSpark team. Published . Updated .

The header and the signed string

CalSpark-Signature: t=<unix>,v1=<hex>[,v1=<hex>]

The value t is Unix seconds when the delivery was sent. Each v1 is a hex HMAC SHA-256 of this exact string, keyed with your endpoint signing secret: the value of t, a period, then the raw request body.

${t}.${rawBody}

A header carries one v1 per active secret. Accept the request if any v1 matches any secret you hold. The developer docs show the same rule.

Verification order

  1. Read the raw body as bytes or an unparsed string.
  2. Split the header on commas, then each part on its first equals sign. Collect t and every v1.
  3. Reject if t is missing, there is no v1, or t is more than 300 seconds from your clock.
  4. For each secret, compute HMAC SHA-256 over t, a period and the raw body. Hex encode it.
  5. Compare against each v1 in constant time. Any match passes. Otherwise answer 400.

Node example

This is the logic from the developer docs, condensed.

const crypto = require('crypto');

function verifyCalSpark(rawBody, header, secrets, tolerance = 300) {
  let t = null;
  const sigs = [];
  for (const part of String(header || '').split(',')) {
    const i = part.indexOf('=');
    if (i === -1) continue;
    const k = part.slice(0, i).trim();
    const v = part.slice(i + 1).trim();
    if (k === 't' && t === null) t = Number(v);
    if (k === 'v1') sigs.push(v);
  }
  if (!Number.isInteger(t) || sigs.length === 0) return false;
  if (Math.abs(Date.now() / 1000 - t) > tolerance) return false;
  let ok = false;
  for (const secret of secrets) {
    const want = crypto.createHmac('sha256', secret).update(t + '.' + rawBody).digest('hex');
    for (const sig of sigs) {
      const a = Buffer.from(sig);
      const b = Buffer.from(want);
      if (a.length === b.length && crypto.timingSafeEqual(a, b)) ok = true;
    }
  }
  return ok;
}

// Express: express.raw() keeps the body as bytes.
app.post('/calspark', express.raw({ type: 'application/json' }), (req, res) => {
  const raw = req.body.toString("utf8");
  if (!verifyCalSpark(raw, req.get('CalSpark-Signature'), [process.env.CALSPARK_WEBHOOK_SECRET])) {
    return res.sendStatus(400);
  }
  res.sendStatus(200); // answer first, work after
  const event = JSON.parse(raw);
});

Python example

Standard library only for the check. The Flask handler is the only dependency.

import hashlib
import hmac
import time


def verify_calspark(raw_body: bytes, header: str, secrets: list, tolerance: int = 300) -> bool:
    t, sigs = None, []
    for part in (header or "").split(","):
        key, sep, value = part.partition("=")
        if not sep:
            continue
        key, value = key.strip(), value.strip()
        if key == "t" and t is None and value.isdigit():
            t = int(value)
        elif key == "v1":
            sigs.append(value)
    if t is None or not sigs or abs(time.time() - t) > tolerance:
        return False
    signed = str(t).encode() + b"." + raw_body
    ok = False
    for secret in secrets:
        want = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest().encode()
        for sig in sigs:
            if hmac.compare_digest(sig.encode(), want):
                ok = True
    return ok


# Flask handler
from flask import Flask, request

app = Flask(__name__)


@app.post("/calspark")
def calspark():
    raw = request.get_data()  # bytes, before any JSON parsing
    if not verify_calspark(raw, request.headers.get("CalSpark-Signature", ""), [SECRET]):
        return "invalid signature", 400
    return "", 200

Raw body pitfalls and secret rotation

Most failed checks come from a body parser. If middleware parses the JSON and you serialize it again, key order or spacing can change and the HMAC no longer matches. Read the bytes first: express.raw in Express, request.get_data() in Flask. Verify, then parse.

In Settings, Developers, Webhook endpoints, open the endpoint and choose Rotate secret. Copy the new secret, or use Reveal secret on the same page later. The old secret keeps working for 24 hours, and deliveries carry both signatures during that time. Deploy the new secret, then drop the old one. Pass both secrets to the verifier in the meantime.

Other headers, retries and dedupe

Headers on every delivery
HeaderMeaning
CalSpark-Event-IdThe event id. Stable across retries.
CalSpark-Event-TypeFor example job.completed.
CalSpark-Delivery-IdStable per delivery across retries.
CalSpark-Delivery-Attempt1 on the first try, then 2 and up.

Answer with any 2xx within 10 seconds, and do the slow work after. A non-2xx answer, a timeout or a network error is retried. Redirects are not followed, so a 3xx counts as a failure. The delays after each failed attempt are 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours, each shifted up to 20 percent either way. The cap is 8 attempts. A 429 with Retry-After can push the next try later, up to 24 hours.

Retries mean the same event can arrive twice. Store the event id and skip any you have seen. Do not count on arrival order either.

What disables an endpoint, and network rules

  • HTTP 410 disables the endpoint right away.
  • Seven days of continuous failure disables it. CalSpark emails the company admins. Fix the receiver, then press Enable on the endpoint page in Settings, Developers.
  • URLs must be HTTPS, with no username or password. Only port 443 is accepted, so https://example.com:8443 is refused.
  • Hostnames such as localhost and names ending in .local or .internal, and private or reserved IP addresses, are refused. Use a public tunnel for local testing.

Test before going live

Open your endpoint in Settings, Developers, Webhook endpoints and click Send test event. It sends one signed customer.updated sample about a made-up customer and shows the HTTP status. It is not recorded in the delivery log. For the list of event types and sample payloads, see the event catalog. To use events for something practical, see sending completed jobs to a Google Sheet.

Questions

Why does my signature never match?

Usually the body was parsed and re-serialized before you hashed it, or you hashed it without the timestamp and period in front. The signed string is the t value, a period, then the untouched body.

What if my server clock is off?

Timestamps more than 300 seconds from your clock are rejected. Sync your server with NTP. Do not widen the tolerance to hide a bad clock.

Why does one header have two v1 values?

You rotated the secret in the last 24 hours. CalSpark signs with both so your receiver works with either while you deploy the new one.

Is it safe to compare signatures with a plain equals check?

No. Use timingSafeEqual in Node or hmac.compare_digest in Python so the comparison time does not leak how many characters matched.

How do I know a request is a retry?

CalSpark-Delivery-Attempt is above 1. The event id is the same each time, so dedupe on it.

Try it with your own jobs

14-day free trial. No card. $49 a month flat for up to 10 users after that.