My App
Webhooks

Signature Verification

Emofy signs every webhook with HMAC-SHA256. Verify the Emofy-Signature header before trusting any payload.

Signature Verification

Every webhook carries a Stripe-compatible Emofy-Signature header:

Emofy-Signature: t=1740000000000,v1=5f8b9e3a...

During the 60-minute window after you rotate the webhook secret, two v1= segments are present — one signed with the new secret, one with the previous secret:

Emofy-Signature: t=1740000000000,v1=<new>,v1=<old>

Iterate all v1= segments and accept if any matches. This way your receiver keeps working while you roll out the new secret on your side.

Canonical string

The signed string is ${timestamp}.${raw_request_body} where timestamp is the unix-milliseconds value from t=…. Use the raw bytes of the request body — do not re-serialize JSON; whitespace and key order matter.

Node.js

import crypto from "node:crypto";

export function verifyEmofySignature(rawBody, header, secret) {
  // header example: "t=1740000000000,v1=abc...,v1=def..."
  if (!header) return false;
  const parts = header.split(",").map((p) => p.trim());
  const t = parts.find((p) => p.startsWith("t="))?.slice(2);
  const signatures = parts
    .filter((p) => p.startsWith("v1="))
    .map((p) => p.slice(3));
  if (!t || signatures.length === 0) return false;

  // 5-min replay window.
  if (Math.abs(Date.now() - Number(t)) > 5 * 60 * 1000) return false;

  const canonical = `${t}.${rawBody}`;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(canonical)
    .digest("hex");
  const expectedBuf = Buffer.from(expected, "hex");

  // Accept if ANY segment matches — covers the rotation grace window.
  return signatures.some((sig) => {
    try {
      return crypto.timingSafeEqual(expectedBuf, Buffer.from(sig, "hex"));
    } catch {
      return false;
    }
  });
}

Python

import hmac
import hashlib
import time

def verify_emofy_signature(raw_body: bytes, header: str, secret: str) -> bool:
    if not header:
        return False
    parts = [p.strip() for p in header.split(",")]
    t = next((p[2:] for p in parts if p.startswith("t=")), None)
    signatures = [p[3:] for p in parts if p.startswith("v1=")]
    if not t or not signatures:
        return False

    # 5-min replay window.
    if abs(int(time.time() * 1000) - int(t)) > 5 * 60 * 1000:
        return False

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

    return any(hmac.compare_digest(expected, sig) for sig in signatures)

curl + openssl (one-shot debug)

# The t and v1 values from the header.
T=1740000000000
SIG=5f8b9e3a...
SECRET='your-webhook-secret'
BODY='{"event":"app.installed","timestamp":1740000000000,...}'

EXPECTED=$(printf '%s.%s' "$T" "$BODY" \
  | openssl dgst -sha256 -hmac "$SECRET" -hex \
  | awk '{print $2}')

[ "$EXPECTED" = "$SIG" ] && echo "✓ signature OK" || echo "✗ signature mismatch"

Legacy X-Webhook-Signature

Until T+30d, Emofy also emits the legacy format for backward compatibility:

X-Webhook-Signature: 5f8b9e3a...
X-Webhook-Timestamp: 1740000000000

The legacy signature is HMAC-SHA256(body, secret) — note that the body is the ONLY signed input (no canonical prefix with timestamp). This header disappears T+30d post-launch. New integrations should verify the Emofy-Signature header only.

Security notes

  • Always verify the signature BEFORE parsing or trusting the body.
  • Always enforce the 5-minute timestamp window so a leaked signature can't be replayed days later.
  • Iterate v1= segments — during rotation, either the new or the old secret is valid. Rejecting after checking only the first segment will break during rotation windows.
  • Use timingSafeEqual (Node) / hmac.compare_digest (Python) to prevent timing-attack leakage of the expected HMAC.

On this page