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

# Outgoing webhooks

> The signed JSON POST Pharus sends to your endpoint: headers, body per event, how to verify the signature, delivery rules and how to respond.

<Info>
  **Plans:** Core and Plus, and the free trial. **Who:** owners and admins add webhook destinations.
</Info>

A webhook destination gets one HTTPS POST per event, with a JSON body and an HMAC-SHA256 signature made with the destination's signing secret. To add a destination and choose its events, see [Notifications](/notifications/overview#add-a-webhook).

## The request

```http theme={null}
POST /your/path HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: Pharus-Alerts/1.0
Pharus-Event-Id: evt_3f6c1e2a-8d4b-4c11-9a57-0b6f2d9e7c41
Pharus-Signature: t=1791973200,v1=5f2b1c9e…
```

| Header | Value |
| - | - |
| `Content-Type` | `application/json` |
| `User-Agent` | `Pharus-Alerts/1.0` |
| `Pharus-Event-Id` | The body's `id`: `evt_` followed by a UUID. Each delivery gets its own; when one event goes to two destinations, each gets a different id. |
| `Pharus-Signature` | `t=<unix seconds>,v1=<signature>`, where the signature is the lowercase hex HMAC-SHA256 of `<t>.<body>` with your signing secret. `t` is the body's `created`, in whole seconds. |

The body is compact JSON on one line, in UTF-8. The examples below are formatted for reading. Verify the signature against the raw bytes you receive, before you parse them.

## The body

| Field | Type | Description |
| - | - | - |
| `id` | string | Unique per delivery, the same as `Pharus-Event-Id`. |
| `type` | string | `incident.opened`, `deadline.warning`, `approval.requested`, `report.received`, or `test`. |
| `created` | string | When Pharus sent it, ISO 8601 in UTC. |
| `workspace` | string | The company's name in Pharus. |
| `url` | string | Where to read it in Pharus: the incident, the report in the disclosure inbox, or **Settings → Notifications** for a test. |
| `data` | object | The event itself; its fields depend on `type`. |

The body holds ids, titles, deadlines and links into Pharus. It never holds a reporter's name, email address or report text, draft text, or who recorded a determination and why.

### incident.opened

An exact match against CISA KEV or EUVD opened an incident.

```json theme={null}
{
  "id": "evt_3f6c1e2a-8d4b-4c11-9a57-0b6f2d9e7c41",
  "type": "incident.opened",
  "created": "2026-10-12T20:47:12.000Z",
  "workspace": "Nordlys Elektronikk AS",
  "url": "https://app.pharussecurity.com/incidents/INC-0042",
  "data": {
    "incident": {
      "id": "6f1c2a40-0000-4000-8000-000000000001",
      "reference": "INC-0042",
      "title": "CVE-2026-1234 in openssl 3.0.13",
      "family": "Gateway G3",
      "vulnKey": "CVE-2026-1234",
      "source": "kev",
      "awareAt": "2026-10-12T20:47:00.000Z"
    }
  }
}
```

| Field | Description |
| - | - |
| `incident.id`, `incident.reference`, `incident.title` | The incident. |
| `incident.family` | The product family's name, or `null`. |
| `incident.vulnKey` | The vulnerability's id, usually a CVE, or `null`. |
| `incident.source` | `kev` (CISA KEV) or `euvd` (EUVD). |
| `incident.awareAt` | When the match made you aware: the CRA clocks run from here. |

### deadline.warning

A report is still open and due soon: 4 hours before a 24-hour or 72-hour report, 1 hour before DORA's initial notification, 72 hours before a final report. One event per report.

```json theme={null}
{
  "id": "evt_b8e0f4d2-1c3a-4e6f-8a9b-2d7c5e1f0a33",
  "type": "deadline.warning",
  "created": "2026-10-12T10:00:00.000Z",
  "workspace": "Nordlys Elektronikk AS",
  "url": "https://app.pharussecurity.com/incidents/INC-0042",
  "data": {
    "incident": {
      "id": "6f1c2a40-0000-4000-8000-000000000001",
      "reference": "INC-0042",
      "title": "CVE-2026-1234 in openssl 3.0.13"
    },
    "regime": "cra",
    "report": "notification",
    "dueAt": "2026-10-12T14:00:00.000Z"
  }
}
```

| Field | Values |
| - | - |
| `regime` | `cra`, `digitalsikkerhetsloven`, `gdpr` or `dora`. |
| `report` | `early_warning`, `notification` or `final_report` (CRA and digitalsikkerhetsloven); `notification` (GDPR); `initial_notification`, `intermediate_report` or `final_report` (DORA). |
| `dueAt` | The deadline. |

### approval.requested

A determination was recorded and waits for four-eyes approval.

```json theme={null}
{
  "id": "evt_0d9a7c3e-5b2f-4a18-9c6d-8e4f1a2b3c55",
  "type": "approval.requested",
  "created": "2026-10-12T21:15:30.000Z",
  "workspace": "Nordlys Elektronikk AS",
  "url": "https://app.pharussecurity.com/incidents/INC-0042",
  "data": {
    "incident": {
      "id": "6f1c2a40-0000-4000-8000-000000000001",
      "reference": "INC-0042",
      "title": "CVE-2026-1234 in openssl 3.0.13"
    },
    "determination": "actively_exploited",
    "scope": "cra",
    "approvers": 1
  }
}
```

| Field | Values |
| - | - |
| `determination` | `actively_exploited`, `not_exploited`, `not_affected`, `severe_incident`, `not_severe` (CRA), or `dora_major`, `dora_not_major` (DORA). |
| `scope` | `cra` for the CRA decision record, `dora` for the DORA classification. |
| `approvers` | How many approvers were asked. |

### report.received

A vulnerability report arrived in the disclosure inbox.

```json theme={null}
{
  "id": "evt_7e2c9b1a-3f4d-4b5e-8c6a-1d0e9f8a7b66",
  "type": "report.received",
  "created": "2026-10-13T08:02:41.000Z",
  "workspace": "Nordlys Elektronikk AS",
  "url": "https://app.pharussecurity.com/inbox/12",
  "data": {
    "report": {
      "number": 12,
      "family": "Gateway G3",
      "receivedAt": "2026-10-13T08:02:40.000Z",
      "acknowledgeBy": "2026-10-16T08:02:40.000Z"
    }
  }
}
```

| Field | Description |
| - | - |
| `report.number` | The report's number in the inbox. |
| `report.family` | The product family it is about, or `null`. |
| `report.receivedAt` | When it arrived. |
| `report.acknowledgeBy` | When your disclosure policy promises an acknowledgement by, or `null` once it is answered. |

### test

Sent when an owner or admin selects **Send a test** on the destination. Pharus sends it whether the destination is on or off.

```json theme={null}
{
  "id": "evt_c4a1e8f2-6b3d-4f7a-9e5c-2a8b0d1f3e77",
  "type": "test",
  "created": "2026-10-14T10:00:00.000Z",
  "workspace": "Nordlys Elektronikk AS",
  "url": "https://app.pharussecurity.com/settings/notifications",
  "data": {
    "destination": "SIEM intake"
  }
}
```

`data.destination` is the destination's name in Pharus.

## Verify the signature

Check every request before you act on it:

1. Read the raw request body, before any JSON parsing.
2. Split `Pharus-Signature` at the commas into `t` and `v1`.
3. Refuse the request if `t` is more than 5 minutes from your clock.
4. Compute the HMAC-SHA256 of `t`, a full stop and the raw body, with your signing secret as the key, and hex-encode it.
5. Compare it with `v1` in constant time.

The key is the whole signing secret as Pharus showed it, `whsec_` prefix included, as UTF-8 text. Don't decode it.

<CodeGroup>
  ```js Node.js theme={null}
  import crypto from 'node:crypto';
  import express from 'express';

  const SECRET = process.env.PHARUS_WEBHOOK_SECRET; // whsec_…
  const TOLERANCE_SECONDS = 300;

  function verifyPharusSignature(rawBody, header, secret, now = Math.floor(Date.now() / 1000)) {
    if (!header || !Buffer.isBuffer(rawBody)) return false;
    const parts = Object.fromEntries(
      header.split(',').map(part => {
        const i = part.indexOf('=');
        return [part.slice(0, i).trim(), part.slice(i + 1).trim()];
      }),
    );
    const t = Number(parts.t);
    if (!Number.isInteger(t) || !/^[0-9a-f]{64}$/.test(parts.v1 ?? '')) return false;
    if (Math.abs(now - t) > TOLERANCE_SECONDS) return false;
    const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
    const given = Buffer.from(parts.v1, 'hex');
    return given.length === expected.length && crypto.timingSafeEqual(given, expected);
  }

  const app = express();

  // express.raw keeps the body as the exact bytes Pharus signed.
  app.post('/pharus', express.raw({ type: 'application/json' }), (req, res) => {
    if (!verifyPharusSignature(req.body, req.get('Pharus-Signature'), SECRET)) {
      return res.status(400).send('Invalid signature');
    }
    const event = JSON.parse(req.body.toString('utf8'));
    res.sendStatus(204); // answer first, then do the work
    if (event.type === 'incident.opened') {
      // open a ticket for event.data.incident.reference …
    }
  });

  app.listen(3000);
  ```

  ```python Python theme={null}
  import hashlib
  import hmac
  import json
  import os
  import time

  from flask import Flask, abort, request

  SECRET = os.environ["PHARUS_WEBHOOK_SECRET"]  # whsec_…
  TOLERANCE_SECONDS = 300


  def verify_pharus_signature(raw_body, header, secret, now=None):
      if not header:
          return False
      parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
      try:
          t = int(parts["t"])
          given = bytes.fromhex(parts["v1"])
      except (KeyError, ValueError):
          return False
      now = int(time.time()) if now is None else now
      if abs(now - t) > TOLERANCE_SECONDS:
          return False
      expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).digest()
      return hmac.compare_digest(given, expected)


  app = Flask(__name__)


  @app.post("/pharus")
  def pharus_webhook():
      raw = request.get_data()  # the exact bytes Pharus signed
      if not verify_pharus_signature(raw, request.headers.get("Pharus-Signature"), SECRET):
          abort(400)
      event = json.loads(raw)
      if event["type"] == "incident.opened":
          pass  # open a ticket for event["data"]["incident"]["reference"] …
      return "", 204
  ```
</CodeGroup>

To try your endpoint before Pharus can reach it, sign a request yourself:

```bash theme={null}
SECRET='whsec_…'
BODY='{"id":"evt_local","type":"test","created":"2026-10-14T10:00:00.000Z","workspace":"Nordlys Elektronikk AS","url":"https://app.pharussecurity.com/settings/notifications","data":{"destination":"Local test"}}'
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.*= //')
curl -i http://localhost:3000/pharus \
  -H 'Content-Type: application/json' \
  -H "Pharus-Signature: t=$T,v1=$SIG" \
  --data-raw "$BODY"
```

**New signing secret** under **Settings → Notifications** replaces the secret at once; the old one stops working with no overlap. Put the new secret in your endpoint straight away, or the deliveries in between fail verification.

## Delivery rules

| Rule | What it means for your endpoint |
| - | - |
| HTTPS only | The URL starts with `https://` and has a valid certificate for its host name. No user name or password in the URL. |
| Public addresses only | Every address the host name resolves to must be public. Private, loopback, link-local, carrier-grade NAT and other reserved ranges are refused, so an internal system needs a public entry point. |
| No redirects | Pharus doesn't follow a 3xx. It counts as a failed delivery. |
| 5 seconds | The whole request has 5 seconds. A slower answer is a failed delivery. |
| 32 KB | Pharus reads at most 32 KB of your answer. |
| No retries | A failed delivery is not sent again. |

Pharus sends nothing while the destination is turned off, when the workspace has no Core or Plus plan or trial, or while the workspace is read-only.

## How to respond

* Answer with any `2xx` status, for example `204 No Content`, within 5 seconds. Do the work after you answer.
* Anything else counts as failed. Pharus records the status and the first 200 characters of your answer as the reason, for example `HTTP 401: invalid token`, and shows it on the destination and under **Delivery problems**. Keep secrets out of error responses.
* Without an answer, the reason is the connection error, for example "No answer within 5 s" or that the host "resolves to a private or reserved address".
* Answer `2xx` to event types you don't handle, such as `test`.
* Use `Pharus-Event-Id` to log deliveries and to drop one you have already seen.

Because nothing is retried, treat a webhook as a prompt, not the record. The incident, the decision record and the disclosure inbox in Pharus stay the source of truth.

## Related

<CardGroup cols={2}>
  <Card title="Notifications" icon="bell" href="/notifications/overview" />

  <Card title="API introduction" icon="book-open" href="/api-reference/introduction" />

  <Card title="Paging" icon="message-square-text" href="/on-call/paging" />

  <Card title="Disclosure inbox" icon="inbox" href="/disclosure/inbox" />
</CardGroup>


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