Skip to main content
Every webhook request from Yoshi is signed with HMAC-SHA256 so you can verify it came from Yoshi and hasn’t been tampered with. Always verify signatures before processing events.

Quick start

The fastest way to verify webhooks is with the SDK:
import { verifySignature } from "@yoshi-ai/sdk/lib/webhooks";

app.post("/webhooks/yoshi", (req, res) => {
  const isValid = verifySignature(
    req.body,
    req.headers["x-yoshi-signature"],
    req.headers["x-yoshi-timestamp"],
    process.env.WEBHOOK_SECRET,
  );

  if (!isValid) return res.status(400).send("Invalid signature");

  const event = JSON.parse(req.body);
  // Process event asynchronously...
  res.status(200).send("OK");
});

How signing works

Every webhook request includes two headers:
HeaderExampleDescription
x-yoshi-signaturea1b2c3d4e5f6...HMAC-SHA256 hex digest of the signed content
x-yoshi-timestamp1714444800Unix timestamp (seconds) when the event was sent
The signature is computed as:
signed_content = "{timestamp}.{raw_body}"
signature = hex(HMAC-SHA256(secret, signed_content))
Where secret is your webhook signing secret (starts with whsec_), timestamp is the value of the x-yoshi-timestamp header, and raw_body is the raw request body string.
You must use the raw request body for verification, not a parsed-then-serialized version. Most frameworks parse JSON automatically — make sure you capture the raw body before parsing.

Framework examples

import express from "express";
import { verifySignature } from "@yoshi-ai/sdk/lib/webhooks";

const app = express();

// Capture raw body for signature verification
app.post("/webhooks/yoshi", express.raw({ type: "application/json" }), (req, res) => {
  const isValid = verifySignature(
    req.body,
    req.headers["x-yoshi-signature"] as string,
    req.headers["x-yoshi-timestamp"] as string,
    process.env.WEBHOOK_SECRET!,
  );

  if (!isValid) return res.status(400).send("Invalid signature");

  const event = JSON.parse(req.body.toString());
  console.log(`Received ${event.type}:`, event.id);
  res.status(200).send("OK");
});

Replay protection

The SDK rejects webhooks with timestamps older than 5 minutes by default. This prevents replay attacks where an attacker resends a captured webhook request. You can customize this tolerance:
verifySignature(payload, signature, timestamp, secret, {
  tolerance: 600, // 10 minutes
});

Manual verification

If you prefer to verify signatures without the SDK:
import { createHmac, timingSafeEqual } from "node:crypto";

function verifyWebhook(body: string, signature: string, timestamp: string, secret: string): boolean {
  // Check timestamp is within tolerance (5 minutes)
  const ts = parseInt(timestamp, 10);
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - ts) > 300) return false;

  // Compute expected signature
  const signedPayload = `${timestamp}.${body}`;
  const expected = createHmac("sha256", secret).update(signedPayload).digest("hex");

  // Constant-time comparison
  const sigBuf = Buffer.from(signature, "utf-8");
  const expectedBuf = Buffer.from(expected, "utf-8");
  if (sigBuf.length !== expectedBuf.length) return false;

  return timingSafeEqual(sigBuf, expectedBuf);
}
Always use constant-time comparison (timingSafeEqual in Node.js, hmac.compare_digest in Python, hmac.Equal in Go) to prevent timing attacks. Never use === or == for signature comparison.

Secret rotation

When you rotate your signing secret, there is a brief window where both the old and new secrets are valid. During this window, Yoshi signs webhooks with both secrets. Your verification logic should try the new secret first, then fall back to the old secret. The SDK handles this automatically if you pass an array of secrets:
const secrets = [process.env.WEBHOOK_SECRET_NEW, process.env.WEBHOOK_SECRET_OLD];

const isValid = secrets.some((secret) =>
  verifySignature(req.body, req.headers["x-yoshi-signature"], req.headers["x-yoshi-timestamp"], secret)
);
Last modified on April 17, 2026