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:
| Header | Value |
|---|---|
X-JOOR-Signature | sha256=<hex digest> |
X-JOOR-Timestamp | Unix seconds, minted per attempt |
X-JOOR-Delivery-Id | Same as delivery_id in the body |
X-JOOR-Event | Same as event in the body |
X-JOOR-Payload-Version | Same 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.
