> ## Documentation Index
> Fetch the complete documentation index at: https://docs.qumo-deploy.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Notification Webhooks

> Receive notification policy alerts on your own HTTPS endpoint and verify that each one came from Qumo.

# Notification Webhooks

A notification policy with the `webhook` channel sends each matching event to your HTTPS
endpoint as a JSON `POST`. Each delivery is signed, so your endpoint can reject any request
that Qumo did not send.

## The signing secret

When you create a webhook policy, Qumo generates its signing secret and returns it once, in
the create response (`signing_secret`). In the console, it appears once, right after you
create the policy. **No later response returns it.** Store it with your receiver's other
secrets.

To change the secret, delete the policy and create it again. The new policy has a new
secret.

> [!NOTE]
>
> Webhook policies created before signing was introduced have no secret. Qumo still delivers
> to them, unsigned, so existing receivers keep working. To start verifying, delete the
> policy and create it again.

## What a delivery carries

```http theme={null}
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Qumo-Notification-Dispatcher/1.0
X-Notification-Timestamp: 1767225600
X-Notification-Signature-256: <64 lowercase hex characters>

{"notification_id":"…","event_id":"…","organization_id":"…","title":"…","body":"…","severity":"critical","timestamp":"2026-01-01T00:00:00Z"}
```

| Header | Value |
| - | - |
| `X-Notification-Timestamp` | When the delivery was signed, in whole Unix seconds. The body's `timestamp` is the same second. Check a delivery's age against this header, which you can do before parsing the body. |
| `X-Notification-Signature-256` | Lowercase hex HMAC-SHA256 of `<X-Notification-Timestamp>.<raw request body>`, keyed with the signing secret. |

The signature covers the timestamp as well as the body. A captured delivery therefore
cannot be replayed later with a new timestamp, and your receiver can reject an old one by
checking its age.

## Verifying a delivery

1. Read the raw request body **before** parsing it. Re-serialized JSON is not byte-for-byte
   the same, so its signature will not match.
2. Compute HMAC-SHA256 over the `X-Notification-Timestamp` value, a `.`, and the raw body,
   using the signing secret as the key. Hex-encode the result in lowercase.
3. Compare it with `X-Notification-Signature-256` using a constant-time comparison.
4. Reject the delivery if the timestamp is more than a few minutes from your clock. Five
   minutes is a reasonable window.

```ts theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";

const MAX_AGE_SECONDS = 300;

export function verifyQumoNotification(
  secret: string,
  timestamp: string,
  signature: string,
  rawBody: string,
): boolean {
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(age) || age > MAX_AGE_SECONDS) return false;

  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && timingSafeEqual(a, b);
}
```

```go theme={null}
func verifyQumoNotification(secret, timestamp, signature string, rawBody []byte) bool {
	secs, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil || time.Since(time.Unix(secs, 0)).Abs() > 5*time.Minute {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(timestamp + "."))
	mac.Write(rawBody)
	expected := hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(expected), []byte(signature))
}
```

A delivery with no signature headers comes from a policy created before signing was
introduced. Once you have recreated the policy, reject unsigned deliveries.

## Retries

A delivery that fails with a network error or a `5xx` response is retried with exponential
backoff for up to five minutes. Each attempt is signed again with a fresh timestamp. Use
the body's `notification_id` to drop duplicates.

Slack and Discord policies are not signed. Those services authenticate the sender through
the secret in the webhook URL itself.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.