Verifying Signatures

The full verification contract: headers, digest, working code, and secret rotation.

Every notification JOOR sends to your callback is signed. This page is the full verification contract: the headers, the digest, working code, and what changes when you rotate your secret. For how to create and manage the subscription this secret belongs to, see Managing Subscriptions.


Verifying the signature

Every request to your callback carries these headers:

HeaderValue
X-JOOR-Signaturesha256=<hex digest>
X-JOOR-TimestampUnix seconds, minted per attempt
X-JOOR-Delivery-IdSame as delivery_id in the body
X-JOOR-EventSame as event in the body
X-JOOR-Payload-VersionSame as version in the body

The digest is HMAC SHA256 over the string "<X-JOOR-Timestamp>.<raw request body>", keyed with your secret_key, hex encoded.

import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300


def verify(raw_body: bytes, headers, secret_key: str) -> bool:
    # Look these up case insensitively; most frameworks hand you a mapping
    # that already does. A missing header means "not from JOOR".
    timestamp = headers.get("X-JOOR-Timestamp")
    signature = headers.get("X-JOOR-Signature")
    if not timestamp or not signature:
        return False
    try:
        sent_at = int(timestamp)
    except (TypeError, ValueError):
        return False
    # Reject stale or replayed timestamps before doing any crypto.
    if abs(time.time() - sent_at) > TOLERANCE_SECONDS:
        return False
    expected = hmac.new(
        secret_key.encode(),
        # RAW bytes. Never the JSON you parsed and serialised again.
        f"{timestamp}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(f"sha256={expected}", signature)
const crypto = require('crypto');

const TOLERANCE_SECONDS = 300;

function verify(rawBody, headers, secretKey) {
  const timestamp = headers['x-joor-timestamp'];
  const signature = headers['x-joor-signature'];
  if (!timestamp || !signature) return false;
  const sentAt = Number(timestamp);
  if (!Number.isFinite(sentAt)) return false;
  if (Math.abs(Date.now() / 1000 - sentAt) > TOLERANCE_SECONDS) return false;
  const expected =
    'sha256=' +
    crypto
      .createHmac('sha256', secretKey)
      // rawBody must be a Buffer of the bytes as received.
      .update(Buffer.concat([Buffer.from(`${timestamp}.`), rawBody]))
      .digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  // timingSafeEqual throws when the lengths differ, so compare them first.
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

Note in both versions that the timestamp is fed into the digest as the string exactly as received, not as a reparsed number. sent_at and sentAt exist only for the tolerance check.

Three things that bite people:

  • Sign the raw bytes. We serialise compactly, with no spaces between JSON tokens. If your framework parses the body and you re serialise it to verify, the digest will not match. Capture the raw body before parsing.
  • Use a constant time comparison. hmac.compare_digest, crypto.timingSafeEqual, or your language's equivalent. Never ==.
  • Enforce a timestamp tolerance. A few minutes is right. We mint a fresh timestamp on every attempt, so a legitimate retry always arrives current, and an old signature is a replay.

Rotating without downtime

Rotation takes effect immediately: every attempt, including retries of deliveries that first failed before you rotated, is signed with the subscription's current secret. Nothing keeps arriving signed with the old one.

So the window you have to bridge is your own deploy, not ours. Between calling rotate and the moment every instance of your service holds the new secret, freshly signed deliveries will reach instances still holding the old one.

Accept both secrets until your rollout completes, then drop the old one. As a backstop we relax the authentication failure suspension rule for roughly 40 minutes after a rotation, so a slow rollout does not park your subscription. Treat that as a safety margin, not a budget.

The rule that makes this urgent is on Delivery, Idempotency & Suspension: three consecutive 401 or 403 responses from your endpoint suspend the subscription. A rotation deployed badly hits that in minutes.