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

# Webhook Event Reference

> Every event type ContraForce can deliver to a webhook endpoint, with the delivery envelope, headers, payload schemas, and retry behavior.

ContraForce delivers events to your endpoint over HTTPS as signed JSON. This page lists every event type the platform can send, the exact body shape for each, and the delivery rules that apply to all of them.

Webhooks are managed under **Settings → Developer Integrations** in the portal, or through the [`/webhooks` endpoints](/api-reference/endpoints) with the `webhooks:read` and `webhooks:manage` scopes.

***

## Event Types

| Event type                         | Display name                  | Routing               | Fires when                                                                                                                |
| ---------------------------------- | ----------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `webhook.test.v1`                  | Webhook Test                  | Manual                | You use **Send test** on a webhook to verify connectivity.                                                                |
| `incident.created.v1`              | Incident Created              | Subscription          | A new incident is ingested into a monitored workspace. Also used by the manual **Trigger webhook** action on an incident. |
| `incident.closed.v1`               | Incident Closed               | Subscription          | An incident reaches Closed, including closures performed in the vendor's own portal.                                      |
| `gamebook.run.v1`                  | Gamebook Run                  | Subscription          | A Gamebook is dispatched against an incident, by an analyst or by the AI agent.                                           |
| `agent.investigation.completed.v1` | Agent Investigation Completed | Externally configured | A Security Delivery Agent finishes an investigation and reaches a verdict whose classification card points at a webhook.  |

### Routing models

<AccordionGroup>
  <Accordion title="Subscription routing">
    The webhook lists the event type in its `eventTypes` array. Every active webhook in scope receives the event.

    Scope resolution covers the incident's own account plus any managing service provider accounts. A service provider webhook with a non-empty `monitoredAccountIds` list only receives events from the accounts it lists; an empty list means all managed accounts.
  </Accordion>

  <Accordion title="Externally configured routing">
    Subscribing alone does not opt a webhook in. An owning entity has to name the webhook explicitly. Today this applies only to `agent.investigation.completed.v1`, where an Agent Configuration's classification card selects the target webhook.

    The webhook must *also* keep `agent.investigation.completed.v1` in its `eventTypes`. If it is unsubscribed while still bound to a classification card, the delivery is recorded as **Failed** in the delivery log rather than dropped silently.
  </Accordion>

  <Accordion title="Manual routing">
    `webhook.test.v1` is only produced by the **Send test** action against one specific webhook. It is not a subscribable event.

    The incident **Trigger webhook** action is also manual, but it reuses `incident.created.v1` so that existing receivers process it without code changes. Subscription matching is bypassed for that fire, though `monitoredAccountIds` is still enforced.
  </Accordion>
</AccordionGroup>

***

## The Delivery Envelope

Every event, regardless of type, is delivered as the same outer envelope. The event-specific schema lives under `data`.

```json theme={null}
{
  "type": "incident.created.v1",
  "timestamp": "2026-08-03T14:22:07.881Z",
  "isTest": false,
  "occurredAt": "2026-08-03T14:21:55.000Z",
  "data": { }
}
```

| Field        | Type    | Description                                                                         |
| ------------ | ------- | ----------------------------------------------------------------------------------- |
| `type`       | string  | The event type. Matches the `X-CF-Schema` header.                                   |
| `timestamp`  | string  | ISO 8601 time the delivery attempt was built.                                       |
| `isTest`     | boolean | `true` for test deliveries only.                                                    |
| `occurredAt` | string  | ISO 8601 time the underlying event happened, which can be earlier than `timestamp`. |
| `data`       | object  | The event payload. Shape depends on `type`.                                         |

<Warning>
  Verify the signature over the full raw body, envelope included. Do not sign or hash only the `data` object.
</Warning>

All payload property names are camelCase.

***

## Request Headers

| Header           | Value                                | Notes                                                         |
| ---------------- | ------------------------------------ | ------------------------------------------------------------- |
| `Content-Type`   | `application/json`                   | Body is UTF-8 encoded JSON.                                   |
| `X-CF-Schema`    | The event type                       | Branch on this. Default-ignore types you do not recognize.    |
| `X-CF-Event-Id`  | GUID                                 | Stable per logical event. Use it to deduplicate.              |
| `X-CF-Timestamp` | ISO 8601 timestamp                   | Reject if more than 5 minutes from your clock.                |
| `X-CF-Signature` | Base64 HMAC-SHA256                   | See below.                                                    |
| `X-CF-Test`      | `true` or `false`                    | `true` only for test deliveries.                              |
| `Authorization`  | `Bearer <token>` or `Basic <base64>` | Present only if you configured authentication on the webhook. |

***

## Verifying the Signature

The signature covers the timestamp and the exact raw request body:

```
signature = Base64( HMAC_SHA256( signing_key, X-CF-Timestamp + "." + raw_body ) )
```

The `signing_key` is the secret shown once when the webhook was created, unless you supplied your own signing token override when setting bearer-token credentials, in which case it is that token.

<CodeGroup>
  ```python verify.py theme={null}
  import base64, hashlib, hmac, time
  from datetime import datetime, timezone

  def verify(signing_key: str, headers: dict, raw_body: bytes) -> bool:
      ts = headers["X-CF-Timestamp"]
      sent = headers["X-CF-Signature"]
      signed = ts.encode() + b"." + raw_body
      expected = base64.b64encode(
          hmac.new(signing_key.encode(), signed, hashlib.sha256).digest()
      ).decode()
      if not hmac.compare_digest(expected, sent):
          return False
      age = abs(time.time() - datetime.fromisoformat(ts).replace(tzinfo=timezone.utc).timestamp())
      return age <= 300
  ```

  ```csharp Verify.cs theme={null}
  static bool Verify(string signingKey, string timestamp, string sentSignature, byte[] rawBody)
  {
      var signed = System.Text.Encoding.UTF8.GetBytes(timestamp + ".")
          .Concat(rawBody).ToArray();
      using var hmac = new System.Security.Cryptography.HMACSHA256(
          System.Text.Encoding.UTF8.GetBytes(signingKey));
      var expected = Convert.ToBase64String(hmac.ComputeHash(signed));
      var match = System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(
          System.Text.Encoding.UTF8.GetBytes(expected),
          System.Text.Encoding.UTF8.GetBytes(sentSignature));
      var age = Math.Abs((DateTimeOffset.UtcNow - DateTimeOffset.Parse(timestamp)).TotalSeconds);
      return match && age <= 300;
  }
  ```
</CodeGroup>

***

## `webhook.test.v1`

A synthetic connectivity check, sent only by the **Send test** action on a single webhook. It carries `X-CF-Test: true` and `isTest: true`, and is signed exactly like a live event, so you can validate your verification code end to end before any real traffic arrives.

The payload mirrors the `incident.created.v1` shape with fixed sample values.

```json theme={null}
{
  "type": "webhook.test.v1",
  "timestamp": "2026-08-03T14:22:07.881Z",
  "isTest": true,
  "occurredAt": "2026-08-03T14:22:07.881Z",
  "data": {
    "accountId": "11111111-1111-1111-1111-111111111111",
    "accountName": "Contoso Production",
    "incidentId": "test-00000000-0000-0000-0000-000000000000",
    "incidentNumber": 0,
    "title": "Test Incident — Webhook Connectivity Verification",
    "description": "This is a test event generated by ContraForce to verify your webhook endpoint is configured correctly and receiving events.",
    "severity": "Informational",
    "source": "contraforce",
    "sourceDisplayName": "ContraForce",
    "createdAt": "2026-08-03T14:22:07.881Z",
    "lastActivityAt": "2026-08-03T14:22:07.881Z",
    "alertProductNames": ["ContraForce EventCast"],
    "alerts": [
      {
        "title": "Webhook Test Alert",
        "severity": "Informational",
        "productName": "ContraForce EventCast",
        "vendorName": "ContraForce"
      }
    ],
    "owner": {
      "displayName": "ContraForce Test User",
      "email": "test-user@example.com"
    },
    "entities": [
      { "type": "account", "displayName": "test-account@example.com" },
      { "type": "host", "displayName": "DESKTOP-TEST001" },
      { "type": "ip", "displayName": "192.0.2.1" }
    ]
  }
}
```

<Note>
  Test deliveries are excluded from the auto-disable circuit breaker, so a failing test never takes a live webhook offline.
</Note>

***

## `incident.created.v1`

Fires when a new incident is ingested into a workspace the webhook monitors. The same event type is emitted by the incident **Trigger webhook** action so that receivers do not need a separate code path for a manually replayed incident.

```json theme={null}
{
  "type": "incident.created.v1",
  "timestamp": "2026-08-03T14:22:07.881Z",
  "isTest": false,
  "occurredAt": "2026-08-03T14:21:55.000Z",
  "data": {
    "accountId": "11111111-1111-1111-1111-111111111111",
    "accountName": "Contoso Production",
    "incidentId": "a7f3c1e2-0000-4b1a-9c3d-5f6e7a8b9c0d",
    "incidentNumber": 4242,
    "title": "Suspicious sign-in from impossible travel",
    "description": "Multiple sign-ins from geographically distant locations within a short window.",
    "severity": "High",
    "source": "sentinel",
    "sourceDisplayName": "Microsoft Sentinel",
    "owner": {
      "displayName": "Jane Doe",
      "email": "jdoe@contoso.example"
    },
    "createdAt": "2026-08-03T14:21:55.000Z",
    "lastActivityAt": "2026-08-03T14:21:58.000Z",
    "occurredAt": "2026-08-03T14:21:55.000Z",
    "alertProductNames": ["Microsoft Entra ID Protection"],
    "alerts": [
      {
        "title": "Impossible travel activity",
        "severity": "High",
        "productName": "Microsoft Entra ID Protection",
        "vendorName": "Microsoft"
      }
    ],
    "entities": [
      { "type": "Account", "displayName": "jdoe@contoso.example" },
      { "type": "IP", "displayName": "198.51.100.24" }
    ]
  }
}
```

### Fields

| Field                       | Type           | Description                                                                                            |
| --------------------------- | -------------- | ------------------------------------------------------------------------------------------------------ |
| `accountId` / `accountName` | string         | The workspace the incident belongs to.                                                                 |
| `incidentId`                | string         | The source system's incident identifier.                                                               |
| `incidentNumber`            | number \| null | The incident number shown in the portal.                                                               |
| `title`                     | string         | Incident title.                                                                                        |
| `description`               | string         | Incident description, empty string when the source provides none.                                      |
| `severity`                  | string         | `Informational`, `Low`, `Medium`, `High`, or `Critical`.                                               |
| `source`                    | string         | Detection source, **lowercased** for this event, for example `sentinel`, `defenderxdr`, `crowdstrike`. |
| `sourceDisplayName`         | string         | Human-readable source name.                                                                            |
| `owner`                     | object \| null | Assigned owner, omitted when unassigned.                                                               |
| `createdAt`                 | string         | ISO 8601 creation time.                                                                                |
| `lastActivityAt`            | string \| null | ISO 8601 time of the most recent activity.                                                             |
| `occurredAt`                | string         | Duplicate of `createdAt`, retained for receivers that read it from `data`.                             |
| `alertProductNames`         | string\[]      | Distinct product names across the incident's alerts.                                                   |
| `alerts[]`                  | array          | Each with `title`, `severity`, `productName`, `vendorName`.                                            |
| `entities[]`                | array          | Each with `type` and `displayName`. `Ip` and `Url` are normalized to `IP` and `URL`.                   |

<Note>
  Null fields are omitted from this payload rather than serialized as `null`. Treat a missing `owner` or `lastActivityAt` as absent.
</Note>

***

## `incident.closed.v1`

Fires when an incident reaches Closed, whichever route got it there: the portal, a bulk close, the AI agent, or the public API. Closures performed directly in the vendor's own console are also picked up by reconciliation and delivered here, distinguished by `closedBy.origin`.

```json theme={null}
{
  "type": "incident.closed.v1",
  "timestamp": "2026-08-03T16:04:12.117Z",
  "isTest": false,
  "occurredAt": "2026-08-03T16:04:12.117Z",
  "data": {
    "accountId": "11111111-1111-1111-1111-111111111111",
    "accountName": "Contoso Production",
    "incidentId": "a7f3c1e2-0000-4b1a-9c3d-5f6e7a8b9c0d",
    "incidentNumber": 4242,
    "title": "Suspicious sign-in from impossible travel",
    "severity": "High",
    "source": "Sentinel",
    "sourceDisplayName": "Microsoft Sentinel",
    "classification": "TruePositive",
    "classificationReason": "MaliciousActivity",
    "comment": "Confirmed credential theft. User reset and sessions revoked.",
    "closedAt": "2026-08-03T16:04:12.117Z",
    "closedBy": {
      "displayName": "Jane Doe",
      "origin": "analyst"
    }
  }
}
```

### Fields

| Field                       | Type           | Description                                                                                                 |
| --------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------- |
| `accountId` / `accountName` | string         | The workspace the incident belongs to.                                                                      |
| `incidentId`                | string         | The source system's incident identifier.                                                                    |
| `incidentNumber`            | number \| null | The incident number shown in the portal.                                                                    |
| `title`                     | string         | Incident title.                                                                                             |
| `severity`                  | string         | Incident severity at close.                                                                                 |
| `source`                    | string         | Detection source in PascalCase for this event, for example `Sentinel`.                                      |
| `sourceDisplayName`         | string         | Human-readable source name.                                                                                 |
| `classification`            | string \| null | Canonical ContraForce classification: `TruePositive`, `FalsePositive`, `BenignPositive`, or `Undetermined`. |
| `classificationReason`      | string \| null | Canonical reason name, when one was recorded.                                                               |
| `comment`                   | string \| null | Free-text closing comment, when one was provided.                                                           |
| `closedAt`                  | string         | ISO 8601 close time.                                                                                        |
| `closedBy.displayName`      | string         | Closing analyst, or the upstream source label for vendor-portal closures.                                   |
| `closedBy.origin`           | string         | `analyst` for closures through ContraForce, `upstream` for closures detected in the vendor's portal.        |

<Tip>
  Branch on `classification`, not on vendor strings. ContraForce normalizes every source's closure vocabulary into the four canonical values, so your integration does not have to track vendor enum changes. See [Incident classification](/concepts/incident-classification).
</Tip>

***

## `gamebook.run.v1`

Fires when a Gamebook is dispatched to the executor against an incident, whether an analyst started it from the portal or public API, or the AI agent did.

<Note>
  Gamebooks awaiting approval do not fire this event while queued. The event is emitted when the run is actually dispatched.
</Note>

```json theme={null}
{
  "type": "gamebook.run.v1",
  "timestamp": "2026-08-03T15:10:44.502Z",
  "isTest": false,
  "occurredAt": "2026-08-03T15:10:44.502Z",
  "data": {
    "accountId": "11111111-1111-1111-1111-111111111111",
    "accountName": "Contoso Production",
    "incidentId": "a7f3c1e2-0000-4b1a-9c3d-5f6e7a8b9c0d",
    "incidentNumber": 4242,
    "title": "Compromised account containment",
    "source": "Sentinel",
    "gamebookId": "3c9d1b7e-2f44-4a86-8c11-9e0f7d5a2b31",
    "gamebookTitle": "Compromised account containment",
    "playbooks": ["Disable user", "Revoke sign-in sessions"],
    "runAt": "2026-08-03T15:10:44.502Z",
    "runBy": {
      "displayName": "Jane Doe"
    }
  }
}
```

### Fields

| Field                       | Type           | Description                                                                     |
| --------------------------- | -------------- | ------------------------------------------------------------------------------- |
| `accountId` / `accountName` | string         | The workspace the incident belongs to.                                          |
| `incidentId`                | string         | The source system's incident identifier.                                        |
| `incidentNumber`            | number \| null | The incident number shown in the portal.                                        |
| `title`                     | string         | The gamebook title. Duplicates `gamebookTitle`; it is not the incident title.   |
| `source`                    | string         | Detection source of the incident in PascalCase, for example `Sentinel`.         |
| `gamebookId`                | string         | GUID of the gamebook that ran.                                                  |
| `gamebookTitle`             | string         | Display title of the gamebook.                                                  |
| `playbooks`                 | string\[]      | Display names of the playbooks in the dispatched gameplan.                      |
| `runAt`                     | string         | ISO 8601 dispatch time.                                                         |
| `runBy.displayName`         | string         | The triggering identity: an analyst's name, or the AI agent's service identity. |

***

## `agent.investigation.completed.v1`

Fires once per investigation, when a Security Delivery Agent completes and reaches a classification whose card has a webhook custom action configured.

<Info>
  This event is configured per classification on an Agent Configuration card, not as a broadcast subscription. Set it up under [Configuring Security Delivery Agents](/guides/getting-started/configuring-security-delivery-agents) by enabling **Advanced** mode and choosing a webhook as the **custom action** for a classification. Full walkthrough and troubleshooting: [Agent Investigation Completed Webhook](/guides/agent-center/agent-investigation-webhook).
</Info>

```json theme={null}
{
  "type": "agent.investigation.completed.v1",
  "timestamp": "2026-08-03T14:31:09.740Z",
  "isTest": false,
  "occurredAt": "2026-08-03T14:31:02.310Z",
  "data": {
    "workspace": {
      "id": "11111111-1111-1111-1111-111111111111",
      "alias": "contoso",
      "name": "Contoso Production"
    },
    "agent": {
      "id": "5b2e9c4a-77d1-4c0b-a8f3-2d6e1c9b0a55",
      "name": "Tier 1 Triage Agent"
    },
    "incident": {
      "id": "a7f3c1e2-0000-4b1a-9c3d-5f6e7a8b9c0d",
      "number": 4242,
      "source": "Sentinel",
      "title": "Suspicious sign-in from impossible travel",
      "severity": "High",
      "status": "Active"
    },
    "verdict": {
      "classificationBucket": "TruePositive",
      "classificationReason": "MaliciousActivity",
      "classificationReasonComment": "Confirmed credential theft",
      "comment": "Sign-in originated from a known-malicious ASN minutes after a login from the user's usual location."
    },
    "gamebookRecommendation": {
      "incidentNumber": 4242,
      "incidentTitle": "Suspicious sign-in from impossible travel",
      "playbooks": [
        {
          "playbookId": "disable-user",
          "affectedEntity": "Account",
          "entityId": "jdoe@contoso.example",
          "sequence": 1
        }
      ]
    }
  }
}
```

### Fields

| Field                                 | Type           | Description                                                                                    |
| ------------------------------------- | -------------- | ---------------------------------------------------------------------------------------------- |
| `workspace.id` / `alias` / `name`     | string         | The ContraForce workspace the incident belongs to.                                             |
| `agent.id` / `name`                   | string         | The Security Delivery Agent that ran the investigation.                                        |
| `incident.id`                         | string         | The source system's incident identifier.                                                       |
| `incident.number`                     | number         | The incident number shown in the portal.                                                       |
| `incident.source`                     | string         | Detection source in PascalCase, for example `Sentinel`.                                        |
| `incident.title`                      | string         | Incident title.                                                                                |
| `incident.severity`                   | string         | Incident severity.                                                                             |
| `incident.status`                     | string         | Incident status at completion.                                                                 |
| `verdict.classificationBucket`        | string         | The agent's verdict. One of `TruePositive`, `BenignPositive`, `FalsePositive`, `Undetermined`. |
| `verdict.classificationReason`        | string \| null | Reason code for the classification.                                                            |
| `verdict.classificationReasonComment` | string \| null | Free-text reason detail.                                                                       |
| `verdict.comment`                     | string         | The agent's investigation summary comment.                                                     |
| `gamebookRecommendation`              | object \| null | Present only when the agent recommended gamebooks.                                             |
| `gamebookRecommendation.playbooks[]`  | array          | Each with `playbookId`, `affectedEntity`, `entityId`, and `sequence`.                          |

***

## Delivery Rules

These apply to every event type.

### Retries

Delivery is at-least-once. A failed attempt is retried with a fixed backoff, up to **10 attempts** within a **5 minute** window from the first attempt. Whichever limit is hit first ends the retry budget and the event is dead-lettered.

| Next attempt | 2  | 3   | 4   | 5   | 6   | 7   | 8   | 9   | 10  |
| ------------ | -- | --- | --- | --- | --- | --- | --- | --- | --- |
| Backoff      | 5s | 10s | 15s | 20s | 30s | 30s | 40s | 50s | 60s |

Retries are triggered by HTTP `408`, `429`, `500`, `502`, `503`, and `504`, and by connection or timeout errors. Any other non-success status is treated as a permanent failure and is not retried.

A `Retry-After` header is honored when it is longer than the scheduled backoff, clamped to the 5 minute retry window.

<Warning>
  Retries reuse the same `X-CF-Event-Id`. Make your handler idempotent and deduplicate on that value.
</Warning>

### Event IDs

`incident.created.v1` and `incident.closed.v1` and `gamebook.run.v1` derive a deterministic event ID from the underlying subject, so a republished event produces the same ID and can be deduplicated. Test events and manually triggered incident fires get a fresh ID per fire, which is what distinguishes a manual replay from the original automatic event.

### Auto-disable

If **5 or more distinct incidents** fail final delivery to the same webhook within a rolling **24 hours**, the webhook is automatically disabled and stops receiving events. Test deliveries never count toward this threshold.

Re-enable it with `POST /webhooks/{id}/enable` or from the webhook detail page in the portal, after fixing the endpoint.

### Requirements for your endpoint

* Must be reachable over HTTPS. Plain HTTP, localhost, and private network addresses are rejected at configuration time.
* Must respond within **30 seconds**. Acknowledge with a 2xx immediately and do the real work asynchronously.
* Response bodies are read up to 1 MB for the delivery log; anything larger is truncated.
* Should default-ignore unknown values of `X-CF-Schema` so new event types do not break your handler.

***

## Subscribing

Set the event types when you create or update a webhook. At least one is required.

```json theme={null}
{
  "name": "SIEM-ingest-prod",
  "url": "https://siem.contoso.example/api/webhook",
  "eventTypes": [
    "incident.created.v1",
    "incident.closed.v1",
    "gamebook.run.v1"
  ],
  "monitoredAccountIds": [
    "11111111-1111-1111-1111-111111111111"
  ],
  "authenticationType": "Bearer",
  "token": "your-bearer-token",
  "signingToken": "your-hmac-signing-secret"
}
```

`webhook.test.v1` is not subscribable. `agent.investigation.completed.v1` must be listed here *and* selected on an Agent Configuration classification card before it delivers anything.

Delivery outcomes for every event are visible per webhook under **Settings → Developer Integrations**, and through `GET /webhooks/{id}/delivery-logs`.

<Note>
  Questions about webhook events? Contact us at [support@contraforce.com](mailto:support@contraforce.com).
</Note>
