Verify every delivery before parsing or acting on its JSON. Myaza signs the exact request bytes with the secret assigned to that endpoint.

Signed headers

HeaderExampleRequired handling
X-Myaza-Signature-V2t=1786441200,v1=8df1…Parse the Unix timestamp and every v1 candidate. Multiple v1 values are present during secret rotation.
X-Myaza-Timestamp1786441200Same signed timestamp, exposed separately for diagnostics.
X-Myaza-Replay-Window300Endpoint replay window in seconds. Reject signatures outside it.
X-Myaza-Eventrisk.signal.createdEvent type for routing. The signed JSON remains authoritative.
X-Myaza-Event-Idevt_…Stable business-deduplication key.
X-Myaza-Deliverydel_…Endpoint delivery record ID.
X-Myaza-Correlation-Idcorr_…Optional end-to-end correlation value when the producer supplied one.
X-Myaza-Webhook-Versionv2Endpoint body contract.
X-Myaza-Signaturesha256=…Exact-body compatibility signature. New integrations should verify V2.

Verification algorithm

  1. Read the raw HTTP request body as bytes.
  2. Parse t and all v1 values from X-Myaza-Signature-V2.
  3. Reject a missing, invalid, too-old or implausibly future timestamp.
  4. Compute lowercase hex HMAC-SHA256("<t>.<rawBody>", endpointSecret).
  5. Compare the expected value with every supplied v1 using a constant-time comparison.
  6. Only after a match, parse the JSON, deduplicate on id, enqueue it durably and return 2xx.

Do not re-serialise parsed JSON before verification. Whitespace and property order change the signed bytes.

Node.js example

js
import crypto from "node:crypto";
import express from "express";

const app = express();

app.post("/webhooks/myaza", express.raw({ type: "application/json" }), (req, res) => {
  const parts = String(req.header("X-Myaza-Signature-V2") || "").split(",");
  const timestamp = parts.find((part) => part.startsWith("t="))?.slice(2);
  const candidates = parts
    .filter((part) => part.startsWith("v1="))
    .map((part) => part.slice(3));

  if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
    return res.status(401).send("Expired signature");
  }

  const expected = crypto
    .createHmac("sha256", process.env.MYAZA_WEBHOOK_SECRET)
    .update(`${timestamp}.`)
    .update(req.body)
    .digest("hex");

  const valid = candidates.some((candidate) =>
    candidate.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(candidate), Buffer.from(expected))
  );
  if (!valid) return res.status(401).send("Invalid signature");

  const event = JSON.parse(req.body.toString("utf8"));
  // Persist event.id before dispatching asynchronous business work.
  res.status(200).send("OK");
});

Secret rotation

During the configured grace period Myaza signs with the active secret and every unexpired grace secret. The V2 header therefore carries more than one v1 value. Keep both endpoint secrets available and accept the request if either expected signature matches. Remove the old secret only after its dashboard grace period ends.

Failure responses

Return 401 for missing, stale or invalid signatures. Return 2xx only after the event is durably accepted. Other non-2xx responses and timeouts are retried according to the delivery schedule.