
# Webhook authentication

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

| Header | Example | Required handling |
|---|---|---|
| `X-Myaza-Signature-V2` | `t=1786441200,v1=8df1…` | Parse the Unix timestamp and every `v1` candidate. Multiple `v1` values are present during secret rotation. |
| `X-Myaza-Timestamp` | `1786441200` | Same signed timestamp, exposed separately for diagnostics. |
| `X-Myaza-Replay-Window` | `300` | Endpoint replay window in seconds. Reject signatures outside it. |
| `X-Myaza-Event` | `risk.signal.created` | Event type for routing. The signed JSON remains authoritative. |
| `X-Myaza-Event-Id` | `evt_…` | Stable business-deduplication key. |
| `X-Myaza-Delivery` | `del_…` | Endpoint delivery record ID. |
| `X-Myaza-Correlation-Id` | `corr_…` | Optional end-to-end correlation value when the producer supplied one. |
| `X-Myaza-Webhook-Version` | `v2` | Endpoint body contract. |
| `X-Myaza-Signature` | `sha256=…` | 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](https://trust.myaza.co/documentation/webhooks/markdown#delivery-semantics).
