> ## 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.

# Agent Investigation Completed Webhook

> Receive, verify, and parse the agent.investigation.completed.v1 webhook that ContraForce sends when a Security Delivery Agent finishes investigating an incident.

When a Security Delivery Agent finishes investigating an incident, ContraForce can send a signed webhook to an endpoint you control. Use it to escalate true-positive incidents into your SIEM, ticketing, or on-call tooling at the moment the agent reaches a verdict.

<Info>
  This event is configured per classification on an Agent Configuration card, not as a broadcast subscription. It is delivered **only** to the webhook a classification card points to. 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.
</Info>

***

## When It Fires

The event fires once per investigation, when the agent completes and reaches a classification, for any classification whose policy has a webhook custom action configured. The event type (schema) is:

```
agent.investigation.completed.v1
```

***

## Request Headers

Every delivery includes these headers. Use them to verify authenticity before trusting the body.

| Header           | Value                                | Notes                                                         |
| ---------------- | ------------------------------------ | ------------------------------------------------------------- |
| `X-CF-Schema`    | `agent.investigation.completed.v1`   | The event type.                                               |
| `X-CF-Event-Id`  | A unique event identifier (GUID)     | Use it to deduplicate retries.                                |
| `X-CF-Timestamp` | ISO 8601 timestamp                   | Reject if it is more than 5 minutes from your clock.          |
| `X-CF-Signature` | Base64-encoded HMAC-SHA256 signature | See verification below.                                       |
| `X-CF-Test`      | `true`                               | Present only for test deliveries.                             |
| `Authorization`  | `Bearer <token>` or `Basic <base64>` | Present only if you configured authentication on the webhook. |

<Warning>
  Retries reuse the same `X-CF-Event-Id`. Treat delivery as at-least-once and make your handler idempotent.
</Warning>

***

## 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.

<Steps>
  <Step title="Read the raw body">
    Compute the signature over the unparsed request body bytes, before any JSON deserialization.
  </Step>

  <Step title="Recompute">
    Concatenate the `X-CF-Timestamp` value, a literal `.`, and the raw body. HMAC-SHA256 it with your signing key and Base64-encode the result.
  </Step>

  <Step title="Compare in constant time">
    Compare your value to `X-CF-Signature` using a constant-time comparison. Reject on mismatch.
  </Step>

  <Step title="Check the timestamp">
    Reject the request if `X-CF-Timestamp` is more than 5 minutes from current time, to limit replay.
  </Step>
</Steps>

<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>

***

## Payload

The body is JSON with camelCase fields:

```json theme={null}
{
  "workspace": {
    "id": "00000000-0000-0000-0000-000000000000",
    "alias": "contoso",
    "name": "Contoso Production"
  },
  "agent": {
    "id": "agent-identifier",
    "name": "Tier 1 Triage Agent"
  },
  "incident": {
    "id": "source-system-incident-id",
    "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.com",
        "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, for example `Sentinel`.                                                      |
| `incident.title`                      | string         | Incident title.                                                                                |
| `incident.severity`                   | string         | Incident severity, for example `High`.                                                         |
| `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          | Recommended gamebooks, each with `playbookId`, `affectedEntity`, `entityId`, and `sequence`.   |

<Tip>
  Branch on `verdict.classificationBucket`. It is a stable, vendor-neutral value, so your integration does not need to handle source-specific classification strings.
</Tip>

***

## Testing

Use the **Send test** action on the webhook in **Developers** to deliver a synthetic event. Test deliveries carry `X-CF-Test: true` and use sample data. They are signed identically to live events, so you can validate your verification code end to end.

***

## Troubleshooting

| Symptom               | Likely cause                                                                    | Resolution                                                                                                                                                              |
| --------------------- | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| No events arrive      | The classification card does not point to this webhook, or Advanced mode is off | Confirm the webhook is set as the custom action for the classification and that Advanced mode is enabled and saved                                                      |
| Events stop arriving  | The webhook was deleted, paused, disabled, or unsubscribed                      | The classification card shows a binding warning. Fix the webhook in Developers. Skipped deliveries are recorded as **Failed** in the delivery log, not dropped silently |
| Signature check fails | Verifying a parsed body instead of the raw bytes, or using the wrong key        | Sign the raw request body, and use the signing token override if one was set                                                                                            |
| Duplicate events      | Normal retry behavior                                                           | Deduplicate on `X-CF-Event-Id`                                                                                                                                          |

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