Webhook HMAC Signature Verification
logi's X-Logi-Signature header comes in two coexisting formats. For which events arrive in which format, see Webhook Integration — request format. An RP verifier must accept both formats.
| Format | Example header value | Source | Secret |
|---|---|---|---|
| Legacy | sha256=a3d9f0... | WebhookDispatchJob (the original 4 events) | A single webhook_secret |
| PLAN-L (canonical) | t=1735000000,kid=whk_2025q4_a1,v1=a3d9f0... | Logi::Webhooks::DeliveryJob (user.merged and other new events) | The key looked up by kid |
Both formats are an HMAC-SHA256 over the body, but the timestamp is in a different place — legacy uses a separate X-Logi-Timestamp header, while PLAN-L uses the t= field inside the signature header.
Verification order
- Branch on the
X-Logi-Signatureformat- If the value contains
,ort=→ parse it as PLAN-L (t=…,kid=…,v1=…) - If it starts with
sha256=→ legacy
- If the value contains
- Check the timestamp range — within ±5 minutes of the current time (replay defense)
- PLAN-L: the
t=value - Legacy: the
X-Logi-Timestampheader value
- PLAN-L: the
- Recompute the signature, then compare in constant time
- PLAN-L: look up the key by
kid→HMAC-SHA256(secret_for_kid, raw_body)→ compare withv1= - Legacy:
HMAC-SHA256(webhook_secret, raw_body)→ compare with the hex insha256=
- PLAN-L: look up the key by
A verifier that supports both formats
import crypto from "node:crypto";
// keyResolver(kid) → a Buffer/Uint8Array secret. If you run a single key,
// you can ignore the kid with something like `(kid) => process.env.LOGI_WEBHOOK_SECRET`.
export function verifyLogiWebhook(req, { legacySecret, keyResolver }) {
const sigHeader = req.header("X-Logi-Signature") ?? "";
const raw = req.rawBody; // ← must be the raw bytes, before parsing
// 1. Format split
if (sigHeader.includes(",") || sigHeader.startsWith("t=")) {
// PLAN-L: t=<ts>,kid=<kid>,v1=<hex>
const fields = Object.fromEntries(
sigHeader.split(",").map(p => p.split("=").map(s => s.trim()))
);
if (!fields.t || !fields.kid || !fields.v1) throw new Error("malformed sig");
if (Math.abs(Date.now() / 1000 - Number(fields.t)) > 300) throw new Error("replay");
const secret = keyResolver(fields.kid);
if (!secret) throw new Error(`unknown kid: ${fields.kid}`);
const expected = crypto.createHmac("sha256", secret).update(raw).digest("hex");
const a = Buffer.from(fields.v1, "hex"), b = Buffer.from(expected, "hex");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) throw new Error("bad sig");
return;
}
// Legacy: sha256=<hex> + X-Logi-Timestamp header
const ts = Number(req.header("X-Logi-Timestamp") ?? "0");
if (Math.abs(Date.now() / 1000 - ts) > 300) throw new Error("replay");
const sig = sigHeader.replace("sha256=", "");
const expected = crypto.createHmac("sha256", legacySecret).update(raw).digest("hex");
const a = Buffer.from(sig, "hex"), b = Buffer.from(expected, "hex");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) throw new Error("bad sig");
}require "openssl"
def verify_logi!(request, legacy_secret:, key_resolver:)
sig_header = request.headers["X-Logi-Signature"].to_s
raw = request.raw_post
if sig_header.include?(",") || sig_header.start_with?("t=")
# PLAN-L: t=<ts>,kid=<kid>,v1=<hex>
fields = sig_header.split(",").to_h { |p| p.split("=", 2).map(&:strip) }
raise "malformed sig" unless fields["t"] && fields["kid"] && fields["v1"]
raise "replay" if (Time.current.to_i - fields["t"].to_i).abs > 300
secret = key_resolver.call(fields["kid"])
raise "unknown kid: #{fields['kid']}" unless secret
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, raw)
raise "bad sig" unless ActiveSupport::SecurityUtils.secure_compare(fields["v1"], expected)
return
end
# Legacy
ts = request.headers["X-Logi-Timestamp"].to_i
raise "replay" if (Time.current.to_i - ts).abs > 300
sig = sig_header.sub("sha256=", "")
expected = OpenSSL::HMAC.hexdigest("SHA256", legacy_secret, raw)
raise "bad sig" unless ActiveSupport::SecurityUtils.secure_compare(sig, expected)
endimport hmac, hashlib, time
def verify_logi(headers, raw_body, legacy_secret, key_resolver):
sig_header = headers.get("X-Logi-Signature", "")
if "," in sig_header or sig_header.startswith("t="):
# PLAN-L: t=<ts>,kid=<kid>,v1=<hex>
fields = dict(part.strip().split("=", 1) for part in sig_header.split(","))
if not all(k in fields for k in ("t", "kid", "v1")):
raise ValueError("malformed sig")
if abs(time.time() - int(fields["t"])) > 300:
raise ValueError("replay")
secret = key_resolver(fields["kid"])
if not secret:
raise ValueError(f"unknown kid: {fields['kid']}")
expected = hmac.new(secret.encode() if isinstance(secret, str) else secret,
raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(fields["v1"], expected):
raise ValueError("bad sig")
return
# Legacy
ts = int(headers.get("X-Logi-Timestamp", "0"))
if abs(time.time() - ts) > 300:
raise ValueError("replay")
sig = sig_header.replace("sha256=", "")
expected = hmac.new(legacy_secret.encode(), raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
raise ValueError("bad sig")Important: always verify against the raw body (the original bytes, before JSON parsing). Serializing again after parsing changes whitespace and ordering, which breaks the signature.
kid key lookup (PLAN-L only)
The kid in the PLAN-L format is the identifier of a rotatable webhook signing key in logi. An RP maintains the kid → secret mapping in one of two ways.
- Polling: call
GET /api/v1/webhook_signing_keysperiodically and cache the active key plus any old keys still within the grace window. - Push: re-fetch immediately on a
webhook_key.compromisedevent.
For the rotation procedure and grace window, see Webhook Key Rotation.
Legacy single-secret rotation
The legacy path rotates a single webhook_secret without a key ID.
# rotate
PAK="logi_pak_..."
curl -X POST -H "Authorization: Bearer $PAK" \
https://api.1pass.dev/api/v1/applications/$APP_ID/rotate_webhook_secret \
| jq -r '.webhook_secret'
# → the plaintext is printed once. Store it in your secret manager immediately.After rotating, replace the legacySecret value in the verification code above with the new plaintext. The new signature applies immediately from the moment of rotation, with no grace window — if you want both secrets to be valid briefly right after rotation, we recommend migrating to the PLAN-L outbox path.
Handling the deprecation header
If an existing app has not rotated its webhook secret, logi signs with a BCrypt fallback (legacy path only). In that case, the following headers are sent along:
X-Logi-Secret-Deprecated: true
Deprecation: @1925000000- We recommend the receiving side log this at WARN level — verification still works, but since it isn't a stable plaintext, the signature can't be reproduced.
- Call
rotate_webhook_secretfrom the developer portal right away → store the plaintext returned in the response in an environment variable, then verify with it in your code.