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

# Outbound Webhooks

> Receive Qwairy notification events (weekly report, export ready, low credits) as signed JSON POSTs to your own endpoint.

Instead of receiving Qwairy emails, a white-label team can have notifications POSTed to its own endpoint as signed JSON events — so you deliver them to your users under your own brand, from your own systems. When a webhook is enabled, the matching Qwairy email is **not** sent.

<Note>
  Outbound webhooks are part of [Agencies](/agencies/introduction), an **Enterprise** capability. The controls are **OWNER**-only.
</Note>

## Events

<CardGroup cols={3}>
  <Card title="report.weekly" icon="chart-line">
    A workspace's weekly report.
  </Card>

  <Card title="export.ready" icon="file-arrow-down">
    A data export has finished and is ready to download.
  </Card>

  <Card title="credits.low" icon="wallet">
    The team is projected to run out of credits before renewal.
  </Card>
</CardGroup>

A `webhook.test` event is also sent by the **Send test event** button so you can validate your endpoint before turning delivery on.

## Set It Up

<Steps>
  <Step title="Open Notifications">
    Go to **Team Management > Notifications** and find **Outbound webhooks**.
  </Step>

  <Step title="Add your endpoint URL">
    Enter a public `https` URL (private addresses and `http` are rejected) and save. A **signing secret** is generated and shown **once** — copy and store it now.
  </Step>

  <Step title="Send a test event">
    Click **Send test event** to confirm Qwairy can reach your endpoint and your signature check passes.
  </Step>

  <Step title="Enable delivery">
    Toggle **Deliver via webhook** on. From now on, the events above go to your endpoint instead of being emailed.
  </Step>
</Steps>

You can rotate the secret at any time with **Regenerate secret** (the new value is shown once), and remove the webhook to fall back to email.

## Payload

Every request is a `POST` with a JSON envelope:

```json theme={null}
{
  "event": "credits.low",
  "id": "8b40e94f-79f4-40cc-8d38-bde11ef0d8d9",
  "createdAt": "2026-06-18T09:00:00.000Z",
  "team": { "id": "team_abc123", "name": "Acme" },
  "data": {
    "creditsRemaining": 120,
    "renewalDate": "2026-07-01T00:00:00.000Z"
  }
}
```

The `data` object is event-specific. Treat it as forward-compatible: new fields may be added, so ignore the ones you don't use rather than rejecting the payload.

## Headers

| Header               | Value                                           |
| -------------------- | ----------------------------------------------- |
| `X-Qwairy-Event`     | the event name, e.g. `credits.low`              |
| `X-Qwairy-Timestamp` | Unix time (seconds) when the request was signed |
| `X-Qwairy-Signature` | `sha256=<hex>` HMAC of the request              |

## Verifying the Signature

The signature is an HMAC-SHA256, keyed with your signing secret, over the timestamp and the **raw** request body joined with a dot — `<timestamp>.<body>` — using the raw bytes, before any JSON parsing.

```js theme={null}
import crypto from 'crypto';

function isValidQwairyWebhook(req, secret) {
  const signature = req.headers['x-qwairy-signature']; // "sha256=<hex>"
  const timestamp = req.headers['x-qwairy-timestamp'];

  // Reject stale requests to prevent replay (e.g. older than 5 minutes).
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const expected =
    'sha256=' +
    crypto
      .createHmac('sha256', secret)
      .update(`${timestamp}.${req.rawBody}`)
      .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}
```

<Warning>
  Verify the signature on every request and reject anything that fails. Use a constant-time comparison, and reject timestamps outside a short window to block replays.
</Warning>

## Delivery Behavior

* Qwairy expects a `2xx` response. Non-`2xx`, timeouts, and redirects count as failures.
* For the weekly report and low-credit events, a failed delivery is retried on the next scheduled run; the matching email is **not** sent in its place.
* The export-ready event is one-shot: if delivery fails, the export still appears in your workspace's **Exports** hub.
