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: 1740000000000The 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.
Webhooks Overview
Emofy delivers HTTP webhooks to subscribed URLs when domain events fire. At-least-once delivery, Stripe-compatible signing, idempotency via delivery ID.
Retries & Replay
Retry policy, dead-letter semantics, and manual replay — per-delivery and replay-since-history — for recovering from receiver outages.