Verifying signatures
Every delivery carries an HMAC-SHA256 signature over the raw body and the timestamp it was sent with. It is the only proof a delivery came from Gemifier. There is no mutual TLS, no fixed source range and no bearer token.
The headers
| Header | Value |
|---|---|
X-Gemifier-Event | The event name, for example catalog_item.fulfillment_requested. |
X-Gemifier-Delivery | The delivery id, a lowercase hyphenated UUID. The same value as deliveryId in the body. |
X-Gemifier-Timestamp | Unix seconds, stamped at this attempt. |
X-Gemifier-Signature | One or more v1= values, separated by commas with no spaces. |
What is signed
{timestamp}.{body}timestampis the string inX-Gemifier-Timestamp, used exactly as it arrived. Do not reformat it, do not turn it into milliseconds, and do not use your own clock.- The separator is a single full stop.
bodyis the raw request body, byte for byte, before any JSON parsing.- The key is the secret exactly as issued, UTF-8, including the
whsec_prefix. Nothing is stripped or decoded. - The digest is lowercase hex, and each value in the header starts with
v1=.
X-Gemifier-Timestamp: 1755604800
X-Gemifier-Signature: v1=2f0b6c1a...9dTwo signatures while you replace a secret
Replacing a secret keeps the old one working for 24 hours. Through that window every delivery is signed with both, the new one first:
X-Gemifier-Signature: v1=2f0b6c1a...9d,v1=8c41ee07...b2The delivery passes if either matches, so you can deploy the new secret whenever you like inside the window. Replacing it twice inside one window drops the oldest, so there are never more than two. Revoke old secret closes the window at once.
Never compare X-Gemifier-Signature as a whole string. Split it on commas and check each v1
entry. Comparing the header whole breaks the moment you replace a secret, and again if a v2= is
ever added beside v1=. Ignore a scheme you do not know rather than failing on it.
Check before you parse
The signature covers the exact bytes that were sent. Parse the body and write it out again and you
get a different key order, different whitespace and a different rendering of 20 against 20.0, so
the signature will not match. An unverified body is also untrusted input.
Read the raw bytes, check, then parse. In Express that means
express.raw({ type: "application/json" }) on the webhook route, not express.json(). In Flask it
means request.get_data(), not request.get_json().
Node
const crypto = require("node:crypto")
const SIGNATURE_SCHEME = "v1"
/**
* @param {Buffer} rawBody the exact bytes of the request body
* @param {string} timestampHeader X-Gemifier-Timestamp, used verbatim
* @param {string} signatureHeader X-Gemifier-Signature
* @param {string} secret whsec_... exactly as issued, prefix included
*/
function verifyGemifierSignature(rawBody, timestampHeader, signatureHeader, secret) {
if (!Buffer.isBuffer(rawBody) || !timestampHeader || !signatureHeader) {
return false
}
const signed = Buffer.concat([Buffer.from(`${timestampHeader}.`, "utf8"), rawBody])
const expected = Buffer.from(
crypto.createHmac("sha256", secret).update(signed).digest("hex"),
"utf8",
)
// One entry per secret in use. Two of them while you are replacing one.
return signatureHeader.split(",").some((part) => {
const separator = part.indexOf("=")
if (separator < 0 || part.slice(0, separator).trim() !== SIGNATURE_SCHEME) {
return false
}
const candidate = Buffer.from(part.slice(separator + 1).trim(), "utf8")
// timingSafeEqual throws on a length mismatch, so guard it. Comparing
// lengths first leaks nothing: the digest length is fixed and public.
return (
candidate.length === expected.length && crypto.timingSafeEqual(candidate, expected)
)
})
}
module.exports = { verifyGemifierSignature }Wired into an Express route:
const express = require("express")
const { verifyGemifierSignature } = require("./gemifier-signature")
const app = express()
const secret = process.env.GEMIFIER_WEBHOOK_SECRET // whsec_...
app.post(
"/gemifier",
express.raw({ type: "application/json" }),
(request, response) => {
const verified = verifyGemifierSignature(
request.body,
request.get("X-Gemifier-Timestamp"),
request.get("X-Gemifier-Signature"),
secret,
)
if (!verified) {
return response.sendStatus(401)
}
const envelope = JSON.parse(request.body.toString("utf8"))
console.log(envelope.event, envelope.deliveryId)
return response.sendStatus(200)
},
)
app.listen(3000)Python
import hashlib
import hmac
SIGNATURE_SCHEME = "v1"
def verify_gemifier_signature(
raw_body: bytes,
timestamp_header: str,
signature_header: str,
secret: str,
) -> bool:
"""raw_body is the exact request body. secret is whsec_..., prefix included."""
if not raw_body or not timestamp_header or not signature_header:
return False
signed = timestamp_header.encode("utf-8") + b"." + raw_body
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
for part in signature_header.split(","):
scheme, separator, value = part.partition("=")
if not separator or scheme.strip() != SIGNATURE_SCHEME:
continue # a scheme we do not know is ignored, not a failure
if hmac.compare_digest(value.strip(), expected):
return True
return FalseWired into a Flask route:
import json
import os
from flask import Flask, request
from gemifier_signature import verify_gemifier_signature
app = Flask(__name__)
SECRET = os.environ["GEMIFIER_WEBHOOK_SECRET"] # whsec_...
@app.post("/gemifier")
def gemifier():
if not verify_gemifier_signature(
request.get_data(),
request.headers.get("X-Gemifier-Timestamp", ""),
request.headers.get("X-Gemifier-Signature", ""),
SECRET,
):
return "", 401
envelope = json.loads(request.get_data())
print(envelope["event"], envelope["deliveryId"])
return "", 200Compare in constant time
hmac.compare_digest in Python and crypto.timingSafeEqual in Node take the same time whatever the
inputs are. A plain == returns as soon as two bytes differ, and somebody who sends many requests
and measures how long each takes can recover a valid signature one character at a time.
timingSafeEqual throws when the two buffers differ in length, which is why the Node sample checks
the length first. That tells an attacker nothing: a SHA-256 hex digest is always 64 characters.
Replay is your decision
Gemifier signs the timestamp and sends it to you. It enforces no freshness window of its own. If you
want one, compare abs(now - X-Gemifier-Timestamp) against a tolerance you choose.
The timestamp is stamped at each attempt, so a retry arrives with a fresh timestamp and an old
occurredAtUtc. Checking occurredAtUtc instead would refuse legitimate retries, and a delivery
somebody sends again by hand can arrive days after the event it describes.
De-duplicating is separate, and it is not optional. See Retries.