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

# SIEM export

> Send every alert to your SIEM, signed and tagged with the Organization, Device and event it came from, or pull them with a read-only export key

PromptGuard gets its security events into your SIEM two ways, and both carry the same record:

| | Push: webhook delivery | Pull: export key |
| - | - | - |
| What | Each alert, as it happens, to a URL you choose | Every event, or only alerts, when your collector asks |
| Configure | A project's **Alert Routing** card | An API key of kind **SIEM export**, made by an organization admin |
| Authenticates | A signature you verify, plus an optional auth header you choose | Your `X-API-Key` |
| Best for | Splunk HEC, a Sentinel Logic App, any HTTPS collector | Backfilling a gap, a scheduled poller, a collector that cannot accept inbound traffic |

Use both if you can: push for speed, and pull with the last cursor you stored to fill in anything a dropped delivery missed. The records are identical, so one SIEM search covers both.

## The SIEM event

Every record names where it came from. An MSP routing many clients' alerts into one SIEM can key on `organization_id`, and open the event in that client's console from `dashboard_url`.

> [!TIP]
>
> **Managing clients as an MSP, with no SIEM in between?** The dashboard has the same queue: **Clients → Client alerts** lists the alerts raised in every client you hold an admin Grant over, newest first, filterable by client, severity and triage status. **Open as client** opens the event the way `dashboard_url` does, acting as that client. A client whose Grant was revoked drops off at once. It is for admins of the managing organization, as the **Clients** page is.

```json theme={"system"}
{
  "schema_version": "1",
  "event": "threat_detected",
  "event_id": "6f0c8f4e-0000-4000-8000-000000000004",
  "timestamp": "2026-10-21T12:00:00+00:00",
  "organization_id": "6f0c8f4e-0000-4000-8000-000000000001",
  "organization_name": "Acme Dental",
  "project_id": "6f0c8f4e-0000-4000-8000-000000000002",
  "project": "Shadow AI",
  "device_id": "6f0c8f4e-0000-4000-8000-000000000003",
  "device_hostname": "ACME-LT-0042",
  "attribution_label_unverified": "jane@acme.example",
  "identity_verified": false,
  "threat_type": "prompt_injection",
  "decision": "block",
  "confidence": 0.95,
  "reason": "Prompt injection detected",
  "direction": "input",
  "surface": "desktop",
  "segment": "current_turn",
  "dashboard_url": "https://app.promptguard.co/dashboard/events/6f0c8f4e-0000-4000-8000-000000000004?org=6f0c8f4e-0000-4000-8000-000000000001",
  "text": "[PromptGuard] Prompt Injection in *Acme Dental / Shadow AI* (95%, block)"
}
```

| Field | Type | Meaning |
| - | - | - |
| `schema_version` | string | `"1"`. Changes only when a field is removed, renamed or retyped; a new field is not a new version. |
| `event` | string | `threat_detected`, or `scan_completed` for a pulled event that found nothing, or `device_fail_open` for a [Device sending prompts unchecked](#device-fail-open-alerts). |
| `event_id` | string | The event's id. Unique; use it to deduplicate. |
| `timestamp` | string | When the event happened, ISO 8601 with offset. |
| `organization_id`, `organization_name` | string | The Organization the event belongs to. |
| `project_id`, `project` | string | The project's id and name. |
| `device_id`, `device_hostname` | string or null | The enrolled Device that sent the request. Null for a request made with an ordinary API key: no machine is guessed. |
| `attribution_label_unverified` | string or null | Who is believed to be behind the Device. Free text set at enrolment and never validated, so treat it as a hint. |
| `identity_verified` | boolean or null | `true` when a person signed in to bind that Device to their account, `false` when the label is all there is, `null` for events recorded before this was tracked. |
| `threat_type` | string or null | What was found. |
| `decision` | string | `block`, `redact`, `allow`, or `flag` (allowed, but reported: over a [warn threshold](/api-reference/guard#warn-and-block-thresholds), or matched by a policy rule whose action is flag). |
| `confidence` | number or null | The detector's confidence, 0 to 1. |
| `threshold` | object | Present only on an event flagged by a warn threshold: the band and thresholds that applied. |
| `reason` | string or null | Why. Withheld on projects with zero retention. |
| `direction` | string or null | `input` (to the model) or `output` (from it). |
| `surface` | string or null | Where the request came from: `desktop`, `browser`, `guard`, `proxy`, `sdk`. |
| `segment` | string or null | On a Shadow AI Device's event, the part of the request the finding came from: `current_turn`, `earlier_turn`, `instructions` (the app's own system prompt) or `tool_definition` (a connector's tool). See [what each part is checked for](/shadow-ai/what-is-scanned). Null for any other caller. |
| `dashboard_url` | string | Opens the event in its Organization, for anyone who may act in it. |
| `text` | string | A one-line summary, for chat tools. |

### Which events raise an alert

An alert, pushed or pulled from `/api/v1/alerts`, is a threat that was **blocked** or **flagged**, whatever its confidence, or found with **confidence 0.8 or higher**. Webhook alerts need a plan that includes them; the pull API does not.

### Device fail-open alerts

The Shadow AI agent fails open by default: when it cannot get a verdict from the engine it sends the prompt anyway (see [If the engine cannot be reached](/shadow-ai/privacy-data-handling#if-the-engine-cannot-be-reached)). When one Device has sent **10** prompts that way that no alert has mentioned yet, every destination on its project gets one event with `event: "device_fail_open"` — at most one per Device per hour, each counting only what is new since the last. It carries every routed field above, and:

| Field | Type | Meaning |
| - | - | - |
| `fail_open_count` | integer | Prompts sent without a verdict that this alert is about. |
| `fail_open_by_reason` | object | Today's totals on the Device per reason: `engine_unreachable`, `engine_timeout`, `contract_mismatch`, `policy_denied`, `rejection_unrecognised`, `revoked`. |
| `window_start` | string or null | When the previous alert for this Device was raised today; null for the first. |

`threat_type` is `fail_open`, `decision` is `allow` (the prompts were sent), `reason` says how many, on which Device and why, and `dashboard_url` opens the organization's Shadow AI page. It is pushed only: it is not a stored event, so `/api/v1/alerts` does not serve it. Like every webhook alert it needs a plan that includes webhook alerts.

### Shapes per destination

Each destination on the Alert Routing card has a **format**. You choose it; it is not inferred from the URL.

| Format | What is sent |
| - | - |
| Generic JSON | The event above, as is. |
| Splunk HEC | `{"time", "host", "source": "promptguard", "sourcetype": "promptguard:security", "event": {...}}`. `event` is the record above without `text`; `time` is the event's time and `host` the Device's hostname. |
| Datadog | An Events API event, tagged `organization_id:`, `project_id:`, `event_id:` and `device_id:`, with `host` set to the Device. |
| PagerDuty | An Events API v2 trigger. `dedup_key` is the `event_id`, the record is in `custom_details`, and `links` holds `dashboard_url`. |
| Slack | A message naming the Organization, Device and attribution, with a link to the event. |

## Verify that a delivery came from PromptGuard

On the project's **Delivery security** card, choose **Generate**. The signing secret is shown once: store it in your receiver. From the next delivery on, every request carries:

| Header | Value |
| - | - |
| `X-PromptGuard-Timestamp` | Unix seconds when this attempt was sent |
| `X-PromptGuard-Signature` | `v1=` and the hex HMAC-SHA256 of `<timestamp>.<raw body>`, keyed with your secret |
| `X-PromptGuard-Delivery` | The delivery's id, the same on every retry |

To verify, compute the HMAC over the timestamp, a full stop and the **raw** request body exactly as received, before any JSON parsing, and compare it in constant time. Refuse a timestamp more than **5 minutes** from your clock: that is what stops a captured delivery being replayed. Each retry is signed again when it is sent, so a retry is never too old.

Replacing the secret takes effect on the next attempt. Receivers using the old one refuse from then on, so update them together.

```python Python theme={"system"}
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300


def verify_promptguard_signature(secret, timestamp, signature, body, now=None):
    """True when `body` (raw bytes) was signed by PromptGuard within the window."""
    if not timestamp or not timestamp.isdigit():
        return False
    if abs((time.time() if now is None else now) - int(timestamp)) > TOLERANCE_SECONDS:
        return False
    digest = hmac.new(secret.encode(), timestamp.encode() + b"." + body, hashlib.sha256)
    expected = "v1=" + digest.hexdigest()
    return any(hmac.compare_digest(expected, part.strip()) for part in (signature or "").split(","))


# Flask:
# @app.post("/promptguard")
# def receive():
#     if not verify_promptguard_signature(
#         SECRET,
#         request.headers.get("X-PromptGuard-Timestamp"),
#         request.headers.get("X-PromptGuard-Signature"),
#         request.get_data(),
#     ):
#         abort(401)
#     event = request.get_json()
#     ...
```

```javascript Node.js theme={"system"}
const crypto = require("node:crypto")

const TOLERANCE_SECONDS = 300

function verifyPromptGuardSignature(secret, timestamp, signature, rawBody, now = Date.now() / 1000) {
  if (!/^\d+$/.test(timestamp ?? "")) return false
  if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) return false
  const expected = Buffer.from(
    `v1=${crypto.createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex")}`,
  )
  return (signature ?? "").split(",").some((part) => {
    const candidate = Buffer.from(part.trim())
    return candidate.length === expected.length && crypto.timingSafeEqual(candidate, expected)
  })
}

// Express: read the raw body, not the parsed one.
// app.post("/promptguard", express.raw({ type: "application/json" }), (req, res) => {
//   const ok = verifyPromptGuardSignature(
//     process.env.PROMPTGUARD_SIGNING_SECRET,
//     req.get("X-PromptGuard-Timestamp"),
//     req.get("X-PromptGuard-Signature"),
//     req.body,
//   )
//   if (!ok) return res.sendStatus(401)
//   const event = JSON.parse(req.body)
//   res.sendStatus(200)
// })
```

A signing secret that can no longer be read on our side stops deliveries rather than sending them unsigned: they show as dead-lettered under the project's **Webhooks** page, and generating a new secret resumes them.

## Send an auth header

Some collectors authenticate on a header: Splunk HEC wants `Authorization: Splunk <token>`, Datadog wants `DD-API-KEY`. On the **Delivery security** card, set the header's name and value. It is stored encrypted and never shown again, only its name.

The header goes only to destinations whose **Auth header** switch is on, so a Splunk token is never sent to a Slack URL on the same project. A project has one auth header; a second collector that needs a different one goes on its own project, or behind a URL that carries its own credential.

## Splunk HEC

1. In Splunk, create an HTTP Event Collector token. Turn **indexer acknowledgement off** for it: with it on, Splunk also requires a channel header, which deliveries do not send.
2. In PromptGuard, open **Settings → Integrations → Splunk HEC**, enter `https://<your-splunk>:8088/services/collector/event` and the token, and save. That stores `Authorization: Splunk <token>` as the project's auth header and adds a destination with format **Splunk HEC** and **Auth header** on.
3. Optionally generate a signing secret. HEC does not check it, but a forwarder in front of Splunk can.

On PromptGuard Cloud the collector URL has to resolve to a public address: one on a private or internal network is refused when you save it. Splunk Cloud's HEC endpoint is public; for Splunk Enterprise inside your network, expose HEC through a public endpoint you control, or run PromptGuard self-hosted, which delivers to internal addresses.

Events arrive with sourcetype `promptguard:security`. For example, every block by client:

```
sourcetype="promptguard:security" decision=block
| stats count by organization_name, device_hostname, threat_type
```

## Microsoft Sentinel

Sentinel ingests through the Azure Monitor Logs Ingestion API, which authenticates with Microsoft Entra ID rather than a static header. Put a small receiver in front of it:

1. Create a custom table (for example `PromptGuard_CL`) with a data collection rule, as Microsoft's Logs Ingestion API documentation describes.
2. Create a Logic App whose trigger is **When an HTTP request is received**, and give it a managed identity allowed to send to that rule.
3. In the Logic App, check the signature (an inline-code or Azure Function step running the verification above), then send the body to the rule.
4. In PromptGuard, open **Settings → Integrations → Microsoft Sentinel** and save the Logic App's trigger URL. Its `sig` query parameter is the Logic App's own credential, so no auth header is needed, and the format is **Generic JSON**.

The older HTTP Data Collector API signs each request with a key derived from the request itself, which no static header can do; use the Logs Ingestion API.

To pull instead of push, point a scheduled job or an Azure Function at the export API below and forward what it reads.

## Pull with an export key

An organization admin creates the key: **API keys → Create key**, kind **SIEM export**. It reads every project in the Organization, and can do nothing else: any other request it makes is refused with `403 read_only_key`. No other key can read these endpoints.

```bash theme={"system"}
curl https://api.promptguard.co/api/v1/alerts?limit=500 \
  -H "X-API-Key: $PROMPTGUARD_EXPORT_KEY"
```

| Endpoint | Returns |
| - | - |
| `GET /api/v1/alerts` | Alerts only |
| `GET /api/v1/interactions` | Every event, threat or not |
| `GET /api/v1/interactions/stream` | Server-sent events, one `siem_event` frame per event; `?alerts_only=true` for alerts |

Each page is oldest first:

```json theme={"system"}
{
  "organization_id": "6f0c8f4e-0000-4000-8000-000000000001",
  "organization_name": "Acme Dental",
  "events": [ { "schema_version": "1", "event_id": "...", "...": "..." } ],
  "next_cursor": "MjAyNi0xMC0yMVQxMjowMDowMCswMDowMHw...",
  "has_more": false
}
```

Query parameters: `since` (ISO 8601, default 24 hours ago), `until`, `limit` (1 to 1000, default 100), and `cursor`. Store `next_cursor` and send it back as `cursor` next time: it is returned on every page, empty ones included, and reading from it never skips or repeats an event however many arrive while you page. Events of a project that has since been deleted are not returned.

The stream sends each event's cursor as its SSE `id`, so a client that reconnects with `Last-Event-ID` (as `EventSource` does) resumes after the last event it received. A comment line every few seconds keeps idle connections open.

Export keys share your plan's per-minute [rate limit](/production/rate-limits) like any other key.


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