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

HeaderValue
X-Gemifier-EventThe event name, for example catalog_item.fulfillment_requested.
X-Gemifier-DeliveryThe delivery id, a lowercase hyphenated UUID. The same value as deliveryId in the body.
X-Gemifier-TimestampUnix seconds, stamped at this attempt.
X-Gemifier-SignatureOne or more v1= values, separated by commas with no spaces.

What is signed

{timestamp}.{body}
  • timestamp is the string in X-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.
  • body is 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...9d

Two 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...b2

The 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

gemifier-signature.js
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:

server.js
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

gemifier_signature.py
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 False

Wired into a Flask route:

app.py
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 "", 200

Compare 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.

Next

On this page