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

# Webhooks

> Receive events as each processing stage completes or fails.

TruEnroll delivers results by pushing events to your registered HTTPS endpoint rather than
requiring you to poll for status changes. Each processing stage emits its own event when it
reaches a terminal state: completed or failed.

## Registering a webhook endpoint

Register the HTTPS endpoint that should receive events yourself, through the Partner API:

```bash theme={null}
curl -X POST https://api.truenroll.com/partner/v1/webhook-config \
  -H "x-api-key: $TRUENROLL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "endpoint": "https://example.com/truenroll/webhook",
    "apiKey": "whsec_a1b2c3d4e5f6g7h8i9j0",
    "audience": "partner"
  }'
```

Set `audience` to `partner` so the endpoint receives Partner API case events; internal platform
events are never mixed into partner delivery. See
[Configuring webhooks](/partner-api/webhook-configuration) for the full request and field reference.

## Authenticating webhook requests

When you [register your endpoint](/partner-api/webhook-configuration), you supply a secret
key (the `apiKey`). TruEnroll includes that key as an **`x-api-key` header** on every webhook
request it sends to you. Verify it on each request and reject anything that doesn't match:

```typescript theme={null}
if (req.get('x-api-key') !== process.env.TRUENROLL_WEBHOOK_KEY) {
  return res.sendStatus(401);
}
```

<Warning>
  Always verify the `x-api-key` header before acting on a webhook. Treat the key like a
  password (store it in a secret manager, never in source control) and serve your endpoint
  over HTTPS so the header is never sent in clear text.
</Warning>

## Event names

| Event                           | Fired when                                     |
| ------------------------------- | ---------------------------------------------- |
| `case.classification.completed` | Document classification succeeded              |
| `case.classification.failed`    | Document classification failed (terminal)      |
| `case.extraction.completed`     | Extraction succeeded; structured data is ready |
| `case.extraction.failed`        | Extraction failed (terminal)                   |
| `case.forensics.completed`      | Forensics analysis succeeded                   |
| `case.forensics.failed`         | Forensics analysis failed (terminal)           |
| `case.translation.completed`    | Translation succeeded; output is ready         |
| `case.translation.failed`       | Translation failed (terminal)                  |

<Info>
  You only receive events for features you requested. If you submitted a case with
  `features=["extraction","forensics"]`, you won't receive `case.translation.*` events.
</Info>

## Event payload

All events share the same envelope. Event-specific fields are carried inside `data`
alongside the common fields:

```json theme={null}
{
  "eventName": "case.extraction.completed",
  "data": {
    "caseId": "6850abc123def456ghi789",
    "externalId": "applicant-9876",
    "uploadId": "6850def456ghi789abc123",
    "credentialId": "6850ghi789abc123def456",
    "occurredAt": "2025-06-22T10:30:00Z"
  }
}
```

<ParamField body="eventName" type="string">
  The event name from the table above. Use this to route incoming events in your handler.
</ParamField>

<ParamField body="data.caseId" type="string">
  TruEnroll's unique case identifier. Use this to call the result endpoint.
</ParamField>

<ParamField body="data.externalId" type="string | null">
  The `externalId` you provided at submission time, or `null` if none was provided.
</ParamField>

<ParamField body="data.uploadId" type="string">
  The upload within the case that this event relates to.
</ParamField>

<ParamField body="data.occurredAt" type="string (ISO 8601)">
  The timestamp when the processing event occurred internally.
</ParamField>

<ParamField body="data.credentialId" type="string">
  Present on extraction events, and on forensics/translation events when classification ran.
  Identifies the specific credential the result belongs to.
</ParamField>

<ParamField body="data.fileId" type="string">
  Present on forensics and translation events. Identifies the source file the result was
  produced from.
</ParamField>

<Note>
  Depending on the event, `data` may carry additional fields. For example,
  `documentIds` appears on `case.classification.completed`, and an `error` string on `*.failed` events.
  Treat `data` as an open object and read only the fields you need.
</Note>

## Event ordering

Events for different features within the same case arrive **independently and in any order**.
Forensics and extraction run in parallel, so `case.forensics.completed` may arrive before
`case.extraction.completed` or vice versa.

Do not assume ordering between events for different features. Handle each event on its own
merits.

Events for the same feature on the same case are ordered: you won't receive
`case.extraction.completed` before `case.extraction.failed` for the same document.

## Delivery & retries

TruEnroll considers a delivery successful when your endpoint returns a `2xx` status. If the
delivery fails (non-`2xx`, a timeout, or a connection error), TruEnroll **retries up to 5
times** before giving up. Each attempt has a 300-second timeout.

Because deliveries can be retried, the same event may reach your endpoint more than once. Make
your handler **idempotent**: processing the same event twice should produce the same outcome.
Deduplicate on a stable key such as `eventName` + `data.uploadId` (+ `data.credentialId` or
`data.fileId` where present).

## Handling events

A minimal webhook handler:

```typescript theme={null}
app.post('/truenroll/webhook', express.json(), async (req, res) => {
  // Verify the shared secret before trusting the request (see below)
  if (req.get('x-api-key') !== process.env.TRUENROLL_WEBHOOK_KEY) {
    return res.sendStatus(401);
  }

  // Acknowledge immediately; TruEnroll will retry if it doesn't get 2xx
  res.sendStatus(200);

  const { eventName, data } = req.body;

  switch (eventName) {
    case 'case.extraction.completed':
      await handleExtractionComplete(data.caseId, data.credentialId);
      break;
    case 'case.extraction.failed':
      await handleExtractionFailed(data.caseId, data.credentialId);
      break;
    case 'case.forensics.completed':
      await handleForensicsComplete(data.caseId, data.fileId);
      break;
    // handle remaining events...
  }
});
```

<Warning>
  **Respond with `2xx` before doing work.** TruEnroll considers delivery successful when
  your endpoint returns a `2xx` status. If your handler times out or throws before
  responding, TruEnroll will retry the delivery. Acknowledge first, then process
  asynchronously.
</Warning>

## Classification suppression

If you submitted an upload with `classify: false`, TruEnroll will not emit
`case.classification.completed` or `case.classification.failed` events for that upload.

## What to do when a stage fails

A `case.*.failed` event means that stage is done and won't be retried automatically.
Other stages may still complete:

```mermaid theme={null}
flowchart LR
    FAIL["case.extraction.failed"] --> CHECK["Check other events"]
    CHECK --> FIN["case.forensics.completed"]
    CHECK --> TRN["case.translation.completed"]
    FIN --> DONE["Partial results available\nFetch what succeeded"]
    TRN --> DONE
```

You can still call the result endpoints for stages that succeeded. Use the overall case
status (`GET /cases/{id}`) to determine whether to resubmit or flag for manual review.
