Verifying webhook signatures

Webhook deliveries are signed with HMAC-SHA256. Verify the signature in constant time before processing any payload.

6 min read

Why verification matters

Webhook URLs are public — anyone who learns the URL can POST to it. The HMAC signature proves the payload came from Journalify and has not been tampered with. Always verify before parsing or trusting the body.

Headers on every delivery

Every webhook POST includes these headers:

X-Journalify-Signature: t=1716470400,v1=4f1a...c92e
X-Journalify-Timestamp: 1716470400
Content-Type: application/json
  • t= — the Unix timestamp the signature was computed at
  • v1= — the lowercase hex HMAC-SHA256 of the signed string (current signature scheme)

The signed string

Journalify computes the HMAC over a deterministic concatenation of the timestamp and the raw request body:

signed_string = f"{timestamp}.{raw_payload_body}"
v1 = HMAC-SHA256(webhook_secret, signed_string)

You MUST hash the raw request body, exactly as received. Do not re-serialise parsed JSON — key order and whitespace will differ and the signature will not match.

Verification steps

  1. Parse the t and v1 values out of X-Journalify-Signature.
  2. Reject the request if the timestamp is more than 5 minutes (300 seconds) older than your current clock — this prevents replay attacks.
  3. Compute the expected HMAC using your stored webhook secret.
  4. Compare the expected value to v1 using a constant-time comparison (never == on strings).
  5. Only then parse the JSON body and act on the event.

Node.js example

const crypto = require('node:crypto');

/**
 * Verify a Journalify webhook signature.
 * @param {Buffer|string} rawBody - The raw request body. Do NOT use parsed JSON.
 * @param {string} signatureHeader - Value of X-Journalify-Signature.
 * @param {string} secret - Your webhook secret (from the Webhooks UI).
 * @param {number} toleranceSeconds - Max age of the signature in seconds (default 300).
 */
function verifyJournalifySignature(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
  if (!signatureHeader) return false;

  const parts = Object.fromEntries(
    signatureHeader.split(',').map((kv) => {
      const idx = kv.indexOf('=');
      return [kv.slice(0, idx), kv.slice(idx + 1)];
    }),
  );
  const timestamp = parts.t;
  const v1 = parts.v1;
  if (!timestamp || !v1) return false;

  // Replay protection.
  const ageSeconds = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (ageSeconds > toleranceSeconds) return false;

  const signedString = `${timestamp}.${rawBody}`;
  const expected = crypto.createHmac('sha256', secret).update(signedString).digest('hex');

  // Constant-time compare. Buffers must be equal length.
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(v1, 'hex');
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

// Example Express handler (must use express.raw — req.body must be a Buffer).
const express = require('express');
const app = express();

app.post('/webhooks/journalify',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const ok = verifyJournalifySignature(
      req.body,
      req.header('X-Journalify-Signature'),
      process.env.JOURNALIFY_WEBHOOK_SECRET,
    );
    if (!ok) return res.status(401).send('bad signature');

    const event = JSON.parse(req.body.toString('utf8'));
    console.log('received', event.type, event.id);
    res.status(204).end();
  },
);

Python example

import hmac
import hashlib
import time


def verify_journalify_signature(
    raw_body: bytes,
    signature_header: str,
    secret: str,
    tolerance_seconds: int = 300,
) -> bool:
    """
    Verify a Journalify webhook signature.

    raw_body: the raw request body (bytes), exactly as received
    signature_header: value of X-Journalify-Signature
    secret: your webhook secret (from the Webhooks UI)
    """
    if not signature_header:
        return False

    parts = dict(
        kv.split("=", 1) for kv in signature_header.split(",")
    )
    timestamp = parts.get("t")
    v1 = parts.get("v1")
    if not timestamp or not v1:
        return False

    # Replay protection.
    if abs(int(time.time()) - int(timestamp)) > tolerance_seconds:
        return False

    signed_string = f"{timestamp}.{raw_body.decode('utf-8')}".encode("utf-8")
    expected = hmac.new(
        secret.encode("utf-8"),
        signed_string,
        hashlib.sha256,
    ).hexdigest()

    # Constant-time compare — never use == on signatures.
    return hmac.compare_digest(expected, v1)


# Example Flask handler.
from flask import Flask, request, abort
import os

app = Flask(__name__)


@app.post("/webhooks/journalify")
def receive():
    ok = verify_journalify_signature(
        request.get_data(),  # raw bytes, not request.json
        request.headers.get("X-Journalify-Signature", ""),
        os.environ["JOURNALIFY_WEBHOOK_SECRET"],
    )
    if not ok:
        abort(401)

    event = request.get_json()
    print("received", event["type"], event["id"])
    return "", 204

Retry behaviour

Your endpoint must respond with a 2xx status within 30 seconds. Any non-2xx response, timeout, or connection error triggers automatic retries:

  1. Attempt 1 — immediate
  2. Attempt 2 — after 30 seconds
  3. Attempt 3 — after 2 minutes
  4. Attempt 4 — after 10 minutes
  5. Attempt 5 — after 1 hour, then 6 hours

After the final failed attempt the delivery is moved to a dead-letter queue. The Webhooks UI lists failed deliveries and lets you replay them manually after you have fixed the receiving service.

Transport security

Journalify only delivers to https:// URLs. Plain http:// endpoints are rejected at subscription time.

  • Outbound requests to private and link-local IP ranges are blocked at the network layer (SSRF protection).
  • The destination hostname is resolved once per delivery and the resolved IP is pinned for the lifetime of the connection (DNS rebinding protection).
  • Self-signed and expired TLS certificates are rejected.

Rotating the webhook secret

In the Webhooks UI, open the subscription and click "Rotate secret". Both the old and new secrets are valid for a 24-hour overlap window so you can deploy the new value without dropping deliveries. After the window, only the new secret verifies.

Was this article helpful?

Was this helpful?

Can't find what you need, or spot something wrong? Let us know — every article is improved based on customer feedback.

Contact support